Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions src/deadline/client/cli/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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:
Expand All @@ -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")
Expand Down
14 changes: 9 additions & 5 deletions src/deadline/client/config/config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
__config_file_path = None
__config_mtime = None

# Setting name constants
_SETTING_FARM_ID = "defaults.farm_id"

Check failure on line 55 in src/deadline/client/config/config_file.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "defaults.farm_id" 3 times.

See more on https://sonarcloud.io/project/issues?id=aws-deadline_deadline-cloud&issues=AZyghHQHbTTkCKzmVCBY&open=AZyghHQHbTTkCKzmVCBY&pullRequest=1015

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_SETTING_FARM_ID' is not used.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

_SETTING_QUEUE_ID = "defaults.queue_id"

Check failure on line 56 in src/deadline/client/config/config_file.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "defaults.queue_id" 3 times.

See more on https://sonarcloud.io/project/issues?id=aws-deadline_deadline-cloud&issues=AZyghHQHbTTkCKzmVCBX&open=AZyghHQHbTTkCKzmVCBX&pullRequest=1015

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_SETTING_QUEUE_ID' is not used.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


# This value defines the AWS Deadline Cloud settings structure. For each named setting,
# it stores:
# "default" - The default value.
Expand All @@ -76,21 +80,21 @@
"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": "{}",
"description": "The Farm ID to use by default.",
},
"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.",
},
Expand Down Expand Up @@ -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": {
Expand Down
58 changes: 55 additions & 3 deletions src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Comment on lines +319 to +321
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: There's a theoretical race window between the flag check and set, but given this is triggered by user actions with natural delays, it's unlikely to be hit in practice. No action needed unless you want to be
extra defensive.


_threading.Thread(target=self._do_auto_select, daemon=True).start()
Comment thread
rickrams marked this conversation as resolved.

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
Expand Down
62 changes: 61 additions & 1 deletion test/unit/deadline_client/cli/test_cli_common.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Loading