From ad462f78b8fc8905056abda276d3d41d758fd582 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:21:53 -0500 Subject: [PATCH 01/89] feat: inital repo browser commit Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 194 +++++++++++ src/deadline/client/config/config_file.py | 7 + src/deadline/client/job_bundle/loader.py | 9 + src/deadline/client/job_bundle/repository.py | 213 ++++++++++++ .../ui/dialogs/job_bundle_browser_dialog.py | 321 ++++++++++++++++++ .../client/ui/job_bundle_submitter.py | 44 ++- .../ui/widgets/job_bundle_settings_tab.py | 46 ++- .../deadline_client/cli/test_cli_config.py | 4 +- .../config/test_config_file.py | 1 + .../job_bundle/test_repository.py | 172 ++++++++++ 10 files changed, 1002 insertions(+), 9 deletions(-) create mode 100644 docs/design/job-bundle-browser.md create mode 100644 src/deadline/client/job_bundle/repository.py create mode 100644 src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py create mode 100644 test/unit/deadline_client/job_bundle/test_repository.py diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md new file mode 100644 index 000000000..9387c2383 --- /dev/null +++ b/docs/design/job-bundle-browser.md @@ -0,0 +1,194 @@ +# Job Bundle Browser + +## Problem + +When using `deadline bundle gui-submit --browse` or the "Load a different job bundle" button, users are presented with a native OS folder picker. This is inadequate because: + +- Job bundles are not distinguishable from regular folders by name alone. +- Users must already know where their bundles live and navigate there manually. +- There is no preview of what a bundle contains — users pick blindly. +- The picker always starts at the job history directory or home, with no way to configure a default location. + +## Overview + +Replace the native folder picker with a custom job bundle browser dialog that: + +1. Provides a navigable directory tree showing only folders and job bundles. +2. Displays a preview panel with bundle metadata when a job bundle is selected. +3. Supports both local filesystem and S3 bucket browsing through a common backend abstraction. +4. For S3, uses the selected queue's job attachment bucket with a `job-bundles/` prefix — no extra configuration needed. +5. Respects a configurable default local browse directory. + +## Design + +### Backend Abstraction + +To support both local and S3 browsing without coupling the UI to either, introduce a `BundleRepository` protocol: + +```python +@dataclass +class BundleInfo: + """Metadata extracted from a job bundle's template.""" + path: str # Local path or s3:// URI + name: str # From template "name" field + description: str # From template "description" field, or "" + step_names: list[str] # Names of each step in the template + parameters: list[dict] # Parameter definitions from the template + +@dataclass +class BrowseEntry: + """A single item in the browser listing.""" + name: str # Display name (folder basename or bundle name) + path: str # Full path or S3 URI + is_bundle: bool # True if this is a valid job bundle + +class BundleRepository(Protocol): + def list_entries(self, path: str) -> list[BrowseEntry]: + """List immediate children of `path`. Returns folders and bundles.""" + ... + + def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + """Load and return metadata for the bundle at `path`, or None if invalid.""" + ... + + def root_path(self) -> str: + """The starting path for browsing.""" + ... +``` + +Two implementations: + +- `LocalBundleRepository` — walks the local filesystem. A directory is a bundle if it contains `template.yaml` or `template.json`. Uses the existing `read_yaml_or_json_object` loader. +- `S3BundleRepository` — lists objects under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Constructed from the queue's `JobAttachmentS3Settings`. + +### S3 Bucket Convention + +The S3 bundle repository browses: + +``` +s3://{s3BucketName}/{rootPrefix}/job-bundles/ +``` + +Where `s3BucketName` and `rootPrefix` come from the selected queue's `jobAttachmentSettings`. This means: + +- No extra configuration is needed — the bucket is derived from the queue the user already has selected. +- Users (or admins) place job bundles in the `job-bundles/` folder within the queue's attachment bucket. +- Each bundle is an S3 "folder" (common prefix) containing a `template.yaml` or `template.json`. + +Example S3 layout: +``` +s3://my-farm-bucket/DeadlineCloud/job-bundles/ + blender-render/ + template.yaml + maya-arnold/ + template.yaml + asset_references.yaml + simple-job/ + template.json +``` + +### Detection: What Is a Job Bundle? + +A directory (local) or prefix (S3) is a job bundle if it contains a `template.yaml` or `template.json`. + +For `list_entries`, we need to check each child directory/prefix. To keep this fast: + +- **Local**: For each child directory, check for the existence of `template.yaml` or `template.json` (stat calls only — don't parse yet). Parse only happens in `get_bundle_info` when the user selects a bundle. +- **S3**: Use `list_objects_v2` with the child prefix to check for `template.yaml`/`template.json` keys. Full parsing happens on selection. + +### Browser Dialog UI + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Job Bundle Browser │ +├────────────────────────────────┬────────────────────────────┤ +│ 📁 my-bundles/ │ Name: Blender Render │ +│ 📦 blender-render/ │ Description: Renders a │ +│ 📦 maya-arnold/ │ Blender scene file... │ +│ 📁 wip/ │ │ +│ 📦 experimental-job/ │ Steps: │ +│ 📦 simple-job/ │ • RenderBlender │ +│ │ │ +│ │ Parameters: │ +│ │ • BlenderSceneFile (PATH)│ +│ │ • Frames (STRING) │ +│ │ • OutputDir (PATH) │ +│ │ • Format (STRING) │ +│ │ │ +├────────────────────────────────┴────────────────────────────┤ +│ Source: ( ) Local (•) S3 (my-farm-bucket) │ +│ Path: [/job-bundles/ ] │ +│ [Cancel] [Select] │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Left panel** — Navigable tree view: +- Shows folders (📁) and job bundles (📦) with distinct icons. +- Folders can be expanded/navigated into. +- Job bundles are leaf nodes (selectable, not expandable). +- Non-bundle, non-directory files are hidden. + +**Right panel** — Preview (shown when a bundle is selected): +- **Name**: From the template's `name` field. +- **Description**: From the template's `description` field, if present. +- **Steps**: List of step names from the template. +- **Parameters**: Name and type of each parameter definition. + +**Bottom bar**: +- Radio toggle between Local and S3 source. S3 option shows the bucket name from the queue. S3 option is disabled if the queue has no job attachment settings. +- Path display showing the current browse location. +- Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. + +### Lazy Loading + +The tree is populated lazily — only the children of expanded nodes are fetched. This keeps the initial load fast and avoids scanning deep directory trees or making excessive S3 API calls. + +### Configuration + +Add a new setting for the default local browse directory: + +```python +# In SETTINGS dict in config_file.py +"settings.job_bundle_default_directory": { + "default": "", + "description": ( + "The default local directory to open when browsing for job bundles. " + "If empty, defaults to the user's home directory." + ), +} +``` + +Environment variable override: `DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY` + +### CLI Integration + +The `--browse` flag on `deadline bundle gui-submit` opens this new dialog instead of `QFileDialog.getExistingDirectory()`. No new flags needed. + +For S3 bundles, the submission flow downloads the bundle to a temporary local directory before submission. This is handled in `show_job_bundle_submitter` after the dialog returns. + +### Changes to Existing Code + +| File | Change | +|---|---| +| `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | +| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog. | +| `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | +| `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True` | +| `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | +| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository` | + +### S3 Considerations + +- **Authentication**: S3 browsing uses the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. +- **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket. If access is denied, show an error in the dialog rather than crashing. +- **Performance**: Each directory expansion is one `list_objects_v2` call. Bundle detection adds one `list_objects_v2` per child prefix. Acceptable for typical bundle repositories (tens of bundles, not thousands). +- **Template download**: `get_bundle_info` for S3 downloads only the `template.yaml`/`template.json` file (typically <10KB) to parse metadata. +- **Bundle selection**: When the user selects an S3 bundle, the full bundle directory is downloaded to a temp directory for submission. This happens after the dialog closes, not during browsing. + +## Out of Scope (Future) + +- Caching/indexing of bundle metadata for faster repeated browsing. +- Search/filter within the browser. +- Favoriting or pinning frequently used bundles. +- Browsing bundles from a Deadline Cloud service API (e.g. farm-level bundle registry). +- Configurable S3 bucket/prefix (currently always derived from the queue). diff --git a/src/deadline/client/config/config_file.py b/src/deadline/client/config/config_file.py index 56709e7d4..f07de0d85 100644 --- a/src/deadline/client/config/config_file.py +++ b/src/deadline/client/config/config_file.py @@ -301,6 +301,13 @@ def get_deadline_regions(config: Optional[ConfigParser] = None) -> List[str]: "default": "20", "description": "The default maximum number of tasks that can fail before the job is marked as failed.", }, + "settings.job_bundle_default_directory": { + "default": "", + "description": ( + "The default local directory to open when browsing for job bundles. " + "If empty, defaults to the user's home directory." + ), + }, } diff --git a/src/deadline/client/job_bundle/loader.py b/src/deadline/client/job_bundle/loader.py index 5094a92e7..ee2ca869f 100644 --- a/src/deadline/client/job_bundle/loader.py +++ b/src/deadline/client/job_bundle/loader.py @@ -3,6 +3,7 @@ from __future__ import annotations __all__ = [ + "is_job_bundle_dir", "parse_yaml_or_json_content", "read_yaml_or_json", "read_yaml_or_json_object", @@ -19,6 +20,14 @@ from ..exceptions import DeadlineOperationError +def is_job_bundle_dir(path: str) -> bool: + """Returns True if the directory contains a template.yaml or template.json file.""" + if not os.path.isdir(path): + return False + template_prefix = os.path.join(path, "template") + return os.path.isfile(template_prefix + ".yaml") or os.path.isfile(template_prefix + ".json") + + def validate_directory_symlink_containment(job_bundle_dir: str) -> None: """ Validates the integrity of the job bundle, validating that all files diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py new file mode 100644 index 000000000..67b6d5193 --- /dev/null +++ b/src/deadline/client/job_bundle/repository.py @@ -0,0 +1,213 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Bundle repository abstraction for browsing job bundles from local filesystem or S3. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from logging import getLogger +from typing import Optional, Protocol + +import yaml +import json + +logger = getLogger(__name__) + +TEMPLATE_FILENAMES = ("template.yaml", "template.json") +S3_JOB_BUNDLES_PREFIX = "job-bundles" + + +@dataclass +class BundleInfo: + """Metadata extracted from a job bundle's template.""" + + path: str + name: str + description: str = "" + step_names: list[str] = field(default_factory=list) + parameters: list[dict] = field(default_factory=list) + + +@dataclass +class BrowseEntry: + """A single item in the browser listing.""" + + name: str + path: str + is_bundle: bool + + +class BundleRepository(Protocol): + def list_entries(self, path: str) -> list[BrowseEntry]: + """List immediate children of `path`. Returns folders and bundles.""" + ... + + def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + """Load and return metadata for the bundle at `path`, or None if invalid.""" + ... + + def root_path(self) -> str: + """The starting path for browsing.""" + ... + + +def _parse_template(raw: str, filename: str) -> Optional[dict]: + """Parse a template file's contents, returning the dict or None on failure.""" + try: + if filename.endswith(".json"): + return json.loads(raw) + else: + return yaml.safe_load(raw) + except Exception: + logger.debug("Failed to parse template %s", filename, exc_info=True) + return None + + +def _extract_bundle_info(template: dict, path: str) -> BundleInfo: + """Extract BundleInfo from a parsed template dict.""" + return BundleInfo( + path=path, + name=template.get("name", os.path.basename(path.rstrip("/"))), + description=template.get("description", ""), + step_names=[s.get("name", "") for s in template.get("steps", [])], + parameters=template.get("parameterDefinitions", []), + ) + + +class LocalBundleRepository: + """Browse job bundles on the local filesystem.""" + + def __init__(self, root: str = ""): + self._root = root or os.path.expanduser("~") + + def root_path(self) -> str: + return self._root + + def list_entries(self, path: str) -> list[BrowseEntry]: + entries: list[BrowseEntry] = [] + try: + children = sorted(os.listdir(path)) + except OSError: + return entries + for name in children: + full = os.path.join(path, name) + if not os.path.isdir(full): + continue + is_bundle = self._is_bundle(full) + entries.append(BrowseEntry(name=name, path=full, is_bundle=is_bundle)) + return entries + + def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + for fname in TEMPLATE_FILENAMES: + fpath = os.path.join(path, fname) + if os.path.isfile(fpath): + try: + with open(fpath, encoding="utf-8") as f: + raw = f.read() + except OSError: + return None + template = _parse_template(raw, fname) + if template: + return _extract_bundle_info(template, path) + return None + + @staticmethod + def _is_bundle(path: str) -> bool: + for fname in TEMPLATE_FILENAMES: + if os.path.isfile(os.path.join(path, fname)): + return True + return False + + +class S3BundleRepository: + """Browse job bundles in an S3 bucket under {rootPrefix}/job-bundles/.""" + + def __init__(self, bucket_name: str, root_prefix: str, session=None): + import boto3 as _boto3 + + self._bucket = bucket_name + # Ensure the prefix ends with /job-bundles/ + base = root_prefix.rstrip("/") + self._prefix = f"{base}/{S3_JOB_BUNDLES_PREFIX}/" + self._session = session or _boto3.Session() + self._s3 = self._session.client("s3") + + def root_path(self) -> str: + return f"s3://{self._bucket}/{self._prefix}" + + def list_entries(self, path: str) -> list[BrowseEntry]: + prefix = self._to_s3_prefix(path) + entries: list[BrowseEntry] = [] + try: + paginator = self._s3.get_paginator("list_objects_v2") + for page in paginator.paginate( + Bucket=self._bucket, Prefix=prefix, Delimiter="/" + ): + for cp in page.get("CommonPrefixes", []): + child_prefix = cp["Prefix"] + name = child_prefix.rstrip("/").rsplit("/", 1)[-1] + child_path = f"s3://{self._bucket}/{child_prefix}" + is_bundle = self._is_bundle(child_prefix) + entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + except Exception: + logger.warning("Failed to list S3 prefix %s", prefix, exc_info=True) + return entries + + def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + prefix = self._to_s3_prefix(path) + for fname in TEMPLATE_FILENAMES: + key = prefix + fname + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + raw = resp["Body"].read().decode("utf-8") + template = _parse_template(raw, fname) + if template: + return _extract_bundle_info(template, path) + except self._s3.exceptions.NoSuchKey: + continue + except Exception: + logger.debug("Failed to get S3 object %s", key, exc_info=True) + continue + return None + + def download_bundle(self, path: str, dest_dir: str) -> str: + """Download all objects under the bundle prefix to a local directory. + Returns the local path to the downloaded bundle.""" + prefix = self._to_s3_prefix(path) + bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] + local_bundle = os.path.join(dest_dir, bundle_name) + os.makedirs(local_bundle, exist_ok=True) + + paginator = self._s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix): + for obj in page.get("Contents", []): + key = obj["Key"] + rel = key[len(prefix) :] + if not rel: + continue + local_path = os.path.join(local_bundle, rel) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + self._s3.download_file(self._bucket, key, local_path) + + return local_bundle + + def _is_bundle(self, prefix: str) -> bool: + """Check if a prefix contains a template file.""" + for fname in TEMPLATE_FILENAMES: + try: + self._s3.head_object(Bucket=self._bucket, Key=prefix + fname) + return True + except Exception: + continue + return False + + def _to_s3_prefix(self, path: str) -> str: + """Convert an s3:// URI or prefix back to a raw S3 prefix.""" + if path.startswith("s3://"): + # s3://bucket/prefix/ -> prefix/ + _, _, prefix = path.partition(f"s3://{self._bucket}/") + return prefix if prefix.endswith("/") else prefix + "/" + return path if path.endswith("/") else path + "/" diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py new file mode 100644 index 000000000..b70e1bab2 --- /dev/null +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -0,0 +1,321 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Custom job bundle browser dialog that replaces the native folder picker. +Shows a navigable tree of directories/bundles with a preview panel. +""" + +from __future__ import annotations + +import os +from logging import getLogger +from typing import Optional, Union + +from qtpy.QtCore import Qt, QModelIndex, Signal # type: ignore +from qtpy.QtGui import QStandardItemModel, QStandardItem, QIcon # type: ignore +from qtpy.QtWidgets import ( # type: ignore + QDialog, + QDialogButtonBox, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QSplitter, + QTreeView, + QVBoxLayout, + QWidget, +) + +from .._utils import tr +from ...job_bundle.repository import ( + BrowseEntry, + BundleInfo, + BundleRepository, + LocalBundleRepository, + S3BundleRepository, +) + +logger = getLogger(__name__) + +# Custom data roles +ROLE_PATH = Qt.UserRole + 1 +ROLE_IS_BUNDLE = Qt.UserRole + 2 +ROLE_LOADED = Qt.UserRole + 3 + + +class JobBundleBrowserDialog(QDialog): + """ + A dialog for browsing and selecting job bundles from local filesystem or S3. + + Args: + local_root: Default local directory to browse. + s3_bucket_name: The queue's job attachment S3 bucket name (optional). + s3_root_prefix: The queue's job attachment S3 root prefix (optional). + parent: Parent widget. + """ + + bundle_selected = Signal(str) # Emits the selected bundle path + + def __init__( + self, + local_root: str = "", + s3_bucket_name: str = "", + s3_root_prefix: str = "", + parent: Optional[QWidget] = None, + ): + super().__init__(parent=parent) + self.setWindowTitle(tr("Browse Job Bundles")) + self.setMinimumSize(700, 500) + self.resize(800, 550) + + self._local_repo = LocalBundleRepository(root=local_root) + self._s3_repo: Optional[S3BundleRepository] = None + self._s3_available = bool(s3_bucket_name) + if s3_bucket_name: + self._s3_repo = S3BundleRepository( + bucket_name=s3_bucket_name, root_prefix=s3_root_prefix + ) + + self._current_repo: BundleRepository = self._local_repo + self._selected_path: Optional[str] = None + self._selected_is_s3 = False + + self._build_ui() + self._populate_root() + + @property + def selected_path(self) -> Optional[str]: + return self._selected_path + + @property + def selected_is_s3(self) -> bool: + return self._selected_is_s3 + + @property + def s3_repo(self) -> Optional[S3BundleRepository]: + return self._s3_repo + + # ── UI Construction ────────────────────────────────────────── + + def _build_ui(self): + layout = QVBoxLayout(self) + + # Main splitter: tree on left, preview on right + splitter = QSplitter(Qt.Horizontal) + layout.addWidget(splitter, stretch=1) + + # Left: tree view + self._model = QStandardItemModel() + self._model.setHorizontalHeaderLabels([tr("Name")]) + self._tree = QTreeView() + self._tree.setModel(self._model) + self._tree.setHeaderHidden(True) + self._tree.setEditTriggers(QTreeView.NoEditTriggers) + self._tree.expanded.connect(self._on_expanded) + self._tree.clicked.connect(self._on_clicked) + self._tree.selectionModel().currentChanged.connect(self._on_selection_changed) + splitter.addWidget(self._tree) + + # Right: preview panel + preview_widget = QWidget() + preview_layout = QVBoxLayout(preview_widget) + preview_layout.setAlignment(Qt.AlignTop) + + self._preview_name = QLabel() + self._preview_name.setWordWrap(True) + self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px;") + preview_layout.addWidget(self._preview_name) + + self._preview_desc = QLabel() + self._preview_desc.setWordWrap(True) + preview_layout.addWidget(self._preview_desc) + + self._preview_steps_label = QLabel(tr("Steps:")) + self._preview_steps_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + preview_layout.addWidget(self._preview_steps_label) + self._preview_steps = QLabel() + self._preview_steps.setWordWrap(True) + preview_layout.addWidget(self._preview_steps) + + self._preview_params_label = QLabel(tr("Parameters:")) + self._preview_params_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + preview_layout.addWidget(self._preview_params_label) + self._preview_params = QLabel() + self._preview_params.setWordWrap(True) + preview_layout.addWidget(self._preview_params) + + self._clear_preview() + splitter.addWidget(preview_widget) + splitter.setSizes([350, 350]) + + # Bottom: source toggle + path + buttons + bottom_layout = QVBoxLayout() + + # Source toggle row + source_row = QHBoxLayout() + source_label = QLabel(tr("Source:")) + source_row.addWidget(source_label) + self._radio_local = QRadioButton(tr("Local")) + self._radio_local.setChecked(True) + self._radio_local.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_local) + self._radio_s3 = QRadioButton( + tr("S3 ({bucket})").format( + bucket=self._s3_repo._bucket if self._s3_repo else tr("not configured") + ) + ) + self._radio_s3.setEnabled(self._s3_available) + source_row.addWidget(self._radio_s3) + source_row.addStretch() + bottom_layout.addLayout(source_row) + + # Path row + path_row = QHBoxLayout() + path_label = QLabel(tr("Path:")) + path_row.addWidget(path_label) + self._path_display = QLineEdit() + self._path_display.setReadOnly(True) + path_row.addWidget(self._path_display) + bottom_layout.addLayout(path_row) + + layout.addLayout(bottom_layout) + + # Dialog buttons + self._button_box = QDialogButtonBox(QDialogButtonBox.Cancel) + self._select_button = QPushButton(tr("Select")) + self._select_button.setEnabled(False) + self._button_box.addButton(self._select_button, QDialogButtonBox.AcceptRole) + self._select_button.clicked.connect(self.accept) + self._button_box.rejected.connect(self.reject) + layout.addWidget(self._button_box) + + # ── Tree Population ────────────────────────────────────────── + + def _populate_root(self): + self._model.clear() + self._model.setHorizontalHeaderLabels([tr("Name")]) + root_path = self._current_repo.root_path() + self._path_display.setText(root_path) + entries = self._current_repo.list_entries(root_path) + root = self._model.invisibleRootItem() + for entry in entries: + self._add_entry_item(root, entry) + + def _add_entry_item(self, parent_item: QStandardItem, entry: BrowseEntry): + item = QStandardItem(self._entry_display(entry)) + item.setData(entry.path, ROLE_PATH) + item.setData(entry.is_bundle, ROLE_IS_BUNDLE) + item.setData(False, ROLE_LOADED) + if not entry.is_bundle: + # Add a placeholder child so the expand arrow shows + placeholder = QStandardItem() + item.appendRow(placeholder) + parent_item.appendRow(item) + + @staticmethod + def _entry_display(entry: BrowseEntry) -> str: + icon = "\U0001F4E6" if entry.is_bundle else "\U0001F4C1" # 📦 or 📁 + return f"{icon} {entry.name}" + + # ── Event Handlers ─────────────────────────────────────────── + + def _on_expanded(self, index: QModelIndex): + item = self._model.itemFromIndex(index) + if not item or item.data(ROLE_IS_BUNDLE) or item.data(ROLE_LOADED): + return + # Mark as loaded and replace placeholder with real children + item.setData(True, ROLE_LOADED) + item.removeRows(0, item.rowCount()) + path = item.data(ROLE_PATH) + entries = self._current_repo.list_entries(path) + for entry in entries: + self._add_entry_item(item, entry) + + def _on_clicked(self, index: QModelIndex): + self._update_selection(index) + + def _on_selection_changed(self, current: QModelIndex, previous: QModelIndex): + self._update_selection(current) + + def _update_selection(self, index: QModelIndex): + item = self._model.itemFromIndex(index) + if not item: + self._clear_preview() + self._select_button.setEnabled(False) + self._selected_path = None + return + + path = item.data(ROLE_PATH) + is_bundle = item.data(ROLE_IS_BUNDLE) + self._path_display.setText(path) + + if is_bundle: + self._selected_path = path + self._selected_is_s3 = not self._radio_local.isChecked() + self._select_button.setEnabled(True) + self._load_preview(path) + else: + self._selected_path = None + self._select_button.setEnabled(False) + self._clear_preview() + + def _on_source_changed(self, checked: bool): + if self._radio_local.isChecked(): + self._current_repo = self._local_repo + elif self._s3_repo: + self._current_repo = self._s3_repo + self._selected_path = None + self._select_button.setEnabled(False) + self._clear_preview() + self._populate_root() + + # ── Preview ────────────────────────────────────────────────── + + def _load_preview(self, path: str): + info = self._current_repo.get_bundle_info(path) + if not info: + self._clear_preview() + return + + self._preview_name.setText(info.name) + self._preview_name.setVisible(True) + + if info.description: + self._preview_desc.setText(info.description) + self._preview_desc.setVisible(True) + else: + self._preview_desc.setVisible(False) + + if info.step_names: + self._preview_steps_label.setVisible(True) + self._preview_steps.setText( + "\n".join(f" \u2022 {name}" for name in info.step_names) + ) + self._preview_steps.setVisible(True) + else: + self._preview_steps_label.setVisible(False) + self._preview_steps.setVisible(False) + + if info.parameters: + self._preview_params_label.setVisible(True) + lines = [] + for p in info.parameters: + pname = p.get("name", "?") + ptype = p.get("type", "?") + lines.append(f" \u2022 {pname} ({ptype})") + self._preview_params.setText("\n".join(lines)) + self._preview_params.setVisible(True) + else: + self._preview_params_label.setVisible(False) + self._preview_params.setVisible(False) + + def _clear_preview(self): + self._preview_name.setText(tr("Select a job bundle to see details")) + self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: gray;") + self._preview_desc.setVisible(False) + self._preview_steps_label.setVisible(False) + self._preview_steps.setVisible(False) + self._preview_params_label.setVisible(False) + self._preview_params.setVisible(False) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index ed1a66f04..da9981b37 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -215,12 +215,50 @@ def show_job_bundle_submitter( parent = main_windows[0] if not input_job_bundle_dir: - input_job_bundle_dir = QFileDialog.getExistingDirectory( - parent, tr("Choose job bundle directory"), input_job_bundle_dir + from .dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog + from ..config import config_file, get_setting + + # Determine the default local browse directory + default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") + if not default_dir: + default_dir = get_setting("settings.job_bundle_default_directory") + + # Try to get the queue's S3 bucket for S3 browsing + s3_bucket = "" + s3_prefix = "" + try: + farm_id = get_setting("defaults.farm_id") + queue_id = get_setting("defaults.queue_id") + if farm_id and queue_id: + from ...job_attachments._aws.deadline import get_queue + + queue = get_queue(farm_id=farm_id, queue_id=queue_id) + if queue.jobAttachmentSettings: + s3_bucket = queue.jobAttachmentSettings.s3BucketName + s3_prefix = queue.jobAttachmentSettings.rootPrefix + except Exception: + logger.debug("Could not retrieve queue S3 settings for bundle browser", exc_info=True) + + browser = JobBundleBrowserDialog( + local_root=default_dir, + s3_bucket_name=s3_bucket, + s3_root_prefix=s3_prefix, + parent=parent, ) - if not input_job_bundle_dir: + if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return None + if browser.selected_is_s3 and browser.s3_repo: + # Download the S3 bundle to a temp directory + import tempfile + + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + input_job_bundle_dir = browser.s3_repo.download_bundle( + browser.selected_path, temp_dir + ) + else: + input_job_bundle_dir = browser.selected_path + def on_create_job_bundle_callback( widget: SubmitJobToDeadlineDialog, job_bundle_dir: str, diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 9c5edf759..b845c0771 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -80,14 +80,50 @@ def on_load_bundle(self): """ Browse and load the selected submission bundle """ - # Open the file picker dialog - bundle_path = os.path.expanduser(config_file.get_setting("settings.job_history_dir")) - input_job_bundle_dir = QFileDialog.getExistingDirectory( - self, "Choose job bundle directory", bundle_path + from ..dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog + from ...config import get_setting + import os + + # Determine the default local browse directory + default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") + if not default_dir: + default_dir = get_setting("settings.job_bundle_default_directory") + + # Try to get the queue's S3 bucket for S3 browsing + s3_bucket = "" + s3_prefix = "" + try: + farm_id = get_setting("defaults.farm_id") + queue_id = get_setting("defaults.queue_id") + if farm_id and queue_id: + from ....job_attachments._aws.deadline import get_queue + + queue = get_queue(farm_id=farm_id, queue_id=queue_id) + if queue.jobAttachmentSettings: + s3_bucket = queue.jobAttachmentSettings.s3BucketName + s3_prefix = queue.jobAttachmentSettings.rootPrefix + except Exception: + pass + + browser = JobBundleBrowserDialog( + local_root=default_dir, + s3_bucket_name=s3_bucket, + s3_root_prefix=s3_prefix, + parent=self, ) - if not input_job_bundle_dir: + if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return + if browser.selected_is_s3 and browser.s3_repo: + import tempfile + + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + input_job_bundle_dir = browser.s3_repo.download_bundle( + browser.selected_path, temp_dir + ) + else: + input_job_bundle_dir = browser.selected_path + # Update job bundle directory path self.input_job_bundle_dir = input_job_bundle_dir diff --git a/test/unit/deadline_client/cli/test_cli_config.py b/test/unit/deadline_client/cli/test_cli_config.py index d454367da..2fe01d3a4 100644 --- a/test/unit/deadline_client/cli/test_cli_config.py +++ b/test/unit/deadline_client/cli/test_cli_config.py @@ -36,7 +36,7 @@ def test_cli_config_show_defaults(fresh_deadline_config): assert fresh_deadline_config in result.output # Assert the expected number of settings - assert len(settings.keys()) == 25 + assert len(settings.keys()) == 26 for setting_name in settings.keys(): assert setting_name in result.output @@ -113,6 +113,7 @@ def test_cli_config_show_modified_config(fresh_deadline_config): config.set_setting("settings.max_failed_tasks_count", "50") config.set_setting("settings.deadline_regions", "us-west-2,us-east-1") config.set_setting("defaults.farm_region", "us-east-1") + config.set_setting("settings.job_bundle_default_directory", "/my/bundles") runner = CliRunner() result = runner.invoke(main, ["config", "show"]) @@ -143,6 +144,7 @@ def test_cli_config_show_modified_config(fresh_deadline_config): assert "\\known\\asset\\path" in result.output else: assert "/known/asset/path" in result.output + assert "/my/bundles" in result.output # It shouldn't say anywhere that there is a default setting assert "(default)" not in result.output diff --git a/test/unit/deadline_client/config/test_config_file.py b/test/unit/deadline_client/config/test_config_file.py index 4945ed638..a7f59dade 100644 --- a/test/unit/deadline_client/config/test_config_file.py +++ b/test/unit/deadline_client/config/test_config_file.py @@ -29,6 +29,7 @@ ("settings.locale", "", "ja_JP"), ("settings.force_s3_check", "false", "true"), ("settings.deadline_regions", "", "us-east-1,eu-west-1"), + ("settings.job_bundle_default_directory", "", "/my/bundles"), ] diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py new file mode 100644 index 000000000..d52980d13 --- /dev/null +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -0,0 +1,172 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Tests for the job bundle repository module.""" + +import json +import os +import pytest +import yaml + +from deadline.client.job_bundle.repository import ( + BrowseEntry, + BundleInfo, + LocalBundleRepository, + S3BundleRepository, + _extract_bundle_info, + _parse_template, +) + + +class TestParseTemplate: + def test_parse_yaml(self): + raw = "name: Test\nsteps:\n- name: Step1\n" + result = _parse_template(raw, "template.yaml") + assert result == {"name": "Test", "steps": [{"name": "Step1"}]} + + def test_parse_json(self): + raw = json.dumps({"name": "Test", "steps": [{"name": "Step1"}]}) + result = _parse_template(raw, "template.json") + assert result == {"name": "Test", "steps": [{"name": "Step1"}]} + + def test_parse_invalid_yaml(self): + result = _parse_template("{{invalid", "template.yaml") + assert result is None + + def test_parse_invalid_json(self): + result = _parse_template("{invalid", "template.json") + assert result is None + + +class TestExtractBundleInfo: + def test_full_template(self): + template = { + "name": "My Job", + "description": "A test job", + "steps": [{"name": "Step1"}, {"name": "Step2"}], + "parameterDefinitions": [ + {"name": "Param1", "type": "STRING"}, + {"name": "Param2", "type": "PATH"}, + ], + } + info = _extract_bundle_info(template, "/path/to/bundle") + assert info.name == "My Job" + assert info.description == "A test job" + assert info.step_names == ["Step1", "Step2"] + assert len(info.parameters) == 2 + + def test_minimal_template(self): + template = {"steps": [{"name": "OnlyStep"}]} + info = _extract_bundle_info(template, "/path/to/bundle") + assert info.name == "bundle" # Falls back to basename + assert info.description == "" + assert info.step_names == ["OnlyStep"] + assert info.parameters == [] + + +class TestLocalBundleRepository: + def test_root_path_default(self): + repo = LocalBundleRepository() + assert repo.root_path() == os.path.expanduser("~") + + def test_root_path_custom(self, tmp_path): + repo = LocalBundleRepository(root=str(tmp_path)) + assert repo.root_path() == str(tmp_path) + + def test_list_entries_empty(self, tmp_path): + repo = LocalBundleRepository(root=str(tmp_path)) + entries = repo.list_entries(str(tmp_path)) + assert entries == [] + + def test_list_entries_with_bundles_and_dirs(self, tmp_path): + # Create a bundle directory + bundle_dir = tmp_path / "my-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.yaml").write_text( + "specificationVersion: 'jobtemplate-2023-09'\nname: Test Bundle\nsteps:\n- name: Step1\n" + ) + + # Create a regular directory + regular_dir = tmp_path / "regular-dir" + regular_dir.mkdir() + + # Create a file (should be ignored) + (tmp_path / "some-file.txt").write_text("not a dir") + + repo = LocalBundleRepository(root=str(tmp_path)) + entries = repo.list_entries(str(tmp_path)) + + assert len(entries) == 2 + names = {e.name for e in entries} + assert "my-bundle" in names + assert "regular-dir" in names + + bundle_entry = next(e for e in entries if e.name == "my-bundle") + assert bundle_entry.is_bundle is True + + dir_entry = next(e for e in entries if e.name == "regular-dir") + assert dir_entry.is_bundle is False + + def test_list_entries_json_template(self, tmp_path): + bundle_dir = tmp_path / "json-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.json").write_text( + json.dumps({"name": "JSON Bundle", "steps": [{"name": "S1"}]}) + ) + + repo = LocalBundleRepository(root=str(tmp_path)) + entries = repo.list_entries(str(tmp_path)) + assert len(entries) == 1 + assert entries[0].is_bundle is True + + def test_list_entries_nonexistent_path(self): + repo = LocalBundleRepository() + entries = repo.list_entries("/nonexistent/path/that/does/not/exist") + assert entries == [] + + def test_get_bundle_info_yaml(self, tmp_path): + bundle_dir = tmp_path / "test-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test Bundle", + "description": "A test", + "steps": [{"name": "Render"}], + "parameterDefinitions": [{"name": "Frames", "type": "STRING"}], + } + ) + ) + + repo = LocalBundleRepository(root=str(tmp_path)) + info = repo.get_bundle_info(str(bundle_dir)) + + assert info is not None + assert info.name == "Test Bundle" + assert info.description == "A test" + assert info.step_names == ["Render"] + assert len(info.parameters) == 1 + assert info.parameters[0]["name"] == "Frames" + + def test_get_bundle_info_not_a_bundle(self, tmp_path): + regular_dir = tmp_path / "not-a-bundle" + regular_dir.mkdir() + + repo = LocalBundleRepository(root=str(tmp_path)) + info = repo.get_bundle_info(str(regular_dir)) + assert info is None + + def test_nested_bundles(self, tmp_path): + """Test that bundles nested inside directories are found when listing the parent.""" + parent = tmp_path / "projects" + parent.mkdir() + + nested_bundle = parent / "my-job" + nested_bundle.mkdir() + (nested_bundle / "template.yaml").write_text("name: Nested\nsteps:\n- name: S1\n") + + repo = LocalBundleRepository(root=str(tmp_path)) + entries = repo.list_entries(str(parent)) + assert len(entries) == 1 + assert entries[0].is_bundle is True + assert entries[0].name == "my-job" From dc9cb6b25c1fa2e2e96e6aa8157e07c53fa7cefa Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:52:59 -0500 Subject: [PATCH 02/89] feat: support bundle archives and add bundle upload/download commands Added archive support for job bundles (.zip, .tar.gz, and other tar variants) across both local and S3 browsing. Archives are inspected without full extraction for preview, and only extracted on selection. S3 archives are cached locally at ~/.deadline/cache/job-bundles/ with ETag validation so repeated access is a single head_object call with no re-download. Added deadline bundle upload and deadline bundle download CLI commands for pushing bundles to and pulling bundles from the queue's S3 job-bundles/ folder. Upload archives by default (zip), download reuses the same S3 cache as the browser. Updated the design doc to cover archive formats, caching strategy, bundle resolution flow, and CLI command usage. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 158 +++++-- .../client/cli/_groups/bundle_group.py | 164 ++++++++ src/deadline/client/job_bundle/repository.py | 394 +++++++++++++++++- .../ui/dialogs/job_bundle_browser_dialog.py | 9 + .../client/ui/job_bundle_submitter.py | 24 +- .../ui/widgets/job_bundle_settings_tab.py | 20 +- .../job_bundle/test_repository.py | 198 ++++++++- 7 files changed, 893 insertions(+), 74 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 9387c2383..341821c50 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -13,14 +13,25 @@ When using `deadline bundle gui-submit --browse` or the "Load a different job bu Replace the native folder picker with a custom job bundle browser dialog that: -1. Provides a navigable directory tree showing only folders and job bundles. +1. Provides a navigable directory tree showing only folders, archives, and job bundles. 2. Displays a preview panel with bundle metadata when a job bundle is selected. 3. Supports both local filesystem and S3 bucket browsing through a common backend abstraction. -4. For S3, uses the selected queue's job attachment bucket with a `job-bundles/` prefix — no extra configuration needed. -5. Respects a configurable default local browse directory. +4. Supports job bundles as directories or archives (`.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz`, `.tar`). +5. For S3, uses the selected queue's job attachment bucket with a `job-bundles/` prefix — no extra configuration needed. +6. Caches S3 archive bundles locally with ETag validation for fast repeated access. +7. Respects a configurable default local browse directory. ## Design +### Bundle Formats + +Job bundles can be either: + +- **Directories** — a folder containing `template.yaml` or `template.json` at the root, plus any scripts, data files, and `asset_references.yaml`. +- **Archives** — a `.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz`, or `.tar` file containing a job bundle. The template can be at the archive root or inside a single wrapper directory. + +Both formats are supported for both local and S3 browsing. Archives are extracted to a local directory before submission. + ### Backend Abstraction To support both local and S3 browsing without coupling the UI to either, introduce a `BundleRepository` protocol: @@ -29,7 +40,7 @@ To support both local and S3 browsing without coupling the UI to either, introdu @dataclass class BundleInfo: """Metadata extracted from a job bundle's template.""" - path: str # Local path or s3:// URI + path: str # Local path, archive path, or s3:// URI name: str # From template "name" field description: str # From template "description" field, or "" step_names: list[str] # Names of each step in the template @@ -38,13 +49,14 @@ class BundleInfo: @dataclass class BrowseEntry: """A single item in the browser listing.""" - name: str # Display name (folder basename or bundle name) + name: str # Display name (folder basename or archive name without extension) path: str # Full path or S3 URI is_bundle: bool # True if this is a valid job bundle + is_archive: bool # True if this is an archive file class BundleRepository(Protocol): def list_entries(self, path: str) -> list[BrowseEntry]: - """List immediate children of `path`. Returns folders and bundles.""" + """List immediate children of `path`. Returns folders, archives, and bundles.""" ... def get_bundle_info(self, path: str) -> Optional[BundleInfo]: @@ -58,8 +70,8 @@ class BundleRepository(Protocol): Two implementations: -- `LocalBundleRepository` — walks the local filesystem. A directory is a bundle if it contains `template.yaml` or `template.json`. Uses the existing `read_yaml_or_json_object` loader. -- `S3BundleRepository` — lists objects under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Constructed from the queue's `JobAttachmentS3Settings`. +- `LocalBundleRepository` — walks the local filesystem. Lists directories and archive files. Directories are bundles if they contain `template.yaml`/`template.json`. Archives are always shown as bundles (validated on preview). Provides `extract_bundle()` for extracting archives to a local directory. +- `S3BundleRepository` — lists objects and prefixes under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Folder prefixes and archive objects are both listed. Provides `resolve_bundle()` which handles both folder downloads and archive download+cache+extract. ### S3 Bucket Convention @@ -73,28 +85,54 @@ Where `s3BucketName` and `rootPrefix` come from the selected queue's `jobAttachm - No extra configuration is needed — the bucket is derived from the queue the user already has selected. - Users (or admins) place job bundles in the `job-bundles/` folder within the queue's attachment bucket. -- Each bundle is an S3 "folder" (common prefix) containing a `template.yaml` or `template.json`. +- Bundles can be either folders (common prefixes containing a template) or archive files. Example S3 layout: ``` s3://my-farm-bucket/DeadlineCloud/job-bundles/ - blender-render/ + blender-render.zip + maya-arnold.tar.gz + simple-job/ template.yaml - maya-arnold/ + data-processing/ template.yaml - asset_references.yaml - simple-job/ - template.json + scripts/ + process.py ``` +### S3 Archive Caching + +Archive bundles from S3 are cached locally to avoid re-downloading on repeated use. + +**Cache location**: `~/.deadline/cache/job-bundles/{hash}/{bundle-name}/` + +Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniqueness. + +**Cache validation**: On each access, a single `head_object` call retrieves the archive's ETag. If it matches the cached ETag, the local copy is used directly. If it differs (or no cache exists), the archive is re-downloaded and re-extracted. + +**Cache metadata** (`.bundle_cache_meta.json`): +```json +{ + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "last_modified": "2026-04-30T12:00:00+00:00" +} +``` + +**Why only archives are cached**: An archive is a single S3 object with a single ETag — one `head_object` validates the entire bundle. Folder-based bundles are multiple objects with no single version identifier, so staleness detection would require checking every file. Folder bundles are downloaded to temp directories with atexit cleanup instead. + ### Detection: What Is a Job Bundle? -A directory (local) or prefix (S3) is a job bundle if it contains a `template.yaml` or `template.json`. +- **Directories** (local or S3 prefix): contains `template.yaml` or `template.json`. +- **Archives** (local file or S3 object): filename ends with a supported archive extension. Validated by reading the template from inside the archive on preview. -For `list_entries`, we need to check each child directory/prefix. To keep this fast: +For `list_entries`, detection is kept fast: -- **Local**: For each child directory, check for the existence of `template.yaml` or `template.json` (stat calls only — don't parse yet). Parse only happens in `get_bundle_info` when the user selects a bundle. -- **S3**: Use `list_objects_v2` with the child prefix to check for `template.yaml`/`template.json` keys. Full parsing happens on selection. +- **Local directories**: stat check for template file existence (no parsing). +- **Local archives**: matched by file extension only. +- **S3 prefixes**: `head_object` for template file existence. +- **S3 archives**: matched by key extension only. + +Full template parsing happens only in `get_bundle_info` when the user selects a bundle for preview. ### Browser Dialog UI @@ -103,10 +141,10 @@ For `list_entries`, we need to check each child directory/prefix. To keep this f │ Job Bundle Browser │ ├────────────────────────────────┬────────────────────────────┤ │ 📁 my-bundles/ │ Name: Blender Render │ -│ 📦 blender-render/ │ Description: Renders a │ -│ 📦 maya-arnold/ │ Blender scene file... │ +│ 📦 blender-render │ Description: Renders a │ +│ 📦 maya-arnold │ Blender scene file... │ │ 📁 wip/ │ │ -│ 📦 experimental-job/ │ Steps: │ +│ 📦 experimental-job │ Steps: │ │ 📦 simple-job/ │ • RenderBlender │ │ │ │ │ │ Parameters: │ @@ -123,10 +161,10 @@ For `list_entries`, we need to check each child directory/prefix. To keep this f ``` **Left panel** — Navigable tree view: -- Shows folders (📁) and job bundles (📦) with distinct icons. +- Shows folders (📁) and job bundles (📦) with distinct icons. Both directory bundles and archive bundles use the 📦 icon. - Folders can be expanded/navigated into. - Job bundles are leaf nodes (selectable, not expandable). -- Non-bundle, non-directory files are hidden. +- Non-bundle, non-archive files are hidden. **Right panel** — Preview (shown when a bundle is selected): - **Name**: From the template's `name` field. @@ -164,31 +202,87 @@ Environment variable override: `DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY` The `--browse` flag on `deadline bundle gui-submit` opens this new dialog instead of `QFileDialog.getExistingDirectory()`. No new flags needed. -For S3 bundles, the submission flow downloads the bundle to a temporary local directory before submission. This is handled in `show_job_bundle_submitter` after the dialog returns. +The "Load a different job bundle" button inside the submitter dialog (`JobBundleSettingsWidget.on_load_bundle`) also uses the new browser dialog, giving users the same browsing experience when switching bundles mid-session. + +### Bundle Resolution Flow + +After the user selects a bundle in the browser, it must be resolved to a local directory for the existing submission pipeline: + +| Source | Format | Resolution | Cleanup | +|---|---|---|---| +| Local | Directory | Used directly (no copy) | None needed | +| Local | Archive | Extracted to temp dir | atexit cleanup | +| S3 | Directory (folder) | Downloaded to temp dir | atexit cleanup | +| S3 | Archive | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | + +Once resolved to a local directory, the standard submission flow takes over: `read_job_bundle_parameters()` parses the template and resolves relative PATH defaults against the bundle directory, `apply_job_parameters()` processes asset references, and the job is submitted normally. + +Bundled assets (scripts, data files) with relative paths resolve correctly against the extracted/downloaded directory because the existing path resolution logic operates on the `bundle_dir` path regardless of its origin. ### Changes to Existing Code | File | Change | |---|---| | `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | +| `cli/_groups/bundle_group.py` | Add `deadline bundle upload` and `deadline bundle download` commands | | `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog. | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | -| `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True` | +| `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | -| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository` | +| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository`, archive helpers, cache management | + +### CLI Commands + +#### `deadline bundle upload ` + +Uploads a local job bundle to the queue's S3 `job-bundles/` folder. + +- **Default behavior**: Archives the bundle as a zip and uploads a single object (e.g. `blender-render.zip`). +- `--format tar.gz`: Use tar.gz instead of zip. +- `--no-archive`: Upload as loose files (folder-based bundle) instead of an archive. +- `--name`: Override the bundle name in S3 (defaults to the directory name). +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. + +``` +$ deadline bundle upload ./my-render-job +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.zip + +$ deadline bundle upload ./my-render-job --format tar.gz --name custom-name +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.tar.gz + +$ deadline bundle upload ./my-render-job --no-archive +Uploaded 5 files to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job/ +``` + +#### `deadline bundle download ` + +Downloads a job bundle from the queue's S3 `job-bundles/` folder. + +- Looks for both archive and folder formats by name. +- Archive bundles use the ETag cache (same as the browser dialog) — repeated downloads are instant if the archive hasn't changed. +- `-o, --output-dir`: Local directory to extract/download to (defaults to `.`). +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. + +``` +$ deadline bundle download blender-render +Downloaded bundle to: ./blender-render + +$ deadline bundle download blender-render -o /tmp/bundles +Downloaded bundle to: /tmp/bundles/blender-render +``` ### S3 Considerations -- **Authentication**: S3 browsing uses the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. -- **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket. If access is denied, show an error in the dialog rather than crashing. -- **Performance**: Each directory expansion is one `list_objects_v2` call. Bundle detection adds one `list_objects_v2` per child prefix. Acceptable for typical bundle repositories (tens of bundles, not thousands). -- **Template download**: `get_bundle_info` for S3 downloads only the `template.yaml`/`template.json` file (typically <10KB) to parse metadata. -- **Bundle selection**: When the user selects an S3 bundle, the full bundle directory is downloaded to a temp directory for submission. This happens after the dialog closes, not during browsing. +- **Authentication**: S3 browsing and CLI commands use the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. +- **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. If access is denied, show an error rather than crashing. +- **Performance**: Each directory expansion is one `list_objects_v2` call. Folder bundle detection adds one `head_object` per child prefix. Archive bundles are detected by extension (no API call). Cached archives validate with one `head_object`. +- **Template preview**: For S3 archives, the full archive is downloaded to parse the template (archives are typically small). For S3 folders, only the template file is fetched. Cached archives read the template from the local cache. +- **Bundled assets**: Scripts, data files, and other assets within the bundle are included in the archive or folder download. Relative PATH parameters resolve against the extracted/downloaded copy. ## Out of Scope (Future) -- Caching/indexing of bundle metadata for faster repeated browsing. - Search/filter within the browser. - Favoriting or pinning frequently used bundles. - Browsing bundles from a Deadline Cloud service API (e.g. farm-level bundle registry). - Configurable S3 bucket/prefix (currently always derived from the queue). +- Cache size limits or TTL-based eviction. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index a087ec360..838824d8d 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -525,3 +525,167 @@ def _print_response( click.echo(f"Job ID: {job_id}") else: click.echo("Job submission canceled.") + + +def _get_queue_s3_settings(config): + """Get the queue's job attachment S3 settings from config.""" + from ....job_attachments._aws.deadline import get_queue + + farm_id = config_file.get_setting("defaults.farm_id", config=config) + queue_id = config_file.get_setting("defaults.queue_id", config=config) + if not farm_id or not queue_id: + raise DeadlineOperationError( + "A default farm and queue must be configured. Run 'deadline config set defaults.farm_id ' and 'deadline config set defaults.queue_id '." + ) + queue = get_queue(farm_id=farm_id, queue_id=queue_id) + if not queue.jobAttachmentSettings: + raise DeadlineOperationError( + f"Queue {queue_id} does not have job attachment settings configured." + ) + return queue.jobAttachmentSettings + + +@cli_bundle.command(name="upload") +@click.argument("job_bundle_dir") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@click.option( + "--name", + help="Name for the archive in S3. Defaults to the bundle directory name.", +) +@click.option( + "--format", + "archive_format", + type=click.Choice(["zip", "tar.gz"], case_sensitive=False), + default="zip", + help="Archive format to upload as.", +) +@click.option( + "--no-archive", + is_flag=True, + help="Upload as a folder (loose files) instead of an archive.", +) +@_handle_error +def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): + """ + Upload a job bundle to the queue's S3 job-bundles folder. + + By default, the bundle is archived as a zip before uploading. + Use --no-archive to upload as loose files instead. + """ + import zipfile + import tarfile + import io + + from ...job_bundle.loader import is_job_bundle_dir + from ...job_bundle.repository import S3_JOB_BUNDLES_PREFIX + + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + s3_settings = _get_queue_s3_settings(config) + + job_bundle_dir = os.path.abspath(job_bundle_dir) + if not is_job_bundle_dir(job_bundle_dir): + raise DeadlineOperationError( + f"Directory does not appear to be a job bundle (no template.yaml or template.json): {job_bundle_dir}" + ) + + bundle_name = name or os.path.basename(job_bundle_dir) + prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" + + import boto3 + + s3 = boto3.client("s3") + + if no_archive: + # Upload as loose files + s3_prefix = f"{prefix}/{bundle_name}/" + file_count = 0 + for root, _dirs, files in os.walk(job_bundle_dir): + for fname in files: + local_path = os.path.join(root, fname) + rel_path = os.path.relpath(local_path, job_bundle_dir) + s3_key = f"{s3_prefix}{rel_path}" + s3.upload_file(local_path, s3_settings.s3BucketName, s3_key) + file_count += 1 + click.echo( + f"Uploaded {file_count} files to s3://{s3_settings.s3BucketName}/{s3_prefix}" + ) + else: + # Archive and upload + buf = io.BytesIO() + if archive_format == "zip": + ext = ".zip" + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(job_bundle_dir): + for fname in files: + local_path = os.path.join(root, fname) + arcname = os.path.relpath(local_path, job_bundle_dir) + zf.write(local_path, arcname) + else: + ext = ".tar.gz" + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for root, _dirs, files in os.walk(job_bundle_dir): + for fname in files: + local_path = os.path.join(root, fname) + arcname = os.path.relpath(local_path, job_bundle_dir) + tf.add(local_path, arcname) + + s3_key = f"{prefix}/{bundle_name}{ext}" + buf.seek(0) + s3.upload_fileobj(buf, s3_settings.s3BucketName, s3_key) + click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") + + +@cli_bundle.command(name="download") +@click.argument("bundle_name") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@click.option( + "-o", + "--output-dir", + default=".", + help="Local directory to download the bundle to. Defaults to current directory.", +) +@_handle_error +def bundle_download(bundle_name, output_dir, **args): + """ + Download a job bundle from the queue's S3 job-bundles folder. + + BUNDLE_NAME is the name of the bundle (e.g. 'blender-render'). + The command will look for both archive and folder formats. + """ + from ...job_bundle.repository import ( + S3BundleRepository, + ARCHIVE_EXTENSIONS, + ) + + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + s3_settings = _get_queue_s3_settings(config) + + repo = S3BundleRepository( + bucket_name=s3_settings.s3BucketName, + root_prefix=s3_settings.rootPrefix, + ) + + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + + # List entries to find the bundle by name + entries = repo.list_entries(repo.root_path()) + match = None + for entry in entries: + if entry.name == bundle_name: + match = entry + break + + if not match: + available = [e.name for e in entries if e.is_bundle] + msg = f"Bundle '{bundle_name}' not found in s3://{s3_settings.s3BucketName}/{repo._prefix}" + if available: + msg += f"\nAvailable bundles: {', '.join(available)}" + raise DeadlineOperationError(msg) + + local_path = repo.resolve_bundle(match.path, output_dir) + click.echo(f"Downloaded bundle to: {local_path}") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 67b6d5193..cbe0063f5 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -2,22 +2,131 @@ """ Bundle repository abstraction for browsing job bundles from local filesystem or S3. +Supports both directory-based bundles and archive bundles (.zip, .tar.gz, etc.). """ from __future__ import annotations +import hashlib +import io +import json import os +import tarfile +import tempfile +import zipfile from dataclasses import dataclass, field from logging import getLogger from typing import Optional, Protocol import yaml -import json logger = getLogger(__name__) TEMPLATE_FILENAMES = ("template.yaml", "template.json") S3_JOB_BUNDLES_PREFIX = "job-bundles" +ARCHIVE_EXTENSIONS = (".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar") +CACHE_META_FILENAME = ".bundle_cache_meta.json" + + +def _is_archive(name: str) -> bool: + """Check if a filename looks like a supported archive.""" + return any(name.endswith(ext) for ext in ARCHIVE_EXTENSIONS) + + +def _strip_archive_ext(name: str) -> str: + """Remove the archive extension from a filename.""" + for ext in ARCHIVE_EXTENSIONS: + if name.endswith(ext): + return name[: -len(ext)] + return name + + +def _extract_archive(archive_path: str, dest_dir: str) -> None: + """Extract an archive to dest_dir.""" + if archive_path.endswith(".zip"): + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(dest_dir) + else: + with tarfile.open(archive_path, "r:*") as tf: + tf.extractall(dest_dir, filter="data") + + +def _read_template_from_archive_path(archive_path: str) -> Optional[tuple[str, str]]: + """Read a template file from a local archive. Returns (contents, filename) or None.""" + if archive_path.endswith(".zip"): + return _read_template_from_zip_path(archive_path) + else: + return _read_template_from_tar_path(archive_path) + + +def _read_template_from_zip_path(archive_path: str) -> Optional[tuple[str, str]]: + try: + with zipfile.ZipFile(archive_path, "r") as zf: + return _read_template_from_zip(zf) + except Exception: + logger.debug("Failed to read template from zip %s", archive_path, exc_info=True) + return None + + +def _read_template_from_tar_path(archive_path: str) -> Optional[tuple[str, str]]: + try: + with tarfile.open(archive_path, "r:*") as tf: + return _read_template_from_tar(tf) + except Exception: + logger.debug("Failed to read template from tar %s", archive_path, exc_info=True) + return None + + +def _read_template_from_zip(zf: zipfile.ZipFile) -> Optional[tuple[str, str]]: + """Read a template file from an open ZipFile. Returns (contents, filename) or None.""" + names = zf.namelist() + for fname in TEMPLATE_FILENAMES: + # Check both root-level and single-directory-wrapped + matches = [n for n in names if n == fname or n.endswith("/" + fname)] + # Prefer the shallowest match + matches.sort(key=lambda n: n.count("/")) + if matches: + return zf.read(matches[0]).decode("utf-8"), fname + return None + + +def _read_template_from_tar(tf: tarfile.TarFile) -> Optional[tuple[str, str]]: + """Read a template file from an open TarFile. Returns (contents, filename) or None.""" + members = tf.getnames() + for fname in TEMPLATE_FILENAMES: + matches = [n for n in members if n == fname or n.endswith("/" + fname)] + matches.sort(key=lambda n: n.count("/")) + if matches: + f = tf.extractfile(matches[0]) + if f: + return f.read().decode("utf-8"), fname + return None + + +def _read_template_from_bytes(data: bytes, filename: str) -> Optional[tuple[str, str]]: + """Read a template from archive bytes in memory. Returns (contents, template_filename) or None.""" + if filename.endswith(".zip"): + try: + with zipfile.ZipFile(io.BytesIO(data), "r") as zf: + return _read_template_from_zip(zf) + except Exception: + return None + else: + try: + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: + return _read_template_from_tar(tf) + except Exception: + return None + + +def _extract_archive_from_bytes(data: bytes, filename: str, dest_dir: str) -> None: + """Extract an archive from bytes in memory to dest_dir.""" + if filename.endswith(".zip"): + with zipfile.ZipFile(io.BytesIO(data), "r") as zf: + zf.extractall(dest_dir) + else: + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: + tf.extractall(dest_dir, filter="data") @dataclass @@ -38,6 +147,7 @@ class BrowseEntry: name: str path: str is_bundle: bool + is_archive: bool = False class BundleRepository(Protocol): @@ -78,7 +188,7 @@ def _extract_bundle_info(template: dict, path: str) -> BundleInfo: class LocalBundleRepository: - """Browse job bundles on the local filesystem.""" + """Browse job bundles on the local filesystem. Supports directories and archives.""" def __init__(self, root: str = ""): self._root = root or os.path.expanduser("~") @@ -94,13 +204,38 @@ def list_entries(self, path: str) -> list[BrowseEntry]: return entries for name in children: full = os.path.join(path, name) - if not os.path.isdir(full): - continue - is_bundle = self._is_bundle(full) - entries.append(BrowseEntry(name=name, path=full, is_bundle=is_bundle)) + if os.path.isdir(full): + is_bundle = self._is_dir_bundle(full) + entries.append(BrowseEntry(name=name, path=full, is_bundle=is_bundle)) + elif _is_archive(name) and os.path.isfile(full): + entries.append( + BrowseEntry( + name=_strip_archive_ext(name), + path=full, + is_bundle=True, + is_archive=True, + ) + ) return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + if os.path.isfile(path) and _is_archive(path): + return self._get_archive_bundle_info(path) + return self._get_dir_bundle_info(path) + + def extract_bundle(self, path: str, dest_dir: str) -> str: + """Extract an archive bundle to dest_dir. Returns path to the extracted bundle.""" + bundle_name = _strip_archive_ext(os.path.basename(path)) + extract_dir = os.path.join(dest_dir, bundle_name) + os.makedirs(extract_dir, exist_ok=True) + _extract_archive(path, extract_dir) + # If the archive contains a single top-level directory, use that + contents = os.listdir(extract_dir) + if len(contents) == 1 and os.path.isdir(os.path.join(extract_dir, contents[0])): + return os.path.join(extract_dir, contents[0]) + return extract_dir + + def _get_dir_bundle_info(self, path: str) -> Optional[BundleInfo]: for fname in TEMPLATE_FILENAMES: fpath = os.path.join(path, fname) if os.path.isfile(fpath): @@ -114,22 +249,69 @@ def get_bundle_info(self, path: str) -> Optional[BundleInfo]: return _extract_bundle_info(template, path) return None + def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: + result = _read_template_from_archive_path(path) + if result: + raw, fname = result + template = _parse_template(raw, fname) + if template: + return _extract_bundle_info(template, path) + return None + @staticmethod - def _is_bundle(path: str) -> bool: + def _is_dir_bundle(path: str) -> bool: for fname in TEMPLATE_FILENAMES: if os.path.isfile(os.path.join(path, fname)): return True return False +# ── S3 Cache ───────────────────────────────────────────────── + + +def _get_bundle_cache_dir() -> str: + """Get the root cache directory for S3 bundle archives.""" + from ..config.config_file import get_cache_directory + + return os.path.join(get_cache_directory(), "job-bundles") + + +def _cache_key(bucket: str, s3_key: str) -> str: + """Deterministic cache subdirectory from bucket + key.""" + h = hashlib.sha256(f"{bucket}/{s3_key}".encode()).hexdigest()[:16] + name = _strip_archive_ext(s3_key.rstrip("/").rsplit("/", 1)[-1]) + return os.path.join(h, name) + + +def _read_cache_meta(cache_dir: str) -> Optional[dict]: + meta_path = os.path.join(cache_dir, CACHE_META_FILENAME) + if os.path.isfile(meta_path): + try: + with open(meta_path, encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return None + + +def _write_cache_meta(cache_dir: str, etag: str, last_modified: str) -> None: + meta_path = os.path.join(cache_dir, CACHE_META_FILENAME) + with open(meta_path, "w", encoding="utf-8") as f: + json.dump({"etag": etag, "last_modified": last_modified}, f) + + +# ── S3 Repository ──────────────────────────────────────────── + + class S3BundleRepository: - """Browse job bundles in an S3 bucket under {rootPrefix}/job-bundles/.""" + """Browse job bundles in an S3 bucket under {rootPrefix}/job-bundles/. + Supports both folder-based bundles and archive bundles (.zip, .tar.gz, etc.). + Archive bundles are cached locally with ETag validation.""" def __init__(self, bucket_name: str, root_prefix: str, session=None): import boto3 as _boto3 self._bucket = bucket_name - # Ensure the prefix ends with /job-bundles/ base = root_prefix.rstrip("/") self._prefix = f"{base}/{S3_JOB_BUNDLES_PREFIX}/" self._session = session or _boto3.Session() @@ -146,17 +328,48 @@ def list_entries(self, path: str) -> list[BrowseEntry]: for page in paginator.paginate( Bucket=self._bucket, Prefix=prefix, Delimiter="/" ): + # Folder-based bundles (common prefixes) for cp in page.get("CommonPrefixes", []): child_prefix = cp["Prefix"] name = child_prefix.rstrip("/").rsplit("/", 1)[-1] child_path = f"s3://{self._bucket}/{child_prefix}" - is_bundle = self._is_bundle(child_prefix) + is_bundle = self._is_folder_bundle(child_prefix) entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + # Archive bundles (objects with archive extensions) + for obj in page.get("Contents", []): + key = obj["Key"] + name = key.rsplit("/", 1)[-1] if "/" in key else key + if _is_archive(name): + s3_path = f"s3://{self._bucket}/{key}" + entries.append( + BrowseEntry( + name=_strip_archive_ext(name), + path=s3_path, + is_bundle=True, + is_archive=True, + ) + ) except Exception: logger.warning("Failed to list S3 prefix %s", prefix, exc_info=True) return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: + if self._path_is_archive(path): + return self._get_archive_bundle_info(path) + return self._get_folder_bundle_info(path) + + def resolve_bundle(self, path: str, dest_dir: str) -> str: + """Resolve an S3 bundle to a local directory path. + For archives: downloads, caches with ETag, and extracts. + For folders: downloads all objects to dest_dir. + Returns the local path to the usable bundle directory.""" + if self._path_is_archive(path): + return self._resolve_archive_bundle(path) + return self._download_folder_bundle(path, dest_dir) + + # ── Folder bundles ─────────────────────────────────────── + + def _get_folder_bundle_info(self, path: str) -> Optional[BundleInfo]: prefix = self._to_s3_prefix(path) for fname in TEMPLATE_FILENAMES: key = prefix + fname @@ -173,9 +386,7 @@ def get_bundle_info(self, path: str) -> Optional[BundleInfo]: continue return None - def download_bundle(self, path: str, dest_dir: str) -> str: - """Download all objects under the bundle prefix to a local directory. - Returns the local path to the downloaded bundle.""" + def _download_folder_bundle(self, path: str, dest_dir: str) -> str: prefix = self._to_s3_prefix(path) bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] local_bundle = os.path.join(dest_dir, bundle_name) @@ -194,8 +405,7 @@ def download_bundle(self, path: str, dest_dir: str) -> str: return local_bundle - def _is_bundle(self, prefix: str) -> bool: - """Check if a prefix contains a template file.""" + def _is_folder_bundle(self, prefix: str) -> bool: for fname in TEMPLATE_FILENAMES: try: self._s3.head_object(Bucket=self._bucket, Key=prefix + fname) @@ -204,10 +414,152 @@ def _is_bundle(self, prefix: str) -> bool: continue return False - def _to_s3_prefix(self, path: str) -> str: - """Convert an s3:// URI or prefix back to a raw S3 prefix.""" + # ── Archive bundles ────────────────────────────────────── + + def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: + key = self._to_s3_key(path) + cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) + meta = _read_cache_meta(cache_dir) + + if meta: + try: + head = self._s3.head_object(Bucket=self._bucket, Key=key) + if head.get("ETag") == meta.get("etag"): + # Cache is valid — read template from extracted cache + return self._read_info_from_cache(cache_dir, path) + except Exception: + pass + + # Cache miss or stale — download, cache, and parse + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + data = resp["Body"].read() + etag = resp.get("ETag", "") + last_modified = str(resp.get("LastModified", "")) + except Exception: + logger.debug("Failed to download S3 archive %s", key, exc_info=True) + return None + + filename = key.rsplit("/", 1)[-1] + + # Extract to cache so resolve_bundle can reuse it + if os.path.exists(cache_dir): + import shutil + + shutil.rmtree(cache_dir) + os.makedirs(cache_dir, exist_ok=True) + try: + _extract_archive_from_bytes(data, filename, cache_dir) + _write_cache_meta(cache_dir, etag, last_modified) + except Exception: + logger.debug("Failed to cache S3 archive %s", key, exc_info=True) + + # Parse template from the downloaded bytes + result = _read_template_from_bytes(data, filename) + if result: + raw, fname = result + template = _parse_template(raw, fname) + if template: + return _extract_bundle_info(template, path) + return None + + def _resolve_archive_bundle(self, path: str) -> str: + key = self._to_s3_key(path) + cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) + + # Check if cache is valid + meta = _read_cache_meta(cache_dir) + if meta: + try: + head = self._s3.head_object(Bucket=self._bucket, Key=key) + if head.get("ETag") == meta.get("etag"): + bundle_path = self._find_bundle_in_cache(cache_dir) + if bundle_path: + logger.info("Using cached bundle: %s", bundle_path) + return bundle_path + except Exception: + pass + + # Download, extract, and cache + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + data = resp["Body"].read() + etag = resp.get("ETag", "") + last_modified = str(resp.get("LastModified", "")) + + # Clear old cache and extract + if os.path.exists(cache_dir): + import shutil + + shutil.rmtree(cache_dir) + os.makedirs(cache_dir, exist_ok=True) + + filename = key.rsplit("/", 1)[-1] + _extract_archive_from_bytes(data, filename, cache_dir) + _write_cache_meta(cache_dir, etag, last_modified) + + bundle_path = self._find_bundle_in_cache(cache_dir) + if bundle_path: + return bundle_path + return cache_dir + + def _read_info_from_cache(self, cache_dir: str, original_path: str) -> Optional[BundleInfo]: + """Read bundle info from an already-extracted cache directory.""" + bundle_dir = self._find_bundle_in_cache(cache_dir) + if not bundle_dir: + return None + for fname in TEMPLATE_FILENAMES: + fpath = os.path.join(bundle_dir, fname) + if os.path.isfile(fpath): + try: + with open(fpath, encoding="utf-8") as f: + raw = f.read() + except OSError: + return None + template = _parse_template(raw, fname) + if template: + return _extract_bundle_info(template, original_path) + return None + + @staticmethod + def _find_bundle_in_cache(cache_dir: str) -> Optional[str]: + """Find the actual bundle directory within a cache dir. + Handles both flat extraction and single-directory-wrapped archives.""" + # Check if template is directly in cache_dir + for fname in TEMPLATE_FILENAMES: + if os.path.isfile(os.path.join(cache_dir, fname)): + return cache_dir + # Check one level deep (single wrapper directory) + try: + contents = [ + d + for d in os.listdir(cache_dir) + if os.path.isdir(os.path.join(cache_dir, d)) and d != CACHE_META_FILENAME + ] + except OSError: + return None + for d in contents: + subdir = os.path.join(cache_dir, d) + for fname in TEMPLATE_FILENAMES: + if os.path.isfile(os.path.join(subdir, fname)): + return subdir + return None + + # ── Helpers ────────────────────────────────────────────── + + @staticmethod + def _path_is_archive(path: str) -> bool: + # Strip s3:// URI to get the key, then check extension + name = path.rstrip("/").rsplit("/", 1)[-1] + return _is_archive(name) + + def _to_s3_key(self, path: str) -> str: + """Convert an s3:// URI to a raw S3 key.""" if path.startswith("s3://"): - # s3://bucket/prefix/ -> prefix/ - _, _, prefix = path.partition(f"s3://{self._bucket}/") - return prefix if prefix.endswith("/") else prefix + "/" - return path if path.endswith("/") else path + "/" + _, _, key = path.partition(f"s3://{self._bucket}/") + return key + return path + + def _to_s3_prefix(self, path: str) -> str: + """Convert an s3:// URI or prefix to a raw S3 prefix ending with /.""" + key = self._to_s3_key(path) + return key if key.endswith("/") else key + "/" diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index b70e1bab2..42927f406 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -43,6 +43,7 @@ ROLE_PATH = Qt.UserRole + 1 ROLE_IS_BUNDLE = Qt.UserRole + 2 ROLE_LOADED = Qt.UserRole + 3 +ROLE_IS_ARCHIVE = Qt.UserRole + 4 class JobBundleBrowserDialog(QDialog): @@ -81,6 +82,7 @@ def __init__( self._current_repo: BundleRepository = self._local_repo self._selected_path: Optional[str] = None self._selected_is_s3 = False + self._selected_is_archive = False self._build_ui() self._populate_root() @@ -93,6 +95,10 @@ def selected_path(self) -> Optional[str]: def selected_is_s3(self) -> bool: return self._selected_is_s3 + @property + def selected_is_archive(self) -> bool: + return self._selected_is_archive + @property def s3_repo(self) -> Optional[S3BundleRepository]: return self._s3_repo @@ -208,6 +214,7 @@ def _add_entry_item(self, parent_item: QStandardItem, entry: BrowseEntry): item.setData(entry.path, ROLE_PATH) item.setData(entry.is_bundle, ROLE_IS_BUNDLE) item.setData(False, ROLE_LOADED) + item.setData(entry.is_archive, ROLE_IS_ARCHIVE) if not entry.is_bundle: # Add a placeholder child so the expand arrow shows placeholder = QStandardItem() @@ -254,11 +261,13 @@ def _update_selection(self, index: QModelIndex): if is_bundle: self._selected_path = path self._selected_is_s3 = not self._radio_local.isChecked() + self._selected_is_archive = bool(item.data(ROLE_IS_ARCHIVE)) self._select_button.setEnabled(True) self._load_preview(path) else: self._selected_path = None self._select_button.setEnabled(False) + self._selected_is_archive = False self._clear_preview() def _on_source_changed(self, checked: bool): diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index da9981b37..c6130b0d5 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -249,11 +249,31 @@ def show_job_bundle_submitter( return None if browser.selected_is_s3 and browser.s3_repo: - # Download the S3 bundle to a temp directory + if browser.selected_is_archive: + # Archive bundles are cached locally with ETag validation + input_job_bundle_dir = browser.s3_repo.resolve_bundle( + browser.selected_path, "" + ) + else: + # Folder bundles are downloaded to a temp directory + import tempfile + import atexit + import shutil + + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + atexit.register(shutil.rmtree, temp_dir, True) + input_job_bundle_dir = browser.s3_repo.resolve_bundle( + browser.selected_path, temp_dir + ) + elif browser.selected_is_archive: + # Local archive — extract to temp dir import tempfile + import atexit + import shutil temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - input_job_bundle_dir = browser.s3_repo.download_bundle( + atexit.register(shutil.rmtree, temp_dir, True) + input_job_bundle_dir = browser._local_repo.extract_bundle( browser.selected_path, temp_dir ) else: diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index b845c0771..87cf5eea1 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -115,10 +115,28 @@ def on_load_bundle(self): return if browser.selected_is_s3 and browser.s3_repo: + if browser.selected_is_archive: + input_job_bundle_dir = browser.s3_repo.resolve_bundle( + browser.selected_path, "" + ) + else: + import tempfile + import atexit + import shutil + + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + atexit.register(shutil.rmtree, temp_dir, True) + input_job_bundle_dir = browser.s3_repo.resolve_bundle( + browser.selected_path, temp_dir + ) + elif browser.selected_is_archive: import tempfile + import atexit + import shutil temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - input_job_bundle_dir = browser.s3_repo.download_bundle( + atexit.register(shutil.rmtree, temp_dir, True) + input_job_bundle_dir = browser._local_repo.extract_bundle( browser.selected_path, temp_dir ) else: diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index d52980d13..19ed99798 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -4,6 +4,9 @@ import json import os +import tarfile +import zipfile + import pytest import yaml @@ -11,9 +14,11 @@ BrowseEntry, BundleInfo, LocalBundleRepository, - S3BundleRepository, _extract_bundle_info, + _is_archive, _parse_template, + _read_template_from_archive_path, + _strip_archive_ext, ) @@ -57,12 +62,101 @@ def test_full_template(self): def test_minimal_template(self): template = {"steps": [{"name": "OnlyStep"}]} info = _extract_bundle_info(template, "/path/to/bundle") - assert info.name == "bundle" # Falls back to basename + assert info.name == "bundle" assert info.description == "" assert info.step_names == ["OnlyStep"] assert info.parameters == [] +class TestArchiveHelpers: + def test_is_archive(self): + assert _is_archive("bundle.zip") + assert _is_archive("bundle.tar.gz") + assert _is_archive("bundle.tgz") + assert _is_archive("bundle.tar.bz2") + assert _is_archive("bundle.tar.xz") + assert _is_archive("bundle.tar") + assert not _is_archive("bundle") + assert not _is_archive("template.yaml") + + def test_strip_archive_ext(self): + assert _strip_archive_ext("bundle.zip") == "bundle" + assert _strip_archive_ext("bundle.tar.gz") == "bundle" + assert _strip_archive_ext("bundle.tgz") == "bundle" + assert _strip_archive_ext("my-job.tar.bz2") == "my-job" + assert _strip_archive_ext("noext") == "noext" + + +class TestReadTemplateFromArchive: + def _make_zip(self, tmp_path, contents: dict[str, str]) -> str: + """Create a zip with the given {filename: content} entries.""" + zip_path = str(tmp_path / "bundle.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + for name, data in contents.items(): + zf.writestr(name, data) + return zip_path + + def _make_tar_gz(self, tmp_path, contents: dict[str, str]) -> str: + """Create a tar.gz with the given {filename: content} entries.""" + tar_path = str(tmp_path / "bundle.tar.gz") + with tarfile.open(tar_path, "w:gz") as tf: + for name, data in contents.items(): + import io + + info = tarfile.TarInfo(name=name) + encoded = data.encode("utf-8") + info.size = len(encoded) + tf.addfile(info, io.BytesIO(encoded)) + return tar_path + + def test_zip_root_template(self, tmp_path): + path = self._make_zip(tmp_path, {"template.yaml": "name: ZipBundle\nsteps: []\n"}) + result = _read_template_from_archive_path(path) + assert result is not None + raw, fname = result + assert "ZipBundle" in raw + assert fname == "template.yaml" + + def test_zip_wrapped_template(self, tmp_path): + path = self._make_zip( + tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"} + ) + result = _read_template_from_archive_path(path) + assert result is not None + raw, fname = result + assert "Wrapped" in raw + + def test_zip_json_template(self, tmp_path): + path = self._make_zip( + tmp_path, + {"template.json": json.dumps({"name": "JSONBundle", "steps": []})}, + ) + result = _read_template_from_archive_path(path) + assert result is not None + raw, fname = result + assert fname == "template.json" + + def test_zip_no_template(self, tmp_path): + path = self._make_zip(tmp_path, {"readme.txt": "no template here"}) + result = _read_template_from_archive_path(path) + assert result is None + + def test_tar_gz_root_template(self, tmp_path): + path = self._make_tar_gz(tmp_path, {"template.yaml": "name: TarBundle\nsteps: []\n"}) + result = _read_template_from_archive_path(path) + assert result is not None + raw, fname = result + assert "TarBundle" in raw + + def test_tar_gz_wrapped_template(self, tmp_path): + path = self._make_tar_gz( + tmp_path, {"my-bundle/template.yaml": "name: TarWrapped\nsteps: []\n"} + ) + result = _read_template_from_archive_path(path) + assert result is not None + assert "TarWrapped" in result[0] + + class TestLocalBundleRepository: def test_root_path_default(self): repo = LocalBundleRepository() @@ -78,18 +172,13 @@ def test_list_entries_empty(self, tmp_path): assert entries == [] def test_list_entries_with_bundles_and_dirs(self, tmp_path): - # Create a bundle directory bundle_dir = tmp_path / "my-bundle" bundle_dir.mkdir() - (bundle_dir / "template.yaml").write_text( - "specificationVersion: 'jobtemplate-2023-09'\nname: Test Bundle\nsteps:\n- name: Step1\n" - ) + (bundle_dir / "template.yaml").write_text("name: Test Bundle\nsteps:\n- name: Step1\n") - # Create a regular directory regular_dir = tmp_path / "regular-dir" regular_dir.mkdir() - # Create a file (should be ignored) (tmp_path / "some-file.txt").write_text("not a dir") repo = LocalBundleRepository(root=str(tmp_path)) @@ -102,21 +191,43 @@ def test_list_entries_with_bundles_and_dirs(self, tmp_path): bundle_entry = next(e for e in entries if e.name == "my-bundle") assert bundle_entry.is_bundle is True + assert bundle_entry.is_archive is False dir_entry = next(e for e in entries if e.name == "regular-dir") assert dir_entry.is_bundle is False - def test_list_entries_json_template(self, tmp_path): - bundle_dir = tmp_path / "json-bundle" - bundle_dir.mkdir() - (bundle_dir / "template.json").write_text( - json.dumps({"name": "JSON Bundle", "steps": [{"name": "S1"}]}) - ) + def test_list_entries_with_archives(self, tmp_path): + # Create a zip archive bundle + zip_path = tmp_path / "render-job.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("template.yaml", "name: Render\nsteps:\n- name: S1\n") + + # Create a tar.gz archive bundle + tar_path = tmp_path / "process-job.tar.gz" + with tarfile.open(str(tar_path), "w:gz") as tf: + import io + + data = b"name: Process\nsteps:\n- name: S1\n" + info = tarfile.TarInfo(name="template.yaml") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + # Create a regular directory bundle + dir_bundle = tmp_path / "dir-bundle" + dir_bundle.mkdir() + (dir_bundle / "template.yaml").write_text("name: Dir\nsteps: []\n") repo = LocalBundleRepository(root=str(tmp_path)) entries = repo.list_entries(str(tmp_path)) - assert len(entries) == 1 - assert entries[0].is_bundle is True + + assert len(entries) == 3 + archive_entries = [e for e in entries if e.is_archive] + assert len(archive_entries) == 2 + archive_names = {e.name for e in archive_entries} + assert "render-job" in archive_names + assert "process-job" in archive_names + for e in archive_entries: + assert e.is_bundle is True def test_list_entries_nonexistent_path(self): repo = LocalBundleRepository() @@ -146,7 +257,30 @@ def test_get_bundle_info_yaml(self, tmp_path): assert info.description == "A test" assert info.step_names == ["Render"] assert len(info.parameters) == 1 - assert info.parameters[0]["name"] == "Frames" + + def test_get_bundle_info_archive(self, tmp_path): + zip_path = tmp_path / "my-job.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr( + "template.yaml", + yaml.dump( + { + "name": "Archive Job", + "description": "From a zip", + "steps": [{"name": "Run"}], + "parameterDefinitions": [{"name": "Input", "type": "PATH"}], + } + ), + ) + + repo = LocalBundleRepository(root=str(tmp_path)) + info = repo.get_bundle_info(str(zip_path)) + + assert info is not None + assert info.name == "Archive Job" + assert info.description == "From a zip" + assert info.step_names == ["Run"] + assert len(info.parameters) == 1 def test_get_bundle_info_not_a_bundle(self, tmp_path): regular_dir = tmp_path / "not-a-bundle" @@ -156,8 +290,36 @@ def test_get_bundle_info_not_a_bundle(self, tmp_path): info = repo.get_bundle_info(str(regular_dir)) assert info is None + def test_extract_bundle_flat(self, tmp_path): + """Test extracting a zip where template is at the root.""" + zip_path = tmp_path / "flat.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("template.yaml", "name: Flat\nsteps: []\n") + zf.writestr("scripts/run.sh", "#!/bin/bash\necho hello\n") + + dest = tmp_path / "extracted" + dest.mkdir() + repo = LocalBundleRepository() + result = repo.extract_bundle(str(zip_path), str(dest)) + + assert os.path.isfile(os.path.join(result, "template.yaml")) + assert os.path.isfile(os.path.join(result, "scripts", "run.sh")) + + def test_extract_bundle_wrapped(self, tmp_path): + """Test extracting a zip where contents are in a single subdirectory.""" + zip_path = tmp_path / "wrapped.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("my-bundle/template.yaml", "name: Wrapped\nsteps: []\n") + zf.writestr("my-bundle/scripts/run.sh", "#!/bin/bash\n") + + dest = tmp_path / "extracted" + dest.mkdir() + repo = LocalBundleRepository() + result = repo.extract_bundle(str(zip_path), str(dest)) + + assert os.path.isfile(os.path.join(result, "template.yaml")) + def test_nested_bundles(self, tmp_path): - """Test that bundles nested inside directories are found when listing the parent.""" parent = tmp_path / "projects" parent.mkdir() From d0ffb782b5845f501cc78d960adb290b58eabee9 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:35:59 -0500 Subject: [PATCH 03/89] feat: add metadata tnd preview downloads to delay full bundle download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 object metadata for zero-download preview: deadline bundle upload now parses the template and attaches bundle name, description, steps, and parameters as S3 user metadata on the archive. The browser's get_bundle_info reads this from head_object — preview requires no archive download at all. Batch S3 bundle detection: Replaced per-folder head_object calls with a single recursive list_objects_v2 that checks all child prefixes for template files in-memory. Reduces listing from N+1 API calls to 2. Split folder bundle download: The browser only downloads metadata files (template, parameters, assetreferences, hooks) for S3 folder bundles — not scripts or data. The CLI `deadline bundle download` has a separate `downloadfull_bundle()` path that gets everything, with a 50 MB size limit. Preview scroll bar: Wrapped the preview panel in a QScrollArea so long parameter lists don't get cut off. Preview style fix: The bundle name label now resets from gray (placeholder) to normal style when showing actual bundle info. Design doc: Updated with S3 object metadata, batch detection, folder download split, size limit, and revised performance characteristics. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 33 +++- .../client/cli/_groups/bundle_group.py | 43 ++++- src/deadline/client/job_bundle/repository.py | 158 +++++++++++++++--- .../ui/dialogs/job_bundle_browser_dialog.py | 23 +-- .../client/ui/job_bundle_submitter.py | 7 +- .../ui/widgets/job_bundle_settings_tab.py | 7 +- .../job_bundle/test_repository.py | 7 +- 7 files changed, 218 insertions(+), 60 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 341821c50..3d20caf2b 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -120,6 +120,24 @@ Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniquenes **Why only archives are cached**: An archive is a single S3 object with a single ETag — one `head_object` validates the entire bundle. Folder-based bundles are multiple objects with no single version identifier, so staleness detection would require checking every file. Folder bundles are downloaded to temp directories with atexit cleanup instead. +### S3 Object Metadata for Preview + +When `deadline bundle upload` uploads an archive, it attaches bundle metadata as S3 user metadata on the object: + +- `bundle-name`: The template's `name` field +- `bundle-description`: The template's `description` field (newlines collapsed to spaces) +- `bundle-steps`: Comma-separated list of step names +- `bundle-parameters`: Comma-separated `name:type` pairs + +This metadata is returned by `head_object`, which is already called for ETag validation. This means preview of uploaded archives requires **zero downloads** — a single `head_object` provides both cache validation and all preview information. + +The preview priority chain for S3 archives: +1. **S3 user metadata** from `head_object` → instant, no download +2. **Local cache** if ETag matches → read template from disk +3. **Download archive** → parse template, populate cache (fallback for archives not uploaded via the CLI) + +S3 user metadata has a 2KB total limit, which is sufficient for typical bundle metadata. Values are truncated to stay within limits. + ### Detection: What Is a Job Bundle? - **Directories** (local or S3 prefix): contains `template.yaml` or `template.json`. @@ -129,10 +147,10 @@ For `list_entries`, detection is kept fast: - **Local directories**: stat check for template file existence (no parsing). - **Local archives**: matched by file extension only. -- **S3 prefixes**: `head_object` for template file existence. -- **S3 archives**: matched by key extension only. +- **S3 folders**: detected via batch recursive listing — a single `list_objects_v2` (without delimiter) returns all keys under the parent prefix, and we check in-memory which child prefixes contain a template file. This replaces per-folder `head_object` calls, reducing N+1 API calls to 2 (one delimited list + one recursive list). +- **S3 archives**: matched by key extension only (no API call). -Full template parsing happens only in `get_bundle_info` when the user selects a bundle for preview. +Full template parsing happens only in `get_bundle_info` when the user clicks a bundle for preview. ### Browser Dialog UI @@ -212,9 +230,11 @@ After the user selects a bundle in the browser, it must be resolved to a local d |---|---|---|---| | Local | Directory | Used directly (no copy) | None needed | | Local | Archive | Extracted to temp dir | atexit cleanup | -| S3 | Directory (folder) | Downloaded to temp dir | atexit cleanup | +| S3 | Directory (folder) | Metadata files only (template, parameters, asset_references, hooks) | atexit cleanup | | S3 | Archive | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | +The CLI `deadline bundle download` command uses a separate `download_full_bundle()` path that downloads all files (including scripts and data), with a 50 MB size limit for folder bundles. Bundles larger than this should be uploaded as archives instead. + Once resolved to a local directory, the standard submission flow takes over: `read_job_bundle_parameters()` parses the template and resolves relative PATH defaults against the bundle directory, `apply_job_parameters()` processes asset references, and the job is submitted normally. Bundled assets (scripts, data files) with relative paths resolve correctly against the extracted/downloaded directory because the existing path resolution logic operates on the `bundle_dir` path regardless of its origin. @@ -275,8 +295,9 @@ Downloaded bundle to: /tmp/bundles/blender-render - **Authentication**: S3 browsing and CLI commands use the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. - **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. If access is denied, show an error rather than crashing. -- **Performance**: Each directory expansion is one `list_objects_v2` call. Folder bundle detection adds one `head_object` per child prefix. Archive bundles are detected by extension (no API call). Cached archives validate with one `head_object`. -- **Template preview**: For S3 archives, the full archive is downloaded to parse the template (archives are typically small). For S3 folders, only the template file is fetched. Cached archives read the template from the local cache. +- **Performance**: Listing is 2 API calls (one delimited + one recursive `list_objects_v2`). Archive preview with S3 metadata is 1 `head_object` (no download). Folder preview is 1 `get_object` for the template. Cached archive selection is 1 `head_object`. +- **S3 object metadata**: `deadline bundle upload` attaches bundle name, description, steps, and parameters as S3 user metadata. This enables zero-download preview via `head_object`. Archives uploaded by other means fall back to downloading the archive for preview. +- **Folder bundle size limit**: Folder bundles larger than 50 MB cannot be downloaded via the CLI. Use `deadline bundle upload` to convert them to archives. - **Bundled assets**: Scripts, data files, and other assets within the bundle are included in the archive or folder download. Relative PATH parameters resolve against the extracted/downloaded copy. ## Out of Scope (Future) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 838824d8d..4e323a3c0 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -579,7 +579,11 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): import io from ...job_bundle.loader import is_job_bundle_dir - from ...job_bundle.repository import S3_JOB_BUNDLES_PREFIX + from ...job_bundle.repository import ( + S3_JOB_BUNDLES_PREFIX, + _extract_bundle_info, + _parse_template, + ) config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -590,6 +594,29 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): f"Directory does not appear to be a job bundle (no template.yaml or template.json): {job_bundle_dir}" ) + # Parse the template to extract metadata for S3 object metadata + bundle_metadata = {} + for tname in ("template.yaml", "template.json"): + tpath = os.path.join(job_bundle_dir, tname) + if os.path.isfile(tpath): + with open(tpath, encoding="utf-8") as f: + template = _parse_template(f.read(), tname) + if template: + info = _extract_bundle_info(template, job_bundle_dir) + bundle_metadata["bundle-name"] = info.name[:256] + if info.description: + # S3 metadata values must be valid HTTP header values (no newlines) + desc = " ".join(info.description.split()) + bundle_metadata["bundle-description"] = desc[:512] + if info.step_names: + bundle_metadata["bundle-steps"] = ",".join(info.step_names)[:512] + if info.parameters: + param_strs = [ + f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters + ] + bundle_metadata["bundle-parameters"] = ",".join(param_strs)[:512] + break + bundle_name = name or os.path.basename(job_bundle_dir) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" @@ -608,9 +635,7 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): s3_key = f"{s3_prefix}{rel_path}" s3.upload_file(local_path, s3_settings.s3BucketName, s3_key) file_count += 1 - click.echo( - f"Uploaded {file_count} files to s3://{s3_settings.s3BucketName}/{s3_prefix}" - ) + click.echo(f"Uploaded {file_count} files to s3://{s3_settings.s3BucketName}/{s3_prefix}") else: # Archive and upload buf = io.BytesIO() @@ -633,7 +658,12 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): s3_key = f"{prefix}/{bundle_name}{ext}" buf.seek(0) - s3.upload_fileobj(buf, s3_settings.s3BucketName, s3_key) + s3.upload_fileobj( + buf, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ) click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") @@ -658,7 +688,6 @@ def bundle_download(bundle_name, output_dir, **args): """ from ...job_bundle.repository import ( S3BundleRepository, - ARCHIVE_EXTENSIONS, ) config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) @@ -687,5 +716,5 @@ def bundle_download(bundle_name, output_dir, **args): msg += f"\nAvailable bundles: {', '.join(available)}" raise DeadlineOperationError(msg) - local_path = repo.resolve_bundle(match.path, output_dir) + local_path = repo.download_full_bundle(match.path, output_dir) click.echo(f"Downloaded bundle to: {local_path}") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index cbe0063f5..3fe43caaa 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -12,7 +12,6 @@ import json import os import tarfile -import tempfile import zipfile from dataclasses import dataclass, field from logging import getLogger @@ -303,6 +302,28 @@ def _write_cache_meta(cache_dir: str, etag: str, last_modified: str) -> None: # ── S3 Repository ──────────────────────────────────────────── +def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleInfo]: + """Try to construct BundleInfo from S3 user metadata set during upload. + Returns None if the required 'bundle-name' key is missing.""" + name = metadata.get("bundle-name") + if not name: + return None + params = [] + params_str = metadata.get("bundle-parameters", "") + if params_str: + for p in params_str.split(","): + parts = p.split(":", 1) + if len(parts) == 2: + params.append({"name": parts[0], "type": parts[1]}) + return BundleInfo( + path=path, + name=name, + description=metadata.get("bundle-description", ""), + step_names=[s for s in metadata.get("bundle-steps", "").split(",") if s], + parameters=params, + ) + + class S3BundleRepository: """Browse job bundles in an S3 bucket under {rootPrefix}/job-bundles/. Supports both folder-based bundles and archive bundles (.zip, .tar.gz, etc.). @@ -323,18 +344,16 @@ def root_path(self) -> str: def list_entries(self, path: str) -> list[BrowseEntry]: prefix = self._to_s3_prefix(path) entries: list[BrowseEntry] = [] + child_prefixes: list[tuple[str, str, str]] = [] # (name, child_prefix, child_path) try: paginator = self._s3.get_paginator("list_objects_v2") - for page in paginator.paginate( - Bucket=self._bucket, Prefix=prefix, Delimiter="/" - ): + for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix, Delimiter="/"): # Folder-based bundles (common prefixes) for cp in page.get("CommonPrefixes", []): child_prefix = cp["Prefix"] name = child_prefix.rstrip("/").rsplit("/", 1)[-1] child_path = f"s3://{self._bucket}/{child_prefix}" - is_bundle = self._is_folder_bundle(child_prefix) - entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + child_prefixes.append((name, child_prefix, child_path)) # Archive bundles (objects with archive extensions) for obj in page.get("Contents", []): key = obj["Key"] @@ -351,6 +370,16 @@ def list_entries(self, path: str) -> list[BrowseEntry]: ) except Exception: logger.warning("Failed to list S3 prefix %s", prefix, exc_info=True) + return entries + + # Batch-detect which folders are bundles with a single recursive listing + # instead of per-folder head_object calls + if child_prefixes: + bundle_prefixes = self._batch_detect_bundles(prefix, child_prefixes) + for name, child_prefix, child_path in child_prefixes: + is_bundle = child_prefix in bundle_prefixes + entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: @@ -359,10 +388,19 @@ def get_bundle_info(self, path: str) -> Optional[BundleInfo]: return self._get_folder_bundle_info(path) def resolve_bundle(self, path: str, dest_dir: str) -> str: - """Resolve an S3 bundle to a local directory path. - For archives: downloads, caches with ETag, and extracts. - For folders: downloads all objects to dest_dir. + """Resolve an S3 bundle to a local directory path for the submitter dialog. + For archives: downloads, caches with ETag, and extracts (full bundle). + For folders: downloads only metadata files (template, parameters, etc.). Returns the local path to the usable bundle directory.""" + if self._path_is_archive(path): + return self._resolve_archive_bundle(path) + return self._download_folder_bundle_metadata(path, dest_dir) + + def download_full_bundle(self, path: str, dest_dir: str) -> str: + """Download a complete S3 bundle to a local directory. + For archives: uses the ETag cache. + For folders: downloads all objects (with size check). + Use this for the CLI 'download' command.""" if self._path_is_archive(path): return self._resolve_archive_bundle(path) return self._download_folder_bundle(path, dest_dir) @@ -386,12 +424,33 @@ def _get_folder_bundle_info(self, path: str) -> Optional[BundleInfo]: continue return None + # Maximum total size (in bytes) for downloading an S3 folder bundle. + # Folder bundles larger than this should be uploaded as archives instead. + MAX_FOLDER_BUNDLE_SIZE = 50 * 1024 * 1024 # 50 MB + + # Files downloaded during resolve (enough to populate the submitter dialog). + # The full bundle is only downloaded at submission time. + _METADATA_FILES = ( + "template.yaml", + "template.json", + "parameter_values.yaml", + "parameter_values.json", + "asset_references.yaml", + "asset_references.json", + "hooks.yaml", + "hooks.json", + ) + def _download_folder_bundle(self, path: str, dest_dir: str) -> str: + """Download all objects under the bundle prefix to a local directory.""" prefix = self._to_s3_prefix(path) bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] local_bundle = os.path.join(dest_dir, bundle_name) os.makedirs(local_bundle, exist_ok=True) + # Collect all objects and check total size before downloading + objects_to_download: list[tuple[str, str]] = [] # (key, rel_path) + total_size = 0 paginator = self._s3.get_paginator("list_objects_v2") for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix): for obj in page.get("Contents", []): @@ -399,9 +458,38 @@ def _download_folder_bundle(self, path: str, dest_dir: str) -> str: rel = key[len(prefix) :] if not rel: continue - local_path = os.path.join(local_bundle, rel) - os.makedirs(os.path.dirname(local_path), exist_ok=True) + total_size += obj.get("Size", 0) + objects_to_download.append((key, rel)) + + if total_size > self.MAX_FOLDER_BUNDLE_SIZE: + raise RuntimeError( + f"S3 folder bundle '{bundle_name}' is {total_size / (1024 * 1024):.1f} MB, " + f"which exceeds the {self.MAX_FOLDER_BUNDLE_SIZE / (1024 * 1024):.0f} MB limit. " + f"Upload it as an archive instead using 'deadline bundle upload'." + ) + + for key, rel in objects_to_download: + local_path = os.path.join(local_bundle, rel) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + self._s3.download_file(self._bucket, key, local_path) + + return local_bundle + + def _download_folder_bundle_metadata(self, path: str, dest_dir: str) -> str: + """Download only the metadata files (template, parameters, asset_references, hooks) + needed to populate the submitter dialog. Skips scripts and data files.""" + prefix = self._to_s3_prefix(path) + bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] + local_bundle = os.path.join(dest_dir, bundle_name) + os.makedirs(local_bundle, exist_ok=True) + + for fname in self._METADATA_FILES: + key = prefix + fname + local_path = os.path.join(local_bundle, fname) + try: self._s3.download_file(self._bucket, key, local_path) + except Exception: + continue # File doesn't exist, skip return local_bundle @@ -414,6 +502,28 @@ def _is_folder_bundle(self, prefix: str) -> bool: continue return False + def _batch_detect_bundles( + self, parent_prefix: str, child_prefixes: list[tuple[str, str, str]] + ) -> set[str]: + """Detect which child prefixes are bundles using a single recursive listing. + Returns the set of child_prefix strings that contain a template file.""" + bundle_set: set[str] = set() + child_prefix_set = {cp for _, cp, _ in child_prefixes} + try: + paginator = self._s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self._bucket, Prefix=parent_prefix): + for obj in page.get("Contents", []): + key = obj["Key"] + # Check if this key is a template file directly inside a child prefix + for cp in child_prefix_set: + for fname in TEMPLATE_FILENAMES: + if key == cp + fname: + bundle_set.add(cp) + break + except Exception: + logger.debug("Failed batch bundle detection for %s", parent_prefix, exc_info=True) + return bundle_set + # ── Archive bundles ────────────────────────────────────── def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: @@ -421,14 +531,24 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) meta = _read_cache_meta(cache_dir) - if meta: - try: - head = self._s3.head_object(Bucket=self._bucket, Key=key) - if head.get("ETag") == meta.get("etag"): - # Cache is valid — read template from extracted cache - return self._read_info_from_cache(cache_dir, path) - except Exception: - pass + # Always do a head_object first — it's cheap and gives us both + # ETag (for cache validation) and user metadata (for preview without download) + head = None + try: + head = self._s3.head_object(Bucket=self._bucket, Key=key) + except Exception: + pass + + if head: + # Try S3 user metadata for preview (set by 'deadline bundle upload') + s3_metadata = head.get("Metadata", {}) + info = _bundle_info_from_s3_metadata(s3_metadata, path) + if info: + return info + + # Check local cache validity + if meta and head.get("ETag") == meta.get("etag"): + return self._read_info_from_cache(cache_dir, path) # Cache miss or stale — download, cache, and parse try: diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 42927f406..777a6323f 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -7,21 +7,20 @@ from __future__ import annotations -import os from logging import getLogger -from typing import Optional, Union +from typing import Optional from qtpy.QtCore import Qt, QModelIndex, Signal # type: ignore -from qtpy.QtGui import QStandardItemModel, QStandardItem, QIcon # type: ignore +from qtpy.QtGui import QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore QDialog, QDialogButtonBox, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton, + QScrollArea, QSplitter, QTreeView, QVBoxLayout, @@ -31,7 +30,6 @@ from .._utils import tr from ...job_bundle.repository import ( BrowseEntry, - BundleInfo, BundleRepository, LocalBundleRepository, S3BundleRepository, @@ -124,7 +122,7 @@ def _build_ui(self): self._tree.selectionModel().currentChanged.connect(self._on_selection_changed) splitter.addWidget(self._tree) - # Right: preview panel + # Right: preview panel in a scroll area preview_widget = QWidget() preview_layout = QVBoxLayout(preview_widget) preview_layout.setAlignment(Qt.AlignTop) @@ -153,7 +151,11 @@ def _build_ui(self): preview_layout.addWidget(self._preview_params) self._clear_preview() - splitter.addWidget(preview_widget) + + preview_scroll = QScrollArea() + preview_scroll.setWidget(preview_widget) + preview_scroll.setWidgetResizable(True) + splitter.addWidget(preview_scroll) splitter.setSizes([350, 350]) # Bottom: source toggle + path + buttons @@ -223,7 +225,7 @@ def _add_entry_item(self, parent_item: QStandardItem, entry: BrowseEntry): @staticmethod def _entry_display(entry: BrowseEntry) -> str: - icon = "\U0001F4E6" if entry.is_bundle else "\U0001F4C1" # 📦 or 📁 + icon = "\U0001f4e6" if entry.is_bundle else "\U0001f4c1" # 📦 or 📁 return f"{icon} {entry.name}" # ── Event Handlers ─────────────────────────────────────────── @@ -289,6 +291,7 @@ def _load_preview(self, path: str): return self._preview_name.setText(info.name) + self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px;") self._preview_name.setVisible(True) if info.description: @@ -299,9 +302,7 @@ def _load_preview(self, path: str): if info.step_names: self._preview_steps_label.setVisible(True) - self._preview_steps.setText( - "\n".join(f" \u2022 {name}" for name in info.step_names) - ) + self._preview_steps.setText("\n".join(f" \u2022 {name}" for name in info.step_names)) self._preview_steps.setVisible(True) else: self._preview_steps_label.setVisible(False) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index c6130b0d5..5fc4e058f 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -10,7 +10,6 @@ from ._utils import tr from qtpy.QtWidgets import ( # pylint: disable=import-error; type: ignore QApplication, - QFileDialog, QMainWindow, QMessageBox, QWidget, @@ -216,7 +215,7 @@ def show_job_bundle_submitter( if not input_job_bundle_dir: from .dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog - from ..config import config_file, get_setting + from ..config import get_setting # Determine the default local browse directory default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") @@ -251,9 +250,7 @@ def show_job_bundle_submitter( if browser.selected_is_s3 and browser.s3_repo: if browser.selected_is_archive: # Archive bundles are cached locally with ETag validation - input_job_bundle_dir = browser.s3_repo.resolve_bundle( - browser.selected_path, "" - ) + input_job_bundle_dir = browser.s3_repo.resolve_bundle(browser.selected_path, "") else: # Folder bundles are downloaded to a temp directory import tempfile diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 87cf5eea1..147dc5615 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -14,7 +14,6 @@ from qtpy.QtWidgets import ( # type: ignore QVBoxLayout, QWidget, - QFileDialog, QMessageBox, ) @@ -23,7 +22,6 @@ from ...job_bundle.submission import AssetReferences from ...job_bundle.loader import read_yaml_or_json_object, validate_directory_symlink_containment from ...job_bundle.parameters import read_job_bundle_parameters -from ...config import config_file logger = getLogger(__name__) @@ -82,7 +80,6 @@ def on_load_bundle(self): """ from ..dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog from ...config import get_setting - import os # Determine the default local browse directory default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") @@ -116,9 +113,7 @@ def on_load_bundle(self): if browser.selected_is_s3 and browser.s3_repo: if browser.selected_is_archive: - input_job_bundle_dir = browser.s3_repo.resolve_bundle( - browser.selected_path, "" - ) + input_job_bundle_dir = browser.s3_repo.resolve_bundle(browser.selected_path, "") else: import tempfile import atexit diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 19ed99798..3104bae8c 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -7,12 +7,9 @@ import tarfile import zipfile -import pytest import yaml from deadline.client.job_bundle.repository import ( - BrowseEntry, - BundleInfo, LocalBundleRepository, _extract_bundle_info, _is_archive, @@ -118,9 +115,7 @@ def test_zip_root_template(self, tmp_path): assert fname == "template.yaml" def test_zip_wrapped_template(self, tmp_path): - path = self._make_zip( - tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"} - ) + path = self._make_zip(tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"}) result = _read_template_from_archive_path(path) assert result is not None raw, fname = result From 7dc1d29b11bc1dc528d3e2421fc9c036bc12908b Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:02:46 -0500 Subject: [PATCH 04/89] feat: make searching -> selecting feel good for local browsing Search/filter: Added a text filter bar above the tree. Case-insensitive, uses QSortFilterProxyModel with recursive filtering so parent folders stay visible when children match. Tree auto-expands during filtering. Folder click behavior: Clicking a folder clears the active filter, expands the folder, selects it, and scrolls it to the top. Uses QTimer.singleShot(0) to defer selection until after Qt processes the filter change, then walks the source model by path to find the correct proxy index. Preview scroll: Wrapped the preview panel in a QScrollArea so bundles with many parameters don't get cut off. Fixed the name label style resetting from gray placeholder to normal on preview. Faster local listing: Switched from os.listdir + os.path.isdir/os.path.isfile to os.scandir which gets file type from directory entries without extra stat calls. Design doc: Updated with filter behavior, folder click UX, and scrollable preview panel. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 22 ++--- src/deadline/client/job_bundle/repository.py | 18 ++--- .../ui/dialogs/job_bundle_browser_dialog.py | 80 ++++++++++++++++--- 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 3d20caf2b..1f1078e90 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -158,13 +158,13 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b ┌─────────────────────────────────────────────────────────────┐ │ Job Bundle Browser │ ├────────────────────────────────┬────────────────────────────┤ -│ 📁 my-bundles/ │ Name: Blender Render │ -│ 📦 blender-render │ Description: Renders a │ -│ 📦 maya-arnold │ Blender scene file... │ -│ 📁 wip/ │ │ -│ 📦 experimental-job │ Steps: │ -│ 📦 simple-job/ │ • RenderBlender │ -│ │ │ +│ [Filter bundles... ] │ Name: Blender Render │ +│ 📁 my-bundles/ │ Description: Renders a │ +│ 📦 blender-render │ Blender scene file... │ +│ 📦 maya-arnold │ │ +│ 📁 wip/ │ Steps: │ +│ 📦 experimental-job │ • RenderBlender │ +│ 📦 simple-job/ │ │ │ │ Parameters: │ │ │ • BlenderSceneFile (PATH)│ │ │ • Frames (STRING) │ @@ -178,13 +178,14 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b └─────────────────────────────────────────────────────────────┘ ``` -**Left panel** — Navigable tree view: +**Left panel** — Filter and navigable tree view: +- A text filter at the top that narrows the tree as you type. Case-insensitive, matches against entry names. Uses recursive filtering so parent folders remain visible when a child matches. The tree auto-expands when filtering to show results. - Shows folders (📁) and job bundles (📦) with distinct icons. Both directory bundles and archive bundles use the 📦 icon. -- Folders can be expanded/navigated into. +- Clicking a folder clears any active filter, expands the folder to show its children, and scrolls it to the top of the view. This makes the search-then-navigate flow natural: search for a folder, click it, see its contents. - Job bundles are leaf nodes (selectable, not expandable). - Non-bundle, non-archive files are hidden. -**Right panel** — Preview (shown when a bundle is selected): +**Right panel** — Preview (shown when a bundle is selected, scrollable): - **Name**: From the template's `name` field. - **Description**: From the template's `description` field, if present. - **Steps**: List of step names from the template. @@ -302,7 +303,6 @@ Downloaded bundle to: /tmp/bundles/blender-render ## Out of Scope (Future) -- Search/filter within the browser. - Favoriting or pinning frequently used bundles. - Browsing bundles from a Deadline Cloud service API (e.g. farm-level bundle registry). - Configurable S3 bucket/prefix (currently always derived from the queue). diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 3fe43caaa..c15dca94c 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -198,19 +198,19 @@ def root_path(self) -> str: def list_entries(self, path: str) -> list[BrowseEntry]: entries: list[BrowseEntry] = [] try: - children = sorted(os.listdir(path)) + with os.scandir(path) as it: + children = sorted(it, key=lambda e: e.name) except OSError: return entries - for name in children: - full = os.path.join(path, name) - if os.path.isdir(full): - is_bundle = self._is_dir_bundle(full) - entries.append(BrowseEntry(name=name, path=full, is_bundle=is_bundle)) - elif _is_archive(name) and os.path.isfile(full): + for entry in children: + if entry.is_dir(follow_symlinks=False): + is_bundle = self._is_dir_bundle(entry.path) + entries.append(BrowseEntry(name=entry.name, path=entry.path, is_bundle=is_bundle)) + elif entry.is_file(follow_symlinks=False) and _is_archive(entry.name): entries.append( BrowseEntry( - name=_strip_archive_ext(name), - path=full, + name=_strip_archive_ext(entry.name), + path=entry.path, is_bundle=True, is_archive=True, ) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 777a6323f..bfd8e3b5e 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -10,7 +10,7 @@ from logging import getLogger from typing import Optional -from qtpy.QtCore import Qt, QModelIndex, Signal # type: ignore +from qtpy.QtCore import Qt, QModelIndex, QSortFilterProxyModel, QTimer, Signal # type: ignore from qtpy.QtGui import QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore QDialog, @@ -110,17 +110,35 @@ def _build_ui(self): splitter = QSplitter(Qt.Horizontal) layout.addWidget(splitter, stretch=1) - # Left: tree view + # Left: tree view with filter + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + + self._filter_edit = QLineEdit() + self._filter_edit.setPlaceholderText("Filter bundles...") + self._filter_edit.setClearButtonEnabled(True) + self._filter_edit.textChanged.connect(self._on_filter_changed) + left_layout.addWidget(self._filter_edit) + self._model = QStandardItemModel() self._model.setHorizontalHeaderLabels([tr("Name")]) + + self._proxy = QSortFilterProxyModel() + self._proxy.setSourceModel(self._model) + self._proxy.setRecursiveFilteringEnabled(True) + self._proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) + self._tree = QTreeView() - self._tree.setModel(self._model) + self._tree.setModel(self._proxy) self._tree.setHeaderHidden(True) self._tree.setEditTriggers(QTreeView.NoEditTriggers) self._tree.expanded.connect(self._on_expanded) self._tree.clicked.connect(self._on_clicked) self._tree.selectionModel().currentChanged.connect(self._on_selection_changed) - splitter.addWidget(self._tree) + left_layout.addWidget(self._tree) + + splitter.addWidget(left_widget) # Right: preview panel in a scroll area preview_widget = QWidget() @@ -230,8 +248,13 @@ def _entry_display(entry: BrowseEntry) -> str: # ── Event Handlers ─────────────────────────────────────────── - def _on_expanded(self, index: QModelIndex): - item = self._model.itemFromIndex(index) + def _source_item(self, proxy_index: QModelIndex): + """Map a proxy model index to the source model item.""" + source_index = self._proxy.mapToSource(proxy_index) + return self._model.itemFromIndex(source_index) + + def _on_expanded(self, proxy_index: QModelIndex): + item = self._source_item(proxy_index) if not item or item.data(ROLE_IS_BUNDLE) or item.data(ROLE_LOADED): return # Mark as loaded and replace placeholder with real children @@ -242,14 +265,19 @@ def _on_expanded(self, index: QModelIndex): for entry in entries: self._add_entry_item(item, entry) - def _on_clicked(self, index: QModelIndex): - self._update_selection(index) + def _on_clicked(self, proxy_index: QModelIndex): + self._update_selection(proxy_index) def _on_selection_changed(self, current: QModelIndex, previous: QModelIndex): self._update_selection(current) - def _update_selection(self, index: QModelIndex): - item = self._model.itemFromIndex(index) + def _on_filter_changed(self, text: str): + self._proxy.setFilterFixedString(text) + if text: + self._tree.expandAll() + + def _update_selection(self, proxy_index: QModelIndex): + item = self._source_item(proxy_index) if not item: self._clear_preview() self._select_button.setEnabled(False) @@ -271,6 +299,38 @@ def _update_selection(self, index: QModelIndex): self._select_button.setEnabled(False) self._selected_is_archive = False self._clear_preview() + # Auto-expand folders when clicked — clear filter first so children are visible + if self._filter_edit.text(): + folder_path = item.data(ROLE_PATH) + self._filter_edit.clear() + # Defer select+expand to after Qt processes the filter change + QTimer.singleShot(0, lambda p=folder_path: self._select_and_expand_path(p)) + else: + if not self._tree.isExpanded(proxy_index): + self._tree.expand(proxy_index) + + def _select_and_expand_path(self, path: str): + """Find an item by path in the proxy model, select it, and expand it.""" + proxy_index = self._find_proxy_index_by_path(path) + if proxy_index and proxy_index.isValid(): + self._tree.setCurrentIndex(proxy_index) + self._tree.expand(proxy_index) + self._tree.scrollTo(proxy_index, QTreeView.PositionAtTop) + + def _find_proxy_index_by_path(self, path: str) -> Optional[QModelIndex]: + """Walk the source model to find an item by ROLE_PATH, return its proxy index.""" + + def _search(parent_item): + for row in range(parent_item.rowCount()): + child = parent_item.child(row) + if child and child.data(ROLE_PATH) == path: + return self._proxy.mapFromSource(child.index()) + result = _search(child) + if result: + return result + return None + + return _search(self._model.invisibleRootItem()) def _on_source_changed(self, checked: bool): if self._radio_local.isChecked(): From 007a3d5d4893884ef83bc081423ed3ec183d3b1e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 1 May 2026 09:46:39 -0500 Subject: [PATCH 05/89] feat: add bundle cache commands, bundle list, job history as a location to select bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI commands (bundle_group.py +189 lines): - deadline bundle list — lists bundles in the queue's S3 job-bundles folder. Plain text (one name per line, pipeable) or --output json with name/path/format. - deadline bundle cache clean — removes cached S3 archives locally. Supports targeting a specific bundle, --dry-run, and cleans up empty parent directories. - deadline bundle cache update — checks ETags for all cached bundles against S3 and re-downloads any that are stale. Browser dialog (job_bundle_browser_dialog.py +47/-13): - Added Job History as a third source (S3 → History → Local radio buttons). Browses the job history directory for the current AWS profile. - Defaults to S3 if available, Local otherwise. - Added text filter bar with recursive filtering and auto-expand. - Clicking a folder clears the filter, expands it, selects it, and scrolls to top. - Preview panel wrapped in QScrollArea for long content. - Sorted S3 entries alphabetically. Settings dialog (deadline_config_dialog.py +33 lines): - Added "Job bundle directory" picker to the settings UI. - Fixed known asset paths widget being squished (size policy + minimum height). - Increased dialog default height to accommodate the new field. Repository (repository.py +1): - S3 entries sorted by name before returning. Submitter + settings tab (+4 each): - Pass job_history_dir to the browser dialog. Design doc (+95 lines): - Added Job History source, bundle list, cache clean, cache update commands with examples, jq examples, config dialog mention, updated changes table. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 95 ++++++++- .../client/cli/_groups/bundle_group.py | 189 ++++++++++++++++++ src/deadline/client/job_bundle/repository.py | 1 + .../ui/dialogs/deadline_config_dialog.py | 33 ++- .../ui/dialogs/job_bundle_browser_dialog.py | 47 ++++- .../client/ui/job_bundle_submitter.py | 4 + .../ui/widgets/job_bundle_settings_tab.py | 4 + 7 files changed, 356 insertions(+), 17 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 1f1078e90..8b0da88c0 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -100,6 +100,17 @@ s3://my-farm-bucket/DeadlineCloud/job-bundles/ process.py ``` +### Job History Source + +The Job History source browses the job history directory for the current AWS profile, as configured by `settings.job_history_dir` (default: `~/.deadline/job_history/{aws_profile_name}`). This directory contains bundles from previous submissions, organized by date. + +This is useful for: +- Re-submitting a previous job with modified parameters. +- Using a previously submitted bundle as a starting point for a new submission. +- Reviewing what was submitted in the past. + +The Job History source uses the same `LocalBundleRepository` as the Local source, just rooted at the job history directory instead of the user's home or configured default. + ### S3 Archive Caching Archive bundles from S3 are cached locally to avoid re-downloading on repeated use. @@ -172,7 +183,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b │ │ • Format (STRING) │ │ │ │ ├────────────────────────────────┴────────────────────────────┤ -│ Source: ( ) Local (•) S3 (my-farm-bucket) │ +│ Source: ( ) Local (•) S3 (my-farm-bucket) ( ) History │ │ Path: [/job-bundles/ ] │ │ [Cancel] [Select] │ └─────────────────────────────────────────────────────────────┘ @@ -192,7 +203,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - **Parameters**: Name and type of each parameter definition. **Bottom bar**: -- Radio toggle between Local and S3 source. S3 option shows the bucket name from the queue. S3 option is disabled if the queue has no job attachment settings. +- Radio toggle between Local, S3, and Job History sources. S3 option shows the bucket name from the queue and is disabled if the queue has no job attachment settings. Job History browses the `settings.job_history_dir` for the current AWS profile, showing previously submitted bundles. - Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. @@ -217,6 +228,8 @@ Add a new setting for the default local browse directory: Environment variable override: `DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY` +This setting is also exposed in the Deadline Cloud settings dialog (Settings → General settings) as a "Job bundle directory" picker, alongside the existing "Job history directory" setting. + ### CLI Integration The `--browse` flag on `deadline bundle gui-submit` opens this new dialog instead of `QFileDialog.getExistingDirectory()`. No new flags needed. @@ -245,8 +258,9 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again | File | Change | |---|---| | `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | -| `cli/_groups/bundle_group.py` | Add `deadline bundle upload` and `deadline bundle download` commands | -| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog. | +| `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, and `deadline bundle cache` (clean/update) commands | +| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Local/S3/History sources. | +| `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | | `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | @@ -254,6 +268,79 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again ### CLI Commands +#### `deadline bundle list` + +Lists job bundles available in the queue's S3 `job-bundles/` folder. + +- Default output is one bundle name per line, suitable for piping. +- `--output json`: JSON array with name, format (archive/folder), and S3 path. +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. + +``` +$ deadline bundle list +blender-render +maya-arnold +monte_carlo_simulation +simple_job + +$ deadline bundle list --output json +[{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.zip", "format": "archive"}, ...] + +$ deadline bundle list | head -1 | xargs deadline bundle gui-submit --browse +``` + +The plain-text output enables chaining with other commands — e.g. selecting a bundle interactively with `fzf`: + +``` +$ deadline bundle submit $(deadline bundle download $(deadline bundle list | fzf) -o /tmp/bundles) +``` + +Use `jq` with JSON output to filter by format or extract paths: + +``` +$ deadline bundle list --output json | jq -r '.[] | select(.format == "archive") | .name' +blender-render +maya-arnold + +$ deadline bundle list --output json | jq -r '.[0].path' +s3://my-farm-bucket/DeadlineCloud/job-bundles/blender-render.zip +``` + +#### `deadline bundle cache clean` + +Removes cached S3 bundle archives from the local cache. + +- With no arguments, removes all cached bundles. +- With a bundle name, removes only that bundle's cache. +- `--dry-run`: Show what would be removed without deleting. + +``` +$ deadline bundle cache clean +Removed 12 cached bundles (4.2 MB) + +$ deadline bundle cache clean blender-render +Removed cached bundle: blender-render + +$ deadline bundle cache clean --dry-run +Would remove 12 cached bundles (4.2 MB) +``` + +#### `deadline bundle cache update` + +Re-downloads any stale cached bundles from S3 by checking ETags. + +- With no arguments, checks all cached bundles. +- With a bundle name, checks only that bundle. +- Only re-downloads if the S3 ETag has changed. + +``` +$ deadline bundle cache update +Checked 12 bundles: 2 updated, 10 up-to-date + +$ deadline bundle cache update blender-render +blender-render: up-to-date +``` + #### `deadline bundle upload ` Uploads a local job bundle to the queue's S3 `job-bundles/` folder. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 4e323a3c0..54f9a483c 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -545,6 +545,195 @@ def _get_queue_s3_settings(config): return queue.jobAttachmentSettings +@cli_bundle.command(name="list") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@click.option( + "--output", + type=click.Choice(["text", "json"], case_sensitive=False), + default="text", + help="Output format. TEXT prints one name per line, JSON prints full details.", +) +@_handle_error +def bundle_list(output, **args): + """ + List job bundles available in the queue's S3 job-bundles folder. + + Prints one bundle name per line by default, suitable for piping + to other commands like `deadline bundle download` or `deadline bundle submit`. + """ + from ...job_bundle.repository import S3BundleRepository + + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + s3_settings = _get_queue_s3_settings(config) + + repo = S3BundleRepository( + bucket_name=s3_settings.s3BucketName, + root_prefix=s3_settings.rootPrefix, + ) + + entries = repo.list_entries(repo.root_path()) + bundles = [e for e in entries if e.is_bundle] + + if output == "json": + result = [ + { + "name": e.name, + "path": e.path, + "format": "archive" if e.is_archive else "folder", + } + for e in bundles + ] + click.echo(json.dumps(result, indent=2)) + else: + for e in bundles: + click.echo(e.name) + + +@cli_bundle.group(name="cache") +@_handle_error +def cli_bundle_cache(): + """Manage the local cache of S3 job bundles.""" + + +@cli_bundle_cache.command(name="clean") +@click.argument("bundle_name", required=False) +@click.option("--dry-run", is_flag=True, help="Show what would be removed without deleting.") +@_handle_error +def bundle_cache_clean(bundle_name, dry_run): + """Remove cached S3 bundle archives from the local cache.""" + from ...job_bundle.repository import _get_bundle_cache_dir + + cache_root = _get_bundle_cache_dir() + if not os.path.isdir(cache_root): + click.echo("No bundle cache found.") + return + + removed = 0 + total_size = 0 + + for hash_dir in os.listdir(cache_root): + hash_path = os.path.join(cache_root, hash_dir) + if not os.path.isdir(hash_path): + continue + for name in os.listdir(hash_path): + bundle_path = os.path.join(hash_path, name) + if not os.path.isdir(bundle_path): + continue + if bundle_name and name != bundle_name: + continue + size = sum( + os.path.getsize(os.path.join(r, f)) + for r, _, files in os.walk(bundle_path) + for f in files + ) + if dry_run: + click.echo(f"Would remove: {name} ({size / 1024:.1f} KB)") + else: + shutil.rmtree(bundle_path) + # Remove parent hash dir if now empty + if not os.listdir(hash_path): + os.rmdir(hash_path) + click.echo(f"Removed cached bundle: {name}") + removed += 1 + total_size += size + + if removed == 0: + click.echo( + "No cached bundles found." + if not bundle_name + else f"Bundle '{bundle_name}' not found in cache." + ) + elif dry_run: + click.echo(f"Would remove {removed} cached bundle(s) ({total_size / (1024 * 1024):.1f} MB)") + else: + click.echo(f"Removed {removed} cached bundle(s) ({total_size / (1024 * 1024):.1f} MB)") + # Remove cache root if now empty + if os.path.isdir(cache_root) and not os.listdir(cache_root): + os.rmdir(cache_root) + + +@cli_bundle_cache.command(name="update") +@click.argument("bundle_name", required=False) +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def bundle_cache_update(bundle_name, **args): + """Re-download any stale cached bundles from S3 by checking ETags.""" + from ...job_bundle.repository import ( + S3BundleRepository, + _get_bundle_cache_dir, + _read_cache_meta, + ) + + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + s3_settings = _get_queue_s3_settings(config) + + repo = S3BundleRepository( + bucket_name=s3_settings.s3BucketName, + root_prefix=s3_settings.rootPrefix, + ) + + # List remote bundles to match against cache + entries = repo.list_entries(repo.root_path()) + archive_bundles = {e.name: e for e in entries if e.is_bundle and e.is_archive} + + cache_root = _get_bundle_cache_dir() + if not os.path.isdir(cache_root): + click.echo("No bundle cache found.") + return + + updated = 0 + up_to_date = 0 + checked = 0 + + for hash_dir in os.listdir(cache_root): + hash_path = os.path.join(cache_root, hash_dir) + if not os.path.isdir(hash_path): + continue + for name in os.listdir(hash_path): + bundle_path = os.path.join(hash_path, name) + if not os.path.isdir(bundle_path): + continue + if bundle_name and name != bundle_name: + continue + + meta = _read_cache_meta(bundle_path) + if not meta: + continue + + # Find the matching remote bundle + if name not in archive_bundles: + continue + + checked += 1 + entry = archive_bundles[name] + + # Force a resolve which checks ETag and re-downloads if stale + result_path = repo.resolve_bundle(entry.path, "") + new_meta = _read_cache_meta( + os.path.dirname(result_path) if result_path != bundle_path else bundle_path + ) + + if new_meta and new_meta.get("etag") != meta.get("etag"): + click.echo(f"{name}: updated") + updated += 1 + else: + click.echo(f"{name}: up-to-date") + up_to_date += 1 + + if checked == 0: + click.echo( + "No cached bundles found." + if not bundle_name + else f"Bundle '{bundle_name}' not found in cache." + ) + else: + click.echo(f"Checked {checked} bundle(s): {updated} updated, {up_to_date} up-to-date") + + @cli_bundle.command(name="upload") @click.argument("job_bundle_dir") @click.option("--profile", help="The AWS profile to use.") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index c15dca94c..778cdbbff 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -380,6 +380,7 @@ def list_entries(self, path: str) -> list[BrowseEntry]: is_bundle = child_prefix in bundle_prefixes entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + entries.sort(key=lambda e: e.name.lower()) return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: diff --git a/src/deadline/client/ui/dialogs/deadline_config_dialog.py b/src/deadline/client/ui/dialogs/deadline_config_dialog.py index e0a320b6c..fdad816e8 100644 --- a/src/deadline/client/ui/dialogs/deadline_config_dialog.py +++ b/src/deadline/client/ui/dialogs/deadline_config_dialog.py @@ -210,7 +210,7 @@ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) def sizeHint(self): - return QSize(500, 400) + return QSize(500, 500) class DeadlineWorkstationConfigWidget(QWidget): @@ -248,7 +248,7 @@ def __init__(self, parent: Optional[QWidget] = None): self.refresh() def minimumSizeHint(self): - return QSize(500, 700) + return QSize(500, 800) def _build_ui(self): # Ensure the widget expands horizontally @@ -336,6 +336,18 @@ def _build_profile_settings_ui(self, group, layout): layout.addRow(job_history_dir_label, self.job_history_dir_edit) self.job_history_dir_edit.path_changed.connect(self.job_history_dir_changed) + self.job_bundle_dir_edit = DirectoryPickerWidget( + initial_directory="", + directory_label=tr("Job bundle directory"), + parent=group, + collapse_user_dir=True, + ) + job_bundle_dir_label = self.labels["settings.job_bundle_default_directory"] = QLabel( + tr("Job bundle directory") + ) + layout.addRow(job_bundle_dir_label, self.job_bundle_dir_edit) + self.job_bundle_dir_edit.path_changed.connect(self.job_bundle_dir_changed) + self.default_farm_box = DeadlineFarmListComboBoxController(parent=group) default_farm_box_label = self.labels["defaults.farm_id"] = QLabel(tr("Default farm")) self.default_farm_box.box.currentIndexChanged.connect(self.default_farm_changed) @@ -497,7 +509,8 @@ def refresh_locale_message(): self.labels["settings.known_asset_paths"] = known_paths_label known_paths_widget = QWidget(parent=group) - known_paths_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + known_paths_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) + known_paths_widget.setMinimumHeight(120) known_paths_layout = QVBoxLayout(known_paths_widget) known_paths_layout.setContentsMargins(0, 0, 0, 0) @@ -849,6 +862,12 @@ def refresh(self): ) self.job_history_dir_edit.setText(job_history_dir) + with block_signals(self.job_bundle_dir_edit): + job_bundle_dir = config_file.get_setting( + "settings.job_bundle_default_directory", config=self.config + ) + self.job_bundle_dir_edit.setText(job_bundle_dir) + self.default_farm_box.refresh_selected_id() for refresh_callback in self._refresh_callbacks: @@ -931,6 +950,14 @@ def job_history_dir_changed(self): self.changes["settings.job_history_dir"] = job_history_dir self.refresh() + def job_bundle_dir_changed(self): + job_bundle_dir = self.job_bundle_dir_edit.text() + if job_bundle_dir != config_file.get_setting( + "settings.job_bundle_default_directory", config=self.config + ): + self.changes["settings.job_bundle_default_directory"] = job_bundle_dir + self.refresh() + def default_farm_changed(self, index): if index < 0: return diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index bfd8e3b5e..4204a279a 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os from logging import getLogger from typing import Optional @@ -62,12 +63,13 @@ def __init__( local_root: str = "", s3_bucket_name: str = "", s3_root_prefix: str = "", + job_history_dir: str = "", parent: Optional[QWidget] = None, ): super().__init__(parent=parent) self.setWindowTitle(tr("Browse Job Bundles")) - self.setMinimumSize(700, 500) - self.resize(800, 550) + self.setMinimumSize(750, 550) + self.resize(850, 620) self._local_repo = LocalBundleRepository(root=local_root) self._s3_repo: Optional[S3BundleRepository] = None @@ -77,12 +79,19 @@ def __init__( bucket_name=s3_bucket_name, root_prefix=s3_root_prefix ) + self._history_dir = job_history_dir + self._history_repo: Optional[LocalBundleRepository] = None + if job_history_dir and os.path.isdir(job_history_dir): + self._history_repo = LocalBundleRepository(root=job_history_dir) + self._current_repo: BundleRepository = self._local_repo self._selected_path: Optional[str] = None self._selected_is_s3 = False self._selected_is_archive = False + self._ready = False self._build_ui() + self._ready = True self._populate_root() @property @@ -143,7 +152,6 @@ def _build_ui(self): # Right: preview panel in a scroll area preview_widget = QWidget() preview_layout = QVBoxLayout(preview_widget) - preview_layout.setAlignment(Qt.AlignTop) self._preview_name = QLabel() self._preview_name.setWordWrap(True) @@ -168,6 +176,9 @@ def _build_ui(self): self._preview_params.setWordWrap(True) preview_layout.addWidget(self._preview_params) + preview_layout.addStretch() + preview_layout.addWidget(self._preview_params) + self._clear_preview() preview_scroll = QScrollArea() @@ -178,25 +189,37 @@ def _build_ui(self): # Bottom: source toggle + path + buttons bottom_layout = QVBoxLayout() + bottom_layout.setContentsMargins(0, 8, 0, 0) - # Source toggle row + # Source toggle row — S3 first (primary use case), then History, then Local source_row = QHBoxLayout() source_label = QLabel(tr("Source:")) source_row.addWidget(source_label) - self._radio_local = QRadioButton(tr("Local")) - self._radio_local.setChecked(True) - self._radio_local.toggled.connect(self._on_source_changed) - source_row.addWidget(self._radio_local) self._radio_s3 = QRadioButton( tr("S3 ({bucket})").format( bucket=self._s3_repo._bucket if self._s3_repo else tr("not configured") ) ) self._radio_s3.setEnabled(self._s3_available) + self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) + self._radio_history = QRadioButton(tr("History")) + self._radio_history.setEnabled(self._history_repo is not None) + self._radio_history.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_history) + self._radio_local = QRadioButton(tr("Local")) + self._radio_local.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_local) source_row.addStretch() bottom_layout.addLayout(source_row) + # Default to S3 if available, otherwise Local + if self._s3_available: + self._radio_s3.setChecked(True) + self._current_repo = self._s3_repo + else: + self._radio_local.setChecked(True) + # Path row path_row = QHBoxLayout() path_label = QLabel(tr("Path:")) @@ -290,7 +313,7 @@ def _update_selection(self, proxy_index: QModelIndex): if is_bundle: self._selected_path = path - self._selected_is_s3 = not self._radio_local.isChecked() + self._selected_is_s3 = self._radio_s3.isChecked() self._selected_is_archive = bool(item.data(ROLE_IS_ARCHIVE)) self._select_button.setEnabled(True) self._load_preview(path) @@ -333,10 +356,14 @@ def _search(parent_item): return _search(self._model.invisibleRootItem()) def _on_source_changed(self, checked: bool): + if not self._ready: + return if self._radio_local.isChecked(): self._current_repo = self._local_repo - elif self._s3_repo: + elif self._radio_s3.isChecked() and self._s3_repo: self._current_repo = self._s3_repo + elif self._radio_history.isChecked() and self._history_repo: + self._current_repo = self._history_repo self._selected_path = None self._select_button.setEnabled(False) self._clear_preview() diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 5fc4e058f..38250dfa1 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -238,10 +238,14 @@ def show_job_bundle_submitter( except Exception: logger.debug("Could not retrieve queue S3 settings for bundle browser", exc_info=True) + # Get the job history directory for the current profile + job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) + browser = JobBundleBrowserDialog( local_root=default_dir, s3_bucket_name=s3_bucket, s3_root_prefix=s3_prefix, + job_history_dir=job_history_dir, parent=parent, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 147dc5615..63c66dc90 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -102,10 +102,14 @@ def on_load_bundle(self): except Exception: pass + # Get the job history directory for the current profile + job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) + browser = JobBundleBrowserDialog( local_root=default_dir, s3_bucket_name=s3_bucket, s3_root_prefix=s3_prefix, + job_history_dir=job_history_dir, parent=self, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: From 1d989de19b985108cf03d98a0b5cd6f1a0bf1a6e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 1 May 2026 10:24:39 -0500 Subject: [PATCH 06/89] feat: add share button, parameter values in preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share button (submit_job_to_deadline_dialog.py +121): "Share" button in the submitter dialog that exports the current bundle, archives it as a zip, attaches S3 user metadata, and uploads to the queue's job-bundles/ folder. Parameter values in preview (repository.py +82/-14): _extract_bundle_info now reads parameter_values.yaml/.json and merges values with template defaults. Preview shows • Frames (STRING) = 1-10 when a value is available, blank when not. For S3 archives, the cache enriches the S3 metadata preview with full parameter values on subsequent clicks. `bundle list` local default (bundle_group.py +51/-43): deadline bundle list (no args) now lists the configured default local directory. --s3 flag for S3 listing. Optional [path] argument overrides the local directory. Added --no-archives flag to skip archive scanning. Local archive validation (repository.py): Local archives are now validated (must contain a template) before being shown as bundles — prevents random zip files from appearing. Added include_archives flag to LocalBundleRepository to skip archive scanning entirely; browser uses this for Local and History sources. Preview layout fix (job_bundle_browser_dialog.py): Removed duplicate params widget and fixed stretch so content stays packed at top. Design doc (+29/-5): Updated preview section with parameter value display, added Share button section, added submit dialog to changes table. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 29 +++-- .../client/cli/_groups/bundle_group.py | 51 ++++++-- src/deadline/client/job_bundle/repository.py | 82 +++++++++--- .../ui/dialogs/job_bundle_browser_dialog.py | 14 +- .../dialogs/submit_job_to_deadline_dialog.py | 121 ++++++++++++++++++ 5 files changed, 254 insertions(+), 43 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 8b0da88c0..5fd9c8578 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -199,14 +199,18 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b **Right panel** — Preview (shown when a bundle is selected, scrollable): - **Name**: From the template's `name` field. - **Description**: From the template's `description` field, if present. -- **Steps**: List of step names from the template. -- **Parameters**: Name and type of each parameter definition. +- **Steps**: List of step names from the template, in definition order. +- **Parameters**: Name, type, and value of each parameter definition, in definition order. Values are resolved in priority order: `parameter_values.yaml`/`.json` > template `default` > blank. For S3 archives, values are available once the bundle is cached locally (first click caches, subsequent clicks show values). **Bottom bar**: - Radio toggle between Local, S3, and Job History sources. S3 option shows the bucket name from the queue and is disabled if the queue has no job attachment settings. Job History browses the `settings.job_history_dir` for the current AWS profile, showing previously submitted bundles. - Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. +### Share Button + +The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. + ### Lazy Loading The tree is populated lazily — only the children of expanded nodes are fetched. This keeps the initial load fast and avoids scanning deep directory trees or making excessive S3 API calls. @@ -261,6 +265,7 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again | `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, and `deadline bundle cache` (clean/update) commands | | `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Local/S3/History sources. | | `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | +| `ui/dialogs/submit_job_to_deadline_dialog.py` | Add "Share" button to upload the current bundle to S3 | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | | `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | @@ -268,22 +273,30 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again ### CLI Commands -#### `deadline bundle list` +#### `deadline bundle list [path]` -Lists job bundles available in the queue's S3 `job-bundles/` folder. +Lists job bundles in a local directory or the queue's S3 `job-bundles/` folder. +- With no arguments, lists bundles in the configured default local directory (`settings.job_bundle_default_directory`, or home if not set). No AWS config needed. +- With `path`, lists bundles in that local directory. +- With `--s3`, lists bundles from the queue's S3 job-bundles folder (requires farm and queue). - Default output is one bundle name per line, suitable for piping. -- `--output json`: JSON array with name, format (archive/folder), and S3 path. -- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. +- `--output json`: JSON array with name, format (archive/folder), and path. ``` $ deadline bundle list blender-render maya-arnold + +$ deadline bundle list ./my-bundles +simple-job + +$ deadline bundle list --s3 +blender-render +maya-arnold monte_carlo_simulation -simple_job -$ deadline bundle list --output json +$ deadline bundle list --s3 --output json [{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.zip", "format": "archive"}, ...] $ deadline bundle list | head -1 | xargs deadline bundle gui-submit --browse diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 54f9a483c..9f8cd0c8b 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -546,6 +546,18 @@ def _get_queue_s3_settings(config): @cli_bundle.command(name="list") +@click.argument("path", required=False) +@click.option( + "--s3", + "use_s3", + is_flag=True, + help="List bundles from the queue's S3 job-bundles folder.", +) +@click.option( + "--no-archives", + is_flag=True, + help="Skip archive files when listing local bundles.", +) @click.option("--profile", help="The AWS profile to use.") @click.option("--farm-id", help="The farm to use.") @click.option("--queue-id", help="The queue to use.") @@ -556,22 +568,35 @@ def _get_queue_s3_settings(config): help="Output format. TEXT prints one name per line, JSON prints full details.", ) @_handle_error -def bundle_list(output, **args): +def bundle_list(path, use_s3, no_archives, output, **args): """ - List job bundles available in the queue's S3 job-bundles folder. + List job bundles. - Prints one bundle name per line by default, suitable for piping - to other commands like `deadline bundle download` or `deadline bundle submit`. + \b + With no arguments, lists bundles in the configured default local directory + (settings.job_bundle_default_directory, or home if not set). + With PATH, lists bundles in that local directory. + With --s3, lists bundles from the queue's S3 job-bundles folder. """ - from ...job_bundle.repository import S3BundleRepository - - config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings = _get_queue_s3_settings(config) - - repo = S3BundleRepository( - bucket_name=s3_settings.s3BucketName, - root_prefix=s3_settings.rootPrefix, - ) + from ...job_bundle.repository import LocalBundleRepository, S3BundleRepository + + if use_s3: + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + s3_settings = _get_queue_s3_settings(config) + repo = S3BundleRepository( + bucket_name=s3_settings.s3BucketName, + root_prefix=s3_settings.rootPrefix, + ) + else: + if path: + local_root = os.path.abspath(path) + else: + local_root = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") + if not local_root: + local_root = config_file.get_setting("settings.job_bundle_default_directory") + if not local_root: + local_root = os.path.expanduser("~") + repo = LocalBundleRepository(root=local_root, include_archives=not no_archives) entries = repo.list_entries(repo.root_path()) bundles = [e for e in entries if e.is_bundle] diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 778cdbbff..76c8d25eb 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -175,22 +175,43 @@ def _parse_template(raw: str, filename: str) -> Optional[dict]: return None -def _extract_bundle_info(template: dict, path: str) -> BundleInfo: - """Extract BundleInfo from a parsed template dict.""" +def _extract_bundle_info( + template: dict, path: str, parameter_values: Optional[dict] = None +) -> BundleInfo: + """Extract BundleInfo from a parsed template dict. + If parameter_values is provided, merges values into the parameter definitions.""" + params = template.get("parameterDefinitions", []) + + # Build a lookup from parameter_values file + pv_map: dict[str, str] = {} + if parameter_values: + for pv in parameter_values.get("parameterValues", []): + if "name" in pv and "value" in pv: + pv_map[pv["name"]] = pv["value"] + + # Attach resolved value to each parameter: parameter_values > default > empty + for p in params: + name = p.get("name", "") + if name in pv_map: + p["_display_value"] = pv_map[name] + elif "default" in p: + p["_display_value"] = str(p["default"]) + return BundleInfo( path=path, name=template.get("name", os.path.basename(path.rstrip("/"))), description=template.get("description", ""), step_names=[s.get("name", "") for s in template.get("steps", [])], - parameters=template.get("parameterDefinitions", []), + parameters=params, ) class LocalBundleRepository: """Browse job bundles on the local filesystem. Supports directories and archives.""" - def __init__(self, root: str = ""): + def __init__(self, root: str = "", include_archives: bool = True): self._root = root or os.path.expanduser("~") + self._include_archives = include_archives def root_path(self) -> str: return self._root @@ -206,15 +227,21 @@ def list_entries(self, path: str) -> list[BrowseEntry]: if entry.is_dir(follow_symlinks=False): is_bundle = self._is_dir_bundle(entry.path) entries.append(BrowseEntry(name=entry.name, path=entry.path, is_bundle=is_bundle)) - elif entry.is_file(follow_symlinks=False) and _is_archive(entry.name): - entries.append( - BrowseEntry( - name=_strip_archive_ext(entry.name), - path=entry.path, - is_bundle=True, - is_archive=True, + elif ( + self._include_archives + and entry.is_file(follow_symlinks=False) + and _is_archive(entry.name) + ): + # Only show archives that actually contain a template + if _read_template_from_archive_path(entry.path) is not None: + entries.append( + BrowseEntry( + name=_strip_archive_ext(entry.name), + path=entry.path, + is_bundle=True, + is_archive=True, + ) ) - ) return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: @@ -245,7 +272,21 @@ def _get_dir_bundle_info(self, path: str) -> Optional[BundleInfo]: return None template = _parse_template(raw, fname) if template: - return _extract_bundle_info(template, path) + pv = self._read_parameter_values(path) + return _extract_bundle_info(template, path, pv) + return None + + @staticmethod + def _read_parameter_values(path: str) -> Optional[dict]: + """Read parameter_values.yaml or .json from a bundle directory.""" + for pvname in ("parameter_values.yaml", "parameter_values.json"): + pvpath = os.path.join(path, pvname) + if os.path.isfile(pvpath): + try: + with open(pvpath, encoding="utf-8") as f: + return _parse_template(f.read(), pvname) + except OSError: + pass return None def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: @@ -541,14 +582,22 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: pass if head: + # Check local cache validity + cache_valid = meta and head.get("ETag") == meta.get("etag") + # Try S3 user metadata for preview (set by 'deadline bundle upload') s3_metadata = head.get("Metadata", {}) info = _bundle_info_from_s3_metadata(s3_metadata, path) if info: + # If cache is valid, enrich with parameter values from the cached bundle + if cache_valid: + cached_info = self._read_info_from_cache(cache_dir, path) + if cached_info: + info.parameters = cached_info.parameters return info - # Check local cache validity - if meta and head.get("ETag") == meta.get("etag"): + # No S3 metadata — fall back to cache + if cache_valid: return self._read_info_from_cache(cache_dir, path) # Cache miss or stale — download, cache, and parse @@ -638,7 +687,8 @@ def _read_info_from_cache(self, cache_dir: str, original_path: str) -> Optional[ return None template = _parse_template(raw, fname) if template: - return _extract_bundle_info(template, original_path) + pv = LocalBundleRepository._read_parameter_values(bundle_dir) + return _extract_bundle_info(template, original_path, pv) return None @staticmethod diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 4204a279a..3ea62ea4a 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -71,7 +71,7 @@ def __init__( self.setMinimumSize(750, 550) self.resize(850, 620) - self._local_repo = LocalBundleRepository(root=local_root) + self._local_repo = LocalBundleRepository(root=local_root, include_archives=False) self._s3_repo: Optional[S3BundleRepository] = None self._s3_available = bool(s3_bucket_name) if s3_bucket_name: @@ -82,7 +82,7 @@ def __init__( self._history_dir = job_history_dir self._history_repo: Optional[LocalBundleRepository] = None if job_history_dir and os.path.isdir(job_history_dir): - self._history_repo = LocalBundleRepository(root=job_history_dir) + self._history_repo = LocalBundleRepository(root=job_history_dir, include_archives=False) self._current_repo: BundleRepository = self._local_repo self._selected_path: Optional[str] = None @@ -175,9 +175,7 @@ def _build_ui(self): self._preview_params = QLabel() self._preview_params.setWordWrap(True) preview_layout.addWidget(self._preview_params) - - preview_layout.addStretch() - preview_layout.addWidget(self._preview_params) + preview_layout.addStretch(1) self._clear_preview() @@ -401,7 +399,11 @@ def _load_preview(self, path: str): for p in info.parameters: pname = p.get("name", "?") ptype = p.get("type", "?") - lines.append(f" \u2022 {pname} ({ptype})") + value = p.get("_display_value") + if value is not None: + lines.append(f" \u2022 {pname} ({ptype}) = {value}") + else: + lines.append(f" \u2022 {pname} ({ptype})") self._preview_params.setText("\n".join(lines)) self._preview_params.setVisible(True) else: 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 b4117b014..316414145 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 @@ -266,6 +266,9 @@ def _build_ui( self.export_bundle_button = QPushButton(tr("Export bundle")) self.export_bundle_button.clicked.connect(self.on_export_bundle) self.button_box.addButton(self.export_bundle_button, QDialogButtonBox.AcceptRole) + self.share_bundle_button = QPushButton("Share") + self.share_bundle_button.clicked.connect(self.on_share_bundle) + self.button_box.addButton(self.share_bundle_button, QDialogButtonBox.AcceptRole) self.lyt.addWidget(self.button_box) @@ -620,6 +623,124 @@ def on_export_bundle(self): message, ) # type: ignore[call-arg] + def on_share_bundle(self): + """Archive the current bundle and upload it to the queue's S3 job-bundles folder.""" + import io + import zipfile + + import boto3 + + from ...config import get_setting + from ....job_attachments._aws.deadline import get_queue + from ...job_bundle.repository import ( + S3_JOB_BUNDLES_PREFIX, + _extract_bundle_info, + _parse_template, + ) + + # First export the bundle locally + settings = self.job_settings_type() + self.shared_job_settings.update_settings(settings) + self.job_settings.update_settings(settings) + queue_parameters = self.shared_job_settings.get_parameters() + asset_references = self.job_attachments.get_asset_references() + + try: + self.job_history_bundle_dir = create_job_history_bundle_dir( + self.submitter_info.submitter_name, settings.name + ) + if self.show_host_requirements_tab: + self.on_create_job_bundle_callback( + self, + self.job_history_bundle_dir, + settings, + queue_parameters, + asset_references, + self.host_requirements.get_requirements(), + purpose=JobBundlePurpose.EXPORT, + ) + else: + self.on_create_job_bundle_callback( + self, + self.job_history_bundle_dir, + settings, + queue_parameters, + asset_references, + purpose=JobBundlePurpose.EXPORT, + ) + except Exception as exc: + QMessageBox.critical(self, "Share failed", f"Failed to create bundle:\n{exc}") + return + + # Get queue S3 settings + try: + farm_id = get_setting("defaults.farm_id") + queue_id = get_setting("defaults.queue_id") + queue_obj = get_queue(farm_id=farm_id, queue_id=queue_id) + if not queue_obj.jobAttachmentSettings: + QMessageBox.warning( + self, "Share failed", "Queue does not have job attachment settings configured." + ) + return + s3_settings = queue_obj.jobAttachmentSettings + except Exception as exc: + QMessageBox.critical(self, "Share failed", f"Failed to get queue settings:\n{exc}") + return + + # Build S3 metadata from the template + bundle_metadata = {} + for tname in ("template.yaml", "template.json"): + tpath = os.path.join(self.job_history_bundle_dir, tname) + if os.path.isfile(tpath): + with open(tpath, encoding="utf-8") as f: + template = _parse_template(f.read(), tname) + if template: + info = _extract_bundle_info(template, self.job_history_bundle_dir) + bundle_metadata["bundle-name"] = info.name[:256] + if info.description: + bundle_metadata["bundle-description"] = " ".join(info.description.split())[ + :512 + ] + if info.step_names: + bundle_metadata["bundle-steps"] = ",".join(info.step_names)[:512] + if info.parameters: + param_strs = [ + f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters + ] + bundle_metadata["bundle-parameters"] = ",".join(param_strs)[:512] + break + + # Archive and upload + try: + bundle_name = settings.name.replace(" ", "_").replace("/", "_") + prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" + s3_key = f"{prefix}/{bundle_name}.zip" + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(self.job_history_bundle_dir): + for fname in files: + local_path = os.path.join(root, fname) + arcname = os.path.relpath(local_path, self.job_history_bundle_dir) + zf.write(local_path, arcname) + + buf.seek(0) + s3 = boto3.client("s3") + s3.upload_fileobj( + buf, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ) + + QMessageBox.information( + self, + "Shared to S3", + f"Bundle shared to:\ns3://{s3_settings.s3BucketName}/{s3_key}", + ) + except Exception as exc: + QMessageBox.critical(self, "Share failed", f"Failed to upload bundle:\n{exc}") + def save_job_parameters_to_job_bundle( self, job_bundle_dir: str, job_parameters: list[JobParameter] ): From b0390121d476e33d3aeb4990a97111bbb6f9631b Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 1 May 2026 10:41:11 -0500 Subject: [PATCH 07/89] feat: resolve parameters in job names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parameter reference resolution in names (repository.py +21, submit_job_to_deadline_dialog.py +25): Template names containing {{Param.X}} are now resolved using values from parameter_values.yaml/.json and template defaults. Applies to the browser preview, the Share button (S3 metadata + archive filename), and the CLI upload command. Local archive validation (repository.py): Local archives are now validated by checking for a template inside the archive before showing them as bundles. Added include_archives flag to LocalBundleRepository — browser disables it for Local/History sources to avoid scanning random zip files. CLI `--no-archives` flag (bundle_group.py +7): deadline bundle list accepts --no-archives to skip archive scanning for local directories. Share button name fix (submit_job_to_deadline_dialog.py): Resolves {{Param.X}} in the job name using current parameter values from both job template and queue parameters before using it as the S3 metadata name and archive filename. Design doc: Updated preview section with parameter value display and name resolution, Share button section with overwrite note, detection section with local archive validation and include_archives. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 8 +++--- .../client/cli/_groups/bundle_group.py | 7 +++++- src/deadline/client/job_bundle/repository.py | 21 +++++++++++++++- .../dialogs/submit_job_to_deadline_dialog.py | 25 ++++++++++++++++--- .../client/ui/job_bundle_submitter.py | 2 ++ .../ui/widgets/job_bundle_settings_tab.py | 2 ++ 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 5fd9c8578..59aebf00a 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -157,7 +157,7 @@ S3 user metadata has a 2KB total limit, which is sufficient for typical bundle m For `list_entries`, detection is kept fast: - **Local directories**: stat check for template file existence (no parsing). -- **Local archives**: matched by file extension only. +- **Local archives**: matched by file extension, then validated by checking for a template inside the archive. This prevents random zip files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). - **S3 folders**: detected via batch recursive listing — a single `list_objects_v2` (without delimiter) returns all keys under the parent prefix, and we check in-memory which child prefixes contain a template file. This replaces per-folder `head_object` calls, reducing N+1 API calls to 2 (one delimited list + one recursive list). - **S3 archives**: matched by key extension only (no API call). @@ -197,7 +197,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - Non-bundle, non-archive files are hidden. **Right panel** — Preview (shown when a bundle is selected, scrollable): -- **Name**: From the template's `name` field. +- **Name**: From the template's `name` field. `{{Param.X}}` references are resolved using values from `parameter_values.yaml` or template defaults. - **Description**: From the template's `description` field, if present. - **Steps**: List of step names from the template, in definition order. - **Parameters**: Name, type, and value of each parameter definition, in definition order. Values are resolved in priority order: `parameter_values.yaml`/`.json` > template `default` > blank. For S3 archives, values are available once the bundle is cached locally (first click caches, subsequent clicks show values). @@ -209,7 +209,9 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b ### Share Button -The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. +The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. + +Note: uploading a bundle with the same name as an existing one silently overwrites it in S3. ### Lazy Loading diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 9f8cd0c8b..1134062b1 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -795,6 +795,7 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): from ...job_bundle.loader import is_job_bundle_dir from ...job_bundle.repository import ( S3_JOB_BUNDLES_PREFIX, + LocalBundleRepository, _extract_bundle_info, _parse_template, ) @@ -816,7 +817,11 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): with open(tpath, encoding="utf-8") as f: template = _parse_template(f.read(), tname) if template: - info = _extract_bundle_info(template, job_bundle_dir) + info = _extract_bundle_info( + template, + job_bundle_dir, + LocalBundleRepository._read_parameter_values(job_bundle_dir), + ) bundle_metadata["bundle-name"] = info.name[:256] if info.description: # S3 metadata values must be valid HTTP header values (no newlines) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 76c8d25eb..4d1edf0b1 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -189,6 +189,15 @@ def _extract_bundle_info( if "name" in pv and "value" in pv: pv_map[pv["name"]] = pv["value"] + # Build a combined value map: parameter_values > defaults + value_map: dict[str, str] = {} + for p in params: + pname = p.get("name", "") + if "default" in p: + value_map[pname] = str(p["default"]) + # parameter_values override defaults + value_map.update(pv_map) + # Attach resolved value to each parameter: parameter_values > default > empty for p in params: name = p.get("name", "") @@ -197,9 +206,19 @@ def _extract_bundle_info( elif "default" in p: p["_display_value"] = str(p["default"]) + # Resolve {{Param.X}} references in the name + raw_name = template.get("name", os.path.basename(path.rstrip("/"))) + import re + + def _replace_param(m): + param_name = m.group(1) + return value_map.get(param_name, m.group(0)) + + resolved_name = re.sub(r"\{\{Param\.(\w+)\}\}", _replace_param, raw_name) + return BundleInfo( path=path, - name=template.get("name", os.path.basename(path.rstrip("/"))), + name=resolved_name, description=template.get("description", ""), step_names=[s.get("name", "") for s in template.get("steps", [])], parameters=params, 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 316414145..279347966 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 @@ -634,6 +634,7 @@ def on_share_bundle(self): from ....job_attachments._aws.deadline import get_queue from ...job_bundle.repository import ( S3_JOB_BUNDLES_PREFIX, + LocalBundleRepository, _extract_bundle_info, _parse_template, ) @@ -695,8 +696,10 @@ def on_share_bundle(self): with open(tpath, encoding="utf-8") as f: template = _parse_template(f.read(), tname) if template: - info = _extract_bundle_info(template, self.job_history_bundle_dir) - bundle_metadata["bundle-name"] = info.name[:256] + pv = LocalBundleRepository._read_parameter_values(self.job_history_bundle_dir) + info = _extract_bundle_info(template, self.job_history_bundle_dir, pv) + # Use settings.name which is already resolved by the UI + bundle_metadata["bundle-name"] = settings.name[:256] if info.description: bundle_metadata["bundle-description"] = " ".join(info.description.split())[ :512 @@ -712,7 +715,23 @@ def on_share_bundle(self): # Archive and upload try: - bundle_name = settings.name.replace(" ", "_").replace("/", "_") + # Resolve any {{Param.X}} in the name using current parameter values + import re + + param_value_map = { + p["name"]: p.get("value", p.get("default", "")) for p in queue_parameters + } + for p in settings.parameters: + param_value_map[p["name"]] = p.get("value", p.get("default", "")) + + resolved_name = re.sub( + r"\{\{Param\.(\w+)\}\}", + lambda m: str(param_value_map.get(m.group(1), m.group(0))), + settings.name, + ) + bundle_metadata["bundle-name"] = resolved_name[:256] + + bundle_name = resolved_name.replace(" ", "_").replace("/", "_") prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.zip" diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 38250dfa1..92990818c 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -221,6 +221,8 @@ def show_job_bundle_submitter( default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") if not default_dir: default_dir = get_setting("settings.job_bundle_default_directory") + if default_dir: + default_dir = os.path.expanduser(default_dir) # Try to get the queue's S3 bucket for S3 browsing s3_bucket = "" diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 63c66dc90..8ceccb4cf 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -85,6 +85,8 @@ def on_load_bundle(self): default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") if not default_dir: default_dir = get_setting("settings.job_bundle_default_directory") + if default_dir: + default_dir = os.path.expanduser(default_dir) # Try to get the queue's S3 bucket for S3 browsing s3_bucket = "" From bb307aab5d85b66c450766540a742d7d431c9366 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 1 May 2026 16:41:52 -0500 Subject: [PATCH 08/89] feat: add archive safety, s3 error handling, and double click to select bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error handling — inline, no popups (job_bundle_browser_dialog.py +82): - S3 unavailable: radio shows ⚠ S3 disabled with tooltip explaining why. Distinguishes "not configured" from actual errors. - Listing/expand failures: error shown in preview panel or as disabled tree entry. - Malformed bundles: preview shows error message, tree icon changes from 📦 to ⚠. - S3 list_entries now propagates exceptions instead of swallowing them. Double-click to select (job_bundle_browser_dialog.py): Double-clicking a bundle accepts the dialog immediately. Archive safety (repository.py +42): Zip extraction validates all entry paths for traversal and absolute paths before extracting. Tar extraction rejects symlinks and hard links. Both reject the entire archive if any entry is suspicious. S3 error passthrough (job_bundle_submitter.py +9, job_bundle_settings_tab.py +10): S3 setup errors (no farm/queue, no JA settings, API failures) are captured and passed to the dialog as s3_error for display. Share button fixes (submit_job_to_deadline_dialog.py +5): Disabled when no queue configured. Empty name after param resolution falls back to directory name. Imports cleanup (bundle_group.py +28/-28): Repository imports moved to top level. Tests (test_repository.py +216): Added tests for parameter values merging, {{Param.X}} name resolution, S3 metadata parsing, archive validation, include_archives flag, _read_parameter_values. Design doc: Added Error Handling and Archive Safety sections. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 33 ++- .../client/cli/_groups/bundle_group.py | 28 +-- src/deadline/client/job_bundle/repository.py | 42 +++- .../ui/dialogs/job_bundle_browser_dialog.py | 82 ++++++- .../dialogs/submit_job_to_deadline_dialog.py | 5 + .../client/ui/job_bundle_submitter.py | 9 +- .../ui/widgets/job_bundle_settings_tab.py | 10 +- .../cli/test_cli_bundle_repository.py | 191 ++++++++++++++++ .../job_bundle/test_repository.py | 216 +++++++++++++++--- 9 files changed, 544 insertions(+), 72 deletions(-) create mode 100644 test/unit/deadline_client/cli/test_cli_bundle_repository.py diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 59aebf00a..1f5c532a7 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -30,7 +30,7 @@ Job bundles can be either: - **Directories** — a folder containing `template.yaml` or `template.json` at the root, plus any scripts, data files, and `asset_references.yaml`. - **Archives** — a `.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz`, or `.tar` file containing a job bundle. The template can be at the archive root or inside a single wrapper directory. -Both formats are supported for both local and S3 browsing. Archives are extracted to a local directory before submission. +Both formats are supported for both local and S3 browsing. Archives are extracted to a local directory before submission. If an archive contains a single top-level wrapper directory (e.g. `my-bundle/template.yaml` instead of `template.yaml` at the root), the wrapper is detected and the inner directory is used as the bundle path. ### Backend Abstraction @@ -147,7 +147,7 @@ The preview priority chain for S3 archives: 2. **Local cache** if ETag matches → read template from disk 3. **Download archive** → parse template, populate cache (fallback for archives not uploaded via the CLI) -S3 user metadata has a 2KB total limit, which is sufficient for typical bundle metadata. Values are truncated to stay within limits. +S3 user metadata has a 2KB total limit, which is sufficient for typical bundle metadata. Per-field limits: `bundle-name` is truncated to 256 characters, `bundle-description`, `bundle-steps`, and `bundle-parameters` are each truncated to 512 characters. ### Detection: What Is a Job Bundle? @@ -157,7 +157,7 @@ S3 user metadata has a 2KB total limit, which is sufficient for typical bundle m For `list_entries`, detection is kept fast: - **Local directories**: stat check for template file existence (no parsing). -- **Local archives**: matched by file extension, then validated by checking for a template inside the archive. This prevents random zip files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). +- **Local archives**: matched by file extension, then validated by checking for a template inside the archive. This prevents random zip files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). The browser dialog disables local archives because the primary use case for archives is S3-shared bundles — local users work with directory bundles directly. - **S3 folders**: detected via batch recursive listing — a single `list_objects_v2` (without delimiter) returns all keys under the parent prefix, and we check in-memory which child prefixes contain a template file. This replaces per-folder `head_object` calls, reducing N+1 API calls to 2 (one delimited list + one recursive list). - **S3 archives**: matched by key extension only (no API call). @@ -203,13 +203,15 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - **Parameters**: Name, type, and value of each parameter definition, in definition order. Values are resolved in priority order: `parameter_values.yaml`/`.json` > template `default` > blank. For S3 archives, values are available once the bundle is cached locally (first click caches, subsequent clicks show values). **Bottom bar**: -- Radio toggle between Local, S3, and Job History sources. S3 option shows the bucket name from the queue and is disabled if the queue has no job attachment settings. Job History browses the `settings.job_history_dir` for the current AWS profile, showing previously submitted bundles. +- Radio toggle between Local, S3, and Job History sources. S3 is selected by default when available; otherwise Local is selected. S3 option shows the bucket name from the queue and is disabled if the queue has no job attachment settings or S3 access fails (with a tooltip explaining why). Job History browses the `settings.job_history_dir` for the current AWS profile, showing previously submitted bundles; it is disabled if the job history directory does not exist on disk. - Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. ### Share Button -The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. +The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. + +Share is enabled when the API is available and a farm and queue are configured — it does not require valid queue parameters (unlike Submit), since sharing only needs S3 access, not a runnable job configuration. Note: uploading a bundle with the same name as an existing one silently overwrites it in S3. @@ -238,7 +240,7 @@ This setting is also exposed in the Deadline Cloud settings dialog (Settings → ### CLI Integration -The `--browse` flag on `deadline bundle gui-submit` opens this new dialog instead of `QFileDialog.getExistingDirectory()`. No new flags needed. +The `--browse` flag on `deadline bundle gui-submit` opens this new dialog instead of `QFileDialog.getExistingDirectory()`. No new flags needed. When `--browse` is used, the browser dialog opens before the submitter dialog. If the user cancels the browser, the command exits. Additionally, a "Load Bundle" button is added to the submitter dialog's button bar, allowing users to switch bundles mid-session by reopening the browser. The "Load a different job bundle" button inside the submitter dialog (`JobBundleSettingsWidget.on_load_bundle`) also uses the new browser dialog, giving users the same browsing experience when switching bundles mid-session. @@ -250,7 +252,7 @@ After the user selects a bundle in the browser, it must be resolved to a local d |---|---|---|---| | Local | Directory | Used directly (no copy) | None needed | | Local | Archive | Extracted to temp dir | atexit cleanup | -| S3 | Directory (folder) | Metadata files only (template, parameters, asset_references, hooks) | atexit cleanup | +| S3 | Directory (folder) | Metadata files only (`template.yaml`/`.json`, `parameter_values.yaml`/`.json`, `asset_references.yaml`/`.json`, `hooks.yaml`/`.json`) | atexit cleanup | | S3 | Archive | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | The CLI `deadline bundle download` command uses a separate `download_full_bundle()` path that downloads all files (including scripts and data), with a 50 MB size limit for folder bundles. Bundles larger than this should be uploaded as archives instead. @@ -394,6 +396,23 @@ $ deadline bundle download blender-render -o /tmp/bundles Downloaded bundle to: /tmp/bundles/blender-render ``` +### Error Handling + +Errors are displayed inline rather than as popup dialogs: + +- **S3 unavailable** (no farm/queue, no JA settings, auth failure): The S3 radio button shows `⚠ S3` and is disabled. Hovering shows the reason in a tooltip. The label distinguishes "not configured" (expected) from errors (⚠ icon). +- **Listing failure** (network error, permissions): The preview panel shows "⚠ Error" in red with the error message. +- **Expand failure** (subfolder listing fails): A disabled `⚠ Error: {message}` entry appears in the tree under that folder. +- **Preview failure** (malformed template, missing fields): The preview panel shows "⚠ Error" with "Could not read bundle template" and the tree entry icon changes from 📦 to ⚠. +- **Double-click**: Double-clicking a bundle selects it and accepts the dialog. Double-clicking a folder does nothing. + +### Archive Safety + +Archives are validated before extraction to prevent path traversal and symlink attacks: + +- **Zip**: All entry paths are checked for absolute paths and `../` traversal before any extraction occurs. The entire archive is rejected if any entry is suspicious. +- **Tar**: Symlinks and hard links are rejected. Absolute paths and path traversal are checked. Python 3.12+ uses `filter="data"` for additional safety; older versions rely on the manual validation. + ### S3 Considerations - **Authentication**: S3 browsing and CLI commands use the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 1134062b1..4fe597b16 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -22,6 +22,16 @@ from ... import api from ...config import config_file from ...dataclasses import SubmitterInfo +from ...job_bundle.loader import is_job_bundle_dir +from ...job_bundle.repository import ( + LocalBundleRepository, + S3BundleRepository, + S3_JOB_BUNDLES_PREFIX, + _extract_bundle_info, + _get_bundle_cache_dir, + _parse_template, + _read_cache_meta, +) from ....job_attachments.exceptions import ( AssetSyncError, AssetSyncCancelledError, @@ -578,7 +588,6 @@ def bundle_list(path, use_s3, no_archives, output, **args): With PATH, lists bundles in that local directory. With --s3, lists bundles from the queue's S3 job-bundles folder. """ - from ...job_bundle.repository import LocalBundleRepository, S3BundleRepository if use_s3: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) @@ -628,7 +637,6 @@ def cli_bundle_cache(): @_handle_error def bundle_cache_clean(bundle_name, dry_run): """Remove cached S3 bundle archives from the local cache.""" - from ...job_bundle.repository import _get_bundle_cache_dir cache_root = _get_bundle_cache_dir() if not os.path.isdir(cache_root): @@ -687,11 +695,6 @@ def bundle_cache_clean(bundle_name, dry_run): @_handle_error def bundle_cache_update(bundle_name, **args): """Re-download any stale cached bundles from S3 by checking ETags.""" - from ...job_bundle.repository import ( - S3BundleRepository, - _get_bundle_cache_dir, - _read_cache_meta, - ) config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -792,14 +795,6 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): import tarfile import io - from ...job_bundle.loader import is_job_bundle_dir - from ...job_bundle.repository import ( - S3_JOB_BUNDLES_PREFIX, - LocalBundleRepository, - _extract_bundle_info, - _parse_template, - ) - config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -905,9 +900,6 @@ def bundle_download(bundle_name, output_dir, **args): BUNDLE_NAME is the name of the bundle (e.g. 'blender-render'). The command will look for both archive and folder formats. """ - from ...job_bundle.repository import ( - S3BundleRepository, - ) config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 4d1edf0b1..6c91eef23 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -40,14 +40,46 @@ def _strip_archive_ext(name: str) -> str: return name +def _safe_zip_extract(zf: zipfile.ZipFile, dest_dir: str) -> None: + """Extract a zip file, rejecting archives with entries that would escape dest_dir.""" + dest = os.path.realpath(dest_dir) + for member in zf.namelist(): + # Reject absolute paths + if os.path.isabs(member): + raise ValueError(f"Zip contains absolute path: {member}") + # Reject path traversal + target = os.path.normpath(os.path.join(dest, member)) + if not (target.startswith(dest + os.sep) or target == dest): + raise ValueError(f"Zip entry would extract outside target directory: {member}") + zf.extractall(dest_dir) + + +def _safe_tar_extract(tf: tarfile.TarFile, dest_dir: str) -> None: + """Extract a tar file safely, rejecting entries that would escape dest_dir.""" + dest = os.path.realpath(dest_dir) + for member in tf.getmembers(): + if member.issym() or member.islnk(): + raise ValueError(f"Tar contains symlink or hard link: {member.name}") + if os.path.isabs(member.name): + raise ValueError(f"Tar contains absolute path: {member.name}") + target = os.path.normpath(os.path.join(dest, member.name)) + if not (target.startswith(dest + os.sep) or target == dest): + raise ValueError(f"Tar entry would extract outside target directory: {member.name}") + try: + tf.extractall(dest_dir, filter="data") + except TypeError: + # Python < 3.12 doesn't support filter= + tf.extractall(dest_dir) + + def _extract_archive(archive_path: str, dest_dir: str) -> None: """Extract an archive to dest_dir.""" if archive_path.endswith(".zip"): with zipfile.ZipFile(archive_path, "r") as zf: - zf.extractall(dest_dir) + _safe_zip_extract(zf, dest_dir) else: with tarfile.open(archive_path, "r:*") as tf: - tf.extractall(dest_dir, filter="data") + _safe_tar_extract(tf, dest_dir) def _read_template_from_archive_path(archive_path: str) -> Optional[tuple[str, str]]: @@ -122,10 +154,10 @@ def _extract_archive_from_bytes(data: bytes, filename: str, dest_dir: str) -> No """Extract an archive from bytes in memory to dest_dir.""" if filename.endswith(".zip"): with zipfile.ZipFile(io.BytesIO(data), "r") as zf: - zf.extractall(dest_dir) + _safe_zip_extract(zf, dest_dir) else: with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - tf.extractall(dest_dir, filter="data") + _safe_tar_extract(tf, dest_dir) @dataclass @@ -430,7 +462,7 @@ def list_entries(self, path: str) -> list[BrowseEntry]: ) except Exception: logger.warning("Failed to list S3 prefix %s", prefix, exc_info=True) - return entries + raise # Batch-detect which folders are bundles with a single recursive listing # instead of per-folder head_object calls diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 3ea62ea4a..40e204097 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -63,6 +63,7 @@ def __init__( local_root: str = "", s3_bucket_name: str = "", s3_root_prefix: str = "", + s3_error: str = "", job_history_dir: str = "", parent: Optional[QWidget] = None, ): @@ -73,6 +74,7 @@ def __init__( self._local_repo = LocalBundleRepository(root=local_root, include_archives=False) self._s3_repo: Optional[S3BundleRepository] = None + self._s3_error = s3_error self._s3_available = bool(s3_bucket_name) if s3_bucket_name: self._s3_repo = S3BundleRepository( @@ -144,6 +146,7 @@ def _build_ui(self): self._tree.setEditTriggers(QTreeView.NoEditTriggers) self._tree.expanded.connect(self._on_expanded) self._tree.clicked.connect(self._on_clicked) + self._tree.doubleClicked.connect(self._on_double_clicked) self._tree.selectionModel().currentChanged.connect(self._on_selection_changed) left_layout.addWidget(self._tree) @@ -193,12 +196,16 @@ def _build_ui(self): source_row = QHBoxLayout() source_label = QLabel(tr("Source:")) source_row.addWidget(source_label) - self._radio_s3 = QRadioButton( - tr("S3 ({bucket})").format( - bucket=self._s3_repo._bucket if self._s3_repo else tr("not configured") - ) - ) + if self._s3_repo: + s3_label = f"S3 ({self._s3_repo._bucket})" + elif self._s3_error: + s3_label = "\u26a0 S3" + else: + s3_label = "S3 (not configured)" + self._radio_s3 = QRadioButton(s3_label) self._radio_s3.setEnabled(self._s3_available) + if not self._s3_available and self._s3_error: + self._radio_s3.setToolTip(f"S3 unavailable: {self._s3_error}") self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) self._radio_history = QRadioButton(tr("History")) @@ -245,7 +252,12 @@ def _populate_root(self): self._model.setHorizontalHeaderLabels([tr("Name")]) root_path = self._current_repo.root_path() self._path_display.setText(root_path) - entries = self._current_repo.list_entries(root_path) + try: + entries = self._current_repo.list_entries(root_path) + except Exception as e: + logger.warning("Failed to list bundles: %s", e, exc_info=True) + self._show_error_preview(f"Failed to list bundles:\n{e}") + entries = [] root = self._model.invisibleRootItem() for entry in entries: self._add_entry_item(root, entry) @@ -282,13 +294,26 @@ def _on_expanded(self, proxy_index: QModelIndex): item.setData(True, ROLE_LOADED) item.removeRows(0, item.rowCount()) path = item.data(ROLE_PATH) - entries = self._current_repo.list_entries(path) + try: + entries = self._current_repo.list_entries(path) + except Exception as e: + logger.warning("Failed to list bundles in %s: %s", path, e, exc_info=True) + error_item = QStandardItem(f"\u26a0 Error: {e}") + error_item.setEnabled(False) + item.appendRow(error_item) + return for entry in entries: self._add_entry_item(item, entry) def _on_clicked(self, proxy_index: QModelIndex): self._update_selection(proxy_index) + def _on_double_clicked(self, proxy_index: QModelIndex): + item = self._source_item(proxy_index) + if item and item.data(ROLE_IS_BUNDLE): + self._update_selection(proxy_index) + self.accept() + def _on_selection_changed(self, current: QModelIndex, previous: QModelIndex): self._update_selection(current) @@ -314,7 +339,7 @@ def _update_selection(self, proxy_index: QModelIndex): self._selected_is_s3 = self._radio_s3.isChecked() self._selected_is_archive = bool(item.data(ROLE_IS_ARCHIVE)) self._select_button.setEnabled(True) - self._load_preview(path) + self._load_preview(path, item) else: self._selected_path = None self._select_button.setEnabled(False) @@ -369,10 +394,23 @@ def _on_source_changed(self, checked: bool): # ── Preview ────────────────────────────────────────────────── - def _load_preview(self, path: str): - info = self._current_repo.get_bundle_info(path) + def _load_preview(self, path: str, item: Optional[QStandardItem] = None): + try: + info = self._current_repo.get_bundle_info(path) + except Exception as e: + logger.warning("Failed to load bundle info for %s: %s", path, e, exc_info=True) + self._show_error_preview(f"Failed to load bundle info:\n{e}") + if item: + self._mark_item_error(item) + self._select_button.setEnabled(False) + return if not info: - self._clear_preview() + self._show_error_preview( + "Could not read bundle template.\nThe template may be missing or malformed." + ) + if item: + self._mark_item_error(item) + self._select_button.setEnabled(False) return self._preview_name.setText(info.name) @@ -418,3 +456,25 @@ def _clear_preview(self): self._preview_steps.setVisible(False) self._preview_params_label.setVisible(False) self._preview_params.setVisible(False) + + def _mark_item_error(self, item: QStandardItem) -> None: + """Replace the bundle/folder icon with a warning icon.""" + text = item.text() + # Remove existing icon prefix + for prefix in ("\U0001f4e6 ", "\U0001f4c1 ", "\u26a0 "): + if text.startswith(prefix): + text = text[len(prefix) :] + break + item.setText(f"\u26a0 {text}") + + def _show_error_preview(self, message: str): + """Show an error message in the preview panel.""" + self._preview_name.setText("\u26a0 Error") + self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: red;") + self._preview_name.setVisible(True) + self._preview_desc.setText(message) + self._preview_desc.setVisible(True) + self._preview_steps_label.setVisible(False) + self._preview_steps.setVisible(False) + self._preview_params_label.setVisible(False) + self._preview_params.setVisible(False) 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 279347966..7192a7d87 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 @@ -283,6 +283,7 @@ def _set_submit_button_state(self): enable = api_available and farm_configured and queue_configured and queue_valid self.submit_button.setEnabled(enable) + self.share_bundle_button.setEnabled(api_available and farm_configured and queue_configured) if not enable: issues = [] @@ -729,6 +730,10 @@ def on_share_bundle(self): lambda m: str(param_value_map.get(m.group(1), m.group(0))), settings.name, ) + if not resolved_name.strip(): + resolved_name = os.path.basename( + settings.input_job_bundle_dir + ) # fallback to dir name bundle_metadata["bundle-name"] = resolved_name[:256] bundle_name = resolved_name.replace(" ", "_").replace("/", "_") diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 92990818c..83bcd94ca 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -227,6 +227,7 @@ def show_job_bundle_submitter( # Try to get the queue's S3 bucket for S3 browsing s3_bucket = "" s3_prefix = "" + s3_error = "" try: farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") @@ -237,8 +238,13 @@ def show_job_bundle_submitter( if queue.jobAttachmentSettings: s3_bucket = queue.jobAttachmentSettings.s3BucketName s3_prefix = queue.jobAttachmentSettings.rootPrefix - except Exception: + else: + s3_error = "Queue does not have job attachment settings" + else: + s3_error = "No farm or queue configured" + except Exception as e: logger.debug("Could not retrieve queue S3 settings for bundle browser", exc_info=True) + s3_error = str(e) # Get the job history directory for the current profile job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) @@ -247,6 +253,7 @@ def show_job_bundle_submitter( local_root=default_dir, s3_bucket_name=s3_bucket, s3_root_prefix=s3_prefix, + s3_error=s3_error, job_history_dir=job_history_dir, parent=parent, ) diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 8ceccb4cf..b1b0c0356 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -91,6 +91,7 @@ def on_load_bundle(self): # Try to get the queue's S3 bucket for S3 browsing s3_bucket = "" s3_prefix = "" + s3_error = "" try: farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") @@ -101,8 +102,12 @@ def on_load_bundle(self): if queue.jobAttachmentSettings: s3_bucket = queue.jobAttachmentSettings.s3BucketName s3_prefix = queue.jobAttachmentSettings.rootPrefix - except Exception: - pass + else: + s3_error = "Queue does not have job attachment settings" + else: + s3_error = "No farm or queue configured" + except Exception as e: + s3_error = str(e) # Get the job history directory for the current profile job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) @@ -111,6 +116,7 @@ def on_load_bundle(self): local_root=default_dir, s3_bucket_name=s3_bucket, s3_root_prefix=s3_prefix, + s3_error=s3_error, job_history_dir=job_history_dir, parent=self, ) diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py new file mode 100644 index 000000000..954113337 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -0,0 +1,191 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Tests for the bundle CLI commands (list, upload, download, cache).""" + +import json +import zipfile + +import yaml +from click.testing import CliRunner +from unittest.mock import MagicMock, patch + +from deadline.client.cli import main + +BUNDLE_GROUP = "deadline.client.cli._groups.bundle_group" + + +class TestBundleList: + def test_list_local_path(self, tmp_path): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps:\n- name: S1\n") + (tmp_path / "not-a-bundle").mkdir() + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert "my-bundle" in result.output + assert "not-a-bundle" not in result.output + + def test_list_local_json(self, tmp_path): + bundle = tmp_path / "render-job" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Render\nsteps: []\n") + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "list", str(tmp_path), "--output", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 1 + assert data[0]["name"] == "render-job" + assert data[0]["format"] == "folder" + + def test_list_local_no_archives(self, tmp_path): + bundle = tmp_path / "dir-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Dir\nsteps: []\n") + + zip_path = tmp_path / "archive-bundle.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("template.yaml", "name: Zipped\nsteps: []\n") + + runner = CliRunner() + + result = runner.invoke(main, ["bundle", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert "dir-bundle" in result.output + assert "archive-bundle" in result.output + + result = runner.invoke(main, ["bundle", "list", str(tmp_path), "--no-archives"]) + assert result.exit_code == 0 + assert "dir-bundle" in result.output + assert "archive-bundle" not in result.output + + def test_list_empty_dir(self, tmp_path): + runner = CliRunner() + result = runner.invoke(main, ["bundle", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert result.output.strip() == "" + + +class TestBundleUpload: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + @patch("boto3.client") + def test_upload_creates_zip(self, mock_boto3_client, mock_s3_settings, mock_config, tmp_path): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test Bundle", + "steps": [{"name": "Run"}], + } + ) + ) + + mock_s3_settings.return_value = MagicMock( + s3BucketName="test-bucket", rootPrefix="DeadlineCloud" + ) + mock_s3 = MagicMock() + mock_boto3_client.return_value = mock_s3 + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle)]) + + assert result.exit_code == 0, result.output + assert "Uploaded bundle to" in result.output + mock_s3.upload_fileobj.assert_called_once() + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + @patch("boto3.client") + def test_upload_no_archive(self, mock_boto3_client, mock_s3_settings, mock_config, tmp_path): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps: []\n") + (bundle / "script.sh").write_text("echo hello") + + mock_s3_settings.return_value = MagicMock( + s3BucketName="test-bucket", rootPrefix="DeadlineCloud" + ) + mock_s3 = MagicMock() + mock_boto3_client.return_value = mock_s3 + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle), "--no-archive"]) + + assert result.exit_code == 0, result.output + assert "Uploaded 2 files" in result.output + assert mock_s3.upload_file.call_count == 2 + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): + not_bundle = tmp_path / "empty" + not_bundle.mkdir() + + mock_s3_settings.return_value = MagicMock( + s3BucketName="test-bucket", rootPrefix="DeadlineCloud" + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(not_bundle)]) + assert result.exit_code == 1 + assert "not appear to be a job bundle" in result.output + + +REPO_MODULE = "deadline.client.job_bundle.repository" + + +class TestBundleCacheClean: + def test_clean_no_cache(self, tmp_path): + with patch( + f"{BUNDLE_GROUP}._get_bundle_cache_dir", + return_value=str(tmp_path / "nonexistent"), + ): + runner = CliRunner() + result = runner.invoke(main, ["bundle", "cache", "clean"]) + assert result.exit_code == 0 + assert "No bundle cache found" in result.output + + def test_clean_dry_run(self, tmp_path): + cache_dir = tmp_path / "cache" + hash_dir = cache_dir / "abc123" + bundle_dir = hash_dir / "test-bundle" + bundle_dir.mkdir(parents=True) + (bundle_dir / "template.yaml").write_text("name: Test\n") + + with patch( + f"{BUNDLE_GROUP}._get_bundle_cache_dir", + return_value=str(cache_dir), + ): + runner = CliRunner() + result = runner.invoke(main, ["bundle", "cache", "clean", "--dry-run"]) + + assert result.exit_code == 0 + assert "Would remove" in result.output + assert (bundle_dir / "template.yaml").exists() + + def test_clean_specific_bundle(self, tmp_path): + cache_dir = tmp_path / "cache" + hash_dir = cache_dir / "abc123" + bundle_a = hash_dir / "bundle-a" + bundle_b = hash_dir / "bundle-b" + bundle_a.mkdir(parents=True) + bundle_b.mkdir(parents=True) + (bundle_a / "template.yaml").write_text("a") + (bundle_b / "template.yaml").write_text("b") + + with patch( + f"{BUNDLE_GROUP}._get_bundle_cache_dir", + return_value=str(cache_dir), + ): + runner = CliRunner() + result = runner.invoke(main, ["bundle", "cache", "clean", "bundle-a"]) + + assert result.exit_code == 0 + assert "Removed cached bundle: bundle-a" in result.output + assert not bundle_a.exists() + assert bundle_b.exists() diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 3104bae8c..cd867e0c9 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -2,6 +2,7 @@ """Tests for the job bundle repository module.""" +import io import json import os import tarfile @@ -11,6 +12,7 @@ from deadline.client.job_bundle.repository import ( LocalBundleRepository, + _bundle_info_from_s3_metadata, _extract_bundle_info, _is_archive, _parse_template, @@ -64,6 +66,104 @@ def test_minimal_template(self): assert info.step_names == ["OnlyStep"] assert info.parameters == [] + def test_parameter_values_from_file(self): + template = { + "name": "Job", + "steps": [], + "parameterDefinitions": [ + {"name": "Frames", "type": "STRING", "default": "1-10"}, + {"name": "Output", "type": "PATH"}, + ], + } + pv = {"parameterValues": [{"name": "Frames", "value": "1-50"}]} + info = _extract_bundle_info(template, "/path", pv) + frames = next(p for p in info.parameters if p["name"] == "Frames") + output = next(p for p in info.parameters if p["name"] == "Output") + assert frames["_display_value"] == "1-50" # from parameter_values + assert "_display_value" not in output # no value or default + + def test_parameter_default_used_when_no_value(self): + template = { + "name": "Job", + "steps": [], + "parameterDefinitions": [ + {"name": "Frames", "type": "STRING", "default": "1-10"}, + ], + } + info = _extract_bundle_info(template, "/path") + frames = info.parameters[0] + assert frames["_display_value"] == "1-10" + + def test_name_resolution_with_param_reference(self): + template = { + "name": "Render {{Param.SceneName}}", + "steps": [], + "parameterDefinitions": [ + {"name": "SceneName", "type": "STRING", "default": "my_scene"}, + ], + } + info = _extract_bundle_info(template, "/path") + assert info.name == "Render my_scene" + + def test_name_resolution_with_parameter_values(self): + template = { + "name": "{{Param.JobName}}", + "steps": [], + "parameterDefinitions": [ + {"name": "JobName", "type": "STRING", "default": "Default Name"}, + ], + } + pv = {"parameterValues": [{"name": "JobName", "value": "Custom Name"}]} + info = _extract_bundle_info(template, "/path", pv) + assert info.name == "Custom Name" + + def test_name_resolution_unresolved_param(self): + template = { + "name": "{{Param.Missing}}", + "steps": [], + "parameterDefinitions": [], + } + info = _extract_bundle_info(template, "/path") + assert info.name == "{{Param.Missing}}" + + def test_name_resolution_param_from_pv_not_in_definitions(self): + """Parameter values can contain params not in parameterDefinitions (e.g. queue params).""" + template = { + "name": "{{Param.JobName}}", + "steps": [], + "parameterDefinitions": [], + } + pv = {"parameterValues": [{"name": "JobName", "value": "From PV"}]} + info = _extract_bundle_info(template, "/path", pv) + assert info.name == "From PV" + + +class TestBundleInfoFromS3Metadata: + def test_full_metadata(self): + metadata = { + "bundle-name": "My Bundle", + "bundle-description": "A description", + "bundle-steps": "Step1,Step2", + "bundle-parameters": "Frames:STRING,Output:PATH", + } + info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key") + assert info.name == "My Bundle" + assert info.description == "A description" + assert info.step_names == ["Step1", "Step2"] + assert len(info.parameters) == 2 + assert info.parameters[0] == {"name": "Frames", "type": "STRING"} + assert info.parameters[1] == {"name": "Output", "type": "PATH"} + + def test_missing_name_returns_none(self): + info = _bundle_info_from_s3_metadata({}, "s3://bucket/key") + assert info is None + + def test_name_only(self): + info = _bundle_info_from_s3_metadata({"bundle-name": "Simple"}, "s3://bucket/key") + assert info.name == "Simple" + assert info.step_names == [] + assert info.parameters == [] + class TestArchiveHelpers: def test_is_archive(self): @@ -86,7 +186,6 @@ def test_strip_archive_ext(self): class TestReadTemplateFromArchive: def _make_zip(self, tmp_path, contents: dict[str, str]) -> str: - """Create a zip with the given {filename: content} entries.""" zip_path = str(tmp_path / "bundle.zip") with zipfile.ZipFile(zip_path, "w") as zf: for name, data in contents.items(): @@ -94,12 +193,9 @@ def _make_zip(self, tmp_path, contents: dict[str, str]) -> str: return zip_path def _make_tar_gz(self, tmp_path, contents: dict[str, str]) -> str: - """Create a tar.gz with the given {filename: content} entries.""" tar_path = str(tmp_path / "bundle.tar.gz") with tarfile.open(tar_path, "w:gz") as tf: for name, data in contents.items(): - import io - info = tarfile.TarInfo(name=name) encoded = data.encode("utf-8") info.size = len(encoded) @@ -191,38 +287,44 @@ def test_list_entries_with_bundles_and_dirs(self, tmp_path): dir_entry = next(e for e in entries if e.name == "regular-dir") assert dir_entry.is_bundle is False - def test_list_entries_with_archives(self, tmp_path): - # Create a zip archive bundle + def test_list_entries_with_valid_archive(self, tmp_path): zip_path = tmp_path / "render-job.zip" with zipfile.ZipFile(str(zip_path), "w") as zf: zf.writestr("template.yaml", "name: Render\nsteps:\n- name: S1\n") - # Create a tar.gz archive bundle - tar_path = tmp_path / "process-job.tar.gz" - with tarfile.open(str(tar_path), "w:gz") as tf: - import io + repo = LocalBundleRepository(root=str(tmp_path)) + entries = repo.list_entries(str(tmp_path)) - data = b"name: Process\nsteps:\n- name: S1\n" - info = tarfile.TarInfo(name="template.yaml") - info.size = len(data) - tf.addfile(info, io.BytesIO(data)) + archive_entries = [e for e in entries if e.is_archive] + assert len(archive_entries) == 1 + assert archive_entries[0].name == "render-job" + assert archive_entries[0].is_bundle is True - # Create a regular directory bundle - dir_bundle = tmp_path / "dir-bundle" - dir_bundle.mkdir() - (dir_bundle / "template.yaml").write_text("name: Dir\nsteps: []\n") + def test_list_entries_invalid_archive_excluded(self, tmp_path): + """A zip without a template should not appear as a bundle.""" + zip_path = tmp_path / "random.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("readme.txt", "not a bundle") repo = LocalBundleRepository(root=str(tmp_path)) entries = repo.list_entries(str(tmp_path)) + assert len(entries) == 0 - assert len(entries) == 3 - archive_entries = [e for e in entries if e.is_archive] - assert len(archive_entries) == 2 - archive_names = {e.name for e in archive_entries} - assert "render-job" in archive_names - assert "process-job" in archive_names - for e in archive_entries: - assert e.is_bundle is True + def test_list_entries_include_archives_false(self, tmp_path): + """With include_archives=False, archives are skipped entirely.""" + zip_path = tmp_path / "bundle.zip" + with zipfile.ZipFile(str(zip_path), "w") as zf: + zf.writestr("template.yaml", "name: Zipped\nsteps: []\n") + + bundle_dir = tmp_path / "dir-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.yaml").write_text("name: Dir\nsteps: []\n") + + repo = LocalBundleRepository(root=str(tmp_path), include_archives=False) + entries = repo.list_entries(str(tmp_path)) + + assert len(entries) == 1 + assert entries[0].name == "dir-bundle" def test_list_entries_nonexistent_path(self): repo = LocalBundleRepository() @@ -253,6 +355,40 @@ def test_get_bundle_info_yaml(self, tmp_path): assert info.step_names == ["Render"] assert len(info.parameters) == 1 + def test_get_bundle_info_with_parameter_values(self, tmp_path): + bundle_dir = tmp_path / "pv-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.yaml").write_text( + yaml.dump( + { + "name": "{{Param.JobName}}", + "steps": [{"name": "Run"}], + "parameterDefinitions": [ + {"name": "JobName", "type": "STRING", "default": "Default"}, + {"name": "Frames", "type": "STRING"}, + ], + } + ) + ) + (bundle_dir / "parameter_values.yaml").write_text( + yaml.dump( + { + "parameterValues": [ + {"name": "JobName", "value": "My Custom Job"}, + {"name": "Frames", "value": "1-100"}, + ] + } + ) + ) + + repo = LocalBundleRepository(root=str(tmp_path)) + info = repo.get_bundle_info(str(bundle_dir)) + + assert info is not None + assert info.name == "My Custom Job" + frames = next(p for p in info.parameters if p["name"] == "Frames") + assert frames["_display_value"] == "1-100" + def test_get_bundle_info_archive(self, tmp_path): zip_path = tmp_path / "my-job.zip" with zipfile.ZipFile(str(zip_path), "w") as zf: @@ -286,7 +422,6 @@ def test_get_bundle_info_not_a_bundle(self, tmp_path): assert info is None def test_extract_bundle_flat(self, tmp_path): - """Test extracting a zip where template is at the root.""" zip_path = tmp_path / "flat.zip" with zipfile.ZipFile(str(zip_path), "w") as zf: zf.writestr("template.yaml", "name: Flat\nsteps: []\n") @@ -301,7 +436,6 @@ def test_extract_bundle_flat(self, tmp_path): assert os.path.isfile(os.path.join(result, "scripts", "run.sh")) def test_extract_bundle_wrapped(self, tmp_path): - """Test extracting a zip where contents are in a single subdirectory.""" zip_path = tmp_path / "wrapped.zip" with zipfile.ZipFile(str(zip_path), "w") as zf: zf.writestr("my-bundle/template.yaml", "name: Wrapped\nsteps: []\n") @@ -327,3 +461,29 @@ def test_nested_bundles(self, tmp_path): assert len(entries) == 1 assert entries[0].is_bundle is True assert entries[0].name == "my-job" + + def test_read_parameter_values_yaml(self, tmp_path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "parameter_values.yaml").write_text( + yaml.dump({"parameterValues": [{"name": "X", "value": "1"}]}) + ) + result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + assert result is not None + assert result["parameterValues"][0]["value"] == "1" + + def test_read_parameter_values_json(self, tmp_path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "parameter_values.json").write_text( + json.dumps({"parameterValues": [{"name": "Y", "value": "2"}]}) + ) + result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + assert result is not None + assert result["parameterValues"][0]["value"] == "2" + + def test_read_parameter_values_none(self, tmp_path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + assert result is None From abf9e6a7ed23fd45727cc3a6a0040417318a44d4 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 29 May 2026 15:16:49 -0500 Subject: [PATCH 09/89] fix: only support .ojd (.zip) format Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 69 ++-- .../client/cli/_groups/bundle_group.py | 75 +---- src/deadline/client/job_bundle/repository.py | 312 +++--------------- .../job_bundle/test_repository.py | 126 +++---- 4 files changed, 138 insertions(+), 444 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 1f5c532a7..2538ea635 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -16,7 +16,7 @@ Replace the native folder picker with a custom job bundle browser dialog that: 1. Provides a navigable directory tree showing only folders, archives, and job bundles. 2. Displays a preview panel with bundle metadata when a job bundle is selected. 3. Supports both local filesystem and S3 bucket browsing through a common backend abstraction. -4. Supports job bundles as directories or archives (`.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz`, `.tar`). +4. Supports job bundles as directories or `.ojd` archives (zip format under the hood). 5. For S3, uses the selected queue's job attachment bucket with a `job-bundles/` prefix — no extra configuration needed. 6. Caches S3 archive bundles locally with ETag validation for fast repeated access. 7. Respects a configurable default local browse directory. @@ -28,7 +28,7 @@ Replace the native folder picker with a custom job bundle browser dialog that: Job bundles can be either: - **Directories** — a folder containing `template.yaml` or `template.json` at the root, plus any scripts, data files, and `asset_references.yaml`. -- **Archives** — a `.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz`, or `.tar` file containing a job bundle. The template can be at the archive root or inside a single wrapper directory. +- **Archives** — an `.ojd` file (zip format under the hood) containing a job bundle. The template can be at the archive root or inside a single wrapper directory. Both formats are supported for both local and S3 browsing. Archives are extracted to a local directory before submission. If an archive contains a single top-level wrapper directory (e.g. `my-bundle/template.yaml` instead of `template.yaml` at the root), the wrapper is detected and the inner directory is used as the bundle path. @@ -84,20 +84,16 @@ s3://{s3BucketName}/{rootPrefix}/job-bundles/ Where `s3BucketName` and `rootPrefix` come from the selected queue's `jobAttachmentSettings`. This means: - No extra configuration is needed — the bucket is derived from the queue the user already has selected. -- Users (or admins) place job bundles in the `job-bundles/` folder within the queue's attachment bucket. -- Bundles can be either folders (common prefixes containing a template) or archive files. +- Users (or admins) place job bundles as `.ojd` archives in the `job-bundles/` folder within the queue's attachment bucket. +- Subfolders within `job-bundles/` are supported for organization but only `.ojd` files are recognized as bundles. Example S3 layout: ``` s3://my-farm-bucket/DeadlineCloud/job-bundles/ - blender-render.zip - maya-arnold.tar.gz - simple-job/ - template.yaml - data-processing/ - template.yaml - scripts/ - process.py + blender-render.ojd + maya-arnold.ojd + rendering/ + custom-renderer.ojd ``` ### Job History Source @@ -129,7 +125,7 @@ Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniquenes } ``` -**Why only archives are cached**: An archive is a single S3 object with a single ETag — one `head_object` validates the entire bundle. Folder-based bundles are multiple objects with no single version identifier, so staleness detection would require checking every file. Folder bundles are downloaded to temp directories with atexit cleanup instead. +**Why only archives are cached**: An archive is a single S3 object with a single ETag — one `head_object` validates the entire bundle. ### S3 Object Metadata for Preview @@ -152,14 +148,14 @@ S3 user metadata has a 2KB total limit, which is sufficient for typical bundle m ### Detection: What Is a Job Bundle? - **Directories** (local or S3 prefix): contains `template.yaml` or `template.json`. -- **Archives** (local file or S3 object): filename ends with a supported archive extension. Validated by reading the template from inside the archive on preview. +- **Archives** (local file or S3 object): filename ends with `.ojd`. Validated by reading the template from inside the archive on preview. For `list_entries`, detection is kept fast: - **Local directories**: stat check for template file existence (no parsing). -- **Local archives**: matched by file extension, then validated by checking for a template inside the archive. This prevents random zip files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). The browser dialog disables local archives because the primary use case for archives is S3-shared bundles — local users work with directory bundles directly. -- **S3 folders**: detected via batch recursive listing — a single `list_objects_v2` (without delimiter) returns all keys under the parent prefix, and we check in-memory which child prefixes contain a template file. This replaces per-folder `head_object` calls, reducing N+1 API calls to 2 (one delimited list + one recursive list). -- **S3 archives**: matched by key extension only (no API call). +- **Local archives**: matched by `.ojd` extension, then validated by checking for a template inside the archive. This prevents random files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). The browser dialog disables local archives because the primary use case for archives is S3-shared bundles — local users work with directory bundles directly. +- **S3 folders**: shown for navigation only (expandable in the tree), never treated as bundles. +- **S3 archives**: matched by `.ojd` extension only (no API call). Full template parsing happens only in `get_bundle_info` when the user clicks a bundle for preview. @@ -197,7 +193,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - Non-bundle, non-archive files are hidden. **Right panel** — Preview (shown when a bundle is selected, scrollable): -- **Name**: From the template's `name` field. `{{Param.X}}` references are resolved using values from `parameter_values.yaml` or template defaults. +- **Name**: From the template's `name` field, shown as-is (with `{{Param.X}}` references unresolved). - **Description**: From the template's `description` field, if present. - **Steps**: List of step names from the template, in definition order. - **Parameters**: Name, type, and value of each parameter definition, in definition order. Values are resolved in priority order: `parameter_values.yaml`/`.json` > template `default` > blank. For S3 archives, values are available once the bundle is cached locally (first click caches, subsequent clicks show values). @@ -209,7 +205,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b ### Share Button -The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" archives the current job bundle and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. +The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" packages the current job bundle as an `.ojd` archive and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. Share is enabled when the API is available and a farm and queue are configured — it does not require valid queue parameters (unlike Submit), since sharing only needs S3 access, not a runnable job configuration. @@ -251,11 +247,10 @@ After the user selects a bundle in the browser, it must be resolved to a local d | Source | Format | Resolution | Cleanup | |---|---|---|---| | Local | Directory | Used directly (no copy) | None needed | -| Local | Archive | Extracted to temp dir | atexit cleanup | -| S3 | Directory (folder) | Metadata files only (`template.yaml`/`.json`, `parameter_values.yaml`/`.json`, `asset_references.yaml`/`.json`, `hooks.yaml`/`.json`) | atexit cleanup | -| S3 | Archive | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | +| Local | Archive (.ojd) | Extracted to temp dir | atexit cleanup | +| S3 | Archive (.ojd) | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | -The CLI `deadline bundle download` command uses a separate `download_full_bundle()` path that downloads all files (including scripts and data), with a 50 MB size limit for folder bundles. Bundles larger than this should be uploaded as archives instead. +The CLI `deadline bundle download` command downloads the `.ojd` archive, caches it locally with ETag validation, and extracts it to the output directory. Once resolved to a local directory, the standard submission flow takes over: `read_job_bundle_parameters()` parses the template and resolves relative PATH defaults against the bundle directory, `apply_job_parameters()` processes asset references, and the job is submitted normally. @@ -301,7 +296,7 @@ maya-arnold monte_carlo_simulation $ deadline bundle list --s3 --output json -[{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.zip", "format": "archive"}, ...] +[{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.ojd", "format": "archive"}, ...] $ deadline bundle list | head -1 | xargs deadline bundle gui-submit --browse ``` @@ -320,7 +315,7 @@ blender-render maya-arnold $ deadline bundle list --output json | jq -r '.[0].path' -s3://my-farm-bucket/DeadlineCloud/job-bundles/blender-render.zip +s3://my-farm-bucket/DeadlineCloud/job-bundles/blender-render.ojd ``` #### `deadline bundle cache clean` @@ -360,23 +355,17 @@ blender-render: up-to-date #### `deadline bundle upload ` -Uploads a local job bundle to the queue's S3 `job-bundles/` folder. +Uploads a local job bundle to the queue's S3 `job-bundles/` folder as an `.ojd` archive. -- **Default behavior**: Archives the bundle as a zip and uploads a single object (e.g. `blender-render.zip`). -- `--format tar.gz`: Use tar.gz instead of zip. -- `--no-archive`: Upload as loose files (folder-based bundle) instead of an archive. - `--name`: Override the bundle name in S3 (defaults to the directory name). - `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. ``` $ deadline bundle upload ./my-render-job -Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.zip +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.ojd -$ deadline bundle upload ./my-render-job --format tar.gz --name custom-name -Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.tar.gz - -$ deadline bundle upload ./my-render-job --no-archive -Uploaded 5 files to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job/ +$ deadline bundle upload ./my-render-job --name custom-name +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.ojd ``` #### `deadline bundle download ` @@ -408,19 +397,17 @@ Errors are displayed inline rather than as popup dialogs: ### Archive Safety -Archives are validated before extraction to prevent path traversal and symlink attacks: +Archives are validated before extraction to prevent path traversal attacks: -- **Zip**: All entry paths are checked for absolute paths and `../` traversal before any extraction occurs. The entire archive is rejected if any entry is suspicious. -- **Tar**: Symlinks and hard links are rejected. Absolute paths and path traversal are checked. Python 3.12+ uses `filter="data"` for additional safety; older versions rely on the manual validation. +- All entry paths are checked for absolute paths and `../` traversal before any extraction occurs. The entire archive is rejected if any entry is suspicious. ### S3 Considerations - **Authentication**: S3 browsing and CLI commands use the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. - **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. If access is denied, show an error rather than crashing. -- **Performance**: Listing is 2 API calls (one delimited + one recursive `list_objects_v2`). Archive preview with S3 metadata is 1 `head_object` (no download). Folder preview is 1 `get_object` for the template. Cached archive selection is 1 `head_object`. +- **Performance**: Listing is a single paginated `list_objects_v2` call with delimiter. Archive preview with S3 metadata is 1 `head_object` (no download). Cached archive selection is 1 `head_object`. - **S3 object metadata**: `deadline bundle upload` attaches bundle name, description, steps, and parameters as S3 user metadata. This enables zero-download preview via `head_object`. Archives uploaded by other means fall back to downloading the archive for preview. -- **Folder bundle size limit**: Folder bundles larger than 50 MB cannot be downloaded via the CLI. Use `deadline bundle upload` to convert them to archives. -- **Bundled assets**: Scripts, data files, and other assets within the bundle are included in the archive or folder download. Relative PATH parameters resolve against the extracted/downloaded copy. +- **Bundled assets**: Scripts, data files, and other assets within the bundle are included in the archive. Relative PATH parameters resolve against the extracted copy. ## Out of Scope (Future) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 4fe597b16..6785d8d78 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -771,28 +771,12 @@ def bundle_cache_update(bundle_name, **args): "--name", help="Name for the archive in S3. Defaults to the bundle directory name.", ) -@click.option( - "--format", - "archive_format", - type=click.Choice(["zip", "tar.gz"], case_sensitive=False), - default="zip", - help="Archive format to upload as.", -) -@click.option( - "--no-archive", - is_flag=True, - help="Upload as a folder (loose files) instead of an archive.", -) @_handle_error -def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): +def bundle_upload(job_bundle_dir, name, **args): """ - Upload a job bundle to the queue's S3 job-bundles folder. - - By default, the bundle is archived as a zip before uploading. - Use --no-archive to upload as loose files instead. + Upload a job bundle to the queue's S3 job-bundles folder as an .ojd archive. """ import zipfile - import tarfile import io config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) @@ -838,47 +822,25 @@ def bundle_upload(job_bundle_dir, name, archive_format, no_archive, **args): s3 = boto3.client("s3") - if no_archive: - # Upload as loose files - s3_prefix = f"{prefix}/{bundle_name}/" - file_count = 0 + # Archive and upload + buf = io.BytesIO() + ext = ".ojd" + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for root, _dirs, files in os.walk(job_bundle_dir): for fname in files: local_path = os.path.join(root, fname) - rel_path = os.path.relpath(local_path, job_bundle_dir) - s3_key = f"{s3_prefix}{rel_path}" - s3.upload_file(local_path, s3_settings.s3BucketName, s3_key) - file_count += 1 - click.echo(f"Uploaded {file_count} files to s3://{s3_settings.s3BucketName}/{s3_prefix}") - else: - # Archive and upload - buf = io.BytesIO() - if archive_format == "zip": - ext = ".zip" - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, _dirs, files in os.walk(job_bundle_dir): - for fname in files: - local_path = os.path.join(root, fname) - arcname = os.path.relpath(local_path, job_bundle_dir) - zf.write(local_path, arcname) - else: - ext = ".tar.gz" - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - for root, _dirs, files in os.walk(job_bundle_dir): - for fname in files: - local_path = os.path.join(root, fname) - arcname = os.path.relpath(local_path, job_bundle_dir) - tf.add(local_path, arcname) - - s3_key = f"{prefix}/{bundle_name}{ext}" - buf.seek(0) - s3.upload_fileobj( - buf, - s3_settings.s3BucketName, - s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, - ) - click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") + arcname = os.path.relpath(local_path, job_bundle_dir) + zf.write(local_path, arcname) + + s3_key = f"{prefix}/{bundle_name}{ext}" + buf.seek(0) + s3.upload_fileobj( + buf, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ) + click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") @cli_bundle.command(name="download") @@ -898,7 +860,6 @@ def bundle_download(bundle_name, output_dir, **args): Download a job bundle from the queue's S3 job-bundles folder. BUNDLE_NAME is the name of the bundle (e.g. 'blender-render'). - The command will look for both archive and folder formats. """ config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 6c91eef23..475bb6c17 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -2,7 +2,7 @@ """ Bundle repository abstraction for browsing job bundles from local filesystem or S3. -Supports both directory-based bundles and archive bundles (.zip, .tar.gz, etc.). +Supports both directory-based bundles and .ojd archive bundles (zip format). """ from __future__ import annotations @@ -11,7 +11,6 @@ import io import json import os -import tarfile import zipfile from dataclasses import dataclass, field from logging import getLogger @@ -23,20 +22,19 @@ TEMPLATE_FILENAMES = ("template.yaml", "template.json") S3_JOB_BUNDLES_PREFIX = "job-bundles" -ARCHIVE_EXTENSIONS = (".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar") +ARCHIVE_EXTENSION = ".ojd" CACHE_META_FILENAME = ".bundle_cache_meta.json" def _is_archive(name: str) -> bool: - """Check if a filename looks like a supported archive.""" - return any(name.endswith(ext) for ext in ARCHIVE_EXTENSIONS) + """Check if a filename is an .ojd archive.""" + return name.endswith(ARCHIVE_EXTENSION) def _strip_archive_ext(name: str) -> str: - """Remove the archive extension from a filename.""" - for ext in ARCHIVE_EXTENSIONS: - if name.endswith(ext): - return name[: -len(ext)] + """Remove the .ojd extension from a filename.""" + if name.endswith(ARCHIVE_EXTENSION): + return name[: -len(ARCHIVE_EXTENSION)] return name @@ -44,67 +42,27 @@ def _safe_zip_extract(zf: zipfile.ZipFile, dest_dir: str) -> None: """Extract a zip file, rejecting archives with entries that would escape dest_dir.""" dest = os.path.realpath(dest_dir) for member in zf.namelist(): - # Reject absolute paths if os.path.isabs(member): - raise ValueError(f"Zip contains absolute path: {member}") - # Reject path traversal + raise ValueError(f"Archive contains absolute path: {member}") target = os.path.normpath(os.path.join(dest, member)) if not (target.startswith(dest + os.sep) or target == dest): - raise ValueError(f"Zip entry would extract outside target directory: {member}") + raise ValueError(f"Archive entry would extract outside target directory: {member}") zf.extractall(dest_dir) -def _safe_tar_extract(tf: tarfile.TarFile, dest_dir: str) -> None: - """Extract a tar file safely, rejecting entries that would escape dest_dir.""" - dest = os.path.realpath(dest_dir) - for member in tf.getmembers(): - if member.issym() or member.islnk(): - raise ValueError(f"Tar contains symlink or hard link: {member.name}") - if os.path.isabs(member.name): - raise ValueError(f"Tar contains absolute path: {member.name}") - target = os.path.normpath(os.path.join(dest, member.name)) - if not (target.startswith(dest + os.sep) or target == dest): - raise ValueError(f"Tar entry would extract outside target directory: {member.name}") - try: - tf.extractall(dest_dir, filter="data") - except TypeError: - # Python < 3.12 doesn't support filter= - tf.extractall(dest_dir) - - def _extract_archive(archive_path: str, dest_dir: str) -> None: - """Extract an archive to dest_dir.""" - if archive_path.endswith(".zip"): - with zipfile.ZipFile(archive_path, "r") as zf: - _safe_zip_extract(zf, dest_dir) - else: - with tarfile.open(archive_path, "r:*") as tf: - _safe_tar_extract(tf, dest_dir) + """Extract an .ojd archive to dest_dir.""" + with zipfile.ZipFile(archive_path, "r") as zf: + _safe_zip_extract(zf, dest_dir) def _read_template_from_archive_path(archive_path: str) -> Optional[tuple[str, str]]: - """Read a template file from a local archive. Returns (contents, filename) or None.""" - if archive_path.endswith(".zip"): - return _read_template_from_zip_path(archive_path) - else: - return _read_template_from_tar_path(archive_path) - - -def _read_template_from_zip_path(archive_path: str) -> Optional[tuple[str, str]]: + """Read a template file from a local .ojd archive. Returns (contents, filename) or None.""" try: with zipfile.ZipFile(archive_path, "r") as zf: return _read_template_from_zip(zf) except Exception: - logger.debug("Failed to read template from zip %s", archive_path, exc_info=True) - return None - - -def _read_template_from_tar_path(archive_path: str) -> Optional[tuple[str, str]]: - try: - with tarfile.open(archive_path, "r:*") as tf: - return _read_template_from_tar(tf) - except Exception: - logger.debug("Failed to read template from tar %s", archive_path, exc_info=True) + logger.debug("Failed to read template from archive %s", archive_path, exc_info=True) return None @@ -112,52 +70,26 @@ def _read_template_from_zip(zf: zipfile.ZipFile) -> Optional[tuple[str, str]]: """Read a template file from an open ZipFile. Returns (contents, filename) or None.""" names = zf.namelist() for fname in TEMPLATE_FILENAMES: - # Check both root-level and single-directory-wrapped matches = [n for n in names if n == fname or n.endswith("/" + fname)] - # Prefer the shallowest match matches.sort(key=lambda n: n.count("/")) if matches: return zf.read(matches[0]).decode("utf-8"), fname return None -def _read_template_from_tar(tf: tarfile.TarFile) -> Optional[tuple[str, str]]: - """Read a template file from an open TarFile. Returns (contents, filename) or None.""" - members = tf.getnames() - for fname in TEMPLATE_FILENAMES: - matches = [n for n in members if n == fname or n.endswith("/" + fname)] - matches.sort(key=lambda n: n.count("/")) - if matches: - f = tf.extractfile(matches[0]) - if f: - return f.read().decode("utf-8"), fname - return None - - -def _read_template_from_bytes(data: bytes, filename: str) -> Optional[tuple[str, str]]: - """Read a template from archive bytes in memory. Returns (contents, template_filename) or None.""" - if filename.endswith(".zip"): - try: - with zipfile.ZipFile(io.BytesIO(data), "r") as zf: - return _read_template_from_zip(zf) - except Exception: - return None - else: - try: - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - return _read_template_from_tar(tf) - except Exception: - return None +def _read_template_from_bytes(data: bytes) -> Optional[tuple[str, str]]: + """Read a template from .ojd archive bytes in memory. Returns (contents, template_filename) or None.""" + try: + with zipfile.ZipFile(io.BytesIO(data), "r") as zf: + return _read_template_from_zip(zf) + except Exception: + return None -def _extract_archive_from_bytes(data: bytes, filename: str, dest_dir: str) -> None: - """Extract an archive from bytes in memory to dest_dir.""" - if filename.endswith(".zip"): - with zipfile.ZipFile(io.BytesIO(data), "r") as zf: - _safe_zip_extract(zf, dest_dir) - else: - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - _safe_tar_extract(tf, dest_dir) +def _extract_archive_from_bytes(data: bytes, dest_dir: str) -> None: + """Extract an .ojd archive from bytes in memory to dest_dir.""" + with zipfile.ZipFile(io.BytesIO(data), "r") as zf: + _safe_zip_extract(zf, dest_dir) @dataclass @@ -221,15 +153,6 @@ def _extract_bundle_info( if "name" in pv and "value" in pv: pv_map[pv["name"]] = pv["value"] - # Build a combined value map: parameter_values > defaults - value_map: dict[str, str] = {} - for p in params: - pname = p.get("name", "") - if "default" in p: - value_map[pname] = str(p["default"]) - # parameter_values override defaults - value_map.update(pv_map) - # Attach resolved value to each parameter: parameter_values > default > empty for p in params: name = p.get("name", "") @@ -238,19 +161,11 @@ def _extract_bundle_info( elif "default" in p: p["_display_value"] = str(p["default"]) - # Resolve {{Param.X}} references in the name raw_name = template.get("name", os.path.basename(path.rstrip("/"))) - import re - - def _replace_param(m): - param_name = m.group(1) - return value_map.get(param_name, m.group(0)) - - resolved_name = re.sub(r"\{\{Param\.(\w+)\}\}", _replace_param, raw_name) return BundleInfo( path=path, - name=resolved_name, + name=raw_name, description=template.get("description", ""), step_names=[s.get("name", "") for s in template.get("steps", [])], parameters=params, @@ -417,8 +332,8 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI class S3BundleRepository: - """Browse job bundles in an S3 bucket under {rootPrefix}/job-bundles/. - Supports both folder-based bundles and archive bundles (.zip, .tar.gz, etc.). + """Browse .ojd job bundles in an S3 bucket under {rootPrefix}/job-bundles/. + Only .ojd archives are supported. Subfolders are shown for navigation only. Archive bundles are cached locally with ETag validation.""" def __init__(self, bucket_name: str, root_prefix: str, session=None): @@ -440,13 +355,13 @@ def list_entries(self, path: str) -> list[BrowseEntry]: try: paginator = self._s3.get_paginator("list_objects_v2") for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix, Delimiter="/"): - # Folder-based bundles (common prefixes) + # Subfolders (for navigation only, not bundles) for cp in page.get("CommonPrefixes", []): child_prefix = cp["Prefix"] name = child_prefix.rstrip("/").rsplit("/", 1)[-1] child_path = f"s3://{self._bucket}/{child_prefix}" child_prefixes.append((name, child_prefix, child_path)) - # Archive bundles (objects with archive extensions) + # .ojd archive bundles for obj in page.get("Contents", []): key = obj["Key"] name = key.rsplit("/", 1)[-1] if "/" in key else key @@ -464,158 +379,26 @@ def list_entries(self, path: str) -> list[BrowseEntry]: logger.warning("Failed to list S3 prefix %s", prefix, exc_info=True) raise - # Batch-detect which folders are bundles with a single recursive listing - # instead of per-folder head_object calls - if child_prefixes: - bundle_prefixes = self._batch_detect_bundles(prefix, child_prefixes) - for name, child_prefix, child_path in child_prefixes: - is_bundle = child_prefix in bundle_prefixes - entries.append(BrowseEntry(name=name, path=child_path, is_bundle=is_bundle)) + # Subfolders are shown for navigation but never as bundles + for name, child_prefix, child_path in child_prefixes: + entries.append(BrowseEntry(name=name, path=child_path, is_bundle=False)) entries.sort(key=lambda e: e.name.lower()) return entries def get_bundle_info(self, path: str) -> Optional[BundleInfo]: - if self._path_is_archive(path): - return self._get_archive_bundle_info(path) - return self._get_folder_bundle_info(path) + return self._get_archive_bundle_info(path) def resolve_bundle(self, path: str, dest_dir: str) -> str: - """Resolve an S3 bundle to a local directory path for the submitter dialog. - For archives: downloads, caches with ETag, and extracts (full bundle). - For folders: downloads only metadata files (template, parameters, etc.). + """Resolve an S3 .ojd bundle to a local directory path. + Downloads, caches with ETag, and extracts. Returns the local path to the usable bundle directory.""" - if self._path_is_archive(path): - return self._resolve_archive_bundle(path) - return self._download_folder_bundle_metadata(path, dest_dir) + return self._resolve_archive_bundle(path) def download_full_bundle(self, path: str, dest_dir: str) -> str: - """Download a complete S3 bundle to a local directory. - For archives: uses the ETag cache. - For folders: downloads all objects (with size check). - Use this for the CLI 'download' command.""" - if self._path_is_archive(path): - return self._resolve_archive_bundle(path) - return self._download_folder_bundle(path, dest_dir) - - # ── Folder bundles ─────────────────────────────────────── - - def _get_folder_bundle_info(self, path: str) -> Optional[BundleInfo]: - prefix = self._to_s3_prefix(path) - for fname in TEMPLATE_FILENAMES: - key = prefix + fname - try: - resp = self._s3.get_object(Bucket=self._bucket, Key=key) - raw = resp["Body"].read().decode("utf-8") - template = _parse_template(raw, fname) - if template: - return _extract_bundle_info(template, path) - except self._s3.exceptions.NoSuchKey: - continue - except Exception: - logger.debug("Failed to get S3 object %s", key, exc_info=True) - continue - return None - - # Maximum total size (in bytes) for downloading an S3 folder bundle. - # Folder bundles larger than this should be uploaded as archives instead. - MAX_FOLDER_BUNDLE_SIZE = 50 * 1024 * 1024 # 50 MB - - # Files downloaded during resolve (enough to populate the submitter dialog). - # The full bundle is only downloaded at submission time. - _METADATA_FILES = ( - "template.yaml", - "template.json", - "parameter_values.yaml", - "parameter_values.json", - "asset_references.yaml", - "asset_references.json", - "hooks.yaml", - "hooks.json", - ) - - def _download_folder_bundle(self, path: str, dest_dir: str) -> str: - """Download all objects under the bundle prefix to a local directory.""" - prefix = self._to_s3_prefix(path) - bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] - local_bundle = os.path.join(dest_dir, bundle_name) - os.makedirs(local_bundle, exist_ok=True) - - # Collect all objects and check total size before downloading - objects_to_download: list[tuple[str, str]] = [] # (key, rel_path) - total_size = 0 - paginator = self._s3.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix): - for obj in page.get("Contents", []): - key = obj["Key"] - rel = key[len(prefix) :] - if not rel: - continue - total_size += obj.get("Size", 0) - objects_to_download.append((key, rel)) - - if total_size > self.MAX_FOLDER_BUNDLE_SIZE: - raise RuntimeError( - f"S3 folder bundle '{bundle_name}' is {total_size / (1024 * 1024):.1f} MB, " - f"which exceeds the {self.MAX_FOLDER_BUNDLE_SIZE / (1024 * 1024):.0f} MB limit. " - f"Upload it as an archive instead using 'deadline bundle upload'." - ) - - for key, rel in objects_to_download: - local_path = os.path.join(local_bundle, rel) - os.makedirs(os.path.dirname(local_path), exist_ok=True) - self._s3.download_file(self._bucket, key, local_path) - - return local_bundle - - def _download_folder_bundle_metadata(self, path: str, dest_dir: str) -> str: - """Download only the metadata files (template, parameters, asset_references, hooks) - needed to populate the submitter dialog. Skips scripts and data files.""" - prefix = self._to_s3_prefix(path) - bundle_name = prefix.rstrip("/").rsplit("/", 1)[-1] - local_bundle = os.path.join(dest_dir, bundle_name) - os.makedirs(local_bundle, exist_ok=True) - - for fname in self._METADATA_FILES: - key = prefix + fname - local_path = os.path.join(local_bundle, fname) - try: - self._s3.download_file(self._bucket, key, local_path) - except Exception: - continue # File doesn't exist, skip - - return local_bundle - - def _is_folder_bundle(self, prefix: str) -> bool: - for fname in TEMPLATE_FILENAMES: - try: - self._s3.head_object(Bucket=self._bucket, Key=prefix + fname) - return True - except Exception: - continue - return False - - def _batch_detect_bundles( - self, parent_prefix: str, child_prefixes: list[tuple[str, str, str]] - ) -> set[str]: - """Detect which child prefixes are bundles using a single recursive listing. - Returns the set of child_prefix strings that contain a template file.""" - bundle_set: set[str] = set() - child_prefix_set = {cp for _, cp, _ in child_prefixes} - try: - paginator = self._s3.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=self._bucket, Prefix=parent_prefix): - for obj in page.get("Contents", []): - key = obj["Key"] - # Check if this key is a template file directly inside a child prefix - for cp in child_prefix_set: - for fname in TEMPLATE_FILENAMES: - if key == cp + fname: - bundle_set.add(cp) - break - except Exception: - logger.debug("Failed batch bundle detection for %s", parent_prefix, exc_info=True) - return bundle_set + """Download a complete S3 .ojd bundle to a local directory. + Uses the ETag cache for repeated access.""" + return self._resolve_archive_bundle(path) # ── Archive bundles ────────────────────────────────────── @@ -661,8 +444,6 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: logger.debug("Failed to download S3 archive %s", key, exc_info=True) return None - filename = key.rsplit("/", 1)[-1] - # Extract to cache so resolve_bundle can reuse it if os.path.exists(cache_dir): import shutil @@ -670,13 +451,13 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: shutil.rmtree(cache_dir) os.makedirs(cache_dir, exist_ok=True) try: - _extract_archive_from_bytes(data, filename, cache_dir) + _extract_archive_from_bytes(data, cache_dir) _write_cache_meta(cache_dir, etag, last_modified) except Exception: logger.debug("Failed to cache S3 archive %s", key, exc_info=True) # Parse template from the downloaded bytes - result = _read_template_from_bytes(data, filename) + result = _read_template_from_bytes(data) if result: raw, fname = result template = _parse_template(raw, fname) @@ -714,8 +495,7 @@ def _resolve_archive_bundle(self, path: str) -> str: shutil.rmtree(cache_dir) os.makedirs(cache_dir, exist_ok=True) - filename = key.rsplit("/", 1)[-1] - _extract_archive_from_bytes(data, filename, cache_dir) + _extract_archive_from_bytes(data, cache_dir) _write_cache_meta(cache_dir, etag, last_modified) bundle_path = self._find_bundle_in_cache(cache_dir) @@ -768,12 +548,6 @@ def _find_bundle_in_cache(cache_dir: str) -> Optional[str]: # ── Helpers ────────────────────────────────────────────── - @staticmethod - def _path_is_archive(path: str) -> bool: - # Strip s3:// URI to get the key, then check extension - name = path.rstrip("/").rsplit("/", 1)[-1] - return _is_archive(name) - def _to_s3_key(self, path: str) -> str: """Convert an s3:// URI to a raw S3 key.""" if path.startswith("s3://"): diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index cd867e0c9..7824ac1aa 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -2,10 +2,8 @@ """Tests for the job bundle repository module.""" -import io import json import os -import tarfile import zipfile import yaml @@ -94,7 +92,7 @@ def test_parameter_default_used_when_no_value(self): frames = info.parameters[0] assert frames["_display_value"] == "1-10" - def test_name_resolution_with_param_reference(self): + def test_name_with_param_reference(self): template = { "name": "Render {{Param.SceneName}}", "steps": [], @@ -103,9 +101,9 @@ def test_name_resolution_with_param_reference(self): ], } info = _extract_bundle_info(template, "/path") - assert info.name == "Render my_scene" + assert info.name == "Render {{Param.SceneName}}" - def test_name_resolution_with_parameter_values(self): + def test_name_not_resolved_with_parameter_values(self): template = { "name": "{{Param.JobName}}", "steps": [], @@ -115,9 +113,9 @@ def test_name_resolution_with_parameter_values(self): } pv = {"parameterValues": [{"name": "JobName", "value": "Custom Name"}]} info = _extract_bundle_info(template, "/path", pv) - assert info.name == "Custom Name" + assert info.name == "{{Param.JobName}}" - def test_name_resolution_unresolved_param(self): + def test_name_unresolved_param(self): template = { "name": "{{Param.Missing}}", "steps": [], @@ -126,8 +124,8 @@ def test_name_resolution_unresolved_param(self): info = _extract_bundle_info(template, "/path") assert info.name == "{{Param.Missing}}" - def test_name_resolution_param_from_pv_not_in_definitions(self): - """Parameter values can contain params not in parameterDefinitions (e.g. queue params).""" + def test_name_not_resolved_from_pv(self): + """Parameter values don't affect the displayed name.""" template = { "name": "{{Param.JobName}}", "steps": [], @@ -135,7 +133,7 @@ def test_name_resolution_param_from_pv_not_in_definitions(self): } pv = {"parameterValues": [{"name": "JobName", "value": "From PV"}]} info = _extract_bundle_info(template, "/path", pv) - assert info.name == "From PV" + assert info.name == "{{Param.JobName}}" class TestBundleInfoFromS3Metadata: @@ -167,58 +165,47 @@ def test_name_only(self): class TestArchiveHelpers: def test_is_archive(self): - assert _is_archive("bundle.zip") - assert _is_archive("bundle.tar.gz") - assert _is_archive("bundle.tgz") - assert _is_archive("bundle.tar.bz2") - assert _is_archive("bundle.tar.xz") - assert _is_archive("bundle.tar") + assert _is_archive("bundle.ojd") + assert not _is_archive("bundle.zip") + assert not _is_archive("bundle.tar.gz") + assert not _is_archive("bundle.tgz") + assert not _is_archive("bundle.tar.bz2") + assert not _is_archive("bundle.tar.xz") + assert not _is_archive("bundle.tar") assert not _is_archive("bundle") assert not _is_archive("template.yaml") def test_strip_archive_ext(self): - assert _strip_archive_ext("bundle.zip") == "bundle" - assert _strip_archive_ext("bundle.tar.gz") == "bundle" - assert _strip_archive_ext("bundle.tgz") == "bundle" - assert _strip_archive_ext("my-job.tar.bz2") == "my-job" + assert _strip_archive_ext("bundle.ojd") == "bundle" + assert _strip_archive_ext("my-job.ojd") == "my-job" assert _strip_archive_ext("noext") == "noext" class TestReadTemplateFromArchive: - def _make_zip(self, tmp_path, contents: dict[str, str]) -> str: - zip_path = str(tmp_path / "bundle.zip") - with zipfile.ZipFile(zip_path, "w") as zf: + def _make_ojd(self, tmp_path, contents: dict[str, str]) -> str: + ojd_path = str(tmp_path / "bundle.ojd") + with zipfile.ZipFile(ojd_path, "w") as zf: for name, data in contents.items(): zf.writestr(name, data) - return zip_path + return ojd_path - def _make_tar_gz(self, tmp_path, contents: dict[str, str]) -> str: - tar_path = str(tmp_path / "bundle.tar.gz") - with tarfile.open(tar_path, "w:gz") as tf: - for name, data in contents.items(): - info = tarfile.TarInfo(name=name) - encoded = data.encode("utf-8") - info.size = len(encoded) - tf.addfile(info, io.BytesIO(encoded)) - return tar_path - - def test_zip_root_template(self, tmp_path): - path = self._make_zip(tmp_path, {"template.yaml": "name: ZipBundle\nsteps: []\n"}) + def test_ojd_root_template(self, tmp_path): + path = self._make_ojd(tmp_path, {"template.yaml": "name: OjdBundle\nsteps: []\n"}) result = _read_template_from_archive_path(path) assert result is not None raw, fname = result - assert "ZipBundle" in raw + assert "OjdBundle" in raw assert fname == "template.yaml" - def test_zip_wrapped_template(self, tmp_path): - path = self._make_zip(tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"}) + def test_ojd_wrapped_template(self, tmp_path): + path = self._make_ojd(tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"}) result = _read_template_from_archive_path(path) assert result is not None raw, fname = result assert "Wrapped" in raw - def test_zip_json_template(self, tmp_path): - path = self._make_zip( + def test_ojd_json_template(self, tmp_path): + path = self._make_ojd( tmp_path, {"template.json": json.dumps({"name": "JSONBundle", "steps": []})}, ) @@ -227,26 +214,11 @@ def test_zip_json_template(self, tmp_path): raw, fname = result assert fname == "template.json" - def test_zip_no_template(self, tmp_path): - path = self._make_zip(tmp_path, {"readme.txt": "no template here"}) + def test_ojd_no_template(self, tmp_path): + path = self._make_ojd(tmp_path, {"readme.txt": "no template here"}) result = _read_template_from_archive_path(path) assert result is None - def test_tar_gz_root_template(self, tmp_path): - path = self._make_tar_gz(tmp_path, {"template.yaml": "name: TarBundle\nsteps: []\n"}) - result = _read_template_from_archive_path(path) - assert result is not None - raw, fname = result - assert "TarBundle" in raw - - def test_tar_gz_wrapped_template(self, tmp_path): - path = self._make_tar_gz( - tmp_path, {"my-bundle/template.yaml": "name: TarWrapped\nsteps: []\n"} - ) - result = _read_template_from_archive_path(path) - assert result is not None - assert "TarWrapped" in result[0] - class TestLocalBundleRepository: def test_root_path_default(self): @@ -288,8 +260,8 @@ def test_list_entries_with_bundles_and_dirs(self, tmp_path): assert dir_entry.is_bundle is False def test_list_entries_with_valid_archive(self, tmp_path): - zip_path = tmp_path / "render-job.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "render-job.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("template.yaml", "name: Render\nsteps:\n- name: S1\n") repo = LocalBundleRepository(root=str(tmp_path)) @@ -301,9 +273,9 @@ def test_list_entries_with_valid_archive(self, tmp_path): assert archive_entries[0].is_bundle is True def test_list_entries_invalid_archive_excluded(self, tmp_path): - """A zip without a template should not appear as a bundle.""" - zip_path = tmp_path / "random.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + """An .ojd without a template should not appear as a bundle.""" + ojd_path = tmp_path / "random.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("readme.txt", "not a bundle") repo = LocalBundleRepository(root=str(tmp_path)) @@ -312,8 +284,8 @@ def test_list_entries_invalid_archive_excluded(self, tmp_path): def test_list_entries_include_archives_false(self, tmp_path): """With include_archives=False, archives are skipped entirely.""" - zip_path = tmp_path / "bundle.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "bundle.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("template.yaml", "name: Zipped\nsteps: []\n") bundle_dir = tmp_path / "dir-bundle" @@ -385,19 +357,19 @@ def test_get_bundle_info_with_parameter_values(self, tmp_path): info = repo.get_bundle_info(str(bundle_dir)) assert info is not None - assert info.name == "My Custom Job" + assert info.name == "{{Param.JobName}}" frames = next(p for p in info.parameters if p["name"] == "Frames") assert frames["_display_value"] == "1-100" def test_get_bundle_info_archive(self, tmp_path): - zip_path = tmp_path / "my-job.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "my-job.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr( "template.yaml", yaml.dump( { "name": "Archive Job", - "description": "From a zip", + "description": "From an ojd", "steps": [{"name": "Run"}], "parameterDefinitions": [{"name": "Input", "type": "PATH"}], } @@ -405,11 +377,11 @@ def test_get_bundle_info_archive(self, tmp_path): ) repo = LocalBundleRepository(root=str(tmp_path)) - info = repo.get_bundle_info(str(zip_path)) + info = repo.get_bundle_info(str(ojd_path)) assert info is not None assert info.name == "Archive Job" - assert info.description == "From a zip" + assert info.description == "From an ojd" assert info.step_names == ["Run"] assert len(info.parameters) == 1 @@ -422,29 +394,29 @@ def test_get_bundle_info_not_a_bundle(self, tmp_path): assert info is None def test_extract_bundle_flat(self, tmp_path): - zip_path = tmp_path / "flat.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "flat.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("template.yaml", "name: Flat\nsteps: []\n") zf.writestr("scripts/run.sh", "#!/bin/bash\necho hello\n") dest = tmp_path / "extracted" dest.mkdir() repo = LocalBundleRepository() - result = repo.extract_bundle(str(zip_path), str(dest)) + result = repo.extract_bundle(str(ojd_path), str(dest)) assert os.path.isfile(os.path.join(result, "template.yaml")) assert os.path.isfile(os.path.join(result, "scripts", "run.sh")) def test_extract_bundle_wrapped(self, tmp_path): - zip_path = tmp_path / "wrapped.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "wrapped.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("my-bundle/template.yaml", "name: Wrapped\nsteps: []\n") zf.writestr("my-bundle/scripts/run.sh", "#!/bin/bash\n") dest = tmp_path / "extracted" dest.mkdir() repo = LocalBundleRepository() - result = repo.extract_bundle(str(zip_path), str(dest)) + result = repo.extract_bundle(str(ojd_path), str(dest)) assert os.path.isfile(os.path.join(result, "template.yaml")) From ba42e80a5b321e3aa07162b32fab2cf3521d8801 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 29 May 2026 15:29:51 -0500 Subject: [PATCH 10/89] feat: add translations for new labels Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 3 +- .../client/ui/translations/locales/de_DE.json | 38 ++++++++++++------- .../client/ui/translations/locales/en_US.json | 38 ++++++++++++------- .../client/ui/translations/locales/es_ES.json | 38 ++++++++++++------- .../client/ui/translations/locales/fr_FR.json | 38 ++++++++++++------- .../client/ui/translations/locales/id_ID.json | 38 ++++++++++++------- .../client/ui/translations/locales/it_IT.json | 38 ++++++++++++------- .../client/ui/translations/locales/ja_JP.json | 38 ++++++++++++------- .../client/ui/translations/locales/ko_KR.json | 38 ++++++++++++------- .../client/ui/translations/locales/pt_BR.json | 38 ++++++++++++------- .../client/ui/translations/locales/tr_TR.json | 38 ++++++++++++------- .../client/ui/translations/locales/zh_CN.json | 38 ++++++++++++------- .../client/ui/translations/locales/zh_TW.json | 38 ++++++++++++------- .../job_bundle/test_repository.py | 2 + 14 files changed, 304 insertions(+), 157 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 6785d8d78..8f91e0f8f 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -24,6 +24,7 @@ from ...dataclasses import SubmitterInfo from ...job_bundle.loader import is_job_bundle_dir from ...job_bundle.repository import ( + BundleRepository, LocalBundleRepository, S3BundleRepository, S3_JOB_BUNDLES_PREFIX, @@ -592,7 +593,7 @@ def bundle_list(path, use_s3, no_archives, output, **args): if use_s3: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) - repo = S3BundleRepository( + repo: BundleRepository = S3BundleRepository( bucket_name=s3_settings.s3BucketName, root_prefix=s3_settings.rootPrefix, ) diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index a705baa32..de587f289 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud-Workstation-Konfiguration", "AWS profile": "AWS-Profil", "About": "Über", - "Application Restart Required": "Neustart der Anwendung erforderlich", "Add": "Hinzufügen", "Add amount": "Menge hinzufügen", "Add attribute": "Attribut hinzufügen", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "S3-Jobanhänge immer prüfen", "Amount name": "Mengenname", "Any": "Beliebig", + "Application Restart Required": "Neustart der Anwendung erforderlich", "Apply": "Anwenden", "Array parameter values": "Array-Parameterwerte", "Attach input directories": "Eingabeverzeichnisse anhängen", "Attach input files": "Eingabedateien anhängen", "Attribute name": "Attributname", "Auto accept prompt defaults": "Standardwerte automatisch akzeptieren", + "Browse Job Bundles": "Job-Bundles durchsuchen", "CPU architecture": "CPU-Architektur", "Cancel": "Abbrechen", "Canceling submission...": "Übermittlung wird abgebrochen...", - "Cannot submit job:\n\n\u2022 {issues}": "Job kann nicht übermittelt werden:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Job kann nicht übermittelt werden:\n\n\\u2022 {issues}", "Choose job bundle directory": "Jobpaket-Verzeichnis auswählen", "Close": "Schließen", "Conflict resolution option": "Option zur Konfliktlösung", "Copy": "Kopieren", - "Current: {current_version} -> New: {latest_version}": "Aktuell: {current_version} -> Neu: {latest_version}", "Current logging level": "Aktuelle Protokollierungsebene", + "Current: {current_version} -> New: {latest_version}": "Aktuell: {current_version} -> Neu: {latest_version}", "Custom host requirements": "Benutzerdefinierte Host-Anforderungen", "Data directory": "Datenverzeichnis", "Deadline Cloud settings": "Deadline Cloud-Einstellungen", "Default farm": "Standard-Farm", + "Default maximum failed tasks count": "Standardmäßige maximale Anzahl fehlgeschlagener Aufgaben", + "Default maximum retries per task": "Standardmäßige maximale Wiederholungen pro Aufgabe", "Default queue": "Standard-Warteschlange", "Default storage profile": "Standard-Speicherprofil", "Delete": "Löschen", @@ -58,6 +61,7 @@ "Hardware requirements": "Hardwareanforderungen", "Hashing progress": "Hashing-Fortschritt", "Help": "Hilfe", + "History": "Verlauf", "Host requirements": "Host-Anforderungen", "Initial state": "Anfangszustand", "Issue With Profile Configuration": "Problem mit Profilkonfiguration", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Bestätigung der Jobübermittlung", "Job attachments": "Arbeitsanhänge", "Job attachments filesystem options": "Dateisystemoptionen für Jobanhänge", + "Job bundle directory": "Job-Bundle-Verzeichnis", "Job history directory": "Jobverlaufsverzeichnis", "Job submission confirmation": "Bestätigung der Jobübermittlung", "Job-specific settings": "Jobspezifische Einstellungen", @@ -72,6 +77,7 @@ "Language": "Sprache", "Language will change next time the submitter is opened": "Die Sprache wird beim nächsten Öffnen des Submitters geändert", "Load Bundle": "Paket laden", + "Local": "Lokal", "Log in": "Anmelden", "Log in to AWS Deadline Cloud": "Bei AWS Deadline Cloud anmelden", "Logging you in...": "Sie werden angemeldet...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Speicher (GiB)", "Min": "Min", "More info": "Weitere Informationen", + "Name": "Name", "New version available": "Neue Version verfügbar", "No farm is configured. Click Settings to select a farm for job submission.": "Es ist keine Farm konfiguriert. Klicken Sie auf Einstellungen, um eine Farm für die Jobübermittlung auszuwählen.", "No max worker count": "Keine maximale Worker-Anzahl", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Ungültige Eingaben erkannt", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud-Monitor wird geöffnet. Bitte melden Sie sich an, bevor Sie hierher zurückkehren.", - "Please run the installer and then restart {integration_name} to use the new version.": "Bitte führen Sie den Installer aus und starten Sie dann {integration_name} neu, um die neue Version zu verwenden.", "Operating system": "Betriebssystem", + "Parameters:": "Parameter:", + "Path:": "Pfad:", + "Please run the installer and then restart {integration_name} to use the new version.": "Bitte führen Sie den Installer aus und starten Sie dann {integration_name} neu, um die neue Version zu verwenden.", "Preparing files...": "Dateien werden vorbereitet...", "Preparing for hashing...": "Hashing wird vorbereitet...", "Preparing for upload...": "Upload wird vorbereitet...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Auf Worker-Hosts ausführen, die die folgenden Anforderungen erfüllen", "Saved the submission as a job bundle:\n{path}": "Die Übermittlung wurde als Jobpaket gespeichert:\n{path}", "Scratch space": "Temporärer Speicherplatz", + "Select": "Auswählen", + "Select a job bundle to see details": "Job-Bundle auswählen, um Details anzuzeigen", "Set max worker count": "Maximale Worker-Anzahl festlegen", "Settings...": "Einstellungen...", "Shared job settings": "Gemeinsame Jobeinstellungen", "Show auto-detected": "Automatisch erkannte anzeigen", "Show submitter update notifications": "Aktualisierungsbenachrichtigungen des Submitters anzeigen", + "Source:": "Quelle:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Geben Sie ein Jobpaket-Verzeichnis an oder führen Sie den Bundle-Befehl mit dem Flag --browse aus", "Specify output directories": "Ausgabeverzeichnisse angeben", + "Steps:": "Schritte:", "Submission canceled": "Übermittlung abgebrochen", "Submission complete": "Übermittlung abgeschlossen", "Submission error": "Übermittlungsfehler", @@ -126,20 +139,19 @@ "Telemetry opt out": "Telemetrie deaktivieren", "Template file format": "Vorlagendateiformat", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Die folgenden Parameter werden von der Jobvorlage oder Warteschlange nicht erkannt:\n\n{params}\n\nDiese Parameter werden bei der Jobübermittlung ignoriert.\n\nMöchten Sie fortfahren?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Es gibt ein Konfigurationsproblem mit dem Profil '{profile}'.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, ob Ihre AWS-Konfigurations- und Anmeldeinformationsdateien korrekt sind\n \\u2022 Standardmäßig befinden sich diese Dateien unter ~/.aws unter Linux/MacOS oder %USERPROFILE%/.aws unter Windows\n\\u2022 Überprüfen Sie, ob die richtige AWS-Region festgelegt ist\n \\u2022 Stellen Sie sicher, dass keine Umgebungsvariablen wie AWS_DEFAULT_REGION auf eine falsche Region gesetzt sind\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden:\n \\u2022 Überprüfen Sie, ob der verwendete Anmeldeinformationsprozess die Anmeldeinformationen abrufen kann oder ob diese nicht abgelaufen sind\n \\u2022 Sie können den folgenden Befehl ausführen, um dies zu überprüfen: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Es gibt ein Konfigurationsproblem mit dem Profil '{profile}'.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, ob Ihre AWS-Konfigurations- und Anmeldeinformationsdateien korrekt sind\n \\u2022 Standardmäßig befinden sich diese Dateien unter ~/.aws unter Linux/MacOS oder %USERPROFILE%/.aws unter Windows\n\\u2022 Überprüfen Sie, ob die richtige AWS-Region festgelegt ist\n \\u2022 Stellen Sie sicher, dass keine Umgebungsvariablen wie AWS_DEFAULT_REGION auf eine falsche Region gesetzt sind\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden:\n \\u2022 Überprüfen Sie, ob der verwendete Anmeldeinformationsprozess die Anmeldeinformationen abrufen kann oder ob diese nicht abgelaufen sind\n \\u2022 Sie können den folgenden Befehl ausführen, um dies zu überprüfen: aws sts get-caller-identity --profile ", "There was an error with authentication": "Es ist ein Fehler bei der Authentifizierung aufgetreten", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Beim Versuch, sich mit dem Profil '{profile}' zu authentifizieren, ist ein unbekanntes Problem aufgetreten.\n\nÜberprüfen Sie verfügbare Konsolenprotokolle auf Fehler, um das Problem zu diagnostizieren.\nProtokolle finden Sie normalerweise:\n \\u2022 Im Terminal, von dem aus der Dialog oder die Software, in der der Submitter ausgeführt wird, gestartet wurde \\u2022 In der integrierten Konsole innerhalb der Software, in der der Submitter ausgeführt wird", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Beim Versuch, sich mit dem Profil '{profile}' zu authentifizieren, ist ein unbekanntes Problem aufgetreten.\n\nÜberprüfen Sie verfügbare Konsolenprotokolle auf Fehler, um das Problem zu diagnostizieren.\nProtokolle finden Sie normalerweise:\n \\u2022 Im Terminal, von dem aus der Dialog oder die Software, in der der Submitter ausgeführt wird, gestartet wurde \\u2022 In der integrierten Konsole innerhalb der Software, in der der Submitter ausgeführt wird", "Timeouts": "Timeouts", "Unknown Issue With Configured Profile": "Unbekanntes Problem mit konfiguriertem Profil", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} von Deadline Cloud für {integration_name}-Submitter ist jetzt verfügbar.", - "View release notes": "Versionshinweise anzeigen", "Unrecognized Parameters": "Nicht erkannte Parameter", "Upload progress": "Upload-Fortschritt", "Use array parameter": "Array-Parameter verwenden", "Value(s)": "Wert(e)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Sie sind mit dem Profil '{profile}' authentifiziert, aber dieses Profil kann AWS Deadline Cloud ListFarms nicht aufrufen und keine Jobs an AWS Deadline Cloud übermitteln.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, dass keine Umgebungsvariablen auf die falsche AWS-Region verweisen (z. B. AWS_DEFAULT_REGION)\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden, überprüfen Sie, ob das Profil Berechtigungen für diese AWS Deadline Cloud-APIs hat, die für die Übermittlung erforderlich sind:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} von Deadline Cloud für {integration_name}-Submitter ist jetzt verfügbar.", + "View release notes": "Versionshinweise anzeigen", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Sie sind mit dem Profil '{profile}' authentifiziert, aber dieses Profil kann AWS Deadline Cloud ListFarms nicht aufrufen und keine Jobs an AWS Deadline Cloud übermitteln.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, dass keine Umgebungsvariablen auf die falsche AWS-Region verweisen (z. B. AWS_DEFAULT_REGION)\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden, überprüfen Sie, ob das Profil Berechtigungen für diese AWS Deadline Cloud-APIs hat, die für die Übermittlung erforderlich sind:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Sie sind abgemeldet.", - "{submitter} job submission": "{submitter}-Jobübermittlung", - "Default maximum retries per task": "Standardm\u00e4\u00dfige maximale Wiederholungen pro Aufgabe", - "Default maximum failed tasks count": "Standardm\u00e4\u00dfige maximale Anzahl fehlgeschlagener Aufgaben" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} hat keine Zugriffsberechtigungen zum Übermitteln eines Jobs.", + "{submitter} job submission": "{submitter}-Jobübermittlung" +} diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index c1322fc04..243a50f3a 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud workstation configuration", "AWS profile": "AWS profile", "About": "About", - "Application Restart Required": "Application Restart Required", "Add": "Add", "Add amount": "Add amount", "Add attribute": "Add attribute", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Always check S3 job attachments", "Amount name": "Amount name", "Any": "Any", + "Application Restart Required": "Application Restart Required", "Apply": "Apply", "Array parameter values": "Array parameter values", "Attach input directories": "Attach input directories", "Attach input files": "Attach input files", "Attribute name": "Attribute name", "Auto accept prompt defaults": "Auto accept prompt defaults", + "Browse Job Bundles": "Browse Job Bundles", "CPU architecture": "CPU architecture", "Cancel": "Cancel", "Canceling submission...": "Canceling submission...", - "Cannot submit job:\n\n\u2022 {issues}": "Cannot submit job:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Cannot submit job:\n\n\\u2022 {issues}", "Choose job bundle directory": "Choose job bundle directory", "Close": "Close", "Conflict resolution option": "Conflict resolution option", "Copy": "Copy", - "Current: {current_version} -> New: {latest_version}": "Current: {current_version} -> New: {latest_version}", "Current logging level": "Current logging level", + "Current: {current_version} -> New: {latest_version}": "Current: {current_version} -> New: {latest_version}", "Custom host requirements": "Custom host requirements", "Data directory": "Data directory", "Deadline Cloud settings": "Deadline Cloud settings", "Default farm": "Default farm", + "Default maximum failed tasks count": "Default maximum failed tasks count", + "Default maximum retries per task": "Default maximum retries per task", "Default queue": "Default queue", "Default storage profile": "Default storage profile", "Delete": "Delete", @@ -58,6 +61,7 @@ "Hardware requirements": "Hardware requirements", "Hashing progress": "Hashing progress", "Help": "Help", + "History": "History", "Host requirements": "Host requirements", "Initial state": "Initial state", "Issue With Profile Configuration": "Issue With Profile Configuration", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Job Submission Confirmation", "Job attachments": "Job attachments", "Job attachments filesystem options": "Job attachments filesystem options", + "Job bundle directory": "Job bundle directory", "Job history directory": "Job history directory", "Job submission confirmation": "Job submission confirmation", "Job-specific settings": "Job-specific settings", @@ -72,6 +77,7 @@ "Language": "Language", "Language will change next time the submitter is opened": "Language will change next time the submitter is opened", "Load Bundle": "Load Bundle", + "Local": "Local", "Log in": "Log in", "Log in to AWS Deadline Cloud": "Log in to AWS Deadline Cloud", "Logging you in...": "Logging you in...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Memory (GiB)", "Min": "Min", "More info": "More info", + "Name": "Name", "New version available": "New version available", "No farm is configured. Click Settings to select a farm for job submission.": "No farm is configured. Click Settings to select a farm for job submission.", "No max worker count": "No max worker count", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Non valid inputs detected", "Ok": "Ok", "Opening Deadline Cloud monitor. Please log in before returning here.": "Opening Deadline Cloud monitor. Please log in before returning here.", - "Please run the installer and then restart {integration_name} to use the new version.": "Please run the installer and then restart {integration_name} to use the new version.", "Operating system": "Operating system", + "Parameters:": "Parameters:", + "Path:": "Path:", + "Please run the installer and then restart {integration_name} to use the new version.": "Please run the installer and then restart {integration_name} to use the new version.", "Preparing files...": "Preparing files...", "Preparing for hashing...": "Preparing for hashing...", "Preparing for upload...": "Preparing for upload...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Run on worker hosts that meet the following requirements", "Saved the submission as a job bundle:\n{path}": "Saved the submission as a job bundle:\n{path}", "Scratch space": "Scratch space", + "Select": "Select", + "Select a job bundle to see details": "Select a job bundle to see details", "Set max worker count": "Set max worker count", "Settings...": "Settings...", "Shared job settings": "Shared job settings", "Show auto-detected": "Show auto-detected", "Show submitter update notifications": "Show submitter update notifications", + "Source:": "Source:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specify a job bundle directory or run the bundle command with the --browse flag", "Specify output directories": "Specify output directories", + "Steps:": "Steps:", "Submission canceled": "Submission canceled", "Submission complete": "Submission complete", "Submission error": "Submission error", @@ -126,20 +139,19 @@ "Telemetry opt out": "Telemetry opt out", "Template file format": "Template file format", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\\u2022 Verify your AWS config and credentials files are correct\n \\u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\\u2022 Verify that the correct AWS region is set\n \\u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\\u2022 If you are not using a Deadline Cloud Monitor profile:\n \\u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \\u2022 You can run the following command to check: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\\u2022 Verify your AWS config and credentials files are correct\n \\u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\\u2022 Verify that the correct AWS region is set\n \\u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\\u2022 If you are not using a Deadline Cloud Monitor profile:\n \\u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \\u2022 You can run the following command to check: aws sts get-caller-identity --profile ", "There was an error with authentication": "There was an error with authentication", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \\u2022 In the terminal that the dialog or software the submitter is running in was launched from \\u2022 In the built-in console within the software that the submitter is running in", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \\u2022 In the terminal that the dialog or software the submitter is running in was launched from \\u2022 In the built-in console within the software that the submitter is running in", "Timeouts": "Timeouts", "Unknown Issue With Configured Profile": "Unknown Issue With Configured Profile", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.", - "View release notes": "View release notes", "Unrecognized Parameters": "Unrecognized Parameters", "Upload progress": "Upload progress", "Use array parameter": "Use array parameter", "Value(s)": "Value(s)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.", + "View release notes": "View release notes", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - You are logged out.", - "{submitter} job submission": "{submitter} job submission", - "Default maximum retries per task": "Default maximum retries per task", - "Default maximum failed tasks count": "Default maximum failed tasks count" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} doesn't have access permissions to submit a job.", + "{submitter} job submission": "{submitter} job submission" +} diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index 6b05b2ef9..723fbf4e0 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "Configuración de estación de trabajo de AWS Deadline Cloud", "AWS profile": "Perfil de AWS", "About": "Acerca de", - "Application Restart Required": "Se requiere reiniciar la aplicación", "Add": "Agregar", "Add amount": "Agregar cantidad", "Add attribute": "Agregar atributo", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Comprobar siempre los archivos adjuntos de trabajo en S3", "Amount name": "Nombre de cantidad", "Any": "Cualquiera", + "Application Restart Required": "Se requiere reiniciar la aplicación", "Apply": "Aplicar", "Array parameter values": "Valores de parámetros de matriz", "Attach input directories": "Adjuntar directorios de entrada", "Attach input files": "Adjuntar archivos de entrada", "Attribute name": "Nombre de atributo", "Auto accept prompt defaults": "Aceptar automáticamente valores predeterminados", + "Browse Job Bundles": "Explorar paquetes de trabajos", "CPU architecture": "Arquitectura de CPU", "Cancel": "Cancelar", "Canceling submission...": "Cancelando envío...", - "Cannot submit job:\n\n\u2022 {issues}": "No se puede enviar el trabajo:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "No se puede enviar el trabajo:\n\n\\u2022 {issues}", "Choose job bundle directory": "Elegir directorio de paquete de trabajos", "Close": "Cerrar", "Conflict resolution option": "Opción de resolución de conflictos", "Copy": "Copiar", - "Current: {current_version} -> New: {latest_version}": "Actual: {current_version} -> Nuevo: {latest_version}", "Current logging level": "Nivel de registro actual", + "Current: {current_version} -> New: {latest_version}": "Actual: {current_version} -> Nuevo: {latest_version}", "Custom host requirements": "Requisitos de host personalizados", "Data directory": "Directorio de datos", "Deadline Cloud settings": "Configuración de Deadline Cloud", "Default farm": "Granja predeterminada", + "Default maximum failed tasks count": "Recuento máximo de tareas fallidas predeterminado", + "Default maximum retries per task": "Reintentos máximos por tarea predeterminados", "Default queue": "Cola predeterminada", "Default storage profile": "Perfil de almacenamiento predeterminado", "Delete": "Eliminar", @@ -58,6 +61,7 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progreso de hash", "Help": "Ayuda", + "History": "Historial", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema con la configuración del perfil", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Confirmación de envío de trabajo", "Job attachments": "Adjuntos de trabajo", "Job attachments filesystem options": "Opciones del sistema de archivos de adjuntos de trabajo", + "Job bundle directory": "Directorio de paquetes de trabajos", "Job history directory": "Directorio de historial de trabajos", "Job submission confirmation": "Confirmación de envío de trabajo", "Job-specific settings": "Configuración específica del trabajo", @@ -72,6 +77,7 @@ "Language": "Idioma", "Language will change next time the submitter is opened": "El idioma cambiará la próxima vez que se abra el submitter", "Load Bundle": "Cargar paquete", + "Local": "Local", "Log in": "Iniciar sesión", "Log in to AWS Deadline Cloud": "Iniciar sesión en AWS Deadline Cloud", "Logging you in...": "Iniciando sesión...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Memoria (GiB)", "Min": "Mín", "More info": "Más información", + "Name": "Nombre", "New version available": "Nueva versión disponible", "No farm is configured. Click Settings to select a farm for job submission.": "No hay ninguna granja configurada. Haga clic en Configuración para seleccionar una granja para el envío de trabajos.", "No max worker count": "Sin recuento máximo de trabajadores", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Se detectaron entradas no válidas", "Ok": "Aceptar", "Opening Deadline Cloud monitor. Please log in before returning here.": "Abriendo el monitor de Deadline Cloud. Inicie sesión antes de volver aquí.", - "Please run the installer and then restart {integration_name} to use the new version.": "Ejecute el instalador y luego reinicie {integration_name} para usar la nueva versión.", "Operating system": "Sistema operativo", + "Parameters:": "Parámetros:", + "Path:": "Ruta:", + "Please run the installer and then restart {integration_name} to use the new version.": "Ejecute el instalador y luego reinicie {integration_name} para usar la nueva versión.", "Preparing files...": "Preparando archivos...", "Preparing for hashing...": "Preparando para hash...", "Preparing for upload...": "Preparando para carga...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Ejecutar en hosts de trabajadores que cumplan los siguientes requisitos", "Saved the submission as a job bundle:\n{path}": "Se guardó el envío como paquete de trabajos:\n{path}", "Scratch space": "Espacio temporal", + "Select": "Seleccionar", + "Select a job bundle to see details": "Seleccione un paquete de trabajo para ver los detalles", "Set max worker count": "Establecer recuento máximo de trabajadores", "Settings...": "Configuración...", "Shared job settings": "Configuración de trabajo compartida", "Show auto-detected": "Mostrar detectados automáticamente", "Show submitter update notifications": "Mostrar notificaciones de actualización del remitente", + "Source:": "Origen:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique un directorio de paquete de trabajos o ejecute el comando bundle con la marca --browse", "Specify output directories": "Especificar directorios de salida", + "Steps:": "Pasos:", "Submission canceled": "Envío cancelado", "Submission complete": "Envío completado", "Submission error": "Error de envío", @@ -126,20 +139,19 @@ "Telemetry opt out": "Desactivar telemetría", "Template file format": "Formato de archivo de plantilla", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "La plantilla de trabajo o la cola no reconocen los siguientes parámetros:\n\n{params}\n\nEstos parámetros se ignorarán durante el envío del trabajo.\n\n¿Desea continuar?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Hay un problema de configuración con el perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique que sus archivos de configuración y credenciales de AWS sean correctos\n \\u2022 De forma predeterminada, estos archivos se encuentran en ~/.aws en Linux/MacOS o %USERPROFILE%/.aws en Windows\n\\u2022 Verifique que esté configurada la región de AWS correcta\n \\u2022 Compruebe que no haya variables de entorno como AWS_DEFAULT_REGION configuradas en una región incorrecta\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor:\n \\u2022 Verifique que cualquier proceso de credenciales que se esté utilizando pueda recuperar las credenciales o que no hayan caducado\n \\u2022 Puede ejecutar el siguiente comando para comprobarlo: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Hay un problema de configuración con el perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique que sus archivos de configuración y credenciales de AWS sean correctos\n \\u2022 De forma predeterminada, estos archivos se encuentran en ~/.aws en Linux/MacOS o %USERPROFILE%/.aws en Windows\n\\u2022 Verifique que esté configurada la región de AWS correcta\n \\u2022 Compruebe que no haya variables de entorno como AWS_DEFAULT_REGION configuradas en una región incorrecta\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor:\n \\u2022 Verifique que cualquier proceso de credenciales que se esté utilizando pueda recuperar las credenciales o que no hayan caducado\n \\u2022 Puede ejecutar el siguiente comando para comprobarlo: aws sts get-caller-identity --profile ", "There was an error with authentication": "Se produjo un error con la autenticación", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Se produjo un problema desconocido al intentar autenticarse con el perfil '{profile}'.\n\nCompruebe los registros de consola disponibles en busca de errores para intentar diagnosticar el problema.\nLos registros se encuentran comúnmente:\n \\u2022 En el terminal desde el que se inició el cuadro de diálogo o el software en el que se ejecuta el remitente \\u2022 En la consola integrada dentro del software en el que se ejecuta el remitente", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Se produjo un problema desconocido al intentar autenticarse con el perfil '{profile}'.\n\nCompruebe los registros de consola disponibles en busca de errores para intentar diagnosticar el problema.\nLos registros se encuentran comúnmente:\n \\u2022 En el terminal desde el que se inició el cuadro de diálogo o el software en el que se ejecuta el remitente \\u2022 En la consola integrada dentro del software en el que se ejecuta el remitente", "Timeouts": "Tiempos de espera", "Unknown Issue With Configured Profile": "Problema desconocido con el perfil configurado", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versión {latest_version} de Deadline Cloud para el remitente de {integration_name} ya está disponible.", - "View release notes": "Ver notas de la versión", "Unrecognized Parameters": "Parámetros no reconocidos", "Upload progress": "Progreso de carga", "Use array parameter": "Usar parámetro de matriz", "Value(s)": "Valor(es)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Está autenticado con el perfil '{profile}', pero este perfil no puede llamar a AWS Deadline Cloud ListFarms y no puede enviar trabajos a AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Compruebe que no haya variables de entorno que apunten a la región de AWS incorrecta (por ejemplo, AWS_DEFAULT_REGION)\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor, compruebe que el perfil tenga permisos para estas API de AWS Deadline Cloud necesarias para el envío:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versión {latest_version} de Deadline Cloud para el remitente de {integration_name} ya está disponible.", + "View release notes": "Ver notas de la versión", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Está autenticado con el perfil '{profile}', pero este perfil no puede llamar a AWS Deadline Cloud ListFarms y no puede enviar trabajos a AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Compruebe que no haya variables de entorno que apunten a la región de AWS incorrecta (por ejemplo, AWS_DEFAULT_REGION)\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor, compruebe que el perfil tenga permisos para estas API de AWS Deadline Cloud necesarias para el envío:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Ha cerrado sesión.", - "{submitter} job submission": "Envío de trabajo de {submitter}", - "Default maximum retries per task": "Reintentos m\u00e1ximos por tarea predeterminados", - "Default maximum failed tasks count": "Recuento m\u00e1ximo de tareas fallidas predeterminado" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} no tiene permisos de acceso para enviar un trabajo.", + "{submitter} job submission": "Envío de trabajo de {submitter}" +} diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index 6cd7db751..a759bad9e 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "Configuration de poste de travail AWS Deadline Cloud", "AWS profile": "Profil AWS", "About": "À propos", - "Application Restart Required": "Redémarrage de l'application requis", "Add": "Ajouter", "Add amount": "Ajouter une quantité", "Add attribute": "Ajouter un attribut", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Toujours vérifier les fichiers joints de tâche S3", "Amount name": "Nom de quantité", "Any": "Quelconque", + "Application Restart Required": "Redémarrage de l'application requis", "Apply": "Appliquer", "Array parameter values": "Valeurs de paramètres de tableau", "Attach input directories": "Joindre des répertoires d'entrée", "Attach input files": "Joindre des fichiers d'entrée", "Attribute name": "Nom d'attribut", "Auto accept prompt defaults": "Accepter automatiquement les valeurs par défaut", + "Browse Job Bundles": "Parcourir les lots de tâches", "CPU architecture": "Architecture CPU", "Cancel": "Annuler", "Canceling submission...": "Annulation de la soumission...", - "Cannot submit job:\n\n\u2022 {issues}": "Impossible de soumettre la tâche :\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Impossible de soumettre la tâche :\n\n\\u2022 {issues}", "Choose job bundle directory": "Choisir le répertoire du lot de tâches", "Close": "Fermer", "Conflict resolution option": "Option de résolution de conflits", "Copy": "Copier", - "Current: {current_version} -> New: {latest_version}": "Actuel : {current_version} -> Nouveau : {latest_version}", "Current logging level": "Niveau de journalisation actuel", + "Current: {current_version} -> New: {latest_version}": "Actuel : {current_version} -> Nouveau : {latest_version}", "Custom host requirements": "Exigences d'hôte personnalisées", "Data directory": "Répertoire de données", "Deadline Cloud settings": "Paramètres Deadline Cloud", "Default farm": "Ferme par défaut", + "Default maximum failed tasks count": "Nombre maximal de tâches échouées par défaut", + "Default maximum retries per task": "Nombre maximal de tentatives par tâche par défaut", "Default queue": "File d'attente par défaut", "Default storage profile": "Profil de stockage par défaut", "Delete": "Supprimer", @@ -58,6 +61,7 @@ "Hardware requirements": "Exigences matérielles", "Hashing progress": "Progression du hachage", "Help": "Aide", + "History": "Historique", "Host requirements": "Exigences d'hôte", "Initial state": "État initial", "Issue With Profile Configuration": "Problème avec la configuration du profil", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Confirmation de soumission de tâche", "Job attachments": "Fichiers joints de tâche", "Job attachments filesystem options": "Options du système de fichiers des pièces jointes aux tâches", + "Job bundle directory": "Répertoire des lots de tâches", "Job history directory": "Répertoire d'historique des tâches", "Job submission confirmation": "Confirmation de soumission de tâche", "Job-specific settings": "Paramètres spécifiques à la tâche", @@ -72,6 +77,7 @@ "Language": "Langue", "Language will change next time the submitter is opened": "La langue changera lors de la prochaine ouverture du submitter", "Load Bundle": "Charger le lot", + "Local": "Local", "Log in": "Se connecter", "Log in to AWS Deadline Cloud": "Se connecter à AWS Deadline Cloud", "Logging you in...": "Connexion en cours...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Mémoire (Gio)", "Min": "Min", "More info": "Plus d'informations", + "Name": "Nom", "New version available": "Nouvelle version disponible", "No farm is configured. Click Settings to select a farm for job submission.": "Aucune ferme n'est configurée. Cliquez sur Paramètres pour sélectionner une ferme pour la soumission de tâches.", "No max worker count": "Aucun nombre maximal de travailleurs", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Entrées non valides détectées", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Ouverture du moniteur Deadline Cloud. Veuillez vous connecter avant de revenir ici.", - "Please run the installer and then restart {integration_name} to use the new version.": "Veuillez exécuter l'installateur puis redémarrer {integration_name} pour utiliser la nouvelle version.", "Operating system": "Système d'exploitation", + "Parameters:": "Paramètres :", + "Path:": "Chemin :", + "Please run the installer and then restart {integration_name} to use the new version.": "Veuillez exécuter l'installateur puis redémarrer {integration_name} pour utiliser la nouvelle version.", "Preparing files...": "Préparation des fichiers...", "Preparing for hashing...": "Préparation du hachage...", "Preparing for upload...": "Préparation du téléchargement...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Exécuter sur les hôtes de travail qui répondent aux exigences suivantes", "Saved the submission as a job bundle:\n{path}": "La soumission a été enregistrée en tant que lot de tâches :\n{path}", "Scratch space": "Espace temporaire", + "Select": "Sélectionner", + "Select a job bundle to see details": "Sélectionnez un lot de tâches pour voir les détails", "Set max worker count": "Définir le nombre maximal de travailleurs", "Settings...": "Paramètres...", "Shared job settings": "Paramètres de tâche partagés", "Show auto-detected": "Afficher les éléments détectés automatiquement", "Show submitter update notifications": "Afficher les notifications de mise à jour du soumetteur", + "Source:": "Source :", "Specify a job bundle directory or run the bundle command with the --browse flag": "Spécifiez un répertoire de lot de tâches ou exécutez la commande bundle avec l'indicateur --browse", "Specify output directories": "Spécifier les répertoires de sortie", + "Steps:": "Étapes :", "Submission canceled": "Soumission annulée", "Submission complete": "Soumission terminée", "Submission error": "Erreur de soumission", @@ -126,20 +139,19 @@ "Telemetry opt out": "Désactiver la télémétrie", "Template file format": "Format de fichier de modèle", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Les paramètres suivants ne sont pas reconnus par le modèle de tâche ou la file d'attente :\n\n{params}\n\nCes paramètres seront ignorés lors de la soumission de la tâche.\n\nVoulez-vous continuer ?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Il y a un problème de configuration avec le profil '{profile}'.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez que vos fichiers de configuration et d'informations d'identification AWS sont corrects\n \\u2022 Par défaut, ces fichiers se trouvent dans ~/.aws sur Linux/MacOS ou %USERPROFILE%/.aws sur Windows\n\\u2022 Vérifiez que la région AWS correcte est définie\n \\u2022 Vérifiez qu'aucune variable d'environnement comme AWS_DEFAULT_REGION n'est définie sur une région incorrecte\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor :\n \\u2022 Vérifiez que tout processus d'informations d'identification utilisé est capable de récupérer les informations d'identification ou qu'elles ne sont pas expirées\n \\u2022 Vous pouvez exécuter la commande suivante pour vérifier : aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Il y a un problème de configuration avec le profil '{profile}'.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez que vos fichiers de configuration et d'informations d'identification AWS sont corrects\n \\u2022 Par défaut, ces fichiers se trouvent dans ~/.aws sur Linux/MacOS ou %USERPROFILE%/.aws sur Windows\n\\u2022 Vérifiez que la région AWS correcte est définie\n \\u2022 Vérifiez qu'aucune variable d'environnement comme AWS_DEFAULT_REGION n'est définie sur une région incorrecte\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor :\n \\u2022 Vérifiez que tout processus d'informations d'identification utilisé est capable de récupérer les informations d'identification ou qu'elles ne sont pas expirées\n \\u2022 Vous pouvez exécuter la commande suivante pour vérifier : aws sts get-caller-identity --profile ", "There was an error with authentication": "Une erreur s'est produite lors de l'authentification", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Un problème inconnu s'est produit lors de la tentative d'authentification avec le profil '{profile}'.\n\nVérifiez les journaux de console disponibles pour détecter les erreurs et essayer de diagnostiquer le problème.\nLes journaux se trouvent généralement :\n \\u2022 Dans le terminal à partir duquel la boîte de dialogue ou le logiciel dans lequel le soumetteur s'exécute a été lancé \\u2022 Dans la console intégrée du logiciel dans lequel le soumetteur s'exécute", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Un problème inconnu s'est produit lors de la tentative d'authentification avec le profil '{profile}'.\n\nVérifiez les journaux de console disponibles pour détecter les erreurs et essayer de diagnostiquer le problème.\nLes journaux se trouvent généralement :\n \\u2022 Dans le terminal à partir duquel la boîte de dialogue ou le logiciel dans lequel le soumetteur s'exécute a été lancé \\u2022 Dans la console intégrée du logiciel dans lequel le soumetteur s'exécute", "Timeouts": "Délais d'expiration", "Unknown Issue With Configured Profile": "Problème inconnu avec le profil configuré", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La version {latest_version} de Deadline Cloud pour le soumetteur {integration_name} est maintenant disponible.", - "View release notes": "Voir les notes de version", "Unrecognized Parameters": "Paramètres non reconnus", "Upload progress": "Progression du téléchargement", "Use array parameter": "Utiliser un paramètre de tableau", "Value(s)": "Valeur(s)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Vous êtes authentifié avec le profil '{profile}', mais ce profil ne peut pas appeler AWS Deadline Cloud ListFarms et ne peut pas soumettre de tâches à AWS Deadline Cloud.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez qu'aucune variable d'environnement ne pointe vers la mauvaise région AWS (par exemple, AWS_DEFAULT_REGION)\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor, vérifiez que le profil dispose des autorisations pour ces API AWS Deadline Cloud nécessaires à la soumission :\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La version {latest_version} de Deadline Cloud pour le soumetteur {integration_name} est maintenant disponible.", + "View release notes": "Voir les notes de version", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Vous êtes authentifié avec le profil '{profile}', mais ce profil ne peut pas appeler AWS Deadline Cloud ListFarms et ne peut pas soumettre de tâches à AWS Deadline Cloud.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez qu'aucune variable d'environnement ne pointe vers la mauvaise région AWS (par exemple, AWS_DEFAULT_REGION)\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor, vérifiez que le profil dispose des autorisations pour ces API AWS Deadline Cloud nécessaires à la soumission :\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Vous êtes déconnecté.", - "{submitter} job submission": "Soumission de tâche {submitter}", - "Default maximum retries per task": "Nombre maximal de tentatives par t\u00e2che par d\u00e9faut", - "Default maximum failed tasks count": "Nombre maximal de t\u00e2ches \u00e9chou\u00e9es par d\u00e9faut" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} n'a pas les autorisations d'accès pour soumettre une tâche.", + "{submitter} job submission": "Soumission de tâche {submitter}" +} diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index f475013b9..cc8a8ded5 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "Konfigurasi workstation AWS Deadline Cloud", "AWS profile": "Profil AWS", "About": "Tentang", - "Application Restart Required": "Diperlukan restart aplikasi", "Add": "Tambah", "Add amount": "Tambah jumlah", "Add attribute": "Tambah atribut", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Selalu periksa lampiran pekerjaan S3", "Amount name": "Nama jumlah", "Any": "Apa saja", + "Application Restart Required": "Diperlukan restart aplikasi", "Apply": "Terapkan", "Array parameter values": "Nilai parameter array", "Attach input directories": "Lampirkan direktori input", "Attach input files": "Lampirkan file input", "Attribute name": "Nama atribut", "Auto accept prompt defaults": "Terima default secara otomatis", + "Browse Job Bundles": "Jelajahi bundel tugas", "CPU architecture": "Arsitektur CPU", "Cancel": "Batal", "Canceling submission...": "Membatalkan pengiriman...", - "Cannot submit job:\n\n\u2022 {issues}": "Tidak dapat mengirim pekerjaan:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Tidak dapat mengirim pekerjaan:\n\n\\u2022 {issues}", "Choose job bundle directory": "Pilih direktori bundel pekerjaan", "Close": "Tutup", "Conflict resolution option": "Opsi resolusi konflik", "Copy": "Salin", - "Current: {current_version} -> New: {latest_version}": "Saat ini: {current_version} -> Baru: {latest_version}", "Current logging level": "Tingkat logging saat ini", + "Current: {current_version} -> New: {latest_version}": "Saat ini: {current_version} -> Baru: {latest_version}", "Custom host requirements": "Persyaratan host kustom", "Data directory": "Direktori data", "Deadline Cloud settings": "Pengaturan Deadline Cloud", "Default farm": "Peternakan default", + "Default maximum failed tasks count": "Jumlah tugas gagal maksimum default", + "Default maximum retries per task": "Percobaan ulang maksimum default per tugas", "Default queue": "Antrian default", "Default storage profile": "Profil penyimpanan default", "Delete": "Hapus", @@ -58,6 +61,7 @@ "Hardware requirements": "Persyaratan perangkat keras", "Hashing progress": "Kemajuan hashing", "Help": "Bantuan", + "History": "Riwayat", "Host requirements": "Persyaratan host", "Initial state": "Status awal", "Issue With Profile Configuration": "Masalah dengan konfigurasi profil", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Konfirmasi pengiriman pekerjaan", "Job attachments": "Lampiran Job", "Job attachments filesystem options": "Opsi sistem file lampiran pekerjaan", + "Job bundle directory": "Direktori bundel tugas", "Job history directory": "Direktori riwayat pekerjaan", "Job submission confirmation": "Konfirmasi pengiriman pekerjaan", "Job-specific settings": "Pengaturan khusus pekerjaan", @@ -72,6 +77,7 @@ "Language": "Bahasa", "Language will change next time the submitter is opened": "Bahasa akan berubah saat submitter dibuka kembali", "Load Bundle": "Muat bundel", + "Local": "Lokal", "Log in": "Masuk", "Log in to AWS Deadline Cloud": "Masuk ke AWS Deadline Cloud", "Logging you in...": "Memasukkan Anda...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Memori (GiB)", "Min": "Min", "More info": "Info lebih lanjut", + "Name": "Nama", "New version available": "Versi baru tersedia", "No farm is configured. Click Settings to select a farm for job submission.": "Tidak ada peternakan yang dikonfigurasi. Klik Pengaturan untuk memilih peternakan untuk pengiriman pekerjaan.", "No max worker count": "Tidak ada jumlah pekerja maksimum", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Input tidak valid terdeteksi", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Membuka monitor Deadline Cloud. Harap masuk sebelum kembali ke sini.", - "Please run the installer and then restart {integration_name} to use the new version.": "Silakan jalankan installer lalu restart {integration_name} untuk menggunakan versi baru.", "Operating system": "Sistem operasi", + "Parameters:": "Parameter:", + "Path:": "Jalur:", + "Please run the installer and then restart {integration_name} to use the new version.": "Silakan jalankan installer lalu restart {integration_name} untuk menggunakan versi baru.", "Preparing files...": "Menyiapkan file...", "Preparing for hashing...": "Mempersiapkan untuk hashing...", "Preparing for upload...": "Mempersiapkan untuk upload...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Jalankan di host pekerja yang memenuhi persyaratan berikut", "Saved the submission as a job bundle:\n{path}": "Menyimpan pengiriman sebagai bundel pekerjaan:\n{path}", "Scratch space": "Ruang sementara", + "Select": "Pilih", + "Select a job bundle to see details": "Pilih bundel tugas untuk melihat detail", "Set max worker count": "Atur jumlah pekerja maksimum", "Settings...": "Pengaturan...", "Shared job settings": "Pengaturan pekerjaan bersama", "Show auto-detected": "Tampilkan yang terdeteksi otomatis", "Show submitter update notifications": "Tampilkan notifikasi pembaruan pengirim", + "Source:": "Sumber:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Tentukan direktori bundel pekerjaan atau jalankan perintah bundle dengan flag --browse", "Specify output directories": "Tentukan direktori output", + "Steps:": "Langkah:", "Submission canceled": "Pengiriman dibatalkan", "Submission complete": "Pengiriman selesai", "Submission error": "Kesalahan pengiriman", @@ -126,20 +139,19 @@ "Telemetry opt out": "Nonaktifkan telemetri", "Template file format": "Format file template", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Parameter berikut tidak dikenali oleh template pekerjaan atau antrian:\n\n{params}\n\nParameter ini akan diabaikan selama pengiriman pekerjaan.\n\nApakah Anda ingin melanjutkan?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Ada masalah konfigurasi dengan profil '{profile}'.\n\nUntuk mengatasi masalah ini:\n\\u2022 Verifikasi bahwa file konfigurasi dan kredensial AWS Anda benar\n \\u2022 Secara default, file ini dapat ditemukan di ~/.aws di Linux/MacOS atau %USERPROFILE%/.aws di Windows\n\\u2022 Verifikasi bahwa wilayah AWS yang benar telah diatur\n \\u2022 Periksa bahwa tidak ada variabel lingkungan seperti AWS_DEFAULT_REGION yang diatur ke wilayah yang salah\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor:\n \\u2022 Verifikasi bahwa proses kredensial apa pun yang digunakan dapat mengambil kredensial atau bahwa kredensial tersebut tidak kedaluwarsa\n \\u2022 Anda dapat menjalankan perintah berikut untuk memeriksa: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Ada masalah konfigurasi dengan profil '{profile}'.\n\nUntuk mengatasi masalah ini:\n\\u2022 Verifikasi bahwa file konfigurasi dan kredensial AWS Anda benar\n \\u2022 Secara default, file ini dapat ditemukan di ~/.aws di Linux/MacOS atau %USERPROFILE%/.aws di Windows\n\\u2022 Verifikasi bahwa wilayah AWS yang benar telah diatur\n \\u2022 Periksa bahwa tidak ada variabel lingkungan seperti AWS_DEFAULT_REGION yang diatur ke wilayah yang salah\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor:\n \\u2022 Verifikasi bahwa proses kredensial apa pun yang digunakan dapat mengambil kredensial atau bahwa kredensial tersebut tidak kedaluwarsa\n \\u2022 Anda dapat menjalankan perintah berikut untuk memeriksa: aws sts get-caller-identity --profile ", "There was an error with authentication": "Terjadi kesalahan dengan autentikasi", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Terjadi masalah yang tidak diketahui saat mencoba mengautentikasi dengan profil '{profile}'.\n\nPeriksa log konsol yang tersedia untuk kesalahan untuk mencoba mendiagnosis masalah.\nLog biasanya ditemukan:\n \\u2022 Di terminal tempat dialog atau perangkat lunak yang menjalankan submitter diluncurkan \\u2022 Di konsol bawaan dalam perangkat lunak yang menjalankan submitter", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Terjadi masalah yang tidak diketahui saat mencoba mengautentikasi dengan profil '{profile}'.\n\nPeriksa log konsol yang tersedia untuk kesalahan untuk mencoba mendiagnosis masalah.\nLog biasanya ditemukan:\n \\u2022 Di terminal tempat dialog atau perangkat lunak yang menjalankan submitter diluncurkan \\u2022 Di konsol bawaan dalam perangkat lunak yang menjalankan submitter", "Timeouts": "Timeout", "Unknown Issue With Configured Profile": "Masalah tidak diketahui dengan profil yang dikonfigurasi", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Versi {latest_version} Deadline Cloud untuk pengirim {integration_name} sekarang tersedia.", - "View release notes": "Lihat catatan rilis", "Unrecognized Parameters": "Parameter tidak dikenali", "Upload progress": "Kemajuan upload", "Use array parameter": "Gunakan parameter array", "Value(s)": "Nilai", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Anda diautentikasi dengan profil '{profile}', tetapi profil ini tidak dapat memanggil AWS Deadline Cloud ListFarms dan tidak dapat mengirim pekerjaan ke AWS Deadline Cloud.\n\nUntuk mengatasi masalah ini:\n\\u2022 Periksa bahwa tidak ada variabel lingkungan yang menunjuk ke wilayah AWS yang salah (misalnya, AWS_DEFAULT_REGION)\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor, periksa bahwa profil memiliki izin untuk API AWS Deadline Cloud ini yang diperlukan untuk pengiriman:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Versi {latest_version} Deadline Cloud untuk pengirim {integration_name} sekarang tersedia.", + "View release notes": "Lihat catatan rilis", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Anda diautentikasi dengan profil '{profile}', tetapi profil ini tidak dapat memanggil AWS Deadline Cloud ListFarms dan tidak dapat mengirim pekerjaan ke AWS Deadline Cloud.\n\nUntuk mengatasi masalah ini:\n\\u2022 Periksa bahwa tidak ada variabel lingkungan yang menunjuk ke wilayah AWS yang salah (misalnya, AWS_DEFAULT_REGION)\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor, periksa bahwa profil memiliki izin untuk API AWS Deadline Cloud ini yang diperlukan untuk pengiriman:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Anda telah keluar.", - "{submitter} job submission": "Pengiriman pekerjaan {submitter}", - "Default maximum retries per task": "Percobaan ulang maksimum default per tugas", - "Default maximum failed tasks count": "Jumlah tugas gagal maksimum default" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} tidak memiliki izin akses untuk mengirim pekerjaan.", + "{submitter} job submission": "Pengiriman pekerjaan {submitter}" +} diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index d3d7b2495..e4f4a3eb1 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "Configurazione workstation AWS Deadline Cloud", "AWS profile": "Profilo AWS", "About": "Informazioni", - "Application Restart Required": "Riavvio dell'applicazione richiesto", "Add": "Aggiungi", "Add amount": "Aggiungi quantità", "Add attribute": "Aggiungi attributo", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Controlla sempre gli allegati lavoro S3", "Amount name": "Nome quantità", "Any": "Qualsiasi", + "Application Restart Required": "Riavvio dell'applicazione richiesto", "Apply": "Applica", "Array parameter values": "Valori parametri array", "Attach input directories": "Allega directory di input", "Attach input files": "Allega file di input", "Attribute name": "Nome attributo", "Auto accept prompt defaults": "Accetta automaticamente i valori predefiniti", + "Browse Job Bundles": "Sfoglia bundle di processi", "CPU architecture": "Architettura CPU", "Cancel": "Annulla", "Canceling submission...": "Annullamento invio...", - "Cannot submit job:\n\n\u2022 {issues}": "Impossibile inviare il lavoro:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Impossibile inviare il lavoro:\n\n\\u2022 {issues}", "Choose job bundle directory": "Scegli directory pacchetto lavoro", "Close": "Chiudi", "Conflict resolution option": "Opzione di risoluzione conflitti", "Copy": "Copia", - "Current: {current_version} -> New: {latest_version}": "Corrente: {current_version} -> Nuovo: {latest_version}", "Current logging level": "Livello di registrazione corrente", + "Current: {current_version} -> New: {latest_version}": "Corrente: {current_version} -> Nuovo: {latest_version}", "Custom host requirements": "Requisiti host personalizzati", "Data directory": "Directory dati", "Deadline Cloud settings": "Impostazioni Deadline Cloud", "Default farm": "Farm predefinita", + "Default maximum failed tasks count": "Numero massimo predefinito di attività non riuscite", + "Default maximum retries per task": "Numero massimo predefinito di tentativi per attività", "Default queue": "Coda predefinita", "Default storage profile": "Profilo di archiviazione predefinito", "Delete": "Elimina", @@ -58,6 +61,7 @@ "Hardware requirements": "Requisiti hardware", "Hashing progress": "Avanzamento hashing", "Help": "Aiuto", + "History": "Cronologia", "Host requirements": "Requisiti host", "Initial state": "Stato iniziale", "Issue With Profile Configuration": "Problema con la configurazione del profilo", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Conferma invio lavoro", "Job attachments": "Allegati Job", "Job attachments filesystem options": "Opzioni filesystem allegati lavoro", + "Job bundle directory": "Directory bundle di processi", "Job history directory": "Directory cronologia lavori", "Job submission confirmation": "Conferma invio lavoro", "Job-specific settings": "Impostazioni specifiche del lavoro", @@ -72,6 +77,7 @@ "Language": "Lingua", "Language will change next time the submitter is opened": "La lingua cambierà alla prossima apertura del submitter", "Load Bundle": "Carica pacchetto", + "Local": "Locale", "Log in": "Accedi", "Log in to AWS Deadline Cloud": "Accedi ad AWS Deadline Cloud", "Logging you in...": "Accesso in corso...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Memoria (GiB)", "Min": "Min", "More info": "Ulteriori informazioni", + "Name": "Nome", "New version available": "Nuova versione disponibile", "No farm is configured. Click Settings to select a farm for job submission.": "Nessuna farm configurata. Fai clic su Impostazioni per selezionare una farm per l'invio dei lavori.", "No max worker count": "Nessun numero massimo di worker", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Rilevati input non validi", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Apertura del monitor Deadline Cloud. Effettua l'accesso prima di tornare qui.", - "Please run the installer and then restart {integration_name} to use the new version.": "Esegui l'installer e poi riavvia {integration_name} per utilizzare la nuova versione.", "Operating system": "Sistema operativo", + "Parameters:": "Parametri:", + "Path:": "Percorso:", + "Please run the installer and then restart {integration_name} to use the new version.": "Esegui l'installer e poi riavvia {integration_name} per utilizzare la nuova versione.", "Preparing files...": "Preparazione file...", "Preparing for hashing...": "Preparazione per hashing...", "Preparing for upload...": "Preparazione per caricamento...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Esegui su host worker che soddisfano i seguenti requisiti", "Saved the submission as a job bundle:\n{path}": "Invio salvato come pacchetto lavoro:\n{path}", "Scratch space": "Spazio temporaneo", + "Select": "Seleziona", + "Select a job bundle to see details": "Seleziona un bundle di processi per visualizzare i dettagli", "Set max worker count": "Imposta numero massimo di worker", "Settings...": "Impostazioni...", "Shared job settings": "Impostazioni lavoro condivise", "Show auto-detected": "Mostra rilevati automaticamente", "Show submitter update notifications": "Mostra notifiche di aggiornamento del submitter", + "Source:": "Origine:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specifica una directory pacchetto lavoro o esegui il comando bundle con il flag --browse", "Specify output directories": "Specifica directory di output", + "Steps:": "Passaggi:", "Submission canceled": "Invio annullato", "Submission complete": "Invio completato", "Submission error": "Errore di invio", @@ -126,20 +139,19 @@ "Telemetry opt out": "Disattiva telemetria", "Template file format": "Formato file modello", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "I seguenti parametri non sono riconosciuti dal modello di lavoro o dalla coda:\n\n{params}\n\nQuesti parametri verranno ignorati durante l'invio del lavoro.\n\nVuoi continuare?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Si è verificato un problema di configurazione con il profilo '{profile}'.\n\nPer risolvere questo problema:\n\\u2022 Verifica che i file di configurazione e credenziali AWS siano corretti\n \\u2022 Per impostazione predefinita, questi file si trovano in ~/.aws su Linux/MacOS o %USERPROFILE%/.aws su Windows\n\\u2022 Verifica che sia impostata la regione AWS corretta\n \\u2022 Verifica che nessuna variabile d'ambiente come AWS_DEFAULT_REGION sia impostata su una regione errata\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor:\n \\u2022 Verifica che qualsiasi processo di credenziali utilizzato sia in grado di recuperare le credenziali o che non siano scadute\n \\u2022 Puoi eseguire il seguente comando per verificare: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Si è verificato un problema di configurazione con il profilo '{profile}'.\n\nPer risolvere questo problema:\n\\u2022 Verifica che i file di configurazione e credenziali AWS siano corretti\n \\u2022 Per impostazione predefinita, questi file si trovano in ~/.aws su Linux/MacOS o %USERPROFILE%/.aws su Windows\n\\u2022 Verifica che sia impostata la regione AWS corretta\n \\u2022 Verifica che nessuna variabile d'ambiente come AWS_DEFAULT_REGION sia impostata su una regione errata\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor:\n \\u2022 Verifica che qualsiasi processo di credenziali utilizzato sia in grado di recuperare le credenziali o che non siano scadute\n \\u2022 Puoi eseguire il seguente comando per verificare: aws sts get-caller-identity --profile ", "There was an error with authentication": "Si è verificato un errore con l'autenticazione", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Si è verificato un problema sconosciuto durante il tentativo di autenticazione con il profilo '{profile}'.\n\nControlla i log della console disponibili per errori per provare a diagnosticare il problema.\nI log si trovano comunemente:\n \\u2022 Nel terminale da cui è stata avviata la finestra di dialogo o il software in cui è in esecuzione il submitter \\u2022 Nella console integrata all'interno del software in cui è in esecuzione il submitter", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Si è verificato un problema sconosciuto durante il tentativo di autenticazione con il profilo '{profile}'.\n\nControlla i log della console disponibili per errori per provare a diagnosticare il problema.\nI log si trovano comunemente:\n \\u2022 Nel terminale da cui è stata avviata la finestra di dialogo o il software in cui è in esecuzione il submitter \\u2022 Nella console integrata all'interno del software in cui è in esecuzione il submitter", "Timeouts": "Timeout", "Unknown Issue With Configured Profile": "Problema sconosciuto con il profilo configurato", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versione {latest_version} di Deadline Cloud per il mittente {integration_name} è ora disponibile.", - "View release notes": "Visualizza note di rilascio", "Unrecognized Parameters": "Parametri non riconosciuti", "Upload progress": "Avanzamento caricamento", "Use array parameter": "Usa parametro array", "Value(s)": "Valore/i", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Sei autenticato con il profilo '{profile}', ma questo profilo non è in grado di chiamare AWS Deadline Cloud ListFarms e non può inviare lavori ad AWS Deadline Cloud.\n\nPer risolvere questo problema:\n\\u2022 Verifica che non ci siano variabili d'ambiente che puntano alla regione AWS errata (ad es. AWS_DEFAULT_REGION)\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor, verifica che il profilo disponga delle autorizzazioni per queste API AWS Deadline Cloud necessarie per l'invio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versione {latest_version} di Deadline Cloud per il mittente {integration_name} è ora disponibile.", + "View release notes": "Visualizza note di rilascio", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Sei autenticato con il profilo '{profile}', ma questo profilo non è in grado di chiamare AWS Deadline Cloud ListFarms e non può inviare lavori ad AWS Deadline Cloud.\n\nPer risolvere questo problema:\n\\u2022 Verifica che non ci siano variabili d'ambiente che puntano alla regione AWS errata (ad es. AWS_DEFAULT_REGION)\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor, verifica che il profilo disponga delle autorizzazioni per queste API AWS Deadline Cloud necessarie per l'invio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Hai effettuato il logout.", - "{submitter} job submission": "Invio lavoro {submitter}", - "Default maximum retries per task": "Numero massimo predefinito di tentativi per attivit\u00e0", - "Default maximum failed tasks count": "Numero massimo predefinito di attivit\u00e0 non riuscite" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} non dispone delle autorizzazioni di accesso per inviare un lavoro.", + "{submitter} job submission": "Invio lavoro {submitter}" +} diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index 9f7a0ee41..5b8036f56 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud ワークステーション設定", "AWS profile": "AWS プロファイル", "About": "バージョン情報", - "Application Restart Required": "アプリケーションの再起動が必要です", "Add": "追加", "Add amount": "量を追加", "Add attribute": "属性を追加", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "S3 ジョブアタッチメントを常にチェック", "Amount name": "量の名前", "Any": "任意", + "Application Restart Required": "アプリケーションの再起動が必要です", "Apply": "適用", "Array parameter values": "配列パラメータ値", "Attach input directories": "入力ディレクトリを添付", "Attach input files": "入力ファイルを添付", "Attribute name": "属性名", "Auto accept prompt defaults": "デフォルト値を自動的に受け入れる", + "Browse Job Bundles": "ジョブバンドルを参照", "CPU architecture": "CPU アーキテクチャ", "Cancel": "キャンセル", "Canceling submission...": "送信をキャンセルしています...", - "Cannot submit job:\n\n\u2022 {issues}": "ジョブを送信できません:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "ジョブを送信できません:\n\n\\u2022 {issues}", "Choose job bundle directory": "ジョブバンドルディレクトリを選択", "Close": "閉じる", "Conflict resolution option": "競合解決オプション", "Copy": "コピー", - "Current: {current_version} -> New: {latest_version}": "現在: {current_version} -> 新規: {latest_version}", "Current logging level": "現在のログレベル", + "Current: {current_version} -> New: {latest_version}": "現在: {current_version} -> 新規: {latest_version}", "Custom host requirements": "カスタムホスト要件", "Data directory": "データディレクトリ", "Deadline Cloud settings": "Deadline Cloud 設定", "Default farm": "デフォルトファーム", + "Default maximum failed tasks count": "デフォルトの失敗したタスクの最大数", + "Default maximum retries per task": "タスクあたりのデフォルトの最大再試行回数", "Default queue": "デフォルトキュー", "Default storage profile": "デフォルトストレージプロファイル", "Delete": "削除", @@ -58,6 +61,7 @@ "Hardware requirements": "ハードウェア要件", "Hashing progress": "ハッシュ進行状況", "Help": "ヘルプ", + "History": "履歴", "Host requirements": "ホスト要件", "Initial state": "初期状態", "Issue With Profile Configuration": "プロファイル設定の問題", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "ジョブ送信の確認", "Job attachments": "ジョブアタッチメント", "Job attachments filesystem options": "ジョブアタッチメントファイルシステムオプション", + "Job bundle directory": "ジョブバンドルディレクトリ", "Job history directory": "ジョブ履歴ディレクトリ", "Job submission confirmation": "ジョブ送信の確認", "Job-specific settings": "ジョブ固有の設定", @@ -72,6 +77,7 @@ "Language": "言語", "Language will change next time the submitter is opened": "言語は次回サブミッターを開いたときに変更されます", "Load Bundle": "バンドルを読み込む", + "Local": "ローカル", "Log in": "ログイン", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud にログイン", "Logging you in...": "ログイン中...", @@ -83,6 +89,7 @@ "Memory (GiB)": "メモリ (GiB)", "Min": "最小", "More info": "詳細情報", + "Name": "名前", "New version available": "新しいバージョンが利用可能です", "No farm is configured. Click Settings to select a farm for job submission.": "ファームが設定されていません。設定をクリックして、ジョブ送信用のファームを選択してください。", "No max worker count": "最大ワーカー数なし", @@ -90,8 +97,10 @@ "Non valid inputs detected": "無効な入力が検出されました", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud モニターを開いています。ここに戻る前にログインしてください。", - "Please run the installer and then restart {integration_name} to use the new version.": "インストーラーを実行してから {integration_name} を再起動して、新しいバージョンをご利用ください。", "Operating system": "オペレーティングシステム", + "Parameters:": "パラメータ:", + "Path:": "パス:", + "Please run the installer and then restart {integration_name} to use the new version.": "インストーラーを実行してから {integration_name} を再起動して、新しいバージョンをご利用ください。", "Preparing files...": "ファイルを準備しています...", "Preparing for hashing...": "ハッシュの準備をしています...", "Preparing for upload...": "アップロードの準備をしています...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "次の要件を満たすワーカーホストで実行", "Saved the submission as a job bundle:\n{path}": "送信をジョブバンドルとして保存しました:\n{path}", "Scratch space": "一時領域", + "Select": "選択", + "Select a job bundle to see details": "ジョブバンドルを選択して詳細を表示", "Set max worker count": "最大ワーカー数を設定", "Settings...": "設定...", "Shared job settings": "共有ジョブ設定", "Show auto-detected": "自動検出されたものを表示", "Show submitter update notifications": "サブミッターの更新通知を表示", + "Source:": "ソース:", "Specify a job bundle directory or run the bundle command with the --browse flag": "ジョブバンドルディレクトリを指定するか、--browse フラグを使用して bundle コマンドを実行してください", "Specify output directories": "出力ディレクトリを指定", + "Steps:": "ステップ:", "Submission canceled": "送信がキャンセルされました", "Submission complete": "送信が完了しました", "Submission error": "送信エラー", @@ -126,20 +139,19 @@ "Telemetry opt out": "テレメトリをオプトアウト", "Template file format": "テンプレートファイル形式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "次のパラメータはジョブテンプレートまたはキューで認識されません:\n\n{params}\n\nこれらのパラメータはジョブ送信時に無視されます。\n\n続行しますか?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "プロファイル '{profile}' に設定の問題があります。\n\nこの問題を解決するには:\n\\u2022 AWS 設定ファイルと認証情報ファイルが正しいことを確認してください\n \\u2022 デフォルトでは、これらのファイルは Linux/MacOS では ~/.aws、Windows では %USERPROFILE%/.aws にあります\n\\u2022 正しい AWS リージョンが設定されていることを確認してください\n \\u2022 AWS_DEFAULT_REGION などの環境変数が誤ったリージョンに設定されていないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合:\n \\u2022 使用されている認証情報プロセスが認証情報を取得できること、または有効期限が切れていないことを確認してください\n \\u2022 次のコマンドを実行して確認できます: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "プロファイル '{profile}' に設定の問題があります。\n\nこの問題を解決するには:\n\\u2022 AWS 設定ファイルと認証情報ファイルが正しいことを確認してください\n \\u2022 デフォルトでは、これらのファイルは Linux/MacOS では ~/.aws、Windows では %USERPROFILE%/.aws にあります\n\\u2022 正しい AWS リージョンが設定されていることを確認してください\n \\u2022 AWS_DEFAULT_REGION などの環境変数が誤ったリージョンに設定されていないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合:\n \\u2022 使用されている認証情報プロセスが認証情報を取得できること、または有効期限が切れていないことを確認してください\n \\u2022 次のコマンドを実行して確認できます: aws sts get-caller-identity --profile ", "There was an error with authentication": "認証でエラーが発生しました", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "プロファイル '{profile}' で認証しようとしたときに不明な問題が発生しました。\n\n利用可能なコンソールログでエラーを確認して、問題を診断してください。\nログは通常、次の場所にあります:\n \\u2022 ダイアログまたはサブミッターが実行されているソフトウェアが起動されたターミナル \\u2022 サブミッターが実行されているソフトウェア内の組み込みコンソール", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "プロファイル '{profile}' で認証しようとしたときに不明な問題が発生しました。\n\n利用可能なコンソールログでエラーを確認して、問題を診断してください。\nログは通常、次の場所にあります:\n \\u2022 ダイアログまたはサブミッターが実行されているソフトウェアが起動されたターミナル \\u2022 サブミッターが実行されているソフトウェア内の組み込みコンソール", "Timeouts": "タイムアウト", "Unknown Issue With Configured Profile": "設定されたプロファイルに不明な問題があります", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} サブミッターのバージョン {latest_version} が利用可能になりました。", - "View release notes": "リリースノートを表示", "Unrecognized Parameters": "認識されないパラメータ", "Upload progress": "アップロード進行状況", "Use array parameter": "配列パラメータを使用", "Value(s)": "値", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "プロファイル '{profile}' で認証されていますが、このプロファイルは AWS Deadline Cloud ListFarms を呼び出すことができず、AWS Deadline Cloud にジョブを送信できません。\n\nこの問題を解決するには:\n\\u2022 誤った AWS リージョンを指す環境変数 (AWS_DEFAULT_REGION など) がないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合は、プロファイルに送信に必要な次の AWS Deadline Cloud API のアクセス許可があることを確認してください:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} サブミッターのバージョン {latest_version} が利用可能になりました。", + "View release notes": "リリースノートを表示", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "プロファイル '{profile}' で認証されていますが、このプロファイルは AWS Deadline Cloud ListFarms を呼び出すことができず、AWS Deadline Cloud にジョブを送信できません。\n\nこの問題を解決するには:\n\\u2022 誤った AWS リージョンを指す環境変数 (AWS_DEFAULT_REGION など) がないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合は、プロファイルに送信に必要な次の AWS Deadline Cloud API のアクセス許可があることを確認してください:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - ログアウトしています。", - "{submitter} job submission": "{submitter} ジョブ送信", - "Default maximum retries per task": "\u30bf\u30b9\u30af\u3042\u305f\u308a\u306e\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u6700\u5927\u518d\u8a66\u884c\u56de\u6570", - "Default maximum failed tasks count": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u5931\u6557\u3057\u305f\u30bf\u30b9\u30af\u306e\u6700\u5927\u6570" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} にはジョブを送信するアクセス許可がありません。", + "{submitter} job submission": "{submitter} ジョブ送信" +} diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index 7d977edc2..fdf752f37 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 워크스테이션 구성", "AWS profile": "AWS 프로필", "About": "정보", - "Application Restart Required": "애플리케이션 재시작 필요", "Add": "추가", "Add amount": "수량 추가", "Add attribute": "속성 추가", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "S3 작업 첨부 파일 항상 확인", "Amount name": "수량 이름", "Any": "모두", + "Application Restart Required": "애플리케이션 재시작 필요", "Apply": "적용", "Array parameter values": "배열 파라미터 값", "Attach input directories": "입력 디렉터리 연결", "Attach input files": "입력 파일 연결", "Attribute name": "속성 이름", "Auto accept prompt defaults": "기본값 자동 수락", + "Browse Job Bundles": "작업 번들 찾아보기", "CPU architecture": "CPU 아키텍처", "Cancel": "취소", "Canceling submission...": "제출 취소 중...", - "Cannot submit job:\n\n\u2022 {issues}": "작업을 제출할 수 없습니다:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "작업을 제출할 수 없습니다:\n\n\\u2022 {issues}", "Choose job bundle directory": "작업 번들 디렉터리 선택", "Close": "닫기", "Conflict resolution option": "충돌 해결 옵션", "Copy": "복사", - "Current: {current_version} -> New: {latest_version}": "현재: {current_version} -> 새 버전: {latest_version}", "Current logging level": "현재 로깅 수준", + "Current: {current_version} -> New: {latest_version}": "현재: {current_version} -> 새 버전: {latest_version}", "Custom host requirements": "사용자 지정 호스트 요구 사항", "Data directory": "데이터 디렉터리", "Deadline Cloud settings": "Deadline Cloud 설정", "Default farm": "기본 팜", + "Default maximum failed tasks count": "기본 최대 실패 작업 수", + "Default maximum retries per task": "작업당 기본 최대 재시도 횟수", "Default queue": "기본 대기열", "Default storage profile": "기본 스토리지 프로필", "Delete": "삭제", @@ -58,6 +61,7 @@ "Hardware requirements": "하드웨어 요구 사항", "Hashing progress": "해싱 진행률", "Help": "도움말", + "History": "기록", "Host requirements": "호스트 요구 사항", "Initial state": "초기 상태", "Issue With Profile Configuration": "프로필 구성 문제", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "작업 제출 확인", "Job attachments": "작업 첨부 파일", "Job attachments filesystem options": "작업 첨부 파일 파일 시스템 옵션", + "Job bundle directory": "작업 번들 디렉터리", "Job history directory": "작업 기록 디렉터리", "Job submission confirmation": "작업 제출 확인", "Job-specific settings": "작업별 설정", @@ -72,6 +77,7 @@ "Language": "언어", "Language will change next time the submitter is opened": "언어는 다음에 제출자를 열 때 변경됩니다", "Load Bundle": "번들 로드", + "Local": "로컬", "Log in": "로그인", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud에 로그인", "Logging you in...": "로그인 중...", @@ -83,6 +89,7 @@ "Memory (GiB)": "메모리(GiB)", "Min": "최소", "More info": "추가 정보", + "Name": "이름", "New version available": "새 버전 사용 가능", "No farm is configured. Click Settings to select a farm for job submission.": "구성된 팜이 없습니다. 설정을 클릭하여 작업 제출을 위한 팜을 선택하세요.", "No max worker count": "최대 작업자 수 없음", @@ -90,8 +97,10 @@ "Non valid inputs detected": "유효하지 않은 입력이 감지되었습니다", "Ok": "확인", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud 모니터를 여는 중입니다. 여기로 돌아오기 전에 로그인하세요.", - "Please run the installer and then restart {integration_name} to use the new version.": "설치 프로그램을 실행한 후 {integration_name}을(를) 다시 시작하여 새 버전을 사용하세요.", "Operating system": "운영 체제", + "Parameters:": "파라미터:", + "Path:": "경로:", + "Please run the installer and then restart {integration_name} to use the new version.": "설치 프로그램을 실행한 후 {integration_name}을(를) 다시 시작하여 새 버전을 사용하세요.", "Preparing files...": "파일 준비 중...", "Preparing for hashing...": "해싱 준비 중...", "Preparing for upload...": "업로드 준비 중...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "다음 요구 사항을 충족하는 작업자 호스트에서 실행", "Saved the submission as a job bundle:\n{path}": "제출을 작업 번들로 저장했습니다:\n{path}", "Scratch space": "임시 공간", + "Select": "선택", + "Select a job bundle to see details": "작업 번들을 선택하여 세부 정보 보기", "Set max worker count": "최대 작업자 수 설정", "Settings...": "설정...", "Shared job settings": "공유 작업 설정", "Show auto-detected": "자동 감지된 항목 표시", "Show submitter update notifications": "제출기 업데이트 알림 표시", + "Source:": "소스:", "Specify a job bundle directory or run the bundle command with the --browse flag": "작업 번들 디렉터리를 지정하거나 --browse 플래그와 함께 bundle 명령을 실행하세요", "Specify output directories": "출력 디렉터리 지정", + "Steps:": "단계:", "Submission canceled": "제출이 취소되었습니다", "Submission complete": "제출 완료", "Submission error": "제출 오류", @@ -126,20 +139,19 @@ "Telemetry opt out": "원격 측정 옵트아웃", "Template file format": "템플릿 파일 형식", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "다음 파라미터는 작업 템플릿 또는 대기열에서 인식되지 않습니다:\n\n{params}\n\n이러한 파라미터는 작업 제출 중에 무시됩니다.\n\n계속하시겠습니까?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "프로필 '{profile}'에 구성 문제가 있습니다.\n\n이 문제를 해결하려면:\n\\u2022 AWS 구성 및 자격 증명 파일이 올바른지 확인하세요\n \\u2022 기본적으로 이러한 파일은 Linux/MacOS의 ~/.aws 또는 Windows의 %USERPROFILE%/.aws에서 찾을 수 있습니다\n\\u2022 올바른 AWS 리전이 설정되어 있는지 확인하세요\n \\u2022 AWS_DEFAULT_REGION과 같은 환경 변수가 잘못된 리전으로 설정되어 있지 않은지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우:\n \\u2022 사용 중인 자격 증명 프로세스가 자격 증명을 검색할 수 있는지 또는 만료되지 않았는지 확인하세요\n \\u2022 다음 명령을 실행하여 확인할 수 있습니다: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "프로필 '{profile}'에 구성 문제가 있습니다.\n\n이 문제를 해결하려면:\n\\u2022 AWS 구성 및 자격 증명 파일이 올바른지 확인하세요\n \\u2022 기본적으로 이러한 파일은 Linux/MacOS의 ~/.aws 또는 Windows의 %USERPROFILE%/.aws에서 찾을 수 있습니다\n\\u2022 올바른 AWS 리전이 설정되어 있는지 확인하세요\n \\u2022 AWS_DEFAULT_REGION과 같은 환경 변수가 잘못된 리전으로 설정되어 있지 않은지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우:\n \\u2022 사용 중인 자격 증명 프로세스가 자격 증명을 검색할 수 있는지 또는 만료되지 않았는지 확인하세요\n \\u2022 다음 명령을 실행하여 확인할 수 있습니다: aws sts get-caller-identity --profile ", "There was an error with authentication": "인증 오류가 발생했습니다", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "프로필 '{profile}'로 인증을 시도하는 동안 알 수 없는 문제가 발생했습니다.\n\n사용 가능한 콘솔 로그에서 오류를 확인하여 문제를 진단하세요.\n로그는 일반적으로 다음 위치에서 찾을 수 있습니다:\n \\u2022 대화 상자 또는 제출자가 실행 중인 소프트웨어가 시작된 터미널 \\u2022 제출자가 실행 중인 소프트웨어 내의 기본 제공 콘솔", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "프로필 '{profile}'로 인증을 시도하는 동안 알 수 없는 문제가 발생했습니다.\n\n사용 가능한 콘솔 로그에서 오류를 확인하여 문제를 진단하세요.\n로그는 일반적으로 다음 위치에서 찾을 수 있습니다:\n \\u2022 대화 상자 또는 제출자가 실행 중인 소프트웨어가 시작된 터미널 \\u2022 제출자가 실행 중인 소프트웨어 내의 기본 제공 콘솔", "Timeouts": "제한 시간", "Unknown Issue With Configured Profile": "구성된 프로필의 알 수 없는 문제", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 제출자 버전 {latest_version}을(를) 사용할 수 있습니다.", - "View release notes": "릴리스 노트 보기", "Unrecognized Parameters": "인식되지 않는 파라미터", "Upload progress": "업로드 진행률", "Use array parameter": "배열 파라미터 사용", "Value(s)": "값", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "프로필 '{profile}'로 인증되었지만 이 프로필은 AWS Deadline Cloud ListFarms를 호출할 수 없으며 AWS Deadline Cloud에 작업을 제출할 수 없습니다.\n\n이 문제를 해결하려면:\n\\u2022 잘못된 AWS 리전을 가리키는 환경 변수(예: AWS_DEFAULT_REGION)가 없는지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우 프로필에 제출에 필요한 다음 AWS Deadline Cloud API에 대한 권한이 있는지 확인하세요:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 제출자 버전 {latest_version}을(를) 사용할 수 있습니다.", + "View release notes": "릴리스 노트 보기", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "프로필 '{profile}'로 인증되었지만 이 프로필은 AWS Deadline Cloud ListFarms를 호출할 수 없으며 AWS Deadline Cloud에 작업을 제출할 수 없습니다.\n\n이 문제를 해결하려면:\n\\u2022 잘못된 AWS 리전을 가리키는 환경 변수(예: AWS_DEFAULT_REGION)가 없는지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우 프로필에 제출에 필요한 다음 AWS Deadline Cloud API에 대한 권한이 있는지 확인하세요:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 로그아웃되었습니다.", - "{submitter} job submission": "{submitter} 작업 제출", - "Default maximum retries per task": "\uc791\uc5c5\ub2f9 \uae30\ubcf8 \ucd5c\ub300 \uc7ac\uc2dc\ub3c4 \ud69f\uc218", - "Default maximum failed tasks count": "\uae30\ubcf8 \ucd5c\ub300 \uc2e4\ud328 \uc791\uc5c5 \uc218" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile}에 작업을 제출할 액세스 권한이 없습니다.", + "{submitter} job submission": "{submitter} 작업 제출" +} diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index fb07e3127..1517082f6 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "Configuração de estação de trabalho do AWS Deadline Cloud", "AWS profile": "Perfil da AWS", "About": "Sobre", - "Application Restart Required": "Reinicialização do aplicativo necessária", "Add": "Adicionar", "Add amount": "Adicionar quantidade", "Add attribute": "Adicionar atributo", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "Sempre verificar anexos de trabalho do S3", "Amount name": "Nome da quantidade", "Any": "Qualquer", + "Application Restart Required": "Reinicialização do aplicativo necessária", "Apply": "Aplicar", "Array parameter values": "Valores de parâmetros de matriz", "Attach input directories": "Anexar diretórios de entrada", "Attach input files": "Anexar arquivos de entrada", "Attribute name": "Nome do atributo", "Auto accept prompt defaults": "Aceitar automaticamente padrões", + "Browse Job Bundles": "Procurar pacotes de trabalho", "CPU architecture": "Arquitetura da CPU", "Cancel": "Cancelar", "Canceling submission...": "Cancelando envio...", - "Cannot submit job:\n\n\u2022 {issues}": "Não é possível enviar o trabalho:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "Não é possível enviar o trabalho:\n\n\\u2022 {issues}", "Choose job bundle directory": "Escolher diretório do pacote de tarefas", "Close": "Fechar", "Conflict resolution option": "Opção de resolução de conflitos", "Copy": "Copiar", - "Current: {current_version} -> New: {latest_version}": "Atual: {current_version} -> Novo: {latest_version}", "Current logging level": "Nível de registro atual", + "Current: {current_version} -> New: {latest_version}": "Atual: {current_version} -> Novo: {latest_version}", "Custom host requirements": "Requisitos de host personalizados", "Data directory": "Diretório de dados", "Deadline Cloud settings": "Configurações do Deadline Cloud", "Default farm": "Fazenda padrão", + "Default maximum failed tasks count": "Contagem máxima padrão de tarefas com falha", + "Default maximum retries per task": "Máximo padrão de tentativas por tarefa", "Default queue": "Fila padrão", "Default storage profile": "Perfil de armazenamento padrão", "Delete": "Excluir", @@ -58,6 +61,7 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progresso de hash", "Help": "Ajuda", + "History": "Histórico", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema com a configuração do perfil", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "Confirmação de envio de trabalho", "Job attachments": "Anexos de trabalho", "Job attachments filesystem options": "Opções do sistema de arquivos de anexos de tarefas", + "Job bundle directory": "Diretório de pacotes de trabalho", "Job history directory": "Diretório de histórico de trabalhos", "Job submission confirmation": "Confirmação de envio de trabalho", "Job-specific settings": "Configurações específicas do trabalho", @@ -72,6 +77,7 @@ "Language": "Idioma", "Language will change next time the submitter is opened": "O idioma será alterado na próxima vez que o submitter for aberto", "Load Bundle": "Carregar pacote", + "Local": "Local", "Log in": "Fazer login", "Log in to AWS Deadline Cloud": "Fazer login no AWS Deadline Cloud", "Logging you in...": "Fazendo login...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Memória (GiB)", "Min": "Mín", "More info": "Mais informações", + "Name": "Nome", "New version available": "Nova versão disponível", "No farm is configured. Click Settings to select a farm for job submission.": "Nenhuma fazenda está configurada. Clique em Configurações para selecionar uma fazenda para envio de trabalhos.", "No max worker count": "Sem contagem máxima de trabalhadores", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Entradas não válidas detectadas", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Abrindo o monitor do Deadline Cloud. Faça login antes de retornar aqui.", - "Please run the installer and then restart {integration_name} to use the new version.": "Execute o instalador e reinicie o {integration_name} para usar a nova versão.", "Operating system": "Sistema operacional", + "Parameters:": "Parâmetros:", + "Path:": "Caminho:", + "Please run the installer and then restart {integration_name} to use the new version.": "Execute o instalador e reinicie o {integration_name} para usar a nova versão.", "Preparing files...": "Preparando arquivos...", "Preparing for hashing...": "Preparando para hash...", "Preparing for upload...": "Preparando para upload...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Executar em hosts de trabalho que atendam aos seguintes requisitos", "Saved the submission as a job bundle:\n{path}": "O envio foi salvo como um pacote de tarefas:\n{path}", "Scratch space": "Espaço temporário", + "Select": "Selecionar", + "Select a job bundle to see details": "Selecione um pacote de trabalho para ver os detalhes", "Set max worker count": "Definir contagem máxima de trabalhadores", "Settings...": "Configurações...", "Shared job settings": "Configurações de trabalho compartilhadas", "Show auto-detected": "Mostrar detectados automaticamente", "Show submitter update notifications": "Mostrar notificações de atualização do submissor", + "Source:": "Origem:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique um diretório de pacote de tarefas ou execute o comando bundle com a flag --browse", "Specify output directories": "Especificar diretórios de saída", + "Steps:": "Etapas:", "Submission canceled": "Envio cancelado", "Submission complete": "Envio concluído", "Submission error": "Erro de envio", @@ -126,20 +139,19 @@ "Telemetry opt out": "Desativar telemetria", "Template file format": "Formato de arquivo de modelo", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Os seguintes parâmetros não são reconhecidos pelo modelo de trabalho ou fila:\n\n{params}\n\nEsses parâmetros serão ignorados durante o envio do trabalho.\n\nDeseja continuar?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Há um problema de configuração com o perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique se seus arquivos de configuração e credenciais da AWS estão corretos\n \\u2022 Por padrão, esses arquivos podem ser encontrados em ~/.aws no Linux/MacOS ou %USERPROFILE%/.aws no Windows\n\\u2022 Verifique se a região da AWS correta está definida\n \\u2022 Verifique se nenhuma variável de ambiente como AWS_DEFAULT_REGION está definida para uma região incorreta\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor:\n \\u2022 Verifique se qualquer processo de credencial que está sendo usado é capaz de recuperar as credenciais ou se elas não expiraram\n \\u2022 Você pode executar o seguinte comando para verificar: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Há um problema de configuração com o perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique se seus arquivos de configuração e credenciais da AWS estão corretos\n \\u2022 Por padrão, esses arquivos podem ser encontrados em ~/.aws no Linux/MacOS ou %USERPROFILE%/.aws no Windows\n\\u2022 Verifique se a região da AWS correta está definida\n \\u2022 Verifique se nenhuma variável de ambiente como AWS_DEFAULT_REGION está definida para uma região incorreta\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor:\n \\u2022 Verifique se qualquer processo de credencial que está sendo usado é capaz de recuperar as credenciais ou se elas não expiraram\n \\u2022 Você pode executar o seguinte comando para verificar: aws sts get-caller-identity --profile ", "There was an error with authentication": "Ocorreu um erro com a autenticação", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Ocorreu um problema desconhecido ao tentar autenticar com o perfil '{profile}'.\n\nVerifique os logs do console disponíveis em busca de erros para tentar diagnosticar o problema.\nOs logs geralmente são encontrados:\n \\u2022 No terminal do qual a caixa de diálogo ou o software em que o remetente está sendo executado foi iniciado \\u2022 No console integrado dentro do software em que o remetente está sendo executado", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Ocorreu um problema desconhecido ao tentar autenticar com o perfil '{profile}'.\n\nVerifique os logs do console disponíveis em busca de erros para tentar diagnosticar o problema.\nOs logs geralmente são encontrados:\n \\u2022 No terminal do qual a caixa de diálogo ou o software em que o remetente está sendo executado foi iniciado \\u2022 No console integrado dentro do software em que o remetente está sendo executado", "Timeouts": "Tempos limite", "Unknown Issue With Configured Profile": "Problema desconhecido com o perfil configurado", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "A versão {latest_version} do Deadline Cloud para o remetente {integration_name} já está disponível.", - "View release notes": "Ver notas de versão", "Unrecognized Parameters": "Parâmetros não reconhecidos", "Upload progress": "Progresso do upload", "Use array parameter": "Usar parâmetro de matriz", "Value(s)": "Valor(es)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Você está autenticado com o perfil '{profile}', mas este perfil não consegue chamar o AWS Deadline Cloud ListFarms e não consegue enviar trabalhos para o AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Verifique se não há variáveis de ambiente apontando para a região da AWS errada (por exemplo, AWS_DEFAULT_REGION)\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor, verifique se o perfil tem permissões para essas APIs do AWS Deadline Cloud necessárias para envio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "A versão {latest_version} do Deadline Cloud para o remetente {integration_name} já está disponível.", + "View release notes": "Ver notas de versão", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Você está autenticado com o perfil '{profile}', mas este perfil não consegue chamar o AWS Deadline Cloud ListFarms e não consegue enviar trabalhos para o AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Verifique se não há variáveis de ambiente apontando para a região da AWS errada (por exemplo, AWS_DEFAULT_REGION)\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor, verifique se o perfil tem permissões para essas APIs do AWS Deadline Cloud necessárias para envio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Você está desconectado.", - "{submitter} job submission": "Envio de trabalho {submitter}", - "Default maximum retries per task": "M\u00e1ximo padr\u00e3o de tentativas por tarefa", - "Default maximum failed tasks count": "Contagem m\u00e1xima padr\u00e3o de tarefas com falha" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} não tem permissões de acesso para enviar um trabalho.", + "{submitter} job submission": "Envio de trabalho {submitter}" +} diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index cfcae03b9..c74b1cbe8 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud iş istasyonu yapılandırması", "AWS profile": "AWS profili", "About": "Hakkında", - "Application Restart Required": "Uygulama yeniden başlatma gerekli", "Add": "Ekle", "Add amount": "Miktar ekle", "Add attribute": "Öznitelik ekle", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "S3 iş eklerini her zaman kontrol et", "Amount name": "Miktar adı", "Any": "Herhangi", + "Application Restart Required": "Uygulama yeniden başlatma gerekli", "Apply": "Uygula", "Array parameter values": "Dizi parametre değerleri", "Attach input directories": "Giriş dizinlerini ekle", "Attach input files": "Giriş dosyalarını ekle", "Attribute name": "Öznitelik adı", "Auto accept prompt defaults": "Varsayılanları otomatik olarak kabul et", + "Browse Job Bundles": "İş paketlerine göz at", "CPU architecture": "CPU mimarisi", "Cancel": "İptal", "Canceling submission...": "Gönderim iptal ediliyor...", - "Cannot submit job:\n\n\u2022 {issues}": "İş gönderilemedi:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "İş gönderilemedi:\n\n\\u2022 {issues}", "Choose job bundle directory": "İş paketi dizini seçin", "Close": "Kapat", "Conflict resolution option": "Çakışma çözümleme seçeneği", "Copy": "Kopyala", - "Current: {current_version} -> New: {latest_version}": "Mevcut: {current_version} -> Yeni: {latest_version}", "Current logging level": "Geçerli günlük kaydı düzeyi", + "Current: {current_version} -> New: {latest_version}": "Mevcut: {current_version} -> Yeni: {latest_version}", "Custom host requirements": "Özel ana bilgisayar gereksinimleri", "Data directory": "Veri dizini", "Deadline Cloud settings": "Deadline Cloud ayarları", "Default farm": "Varsayılan farm", + "Default maximum failed tasks count": "Varsayılan maksimum başarısız görev sayısı", + "Default maximum retries per task": "Görev başına varsayılan maksimum yeniden deneme", "Default queue": "Varsayılan kuyruk", "Default storage profile": "Varsayılan depolama profili", "Delete": "Sil", @@ -58,6 +61,7 @@ "Hardware requirements": "Donanım gereksinimleri", "Hashing progress": "Karma oluşturma ilerlemesi", "Help": "Yardım", + "History": "Geçmiş", "Host requirements": "Ana bilgisayar gereksinimleri", "Initial state": "Başlangıç durumu", "Issue With Profile Configuration": "Profil yapılandırmasıyla ilgili sorun", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "İş gönderimi onayı", "Job attachments": "İş ekleri", "Job attachments filesystem options": "İş ekleri dosya sistemi seçenekleri", + "Job bundle directory": "İş paketi dizini", "Job history directory": "İş geçmişi dizini", "Job submission confirmation": "İş gönderimi onayı", "Job-specific settings": "İşe özel ayarlar", @@ -72,6 +77,7 @@ "Language": "Dil", "Language will change next time the submitter is opened": "Dil, submitter bir sonraki açılışında değişecektir", "Load Bundle": "Paket yükle", + "Local": "Yerel", "Log in": "Oturum aç", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud'da oturum aç", "Logging you in...": "Oturum açılıyor...", @@ -83,6 +89,7 @@ "Memory (GiB)": "Bellek (GiB)", "Min": "Min", "More info": "Daha fazla bilgi", + "Name": "Ad", "New version available": "Yeni sürüm mevcut", "No farm is configured. Click Settings to select a farm for job submission.": "Yapılandırılmış farm yok. İş gönderimi için bir farm seçmek üzere Ayarlar'a tıklayın.", "No max worker count": "Maksimum çalışan sayısı yok", @@ -90,8 +97,10 @@ "Non valid inputs detected": "Geçersiz girişler algılandı", "Ok": "Tamam", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud monitörü açılıyor. Buraya dönmeden önce lütfen oturum açın.", - "Please run the installer and then restart {integration_name} to use the new version.": "Lütfen installer'ı çalıştırın ve ardından yeni sürümü kullanmak için {integration_name} uygulamasını yeniden başlatın.", "Operating system": "İşletim sistemi", + "Parameters:": "Parametreler:", + "Path:": "Yol:", + "Please run the installer and then restart {integration_name} to use the new version.": "Lütfen installer'ı çalıştırın ve ardından yeni sürümü kullanmak için {integration_name} uygulamasını yeniden başlatın.", "Preparing files...": "Dosyalar hazırlanıyor...", "Preparing for hashing...": "Karma oluşturma için hazırlanıyor...", "Preparing for upload...": "Yükleme için hazırlanıyor...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "Aşağıdaki gereksinimleri karşılayan çalışan ana bilgisayarlarda çalıştır", "Saved the submission as a job bundle:\n{path}": "Gönderim iş paketi olarak kaydedildi:\n{path}", "Scratch space": "Geçici alan", + "Select": "Seç", + "Select a job bundle to see details": "Ayrıntıları görmek için bir iş paketi seçin", "Set max worker count": "Maksimum çalışan sayısını ayarla", "Settings...": "Ayarlar...", "Shared job settings": "Paylaşılan iş ayarları", "Show auto-detected": "Otomatik algılanmışları göster", "Show submitter update notifications": "Gönderi aracı güncelleme bildirimlerini göster", + "Source:": "Kaynak:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Bir iş paketi dizini belirtin veya bundle komutunu --browse bayrağıyla çalıştırın", "Specify output directories": "Çıkış dizinlerini belirtin", + "Steps:": "Adımlar:", "Submission canceled": "Gönderim iptal edildi", "Submission complete": "Gönderim tamamlandı", "Submission error": "Gönderim hatası", @@ -126,20 +139,19 @@ "Telemetry opt out": "Telemetriyi devre dışı bırak", "Template file format": "Şablon dosya biçimi", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Aşağıdaki parametreler iş şablonu veya kuyruk tarafından tanınmıyor:\n\n{params}\n\nBu parametreler iş gönderimi sırasında yok sayılacak.\n\nDevam etmek istiyor musunuz?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "'{profile}' profiliyle ilgili bir yapılandırma sorunu var.\n\nBu sorunu çözmek için:\n\\u2022 AWS yapılandırma ve kimlik bilgileri dosyalarınızın doğru olduğunu doğrulayın\n \\u2022 Varsayılan olarak bu dosyalar Linux/MacOS'ta ~/.aws veya Windows'ta %USERPROFILE%/.aws konumunda bulunabilir\n\\u2022 Doğru AWS bölgesinin ayarlandığını doğrulayın\n \\u2022 AWS_DEFAULT_REGION gibi ortam değişkenlerinin yanlış bir bölgeye ayarlanmadığını kontrol edin\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız:\n \\u2022 Kullanılan kimlik bilgisi işleminin kimlik bilgilerini alabileceğini veya sürelerinin dolmadığını doğrulayın\n \\u2022 Kontrol etmek için şu komutu çalıştırabilirsiniz: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "'{profile}' profiliyle ilgili bir yapılandırma sorunu var.\n\nBu sorunu çözmek için:\n\\u2022 AWS yapılandırma ve kimlik bilgileri dosyalarınızın doğru olduğunu doğrulayın\n \\u2022 Varsayılan olarak bu dosyalar Linux/MacOS'ta ~/.aws veya Windows'ta %USERPROFILE%/.aws konumunda bulunabilir\n\\u2022 Doğru AWS bölgesinin ayarlandığını doğrulayın\n \\u2022 AWS_DEFAULT_REGION gibi ortam değişkenlerinin yanlış bir bölgeye ayarlanmadığını kontrol edin\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız:\n \\u2022 Kullanılan kimlik bilgisi işleminin kimlik bilgilerini alabileceğini veya sürelerinin dolmadığını doğrulayın\n \\u2022 Kontrol etmek için şu komutu çalıştırabilirsiniz: aws sts get-caller-identity --profile ", "There was an error with authentication": "Kimlik doğrulamada bir hata oluştu", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "'{profile}' profiliyle kimlik doğrulamaya çalışırken bilinmeyen bir sorun oluştu.\n\nSorunu teşhis etmeye çalışmak için hataları görmek üzere mevcut konsol günlüklerini kontrol edin.\nGünlükler genellikle şurada bulunur:\n \\u2022 Gönderenin çalıştığı iletişim kutusunun veya yazılımın başlatıldığı terminalde \\u2022 Gönderenin çalıştığı yazılımın içindeki yerleşik konsolda", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "'{profile}' profiliyle kimlik doğrulamaya çalışırken bilinmeyen bir sorun oluştu.\n\nSorunu teşhis etmeye çalışmak için hataları görmek üzere mevcut konsol günlüklerini kontrol edin.\nGünlükler genellikle şurada bulunur:\n \\u2022 Gönderenin çalıştığı iletişim kutusunun veya yazılımın başlatıldığı terminalde \\u2022 Gönderenin çalıştığı yazılımın içindeki yerleşik konsolda", "Timeouts": "Zaman aşımları", "Unknown Issue With Configured Profile": "Yapılandırılmış profille ilgili bilinmeyen sorun", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} göndericisi sürüm {latest_version} artık kullanılabilir.", - "View release notes": "Sürüm notlarını görüntüle", "Unrecognized Parameters": "Tanınmayan parametreler", "Upload progress": "Yükleme ilerlemesi", "Use array parameter": "Dizi parametresi kullan", "Value(s)": "Değer(ler)", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "'{profile}' profiliyle kimlik doğrulandınız, ancak bu profil AWS Deadline Cloud ListFarms'ı çağıramıyor ve AWS Deadline Cloud'a iş gönderemiyor.\n\nBu sorunu çözmek için:\n\\u2022 Yanlış AWS bölgesine işaret eden ortam değişkenleri olmadığını kontrol edin (örn. AWS_DEFAULT_REGION)\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız, profilin gönderim için gereken şu AWS Deadline Cloud API'leri için izinlere sahip olduğunu kontrol edin:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} göndericisi sürüm {latest_version} artık kullanılabilir.", + "View release notes": "Sürüm notlarını görüntüle", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "'{profile}' profiliyle kimlik doğrulandınız, ancak bu profil AWS Deadline Cloud ListFarms'ı çağıramıyor ve AWS Deadline Cloud'a iş gönderemiyor.\n\nBu sorunu çözmek için:\n\\u2022 Yanlış AWS bölgesine işaret eden ortam değişkenleri olmadığını kontrol edin (örn. AWS_DEFAULT_REGION)\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız, profilin gönderim için gereken şu AWS Deadline Cloud API'leri için izinlere sahip olduğunu kontrol edin:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Oturumunuz kapatıldı.", - "{submitter} job submission": "{submitter} iş gönderimi", - "Default maximum retries per task": "G\u00f6rev ba\u015f\u0131na varsay\u0131lan maksimum yeniden deneme", - "Default maximum failed tasks count": "Varsay\u0131lan maksimum ba\u015far\u0131s\u0131z g\u00f6rev say\u0131s\u0131" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} bir iş göndermek için erişim izinlerine sahip değil.", + "{submitter} job submission": "{submitter} iş gönderimi" +} diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index 471187d4a..d456adfa0 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 工作站配置", "AWS profile": "AWS 配置文件", "About": "关于", - "Application Restart Required": "需要重新启动应用程序", "Add": "添加", "Add amount": "添加数量", "Add attribute": "添加属性", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "始终检查 S3 作业附件", "Amount name": "数量名称", "Any": "任意", + "Application Restart Required": "需要重新启动应用程序", "Apply": "应用", "Array parameter values": "数组参数值", "Attach input directories": "附加输入目录", "Attach input files": "附加输入文件", "Attribute name": "属性名称", "Auto accept prompt defaults": "自动接受默认值", + "Browse Job Bundles": "浏览作业包", "CPU architecture": "CPU 架构", "Cancel": "取消", "Canceling submission...": "正在取消提交...", - "Cannot submit job:\n\n\u2022 {issues}": "无法提交作业:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "无法提交作业:\n\n\\u2022 {issues}", "Choose job bundle directory": "选择作业捆绑包目录", "Close": "关闭", "Conflict resolution option": "冲突解决选项", "Copy": "复制", - "Current: {current_version} -> New: {latest_version}": "当前: {current_version} -> 新版本: {latest_version}", "Current logging level": "当前日志记录级别", + "Current: {current_version} -> New: {latest_version}": "当前: {current_version} -> 新版本: {latest_version}", "Custom host requirements": "自定义主机要求", "Data directory": "数据目录", "Deadline Cloud settings": "Deadline Cloud 设置", "Default farm": "默认服务器农场", + "Default maximum failed tasks count": "默认最大失败任务数", + "Default maximum retries per task": "每个任务的默认最大重试次数", "Default queue": "默认队列", "Default storage profile": "默认存储配置文件", "Delete": "删除", @@ -58,6 +61,7 @@ "Hardware requirements": "硬件要求", "Hashing progress": "哈希进度", "Help": "帮助", + "History": "历史记录", "Host requirements": "主机要求", "Initial state": "初始状态", "Issue With Profile Configuration": "配置文件配置问题", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "作业提交确认", "Job attachments": "作业附件", "Job attachments filesystem options": "作业附件文件系统选项", + "Job bundle directory": "作业包目录", "Job history directory": "作业历史记录目录", "Job submission confirmation": "作业提交确认", "Job-specific settings": "作业特定设置", @@ -72,6 +77,7 @@ "Language": "语言", "Language will change next time the submitter is opened": "语言将在下次打开提交器时更改", "Load Bundle": "加载捆绑包", + "Local": "本地", "Log in": "登录", "Log in to AWS Deadline Cloud": "登录 AWS Deadline Cloud", "Logging you in...": "正在登录...", @@ -83,6 +89,7 @@ "Memory (GiB)": "内存 (GiB)", "Min": "最小", "More info": "更多信息", + "Name": "名称", "New version available": "有新版本可用", "No farm is configured. Click Settings to select a farm for job submission.": "未配置服务器农场。单击设置以选择用于作业提交的服务器农场。", "No max worker count": "无最大工作线程数", @@ -90,8 +97,10 @@ "Non valid inputs detected": "检测到无效输入", "Ok": "确定", "Opening Deadline Cloud monitor. Please log in before returning here.": "正在打开 Deadline Cloud 监控器。请在返回此处之前登录。", - "Please run the installer and then restart {integration_name} to use the new version.": "请运行安装程序,然后重新启动 {integration_name} 以使用新版本。", "Operating system": "操作系统", + "Parameters:": "参数:", + "Path:": "路径:", + "Please run the installer and then restart {integration_name} to use the new version.": "请运行安装程序,然后重新启动 {integration_name} 以使用新版本。", "Preparing files...": "正在准备文件...", "Preparing for hashing...": "正在准备哈希...", "Preparing for upload...": "正在准备上传...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "在满足以下要求的工作主机上运行", "Saved the submission as a job bundle:\n{path}": "已将提交保存为作业捆绑包:\n{path}", "Scratch space": "临时空间", + "Select": "选择", + "Select a job bundle to see details": "选择作业包以查看详细信息", "Set max worker count": "设置最大工作线程数", "Settings...": "设置...", "Shared job settings": "共享作业设置", "Show auto-detected": "显示自动检测的", "Show submitter update notifications": "显示提交器更新通知", + "Source:": "来源:", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定作业捆绑包目录或使用 --browse 标志运行 bundle 命令", "Specify output directories": "指定输出目录", + "Steps:": "步骤:", "Submission canceled": "提交已取消", "Submission complete": "提交完成", "Submission error": "提交错误", @@ -126,20 +139,19 @@ "Telemetry opt out": "选择退出遥测", "Template file format": "模板文件格式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "作业模板或队列无法识别以下参数:\n\n{params}\n\n这些参数将在作业提交期间被忽略。\n\n是否要继续?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "配置文件 '{profile}' 存在配置问题。\n\n要解决此问题:\n\\u2022 验证您的 AWS 配置和凭证文件是否正确\n \\u2022 默认情况下,这些文件可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 验证是否设置了正确的 AWS 区域\n \\u2022 检查是否没有将 AWS_DEFAULT_REGION 等环境变量设置为错误的区域\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件:\n \\u2022 验证正在使用的任何凭证进程是否能够检索凭证或凭证是否未过期\n \\u2022 您可以运行以下命令进行检查: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "配置文件 '{profile}' 存在配置问题。\n\n要解决此问题:\n\\u2022 验证您的 AWS 配置和凭证文件是否正确\n \\u2022 默认情况下,这些文件可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 验证是否设置了正确的 AWS 区域\n \\u2022 检查是否没有将 AWS_DEFAULT_REGION 等环境变量设置为错误的区域\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件:\n \\u2022 验证正在使用的任何凭证进程是否能够检索凭证或凭证是否未过期\n \\u2022 您可以运行以下命令进行检查: aws sts get-caller-identity --profile ", "There was an error with authentication": "身份验证出错", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "尝试使用配置文件 '{profile}' 进行身份验证时出现未知问题。\n\n检查任何可用的控制台日志以查找错误,以尝试诊断问题。\n日志通常位于:\n \\u2022 在启动对话框或提交者正在运行的软件的终端中 \\u2022 在提交者正在运行的软件内的内置控制台中", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "尝试使用配置文件 '{profile}' 进行身份验证时出现未知问题。\n\n检查任何可用的控制台日志以查找错误,以尝试诊断问题。\n日志通常位于:\n \\u2022 在启动对话框或提交者正在运行的软件的终端中 \\u2022 在提交者正在运行的软件内的内置控制台中", "Timeouts": "超时", "Unknown Issue With Configured Profile": "配置的配置文件存在未知问题", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 现已可用。", - "View release notes": "查看发行说明", "Unrecognized Parameters": "无法识别的参数", "Upload progress": "上传进度", "Use array parameter": "使用数组参数", "Value(s)": "值", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "您已使用配置文件 '{profile}' 进行身份验证,但此配置文件无法调用 AWS Deadline Cloud ListFarms 并且无法向 AWS Deadline Cloud 提交作业。\n\n要解决此问题:\n\\u2022 检查是否没有指向错误 AWS 区域的环境变量 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件,请检查配置文件是否具有提交所需的这些 AWS Deadline Cloud API 的权限:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 现已可用。", + "View release notes": "查看发行说明", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "您已使用配置文件 '{profile}' 进行身份验证,但此配置文件无法调用 AWS Deadline Cloud ListFarms 并且无法向 AWS Deadline Cloud 提交作业。\n\n要解决此问题:\n\\u2022 检查是否没有指向错误 AWS 区域的环境变量 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件,请检查配置文件是否具有提交所需的这些 AWS Deadline Cloud API 的权限:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 您已登出。", - "{submitter} job submission": "{submitter} 作业提交", - "Default maximum retries per task": "\u6bcf\u4e2a\u4efb\u52a1\u7684\u9ed8\u8ba4\u6700\u5927\u91cd\u8bd5\u6b21\u6570", - "Default maximum failed tasks count": "\u9ed8\u8ba4\u6700\u5927\u5931\u8d25\u4efb\u52a1\u6570" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} 没有提交作业的访问权限。", + "{submitter} job submission": "{submitter} 作业提交" +} diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index 2d3877dfe..aaefa1c61 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -6,7 +6,6 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 工作站組態", "AWS profile": "AWS 設定檔", "About": "關於", - "Application Restart Required": "需要重新啟動應用程式", "Add": "新增", "Add amount": "新增數量", "Add attribute": "新增屬性", @@ -16,26 +15,30 @@ "Always check S3 job attachments": "一律檢查 S3 任務附件", "Amount name": "數量名稱", "Any": "任何", + "Application Restart Required": "需要重新啟動應用程式", "Apply": "套用", "Array parameter values": "陣列參數值", "Attach input directories": "附加輸入目錄", "Attach input files": "附加輸入檔案", "Attribute name": "屬性名稱", "Auto accept prompt defaults": "自動接受預設值", + "Browse Job Bundles": "瀏覽工作套件", "CPU architecture": "CPU 架構", "Cancel": "取消", "Canceling submission...": "正在取消提交...", - "Cannot submit job:\n\n\u2022 {issues}": "無法提交任務:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n• {issues}": "無法提交任務:\n\n\\u2022 {issues}", "Choose job bundle directory": "選擇任務套件目錄", "Close": "關閉", "Conflict resolution option": "衝突解決選項", "Copy": "複製", - "Current: {current_version} -> New: {latest_version}": "目前: {current_version} -> 新版本: {latest_version}", "Current logging level": "目前的日誌記錄層級", + "Current: {current_version} -> New: {latest_version}": "目前: {current_version} -> 新版本: {latest_version}", "Custom host requirements": "自訂主機需求", "Data directory": "資料目錄", "Deadline Cloud settings": "Deadline Cloud 設定", "Default farm": "預設伺服器陣列", + "Default maximum failed tasks count": "預設失敗任務數上限", + "Default maximum retries per task": "每個任務的預設重試次數上限", "Default queue": "預設佇列", "Default storage profile": "預設儲存設定檔", "Delete": "刪除", @@ -58,6 +61,7 @@ "Hardware requirements": "硬體需求", "Hashing progress": "雜湊進度", "Help": "說明", + "History": "歷史記錄", "Host requirements": "主機需求", "Initial state": "初始狀態", "Issue With Profile Configuration": "設定檔組態問題", @@ -65,6 +69,7 @@ "Job Submission Confirmation": "任務提交確認", "Job attachments": "任務附件", "Job attachments filesystem options": "任務附件檔案系統選項", + "Job bundle directory": "工作套件目錄", "Job history directory": "任務歷史記錄目錄", "Job submission confirmation": "任務提交確認", "Job-specific settings": "任務特定設定", @@ -72,6 +77,7 @@ "Language": "語言", "Language will change next time the submitter is opened": "語言將在下次開啟提交器時變更", "Load Bundle": "載入套件", + "Local": "本機", "Log in": "登入", "Log in to AWS Deadline Cloud": "登入 AWS Deadline Cloud", "Logging you in...": "正在登入...", @@ -83,6 +89,7 @@ "Memory (GiB)": "記憶體 (GiB)", "Min": "最小", "More info": "更多資訊", + "Name": "名稱", "New version available": "有新版本可用", "No farm is configured. Click Settings to select a farm for job submission.": "未設定伺服器陣列。按一下設定以選取用於任務提交的伺服器陣列。", "No max worker count": "無工作程序數上限", @@ -90,8 +97,10 @@ "Non valid inputs detected": "偵測到無效的輸入", "Ok": "確定", "Opening Deadline Cloud monitor. Please log in before returning here.": "正在開啟 Deadline Cloud 監視器。請在返回此處之前登入。", - "Please run the installer and then restart {integration_name} to use the new version.": "請執行安裝程式,然後重新啟動 {integration_name} 以使用新版本。", "Operating system": "作業系統", + "Parameters:": "參數:", + "Path:": "路徑:", + "Please run the installer and then restart {integration_name} to use the new version.": "請執行安裝程式,然後重新啟動 {integration_name} 以使用新版本。", "Preparing files...": "正在準備檔案...", "Preparing for hashing...": "正在準備雜湊...", "Preparing for upload...": "正在準備上傳...", @@ -108,13 +117,17 @@ "Run on worker hosts that meet the following requirements": "在符合下列需求的工作者主機上執行", "Saved the submission as a job bundle:\n{path}": "已將提交儲存為任務套件:\n{path}", "Scratch space": "暫存空間", + "Select": "選取", + "Select a job bundle to see details": "選取工作套件以查看詳細資訊", "Set max worker count": "設定工作程序數上限", "Settings...": "設定...", "Shared job settings": "共用任務設定", "Show auto-detected": "顯示自動偵測的", "Show submitter update notifications": "顯示提交器更新通知", + "Source:": "來源:", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定任務套件目錄或使用 --browse 旗標執行 bundle 命令", "Specify output directories": "指定輸出目錄", + "Steps:": "步驟:", "Submission canceled": "提交已取消", "Submission complete": "提交完成", "Submission error": "提交錯誤", @@ -126,20 +139,19 @@ "Telemetry opt out": "選擇退出遙測", "Template file format": "範本檔案格式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "任務範本或佇列無法識別下列參數:\n\n{params}\n\n這些參數將在任務提交期間被忽略。\n\n是否要繼續?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "設定檔 '{profile}' 存在組態問題。\n\n若要解決此問題:\n\\u2022 驗證您的 AWS 組態和憑證檔案是否正確\n \\u2022 根據預設,這些檔案可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 驗證是否設定了正確的 AWS 區域\n \\u2022 檢查是否沒有將 AWS_DEFAULT_REGION 等環境變數設定為錯誤的區域\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔:\n \\u2022 驗證正在使用的任何憑證程序是否能夠擷取憑證或憑證是否未過期\n \\u2022 您可以執行下列命令進行檢查: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "設定檔 '{profile}' 存在組態問題。\n\n若要解決此問題:\n\\u2022 驗證您的 AWS 組態和憑證檔案是否正確\n \\u2022 根據預設,這些檔案可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 驗證是否設定了正確的 AWS 區域\n \\u2022 檢查是否沒有將 AWS_DEFAULT_REGION 等環境變數設定為錯誤的區域\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔:\n \\u2022 驗證正在使用的任何憑證程序是否能夠擷取憑證或憑證是否未過期\n \\u2022 您可以執行下列命令進行檢查: aws sts get-caller-identity --profile ", "There was an error with authentication": "身分驗證發生錯誤", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "嘗試使用設定檔 '{profile}' 進行身分驗證時發生未知問題。\n\n檢查任何可用的主控台日誌以查找錯誤,以嘗試診斷問題。\n日誌通常位於:\n \\u2022 在啟動對話方塊或提交者正在執行的軟體的終端機中 \\u2022 在提交者正在執行的軟體內的內建主控台中", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "嘗試使用設定檔 '{profile}' 進行身分驗證時發生未知問題。\n\n檢查任何可用的主控台日誌以查找錯誤,以嘗試診斷問題。\n日誌通常位於:\n \\u2022 在啟動對話方塊或提交者正在執行的軟體的終端機中 \\u2022 在提交者正在執行的軟體內的內建主控台中", "Timeouts": "逾時", "Unknown Issue With Configured Profile": "設定的設定檔存在未知問題", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 現已可用。", - "View release notes": "檢視版本資訊", "Unrecognized Parameters": "無法識別的參數", "Upload progress": "上傳進度", "Use array parameter": "使用陣列參數", "Value(s)": "值", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "您已使用設定檔 '{profile}' 進行身分驗證,但此設定檔無法呼叫 AWS Deadline Cloud ListFarms 並且無法向 AWS Deadline Cloud 提交任務。\n\n若要解決此問題:\n\\u2022 檢查是否沒有指向錯誤 AWS 區域的環境變數 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔,請檢查設定檔是否具有提交所需的這些 AWS Deadline Cloud API 的許可:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 現已可用。", + "View release notes": "檢視版本資訊", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "您已使用設定檔 '{profile}' 進行身分驗證,但此設定檔無法呼叫 AWS Deadline Cloud ListFarms 並且無法向 AWS Deadline Cloud 提交任務。\n\n若要解決此問題:\n\\u2022 檢查是否沒有指向錯誤 AWS 區域的環境變數 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔,請檢查設定檔是否具有提交所需的這些 AWS Deadline Cloud API 的許可:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 您已登出。", - "{submitter} job submission": "{submitter} 任務提交", - "Default maximum retries per task": "\u6bcf\u500b\u4efb\u52d9\u7684\u9810\u8a2d\u91cd\u8a66\u6b21\u6578\u4e0a\u9650", - "Default maximum failed tasks count": "\u9810\u8a2d\u5931\u6557\u4efb\u52d9\u6578\u4e0a\u9650" -} \ No newline at end of file + "{profile} doesn't have access permissions to submit a job.": "{profile} 沒有提交任務的存取許可。", + "{submitter} job submission": "{submitter} 任務提交" +} diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 7824ac1aa..bc4e7b58d 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -145,6 +145,7 @@ def test_full_metadata(self): "bundle-parameters": "Frames:STRING,Output:PATH", } info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key") + assert info is not None assert info.name == "My Bundle" assert info.description == "A description" assert info.step_names == ["Step1", "Step2"] @@ -158,6 +159,7 @@ def test_missing_name_returns_none(self): def test_name_only(self): info = _bundle_info_from_s3_metadata({"bundle-name": "Simple"}, "s3://bucket/key") + assert info is not None assert info.name == "Simple" assert info.step_names == [] assert info.parameters == [] From c4882ab1e5a93444420d13609174a8633ebd7362 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 29 May 2026 15:33:45 -0500 Subject: [PATCH 11/89] chore: update design doc Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 8 +-- .../client/cli/_groups/bundle_group.py | 8 +-- src/deadline/client/job_bundle/repository.py | 9 +-- .../client/ui/translations/locales/de_DE.json | 46 +++++++-------- .../client/ui/translations/locales/en_US.json | 46 +++++++-------- .../client/ui/translations/locales/es_ES.json | 46 +++++++-------- .../client/ui/translations/locales/fr_FR.json | 46 +++++++-------- .../client/ui/translations/locales/id_ID.json | 46 +++++++-------- .../client/ui/translations/locales/it_IT.json | 46 +++++++-------- .../client/ui/translations/locales/ja_JP.json | 46 +++++++-------- .../client/ui/translations/locales/ko_KR.json | 46 +++++++-------- .../client/ui/translations/locales/pt_BR.json | 46 +++++++-------- .../client/ui/translations/locales/tr_TR.json | 46 +++++++-------- .../client/ui/translations/locales/zh_CN.json | 46 +++++++-------- .../client/ui/translations/locales/zh_TW.json | 46 +++++++-------- .../ui/widgets/job_bundle_settings_tab.py | 13 ++--- .../cli/test_cli_bundle_repository.py | 26 +-------- .../job_bundle/test_repository.py | 2 + .../widgets/test_job_bundle_settings_tab.py | 57 ++++++++++--------- 19 files changed, 324 insertions(+), 351 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 2538ea635..5c99f1d4a 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -30,7 +30,7 @@ Job bundles can be either: - **Directories** — a folder containing `template.yaml` or `template.json` at the root, plus any scripts, data files, and `asset_references.yaml`. - **Archives** — an `.ojd` file (zip format under the hood) containing a job bundle. The template can be at the archive root or inside a single wrapper directory. -Both formats are supported for both local and S3 browsing. Archives are extracted to a local directory before submission. If an archive contains a single top-level wrapper directory (e.g. `my-bundle/template.yaml` instead of `template.yaml` at the root), the wrapper is detected and the inner directory is used as the bundle path. +Both formats are supported for local browsing. S3 browsing only supports `.ojd` archives — this is the canonical sharing format. Archives are extracted to a local directory before submission. If an archive contains a single top-level wrapper directory (e.g. `my-bundle/template.yaml` instead of `template.yaml` at the root), the wrapper is detected and the inner directory is used as the bundle path. ### Backend Abstraction @@ -71,7 +71,7 @@ class BundleRepository(Protocol): Two implementations: - `LocalBundleRepository` — walks the local filesystem. Lists directories and archive files. Directories are bundles if they contain `template.yaml`/`template.json`. Archives are always shown as bundles (validated on preview). Provides `extract_bundle()` for extracting archives to a local directory. -- `S3BundleRepository` — lists objects and prefixes under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Folder prefixes and archive objects are both listed. Provides `resolve_bundle()` which handles both folder downloads and archive download+cache+extract. +- `S3BundleRepository` — lists objects and prefixes under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Only `.ojd` archives are recognized as bundles; subfolders are shown for navigation only. Provides `resolve_bundle()` which handles archive download+cache+extract. ### S3 Bucket Convention @@ -372,8 +372,8 @@ Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.ojd Downloads a job bundle from the queue's S3 `job-bundles/` folder. -- Looks for both archive and folder formats by name. -- Archive bundles use the ETag cache (same as the browser dialog) — repeated downloads are instant if the archive hasn't changed. +- Finds the `.ojd` archive matching the given name. +- Uses the ETag cache (same as the browser dialog) — repeated downloads are instant if the archive hasn't changed. - `-o, --output-dir`: Local directory to extract/download to (defaults to `.`). - `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 8f91e0f8f..10cbb34d3 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -10,12 +10,15 @@ import logging import sys import re +import io +import zipfile from typing import Any, Optional import tempfile import shutil import os from dataclasses import fields +import boto3 import click from botocore.exceptions import ClientError @@ -777,9 +780,6 @@ def bundle_upload(job_bundle_dir, name, **args): """ Upload a job bundle to the queue's S3 job-bundles folder as an .ojd archive. """ - import zipfile - import io - config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -819,8 +819,6 @@ def bundle_upload(job_bundle_dir, name, **args): bundle_name = name or os.path.basename(job_bundle_dir) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" - import boto3 - s3 = boto3.client("s3") # Archive and upload diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 475bb6c17..9ef7668a0 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -11,6 +11,7 @@ import io import json import os +import shutil import zipfile from dataclasses import dataclass, field from logging import getLogger @@ -413,7 +414,7 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: try: head = self._s3.head_object(Bucket=self._bucket, Key=key) except Exception: - pass + pass # head_object failure is non-fatal; we fall through to download if head: # Check local cache validity @@ -446,8 +447,6 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: # Extract to cache so resolve_bundle can reuse it if os.path.exists(cache_dir): - import shutil - shutil.rmtree(cache_dir) os.makedirs(cache_dir, exist_ok=True) try: @@ -480,7 +479,7 @@ def _resolve_archive_bundle(self, path: str) -> str: logger.info("Using cached bundle: %s", bundle_path) return bundle_path except Exception: - pass + pass # Cache validation failed; re-download below # Download, extract, and cache resp = self._s3.get_object(Bucket=self._bucket, Key=key) @@ -490,8 +489,6 @@ def _resolve_archive_bundle(self, path: str) -> str: # Clear old cache and extract if os.path.exists(cache_dir): - import shutil - shutil.rmtree(cache_dir) os.makedirs(cache_dir, exist_ok=True) diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index de587f289..68a028e50 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud-Workstation-Konfiguration", "AWS profile": "AWS-Profil", "About": "Über", + "Application Restart Required": "Neustart der Anwendung erforderlich", "Add": "Hinzufügen", "Add amount": "Menge hinzufügen", "Add attribute": "Attribut hinzufügen", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "S3-Jobanhänge immer prüfen", "Amount name": "Mengenname", "Any": "Beliebig", - "Application Restart Required": "Neustart der Anwendung erforderlich", "Apply": "Anwenden", "Array parameter values": "Array-Parameterwerte", "Attach input directories": "Eingabeverzeichnisse anhängen", "Attach input files": "Eingabedateien anhängen", "Attribute name": "Attributname", "Auto accept prompt defaults": "Standardwerte automatisch akzeptieren", - "Browse Job Bundles": "Job-Bundles durchsuchen", "CPU architecture": "CPU-Architektur", "Cancel": "Abbrechen", "Canceling submission...": "Übermittlung wird abgebrochen...", - "Cannot submit job:\n\n• {issues}": "Job kann nicht übermittelt werden:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Job kann nicht übermittelt werden:\n\n\\u2022 {issues}", "Choose job bundle directory": "Jobpaket-Verzeichnis auswählen", "Close": "Schließen", "Conflict resolution option": "Option zur Konfliktlösung", "Copy": "Kopieren", - "Current logging level": "Aktuelle Protokollierungsebene", "Current: {current_version} -> New: {latest_version}": "Aktuell: {current_version} -> Neu: {latest_version}", + "Current logging level": "Aktuelle Protokollierungsebene", "Custom host requirements": "Benutzerdefinierte Host-Anforderungen", "Data directory": "Datenverzeichnis", "Deadline Cloud settings": "Deadline Cloud-Einstellungen", "Default farm": "Standard-Farm", - "Default maximum failed tasks count": "Standardmäßige maximale Anzahl fehlgeschlagener Aufgaben", - "Default maximum retries per task": "Standardmäßige maximale Wiederholungen pro Aufgabe", "Default queue": "Standard-Warteschlange", "Default storage profile": "Standard-Speicherprofil", "Delete": "Löschen", @@ -61,7 +58,6 @@ "Hardware requirements": "Hardwareanforderungen", "Hashing progress": "Hashing-Fortschritt", "Help": "Hilfe", - "History": "Verlauf", "Host requirements": "Host-Anforderungen", "Initial state": "Anfangszustand", "Issue With Profile Configuration": "Problem mit Profilkonfiguration", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Bestätigung der Jobübermittlung", "Job attachments": "Arbeitsanhänge", "Job attachments filesystem options": "Dateisystemoptionen für Jobanhänge", - "Job bundle directory": "Job-Bundle-Verzeichnis", "Job history directory": "Jobverlaufsverzeichnis", "Job submission confirmation": "Bestätigung der Jobübermittlung", "Job-specific settings": "Jobspezifische Einstellungen", @@ -77,7 +72,6 @@ "Language": "Sprache", "Language will change next time the submitter is opened": "Die Sprache wird beim nächsten Öffnen des Submitters geändert", "Load Bundle": "Paket laden", - "Local": "Lokal", "Log in": "Anmelden", "Log in to AWS Deadline Cloud": "Bei AWS Deadline Cloud anmelden", "Logging you in...": "Sie werden angemeldet...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Speicher (GiB)", "Min": "Min", "More info": "Weitere Informationen", - "Name": "Name", "New version available": "Neue Version verfügbar", "No farm is configured. Click Settings to select a farm for job submission.": "Es ist keine Farm konfiguriert. Klicken Sie auf Einstellungen, um eine Farm für die Jobübermittlung auszuwählen.", "No max worker count": "Keine maximale Worker-Anzahl", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Ungültige Eingaben erkannt", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud-Monitor wird geöffnet. Bitte melden Sie sich an, bevor Sie hierher zurückkehren.", - "Operating system": "Betriebssystem", - "Parameters:": "Parameter:", - "Path:": "Pfad:", "Please run the installer and then restart {integration_name} to use the new version.": "Bitte führen Sie den Installer aus und starten Sie dann {integration_name} neu, um die neue Version zu verwenden.", + "Operating system": "Betriebssystem", "Preparing files...": "Dateien werden vorbereitet...", "Preparing for hashing...": "Hashing wird vorbereitet...", "Preparing for upload...": "Upload wird vorbereitet...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Auf Worker-Hosts ausführen, die die folgenden Anforderungen erfüllen", "Saved the submission as a job bundle:\n{path}": "Die Übermittlung wurde als Jobpaket gespeichert:\n{path}", "Scratch space": "Temporärer Speicherplatz", - "Select": "Auswählen", - "Select a job bundle to see details": "Job-Bundle auswählen, um Details anzuzeigen", "Set max worker count": "Maximale Worker-Anzahl festlegen", "Settings...": "Einstellungen...", "Shared job settings": "Gemeinsame Jobeinstellungen", "Show auto-detected": "Automatisch erkannte anzeigen", "Show submitter update notifications": "Aktualisierungsbenachrichtigungen des Submitters anzeigen", - "Source:": "Quelle:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Geben Sie ein Jobpaket-Verzeichnis an oder führen Sie den Bundle-Befehl mit dem Flag --browse aus", "Specify output directories": "Ausgabeverzeichnisse angeben", - "Steps:": "Schritte:", "Submission canceled": "Übermittlung abgebrochen", "Submission complete": "Übermittlung abgeschlossen", "Submission error": "Übermittlungsfehler", @@ -139,19 +126,32 @@ "Telemetry opt out": "Telemetrie deaktivieren", "Template file format": "Vorlagendateiformat", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Die folgenden Parameter werden von der Jobvorlage oder Warteschlange nicht erkannt:\n\n{params}\n\nDiese Parameter werden bei der Jobübermittlung ignoriert.\n\nMöchten Sie fortfahren?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Es gibt ein Konfigurationsproblem mit dem Profil '{profile}'.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, ob Ihre AWS-Konfigurations- und Anmeldeinformationsdateien korrekt sind\n \\u2022 Standardmäßig befinden sich diese Dateien unter ~/.aws unter Linux/MacOS oder %USERPROFILE%/.aws unter Windows\n\\u2022 Überprüfen Sie, ob die richtige AWS-Region festgelegt ist\n \\u2022 Stellen Sie sicher, dass keine Umgebungsvariablen wie AWS_DEFAULT_REGION auf eine falsche Region gesetzt sind\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden:\n \\u2022 Überprüfen Sie, ob der verwendete Anmeldeinformationsprozess die Anmeldeinformationen abrufen kann oder ob diese nicht abgelaufen sind\n \\u2022 Sie können den folgenden Befehl ausführen, um dies zu überprüfen: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Es gibt ein Konfigurationsproblem mit dem Profil '{profile}'.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, ob Ihre AWS-Konfigurations- und Anmeldeinformationsdateien korrekt sind\n \\u2022 Standardmäßig befinden sich diese Dateien unter ~/.aws unter Linux/MacOS oder %USERPROFILE%/.aws unter Windows\n\\u2022 Überprüfen Sie, ob die richtige AWS-Region festgelegt ist\n \\u2022 Stellen Sie sicher, dass keine Umgebungsvariablen wie AWS_DEFAULT_REGION auf eine falsche Region gesetzt sind\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden:\n \\u2022 Überprüfen Sie, ob der verwendete Anmeldeinformationsprozess die Anmeldeinformationen abrufen kann oder ob diese nicht abgelaufen sind\n \\u2022 Sie können den folgenden Befehl ausführen, um dies zu überprüfen: aws sts get-caller-identity --profile ", "There was an error with authentication": "Es ist ein Fehler bei der Authentifizierung aufgetreten", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Beim Versuch, sich mit dem Profil '{profile}' zu authentifizieren, ist ein unbekanntes Problem aufgetreten.\n\nÜberprüfen Sie verfügbare Konsolenprotokolle auf Fehler, um das Problem zu diagnostizieren.\nProtokolle finden Sie normalerweise:\n \\u2022 Im Terminal, von dem aus der Dialog oder die Software, in der der Submitter ausgeführt wird, gestartet wurde \\u2022 In der integrierten Konsole innerhalb der Software, in der der Submitter ausgeführt wird", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Beim Versuch, sich mit dem Profil '{profile}' zu authentifizieren, ist ein unbekanntes Problem aufgetreten.\n\nÜberprüfen Sie verfügbare Konsolenprotokolle auf Fehler, um das Problem zu diagnostizieren.\nProtokolle finden Sie normalerweise:\n \\u2022 Im Terminal, von dem aus der Dialog oder die Software, in der der Submitter ausgeführt wird, gestartet wurde \\u2022 In der integrierten Konsole innerhalb der Software, in der der Submitter ausgeführt wird", "Timeouts": "Timeouts", "Unknown Issue With Configured Profile": "Unbekanntes Problem mit konfiguriertem Profil", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} von Deadline Cloud für {integration_name}-Submitter ist jetzt verfügbar.", + "View release notes": "Versionshinweise anzeigen", "Unrecognized Parameters": "Nicht erkannte Parameter", "Upload progress": "Upload-Fortschritt", "Use array parameter": "Array-Parameter verwenden", "Value(s)": "Wert(e)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} von Deadline Cloud für {integration_name}-Submitter ist jetzt verfügbar.", - "View release notes": "Versionshinweise anzeigen", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Sie sind mit dem Profil '{profile}' authentifiziert, aber dieses Profil kann AWS Deadline Cloud ListFarms nicht aufrufen und keine Jobs an AWS Deadline Cloud übermitteln.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, dass keine Umgebungsvariablen auf die falsche AWS-Region verweisen (z. B. AWS_DEFAULT_REGION)\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden, überprüfen Sie, ob das Profil Berechtigungen für diese AWS Deadline Cloud-APIs hat, die für die Übermittlung erforderlich sind:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Sie sind mit dem Profil '{profile}' authentifiziert, aber dieses Profil kann AWS Deadline Cloud ListFarms nicht aufrufen und keine Jobs an AWS Deadline Cloud übermitteln.\n\nSo beheben Sie dieses Problem:\n\\u2022 Überprüfen Sie, dass keine Umgebungsvariablen auf die falsche AWS-Region verweisen (z. B. AWS_DEFAULT_REGION)\n\\u2022 Wenn Sie kein Deadline Cloud Monitor-Profil verwenden, überprüfen Sie, ob das Profil Berechtigungen für diese AWS Deadline Cloud-APIs hat, die für die Übermittlung erforderlich sind:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Sie sind abgemeldet.", "{profile} doesn't have access permissions to submit a job.": "{profile} hat keine Zugriffsberechtigungen zum Übermitteln eines Jobs.", - "{submitter} job submission": "{submitter}-Jobübermittlung" + "{submitter} job submission": "{submitter}-Jobübermittlung", + "Default maximum retries per task": "Standardm\u00e4\u00dfige maximale Wiederholungen pro Aufgabe", + "Default maximum failed tasks count": "Standardm\u00e4\u00dfige maximale Anzahl fehlgeschlagener Aufgaben", + "Browse Job Bundles": "Job-Bundles durchsuchen", + "History": "Verlauf", + "Job bundle directory": "Job-Bundle-Verzeichnis", + "Local": "Lokal", + "Name": "Name", + "Parameters:": "Parameter:", + "Path:": "Pfad:", + "Select": "Auswählen", + "Select a job bundle to see details": "Job-Bundle auswählen, um Details anzuzeigen", + "Source:": "Quelle:", + "Steps:": "Schritte:" } diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index 243a50f3a..d975870f4 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud workstation configuration", "AWS profile": "AWS profile", "About": "About", + "Application Restart Required": "Application Restart Required", "Add": "Add", "Add amount": "Add amount", "Add attribute": "Add attribute", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Always check S3 job attachments", "Amount name": "Amount name", "Any": "Any", - "Application Restart Required": "Application Restart Required", "Apply": "Apply", "Array parameter values": "Array parameter values", "Attach input directories": "Attach input directories", "Attach input files": "Attach input files", "Attribute name": "Attribute name", "Auto accept prompt defaults": "Auto accept prompt defaults", - "Browse Job Bundles": "Browse Job Bundles", "CPU architecture": "CPU architecture", "Cancel": "Cancel", "Canceling submission...": "Canceling submission...", - "Cannot submit job:\n\n• {issues}": "Cannot submit job:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Cannot submit job:\n\n\\u2022 {issues}", "Choose job bundle directory": "Choose job bundle directory", "Close": "Close", "Conflict resolution option": "Conflict resolution option", "Copy": "Copy", - "Current logging level": "Current logging level", "Current: {current_version} -> New: {latest_version}": "Current: {current_version} -> New: {latest_version}", + "Current logging level": "Current logging level", "Custom host requirements": "Custom host requirements", "Data directory": "Data directory", "Deadline Cloud settings": "Deadline Cloud settings", "Default farm": "Default farm", - "Default maximum failed tasks count": "Default maximum failed tasks count", - "Default maximum retries per task": "Default maximum retries per task", "Default queue": "Default queue", "Default storage profile": "Default storage profile", "Delete": "Delete", @@ -61,7 +58,6 @@ "Hardware requirements": "Hardware requirements", "Hashing progress": "Hashing progress", "Help": "Help", - "History": "History", "Host requirements": "Host requirements", "Initial state": "Initial state", "Issue With Profile Configuration": "Issue With Profile Configuration", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Job Submission Confirmation", "Job attachments": "Job attachments", "Job attachments filesystem options": "Job attachments filesystem options", - "Job bundle directory": "Job bundle directory", "Job history directory": "Job history directory", "Job submission confirmation": "Job submission confirmation", "Job-specific settings": "Job-specific settings", @@ -77,7 +72,6 @@ "Language": "Language", "Language will change next time the submitter is opened": "Language will change next time the submitter is opened", "Load Bundle": "Load Bundle", - "Local": "Local", "Log in": "Log in", "Log in to AWS Deadline Cloud": "Log in to AWS Deadline Cloud", "Logging you in...": "Logging you in...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Memory (GiB)", "Min": "Min", "More info": "More info", - "Name": "Name", "New version available": "New version available", "No farm is configured. Click Settings to select a farm for job submission.": "No farm is configured. Click Settings to select a farm for job submission.", "No max worker count": "No max worker count", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Non valid inputs detected", "Ok": "Ok", "Opening Deadline Cloud monitor. Please log in before returning here.": "Opening Deadline Cloud monitor. Please log in before returning here.", - "Operating system": "Operating system", - "Parameters:": "Parameters:", - "Path:": "Path:", "Please run the installer and then restart {integration_name} to use the new version.": "Please run the installer and then restart {integration_name} to use the new version.", + "Operating system": "Operating system", "Preparing files...": "Preparing files...", "Preparing for hashing...": "Preparing for hashing...", "Preparing for upload...": "Preparing for upload...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Run on worker hosts that meet the following requirements", "Saved the submission as a job bundle:\n{path}": "Saved the submission as a job bundle:\n{path}", "Scratch space": "Scratch space", - "Select": "Select", - "Select a job bundle to see details": "Select a job bundle to see details", "Set max worker count": "Set max worker count", "Settings...": "Settings...", "Shared job settings": "Shared job settings", "Show auto-detected": "Show auto-detected", "Show submitter update notifications": "Show submitter update notifications", - "Source:": "Source:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specify a job bundle directory or run the bundle command with the --browse flag", "Specify output directories": "Specify output directories", - "Steps:": "Steps:", "Submission canceled": "Submission canceled", "Submission complete": "Submission complete", "Submission error": "Submission error", @@ -139,19 +126,32 @@ "Telemetry opt out": "Telemetry opt out", "Template file format": "Template file format", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\\u2022 Verify your AWS config and credentials files are correct\n \\u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\\u2022 Verify that the correct AWS region is set\n \\u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\\u2022 If you are not using a Deadline Cloud Monitor profile:\n \\u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \\u2022 You can run the following command to check: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\\u2022 Verify your AWS config and credentials files are correct\n \\u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\\u2022 Verify that the correct AWS region is set\n \\u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\\u2022 If you are not using a Deadline Cloud Monitor profile:\n \\u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \\u2022 You can run the following command to check: aws sts get-caller-identity --profile ", "There was an error with authentication": "There was an error with authentication", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \\u2022 In the terminal that the dialog or software the submitter is running in was launched from \\u2022 In the built-in console within the software that the submitter is running in", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \\u2022 In the terminal that the dialog or software the submitter is running in was launched from \\u2022 In the built-in console within the software that the submitter is running in", "Timeouts": "Timeouts", "Unknown Issue With Configured Profile": "Unknown Issue With Configured Profile", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.", + "View release notes": "View release notes", "Unrecognized Parameters": "Unrecognized Parameters", "Upload progress": "Upload progress", "Use array parameter": "Use array parameter", "Value(s)": "Value(s)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.", - "View release notes": "View release notes", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - You are logged out.", "{profile} doesn't have access permissions to submit a job.": "{profile} doesn't have access permissions to submit a job.", - "{submitter} job submission": "{submitter} job submission" + "{submitter} job submission": "{submitter} job submission", + "Default maximum retries per task": "Default maximum retries per task", + "Default maximum failed tasks count": "Default maximum failed tasks count", + "Browse Job Bundles": "Browse Job Bundles", + "History": "History", + "Job bundle directory": "Job bundle directory", + "Local": "Local", + "Name": "Name", + "Parameters:": "Parameters:", + "Path:": "Path:", + "Select": "Select", + "Select a job bundle to see details": "Select a job bundle to see details", + "Source:": "Source:", + "Steps:": "Steps:" } diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index 723fbf4e0..ec2c96b19 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "Configuración de estación de trabajo de AWS Deadline Cloud", "AWS profile": "Perfil de AWS", "About": "Acerca de", + "Application Restart Required": "Se requiere reiniciar la aplicación", "Add": "Agregar", "Add amount": "Agregar cantidad", "Add attribute": "Agregar atributo", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Comprobar siempre los archivos adjuntos de trabajo en S3", "Amount name": "Nombre de cantidad", "Any": "Cualquiera", - "Application Restart Required": "Se requiere reiniciar la aplicación", "Apply": "Aplicar", "Array parameter values": "Valores de parámetros de matriz", "Attach input directories": "Adjuntar directorios de entrada", "Attach input files": "Adjuntar archivos de entrada", "Attribute name": "Nombre de atributo", "Auto accept prompt defaults": "Aceptar automáticamente valores predeterminados", - "Browse Job Bundles": "Explorar paquetes de trabajos", "CPU architecture": "Arquitectura de CPU", "Cancel": "Cancelar", "Canceling submission...": "Cancelando envío...", - "Cannot submit job:\n\n• {issues}": "No se puede enviar el trabajo:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "No se puede enviar el trabajo:\n\n\\u2022 {issues}", "Choose job bundle directory": "Elegir directorio de paquete de trabajos", "Close": "Cerrar", "Conflict resolution option": "Opción de resolución de conflictos", "Copy": "Copiar", - "Current logging level": "Nivel de registro actual", "Current: {current_version} -> New: {latest_version}": "Actual: {current_version} -> Nuevo: {latest_version}", + "Current logging level": "Nivel de registro actual", "Custom host requirements": "Requisitos de host personalizados", "Data directory": "Directorio de datos", "Deadline Cloud settings": "Configuración de Deadline Cloud", "Default farm": "Granja predeterminada", - "Default maximum failed tasks count": "Recuento máximo de tareas fallidas predeterminado", - "Default maximum retries per task": "Reintentos máximos por tarea predeterminados", "Default queue": "Cola predeterminada", "Default storage profile": "Perfil de almacenamiento predeterminado", "Delete": "Eliminar", @@ -61,7 +58,6 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progreso de hash", "Help": "Ayuda", - "History": "Historial", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema con la configuración del perfil", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Confirmación de envío de trabajo", "Job attachments": "Adjuntos de trabajo", "Job attachments filesystem options": "Opciones del sistema de archivos de adjuntos de trabajo", - "Job bundle directory": "Directorio de paquetes de trabajos", "Job history directory": "Directorio de historial de trabajos", "Job submission confirmation": "Confirmación de envío de trabajo", "Job-specific settings": "Configuración específica del trabajo", @@ -77,7 +72,6 @@ "Language": "Idioma", "Language will change next time the submitter is opened": "El idioma cambiará la próxima vez que se abra el submitter", "Load Bundle": "Cargar paquete", - "Local": "Local", "Log in": "Iniciar sesión", "Log in to AWS Deadline Cloud": "Iniciar sesión en AWS Deadline Cloud", "Logging you in...": "Iniciando sesión...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Memoria (GiB)", "Min": "Mín", "More info": "Más información", - "Name": "Nombre", "New version available": "Nueva versión disponible", "No farm is configured. Click Settings to select a farm for job submission.": "No hay ninguna granja configurada. Haga clic en Configuración para seleccionar una granja para el envío de trabajos.", "No max worker count": "Sin recuento máximo de trabajadores", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Se detectaron entradas no válidas", "Ok": "Aceptar", "Opening Deadline Cloud monitor. Please log in before returning here.": "Abriendo el monitor de Deadline Cloud. Inicie sesión antes de volver aquí.", - "Operating system": "Sistema operativo", - "Parameters:": "Parámetros:", - "Path:": "Ruta:", "Please run the installer and then restart {integration_name} to use the new version.": "Ejecute el instalador y luego reinicie {integration_name} para usar la nueva versión.", + "Operating system": "Sistema operativo", "Preparing files...": "Preparando archivos...", "Preparing for hashing...": "Preparando para hash...", "Preparing for upload...": "Preparando para carga...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Ejecutar en hosts de trabajadores que cumplan los siguientes requisitos", "Saved the submission as a job bundle:\n{path}": "Se guardó el envío como paquete de trabajos:\n{path}", "Scratch space": "Espacio temporal", - "Select": "Seleccionar", - "Select a job bundle to see details": "Seleccione un paquete de trabajo para ver los detalles", "Set max worker count": "Establecer recuento máximo de trabajadores", "Settings...": "Configuración...", "Shared job settings": "Configuración de trabajo compartida", "Show auto-detected": "Mostrar detectados automáticamente", "Show submitter update notifications": "Mostrar notificaciones de actualización del remitente", - "Source:": "Origen:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique un directorio de paquete de trabajos o ejecute el comando bundle con la marca --browse", "Specify output directories": "Especificar directorios de salida", - "Steps:": "Pasos:", "Submission canceled": "Envío cancelado", "Submission complete": "Envío completado", "Submission error": "Error de envío", @@ -139,19 +126,32 @@ "Telemetry opt out": "Desactivar telemetría", "Template file format": "Formato de archivo de plantilla", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "La plantilla de trabajo o la cola no reconocen los siguientes parámetros:\n\n{params}\n\nEstos parámetros se ignorarán durante el envío del trabajo.\n\n¿Desea continuar?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Hay un problema de configuración con el perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique que sus archivos de configuración y credenciales de AWS sean correctos\n \\u2022 De forma predeterminada, estos archivos se encuentran en ~/.aws en Linux/MacOS o %USERPROFILE%/.aws en Windows\n\\u2022 Verifique que esté configurada la región de AWS correcta\n \\u2022 Compruebe que no haya variables de entorno como AWS_DEFAULT_REGION configuradas en una región incorrecta\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor:\n \\u2022 Verifique que cualquier proceso de credenciales que se esté utilizando pueda recuperar las credenciales o que no hayan caducado\n \\u2022 Puede ejecutar el siguiente comando para comprobarlo: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Hay un problema de configuración con el perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique que sus archivos de configuración y credenciales de AWS sean correctos\n \\u2022 De forma predeterminada, estos archivos se encuentran en ~/.aws en Linux/MacOS o %USERPROFILE%/.aws en Windows\n\\u2022 Verifique que esté configurada la región de AWS correcta\n \\u2022 Compruebe que no haya variables de entorno como AWS_DEFAULT_REGION configuradas en una región incorrecta\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor:\n \\u2022 Verifique que cualquier proceso de credenciales que se esté utilizando pueda recuperar las credenciales o que no hayan caducado\n \\u2022 Puede ejecutar el siguiente comando para comprobarlo: aws sts get-caller-identity --profile ", "There was an error with authentication": "Se produjo un error con la autenticación", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Se produjo un problema desconocido al intentar autenticarse con el perfil '{profile}'.\n\nCompruebe los registros de consola disponibles en busca de errores para intentar diagnosticar el problema.\nLos registros se encuentran comúnmente:\n \\u2022 En el terminal desde el que se inició el cuadro de diálogo o el software en el que se ejecuta el remitente \\u2022 En la consola integrada dentro del software en el que se ejecuta el remitente", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Se produjo un problema desconocido al intentar autenticarse con el perfil '{profile}'.\n\nCompruebe los registros de consola disponibles en busca de errores para intentar diagnosticar el problema.\nLos registros se encuentran comúnmente:\n \\u2022 En el terminal desde el que se inició el cuadro de diálogo o el software en el que se ejecuta el remitente \\u2022 En la consola integrada dentro del software en el que se ejecuta el remitente", "Timeouts": "Tiempos de espera", "Unknown Issue With Configured Profile": "Problema desconocido con el perfil configurado", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versión {latest_version} de Deadline Cloud para el remitente de {integration_name} ya está disponible.", + "View release notes": "Ver notas de la versión", "Unrecognized Parameters": "Parámetros no reconocidos", "Upload progress": "Progreso de carga", "Use array parameter": "Usar parámetro de matriz", "Value(s)": "Valor(es)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versión {latest_version} de Deadline Cloud para el remitente de {integration_name} ya está disponible.", - "View release notes": "Ver notas de la versión", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Está autenticado con el perfil '{profile}', pero este perfil no puede llamar a AWS Deadline Cloud ListFarms y no puede enviar trabajos a AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Compruebe que no haya variables de entorno que apunten a la región de AWS incorrecta (por ejemplo, AWS_DEFAULT_REGION)\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor, compruebe que el perfil tenga permisos para estas API de AWS Deadline Cloud necesarias para el envío:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Está autenticado con el perfil '{profile}', pero este perfil no puede llamar a AWS Deadline Cloud ListFarms y no puede enviar trabajos a AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Compruebe que no haya variables de entorno que apunten a la región de AWS incorrecta (por ejemplo, AWS_DEFAULT_REGION)\n\\u2022 Si no está utilizando un perfil de Deadline Cloud Monitor, compruebe que el perfil tenga permisos para estas API de AWS Deadline Cloud necesarias para el envío:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Ha cerrado sesión.", "{profile} doesn't have access permissions to submit a job.": "{profile} no tiene permisos de acceso para enviar un trabajo.", - "{submitter} job submission": "Envío de trabajo de {submitter}" + "{submitter} job submission": "Envío de trabajo de {submitter}", + "Default maximum retries per task": "Reintentos m\u00e1ximos por tarea predeterminados", + "Default maximum failed tasks count": "Recuento m\u00e1ximo de tareas fallidas predeterminado", + "Browse Job Bundles": "Explorar paquetes de trabajos", + "History": "Historial", + "Job bundle directory": "Directorio de paquetes de trabajos", + "Local": "Local", + "Name": "Nombre", + "Parameters:": "Parámetros:", + "Path:": "Ruta:", + "Select": "Seleccionar", + "Select a job bundle to see details": "Seleccione un paquete de trabajo para ver los detalles", + "Source:": "Origen:", + "Steps:": "Pasos:" } diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index a759bad9e..b95a9a0a2 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "Configuration de poste de travail AWS Deadline Cloud", "AWS profile": "Profil AWS", "About": "À propos", + "Application Restart Required": "Redémarrage de l'application requis", "Add": "Ajouter", "Add amount": "Ajouter une quantité", "Add attribute": "Ajouter un attribut", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Toujours vérifier les fichiers joints de tâche S3", "Amount name": "Nom de quantité", "Any": "Quelconque", - "Application Restart Required": "Redémarrage de l'application requis", "Apply": "Appliquer", "Array parameter values": "Valeurs de paramètres de tableau", "Attach input directories": "Joindre des répertoires d'entrée", "Attach input files": "Joindre des fichiers d'entrée", "Attribute name": "Nom d'attribut", "Auto accept prompt defaults": "Accepter automatiquement les valeurs par défaut", - "Browse Job Bundles": "Parcourir les lots de tâches", "CPU architecture": "Architecture CPU", "Cancel": "Annuler", "Canceling submission...": "Annulation de la soumission...", - "Cannot submit job:\n\n• {issues}": "Impossible de soumettre la tâche :\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Impossible de soumettre la tâche :\n\n\\u2022 {issues}", "Choose job bundle directory": "Choisir le répertoire du lot de tâches", "Close": "Fermer", "Conflict resolution option": "Option de résolution de conflits", "Copy": "Copier", - "Current logging level": "Niveau de journalisation actuel", "Current: {current_version} -> New: {latest_version}": "Actuel : {current_version} -> Nouveau : {latest_version}", + "Current logging level": "Niveau de journalisation actuel", "Custom host requirements": "Exigences d'hôte personnalisées", "Data directory": "Répertoire de données", "Deadline Cloud settings": "Paramètres Deadline Cloud", "Default farm": "Ferme par défaut", - "Default maximum failed tasks count": "Nombre maximal de tâches échouées par défaut", - "Default maximum retries per task": "Nombre maximal de tentatives par tâche par défaut", "Default queue": "File d'attente par défaut", "Default storage profile": "Profil de stockage par défaut", "Delete": "Supprimer", @@ -61,7 +58,6 @@ "Hardware requirements": "Exigences matérielles", "Hashing progress": "Progression du hachage", "Help": "Aide", - "History": "Historique", "Host requirements": "Exigences d'hôte", "Initial state": "État initial", "Issue With Profile Configuration": "Problème avec la configuration du profil", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Confirmation de soumission de tâche", "Job attachments": "Fichiers joints de tâche", "Job attachments filesystem options": "Options du système de fichiers des pièces jointes aux tâches", - "Job bundle directory": "Répertoire des lots de tâches", "Job history directory": "Répertoire d'historique des tâches", "Job submission confirmation": "Confirmation de soumission de tâche", "Job-specific settings": "Paramètres spécifiques à la tâche", @@ -77,7 +72,6 @@ "Language": "Langue", "Language will change next time the submitter is opened": "La langue changera lors de la prochaine ouverture du submitter", "Load Bundle": "Charger le lot", - "Local": "Local", "Log in": "Se connecter", "Log in to AWS Deadline Cloud": "Se connecter à AWS Deadline Cloud", "Logging you in...": "Connexion en cours...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Mémoire (Gio)", "Min": "Min", "More info": "Plus d'informations", - "Name": "Nom", "New version available": "Nouvelle version disponible", "No farm is configured. Click Settings to select a farm for job submission.": "Aucune ferme n'est configurée. Cliquez sur Paramètres pour sélectionner une ferme pour la soumission de tâches.", "No max worker count": "Aucun nombre maximal de travailleurs", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Entrées non valides détectées", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Ouverture du moniteur Deadline Cloud. Veuillez vous connecter avant de revenir ici.", - "Operating system": "Système d'exploitation", - "Parameters:": "Paramètres :", - "Path:": "Chemin :", "Please run the installer and then restart {integration_name} to use the new version.": "Veuillez exécuter l'installateur puis redémarrer {integration_name} pour utiliser la nouvelle version.", + "Operating system": "Système d'exploitation", "Preparing files...": "Préparation des fichiers...", "Preparing for hashing...": "Préparation du hachage...", "Preparing for upload...": "Préparation du téléchargement...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Exécuter sur les hôtes de travail qui répondent aux exigences suivantes", "Saved the submission as a job bundle:\n{path}": "La soumission a été enregistrée en tant que lot de tâches :\n{path}", "Scratch space": "Espace temporaire", - "Select": "Sélectionner", - "Select a job bundle to see details": "Sélectionnez un lot de tâches pour voir les détails", "Set max worker count": "Définir le nombre maximal de travailleurs", "Settings...": "Paramètres...", "Shared job settings": "Paramètres de tâche partagés", "Show auto-detected": "Afficher les éléments détectés automatiquement", "Show submitter update notifications": "Afficher les notifications de mise à jour du soumetteur", - "Source:": "Source :", "Specify a job bundle directory or run the bundle command with the --browse flag": "Spécifiez un répertoire de lot de tâches ou exécutez la commande bundle avec l'indicateur --browse", "Specify output directories": "Spécifier les répertoires de sortie", - "Steps:": "Étapes :", "Submission canceled": "Soumission annulée", "Submission complete": "Soumission terminée", "Submission error": "Erreur de soumission", @@ -139,19 +126,32 @@ "Telemetry opt out": "Désactiver la télémétrie", "Template file format": "Format de fichier de modèle", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Les paramètres suivants ne sont pas reconnus par le modèle de tâche ou la file d'attente :\n\n{params}\n\nCes paramètres seront ignorés lors de la soumission de la tâche.\n\nVoulez-vous continuer ?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Il y a un problème de configuration avec le profil '{profile}'.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez que vos fichiers de configuration et d'informations d'identification AWS sont corrects\n \\u2022 Par défaut, ces fichiers se trouvent dans ~/.aws sur Linux/MacOS ou %USERPROFILE%/.aws sur Windows\n\\u2022 Vérifiez que la région AWS correcte est définie\n \\u2022 Vérifiez qu'aucune variable d'environnement comme AWS_DEFAULT_REGION n'est définie sur une région incorrecte\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor :\n \\u2022 Vérifiez que tout processus d'informations d'identification utilisé est capable de récupérer les informations d'identification ou qu'elles ne sont pas expirées\n \\u2022 Vous pouvez exécuter la commande suivante pour vérifier : aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Il y a un problème de configuration avec le profil '{profile}'.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez que vos fichiers de configuration et d'informations d'identification AWS sont corrects\n \\u2022 Par défaut, ces fichiers se trouvent dans ~/.aws sur Linux/MacOS ou %USERPROFILE%/.aws sur Windows\n\\u2022 Vérifiez que la région AWS correcte est définie\n \\u2022 Vérifiez qu'aucune variable d'environnement comme AWS_DEFAULT_REGION n'est définie sur une région incorrecte\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor :\n \\u2022 Vérifiez que tout processus d'informations d'identification utilisé est capable de récupérer les informations d'identification ou qu'elles ne sont pas expirées\n \\u2022 Vous pouvez exécuter la commande suivante pour vérifier : aws sts get-caller-identity --profile ", "There was an error with authentication": "Une erreur s'est produite lors de l'authentification", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Un problème inconnu s'est produit lors de la tentative d'authentification avec le profil '{profile}'.\n\nVérifiez les journaux de console disponibles pour détecter les erreurs et essayer de diagnostiquer le problème.\nLes journaux se trouvent généralement :\n \\u2022 Dans le terminal à partir duquel la boîte de dialogue ou le logiciel dans lequel le soumetteur s'exécute a été lancé \\u2022 Dans la console intégrée du logiciel dans lequel le soumetteur s'exécute", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Un problème inconnu s'est produit lors de la tentative d'authentification avec le profil '{profile}'.\n\nVérifiez les journaux de console disponibles pour détecter les erreurs et essayer de diagnostiquer le problème.\nLes journaux se trouvent généralement :\n \\u2022 Dans le terminal à partir duquel la boîte de dialogue ou le logiciel dans lequel le soumetteur s'exécute a été lancé \\u2022 Dans la console intégrée du logiciel dans lequel le soumetteur s'exécute", "Timeouts": "Délais d'expiration", "Unknown Issue With Configured Profile": "Problème inconnu avec le profil configuré", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La version {latest_version} de Deadline Cloud pour le soumetteur {integration_name} est maintenant disponible.", + "View release notes": "Voir les notes de version", "Unrecognized Parameters": "Paramètres non reconnus", "Upload progress": "Progression du téléchargement", "Use array parameter": "Utiliser un paramètre de tableau", "Value(s)": "Valeur(s)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La version {latest_version} de Deadline Cloud pour le soumetteur {integration_name} est maintenant disponible.", - "View release notes": "Voir les notes de version", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Vous êtes authentifié avec le profil '{profile}', mais ce profil ne peut pas appeler AWS Deadline Cloud ListFarms et ne peut pas soumettre de tâches à AWS Deadline Cloud.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez qu'aucune variable d'environnement ne pointe vers la mauvaise région AWS (par exemple, AWS_DEFAULT_REGION)\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor, vérifiez que le profil dispose des autorisations pour ces API AWS Deadline Cloud nécessaires à la soumission :\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Vous êtes authentifié avec le profil '{profile}', mais ce profil ne peut pas appeler AWS Deadline Cloud ListFarms et ne peut pas soumettre de tâches à AWS Deadline Cloud.\n\nPour résoudre ce problème :\n\\u2022 Vérifiez qu'aucune variable d'environnement ne pointe vers la mauvaise région AWS (par exemple, AWS_DEFAULT_REGION)\n\\u2022 Si vous n'utilisez pas un profil Deadline Cloud Monitor, vérifiez que le profil dispose des autorisations pour ces API AWS Deadline Cloud nécessaires à la soumission :\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Vous êtes déconnecté.", "{profile} doesn't have access permissions to submit a job.": "{profile} n'a pas les autorisations d'accès pour soumettre une tâche.", - "{submitter} job submission": "Soumission de tâche {submitter}" + "{submitter} job submission": "Soumission de tâche {submitter}", + "Default maximum retries per task": "Nombre maximal de tentatives par t\u00e2che par d\u00e9faut", + "Default maximum failed tasks count": "Nombre maximal de t\u00e2ches \u00e9chou\u00e9es par d\u00e9faut", + "Browse Job Bundles": "Parcourir les lots de tâches", + "History": "Historique", + "Job bundle directory": "Répertoire des lots de tâches", + "Local": "Local", + "Name": "Nom", + "Parameters:": "Paramètres :", + "Path:": "Chemin :", + "Select": "Sélectionner", + "Select a job bundle to see details": "Sélectionnez un lot de tâches pour voir les détails", + "Source:": "Source :", + "Steps:": "Étapes :" } diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index cc8a8ded5..e99617c08 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "Konfigurasi workstation AWS Deadline Cloud", "AWS profile": "Profil AWS", "About": "Tentang", + "Application Restart Required": "Diperlukan restart aplikasi", "Add": "Tambah", "Add amount": "Tambah jumlah", "Add attribute": "Tambah atribut", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Selalu periksa lampiran pekerjaan S3", "Amount name": "Nama jumlah", "Any": "Apa saja", - "Application Restart Required": "Diperlukan restart aplikasi", "Apply": "Terapkan", "Array parameter values": "Nilai parameter array", "Attach input directories": "Lampirkan direktori input", "Attach input files": "Lampirkan file input", "Attribute name": "Nama atribut", "Auto accept prompt defaults": "Terima default secara otomatis", - "Browse Job Bundles": "Jelajahi bundel tugas", "CPU architecture": "Arsitektur CPU", "Cancel": "Batal", "Canceling submission...": "Membatalkan pengiriman...", - "Cannot submit job:\n\n• {issues}": "Tidak dapat mengirim pekerjaan:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Tidak dapat mengirim pekerjaan:\n\n\\u2022 {issues}", "Choose job bundle directory": "Pilih direktori bundel pekerjaan", "Close": "Tutup", "Conflict resolution option": "Opsi resolusi konflik", "Copy": "Salin", - "Current logging level": "Tingkat logging saat ini", "Current: {current_version} -> New: {latest_version}": "Saat ini: {current_version} -> Baru: {latest_version}", + "Current logging level": "Tingkat logging saat ini", "Custom host requirements": "Persyaratan host kustom", "Data directory": "Direktori data", "Deadline Cloud settings": "Pengaturan Deadline Cloud", "Default farm": "Peternakan default", - "Default maximum failed tasks count": "Jumlah tugas gagal maksimum default", - "Default maximum retries per task": "Percobaan ulang maksimum default per tugas", "Default queue": "Antrian default", "Default storage profile": "Profil penyimpanan default", "Delete": "Hapus", @@ -61,7 +58,6 @@ "Hardware requirements": "Persyaratan perangkat keras", "Hashing progress": "Kemajuan hashing", "Help": "Bantuan", - "History": "Riwayat", "Host requirements": "Persyaratan host", "Initial state": "Status awal", "Issue With Profile Configuration": "Masalah dengan konfigurasi profil", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Konfirmasi pengiriman pekerjaan", "Job attachments": "Lampiran Job", "Job attachments filesystem options": "Opsi sistem file lampiran pekerjaan", - "Job bundle directory": "Direktori bundel tugas", "Job history directory": "Direktori riwayat pekerjaan", "Job submission confirmation": "Konfirmasi pengiriman pekerjaan", "Job-specific settings": "Pengaturan khusus pekerjaan", @@ -77,7 +72,6 @@ "Language": "Bahasa", "Language will change next time the submitter is opened": "Bahasa akan berubah saat submitter dibuka kembali", "Load Bundle": "Muat bundel", - "Local": "Lokal", "Log in": "Masuk", "Log in to AWS Deadline Cloud": "Masuk ke AWS Deadline Cloud", "Logging you in...": "Memasukkan Anda...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Memori (GiB)", "Min": "Min", "More info": "Info lebih lanjut", - "Name": "Nama", "New version available": "Versi baru tersedia", "No farm is configured. Click Settings to select a farm for job submission.": "Tidak ada peternakan yang dikonfigurasi. Klik Pengaturan untuk memilih peternakan untuk pengiriman pekerjaan.", "No max worker count": "Tidak ada jumlah pekerja maksimum", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Input tidak valid terdeteksi", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Membuka monitor Deadline Cloud. Harap masuk sebelum kembali ke sini.", - "Operating system": "Sistem operasi", - "Parameters:": "Parameter:", - "Path:": "Jalur:", "Please run the installer and then restart {integration_name} to use the new version.": "Silakan jalankan installer lalu restart {integration_name} untuk menggunakan versi baru.", + "Operating system": "Sistem operasi", "Preparing files...": "Menyiapkan file...", "Preparing for hashing...": "Mempersiapkan untuk hashing...", "Preparing for upload...": "Mempersiapkan untuk upload...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Jalankan di host pekerja yang memenuhi persyaratan berikut", "Saved the submission as a job bundle:\n{path}": "Menyimpan pengiriman sebagai bundel pekerjaan:\n{path}", "Scratch space": "Ruang sementara", - "Select": "Pilih", - "Select a job bundle to see details": "Pilih bundel tugas untuk melihat detail", "Set max worker count": "Atur jumlah pekerja maksimum", "Settings...": "Pengaturan...", "Shared job settings": "Pengaturan pekerjaan bersama", "Show auto-detected": "Tampilkan yang terdeteksi otomatis", "Show submitter update notifications": "Tampilkan notifikasi pembaruan pengirim", - "Source:": "Sumber:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Tentukan direktori bundel pekerjaan atau jalankan perintah bundle dengan flag --browse", "Specify output directories": "Tentukan direktori output", - "Steps:": "Langkah:", "Submission canceled": "Pengiriman dibatalkan", "Submission complete": "Pengiriman selesai", "Submission error": "Kesalahan pengiriman", @@ -139,19 +126,32 @@ "Telemetry opt out": "Nonaktifkan telemetri", "Template file format": "Format file template", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Parameter berikut tidak dikenali oleh template pekerjaan atau antrian:\n\n{params}\n\nParameter ini akan diabaikan selama pengiriman pekerjaan.\n\nApakah Anda ingin melanjutkan?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Ada masalah konfigurasi dengan profil '{profile}'.\n\nUntuk mengatasi masalah ini:\n\\u2022 Verifikasi bahwa file konfigurasi dan kredensial AWS Anda benar\n \\u2022 Secara default, file ini dapat ditemukan di ~/.aws di Linux/MacOS atau %USERPROFILE%/.aws di Windows\n\\u2022 Verifikasi bahwa wilayah AWS yang benar telah diatur\n \\u2022 Periksa bahwa tidak ada variabel lingkungan seperti AWS_DEFAULT_REGION yang diatur ke wilayah yang salah\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor:\n \\u2022 Verifikasi bahwa proses kredensial apa pun yang digunakan dapat mengambil kredensial atau bahwa kredensial tersebut tidak kedaluwarsa\n \\u2022 Anda dapat menjalankan perintah berikut untuk memeriksa: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Ada masalah konfigurasi dengan profil '{profile}'.\n\nUntuk mengatasi masalah ini:\n\\u2022 Verifikasi bahwa file konfigurasi dan kredensial AWS Anda benar\n \\u2022 Secara default, file ini dapat ditemukan di ~/.aws di Linux/MacOS atau %USERPROFILE%/.aws di Windows\n\\u2022 Verifikasi bahwa wilayah AWS yang benar telah diatur\n \\u2022 Periksa bahwa tidak ada variabel lingkungan seperti AWS_DEFAULT_REGION yang diatur ke wilayah yang salah\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor:\n \\u2022 Verifikasi bahwa proses kredensial apa pun yang digunakan dapat mengambil kredensial atau bahwa kredensial tersebut tidak kedaluwarsa\n \\u2022 Anda dapat menjalankan perintah berikut untuk memeriksa: aws sts get-caller-identity --profile ", "There was an error with authentication": "Terjadi kesalahan dengan autentikasi", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Terjadi masalah yang tidak diketahui saat mencoba mengautentikasi dengan profil '{profile}'.\n\nPeriksa log konsol yang tersedia untuk kesalahan untuk mencoba mendiagnosis masalah.\nLog biasanya ditemukan:\n \\u2022 Di terminal tempat dialog atau perangkat lunak yang menjalankan submitter diluncurkan \\u2022 Di konsol bawaan dalam perangkat lunak yang menjalankan submitter", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Terjadi masalah yang tidak diketahui saat mencoba mengautentikasi dengan profil '{profile}'.\n\nPeriksa log konsol yang tersedia untuk kesalahan untuk mencoba mendiagnosis masalah.\nLog biasanya ditemukan:\n \\u2022 Di terminal tempat dialog atau perangkat lunak yang menjalankan submitter diluncurkan \\u2022 Di konsol bawaan dalam perangkat lunak yang menjalankan submitter", "Timeouts": "Timeout", "Unknown Issue With Configured Profile": "Masalah tidak diketahui dengan profil yang dikonfigurasi", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Versi {latest_version} Deadline Cloud untuk pengirim {integration_name} sekarang tersedia.", + "View release notes": "Lihat catatan rilis", "Unrecognized Parameters": "Parameter tidak dikenali", "Upload progress": "Kemajuan upload", "Use array parameter": "Gunakan parameter array", "Value(s)": "Nilai", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Versi {latest_version} Deadline Cloud untuk pengirim {integration_name} sekarang tersedia.", - "View release notes": "Lihat catatan rilis", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Anda diautentikasi dengan profil '{profile}', tetapi profil ini tidak dapat memanggil AWS Deadline Cloud ListFarms dan tidak dapat mengirim pekerjaan ke AWS Deadline Cloud.\n\nUntuk mengatasi masalah ini:\n\\u2022 Periksa bahwa tidak ada variabel lingkungan yang menunjuk ke wilayah AWS yang salah (misalnya, AWS_DEFAULT_REGION)\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor, periksa bahwa profil memiliki izin untuk API AWS Deadline Cloud ini yang diperlukan untuk pengiriman:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Anda diautentikasi dengan profil '{profile}', tetapi profil ini tidak dapat memanggil AWS Deadline Cloud ListFarms dan tidak dapat mengirim pekerjaan ke AWS Deadline Cloud.\n\nUntuk mengatasi masalah ini:\n\\u2022 Periksa bahwa tidak ada variabel lingkungan yang menunjuk ke wilayah AWS yang salah (misalnya, AWS_DEFAULT_REGION)\n\\u2022 Jika Anda tidak menggunakan profil Deadline Cloud Monitor, periksa bahwa profil memiliki izin untuk API AWS Deadline Cloud ini yang diperlukan untuk pengiriman:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Anda telah keluar.", "{profile} doesn't have access permissions to submit a job.": "{profile} tidak memiliki izin akses untuk mengirim pekerjaan.", - "{submitter} job submission": "Pengiriman pekerjaan {submitter}" + "{submitter} job submission": "Pengiriman pekerjaan {submitter}", + "Default maximum retries per task": "Percobaan ulang maksimum default per tugas", + "Default maximum failed tasks count": "Jumlah tugas gagal maksimum default", + "Browse Job Bundles": "Jelajahi bundel tugas", + "History": "Riwayat", + "Job bundle directory": "Direktori bundel tugas", + "Local": "Lokal", + "Name": "Nama", + "Parameters:": "Parameter:", + "Path:": "Jalur:", + "Select": "Pilih", + "Select a job bundle to see details": "Pilih bundel tugas untuk melihat detail", + "Source:": "Sumber:", + "Steps:": "Langkah:" } diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index e4f4a3eb1..9cb435c52 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "Configurazione workstation AWS Deadline Cloud", "AWS profile": "Profilo AWS", "About": "Informazioni", + "Application Restart Required": "Riavvio dell'applicazione richiesto", "Add": "Aggiungi", "Add amount": "Aggiungi quantità", "Add attribute": "Aggiungi attributo", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Controlla sempre gli allegati lavoro S3", "Amount name": "Nome quantità", "Any": "Qualsiasi", - "Application Restart Required": "Riavvio dell'applicazione richiesto", "Apply": "Applica", "Array parameter values": "Valori parametri array", "Attach input directories": "Allega directory di input", "Attach input files": "Allega file di input", "Attribute name": "Nome attributo", "Auto accept prompt defaults": "Accetta automaticamente i valori predefiniti", - "Browse Job Bundles": "Sfoglia bundle di processi", "CPU architecture": "Architettura CPU", "Cancel": "Annulla", "Canceling submission...": "Annullamento invio...", - "Cannot submit job:\n\n• {issues}": "Impossibile inviare il lavoro:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Impossibile inviare il lavoro:\n\n\\u2022 {issues}", "Choose job bundle directory": "Scegli directory pacchetto lavoro", "Close": "Chiudi", "Conflict resolution option": "Opzione di risoluzione conflitti", "Copy": "Copia", - "Current logging level": "Livello di registrazione corrente", "Current: {current_version} -> New: {latest_version}": "Corrente: {current_version} -> Nuovo: {latest_version}", + "Current logging level": "Livello di registrazione corrente", "Custom host requirements": "Requisiti host personalizzati", "Data directory": "Directory dati", "Deadline Cloud settings": "Impostazioni Deadline Cloud", "Default farm": "Farm predefinita", - "Default maximum failed tasks count": "Numero massimo predefinito di attività non riuscite", - "Default maximum retries per task": "Numero massimo predefinito di tentativi per attività", "Default queue": "Coda predefinita", "Default storage profile": "Profilo di archiviazione predefinito", "Delete": "Elimina", @@ -61,7 +58,6 @@ "Hardware requirements": "Requisiti hardware", "Hashing progress": "Avanzamento hashing", "Help": "Aiuto", - "History": "Cronologia", "Host requirements": "Requisiti host", "Initial state": "Stato iniziale", "Issue With Profile Configuration": "Problema con la configurazione del profilo", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Conferma invio lavoro", "Job attachments": "Allegati Job", "Job attachments filesystem options": "Opzioni filesystem allegati lavoro", - "Job bundle directory": "Directory bundle di processi", "Job history directory": "Directory cronologia lavori", "Job submission confirmation": "Conferma invio lavoro", "Job-specific settings": "Impostazioni specifiche del lavoro", @@ -77,7 +72,6 @@ "Language": "Lingua", "Language will change next time the submitter is opened": "La lingua cambierà alla prossima apertura del submitter", "Load Bundle": "Carica pacchetto", - "Local": "Locale", "Log in": "Accedi", "Log in to AWS Deadline Cloud": "Accedi ad AWS Deadline Cloud", "Logging you in...": "Accesso in corso...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Memoria (GiB)", "Min": "Min", "More info": "Ulteriori informazioni", - "Name": "Nome", "New version available": "Nuova versione disponibile", "No farm is configured. Click Settings to select a farm for job submission.": "Nessuna farm configurata. Fai clic su Impostazioni per selezionare una farm per l'invio dei lavori.", "No max worker count": "Nessun numero massimo di worker", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Rilevati input non validi", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Apertura del monitor Deadline Cloud. Effettua l'accesso prima di tornare qui.", - "Operating system": "Sistema operativo", - "Parameters:": "Parametri:", - "Path:": "Percorso:", "Please run the installer and then restart {integration_name} to use the new version.": "Esegui l'installer e poi riavvia {integration_name} per utilizzare la nuova versione.", + "Operating system": "Sistema operativo", "Preparing files...": "Preparazione file...", "Preparing for hashing...": "Preparazione per hashing...", "Preparing for upload...": "Preparazione per caricamento...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Esegui su host worker che soddisfano i seguenti requisiti", "Saved the submission as a job bundle:\n{path}": "Invio salvato come pacchetto lavoro:\n{path}", "Scratch space": "Spazio temporaneo", - "Select": "Seleziona", - "Select a job bundle to see details": "Seleziona un bundle di processi per visualizzare i dettagli", "Set max worker count": "Imposta numero massimo di worker", "Settings...": "Impostazioni...", "Shared job settings": "Impostazioni lavoro condivise", "Show auto-detected": "Mostra rilevati automaticamente", "Show submitter update notifications": "Mostra notifiche di aggiornamento del submitter", - "Source:": "Origine:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specifica una directory pacchetto lavoro o esegui il comando bundle con il flag --browse", "Specify output directories": "Specifica directory di output", - "Steps:": "Passaggi:", "Submission canceled": "Invio annullato", "Submission complete": "Invio completato", "Submission error": "Errore di invio", @@ -139,19 +126,32 @@ "Telemetry opt out": "Disattiva telemetria", "Template file format": "Formato file modello", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "I seguenti parametri non sono riconosciuti dal modello di lavoro o dalla coda:\n\n{params}\n\nQuesti parametri verranno ignorati durante l'invio del lavoro.\n\nVuoi continuare?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Si è verificato un problema di configurazione con il profilo '{profile}'.\n\nPer risolvere questo problema:\n\\u2022 Verifica che i file di configurazione e credenziali AWS siano corretti\n \\u2022 Per impostazione predefinita, questi file si trovano in ~/.aws su Linux/MacOS o %USERPROFILE%/.aws su Windows\n\\u2022 Verifica che sia impostata la regione AWS corretta\n \\u2022 Verifica che nessuna variabile d'ambiente come AWS_DEFAULT_REGION sia impostata su una regione errata\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor:\n \\u2022 Verifica che qualsiasi processo di credenziali utilizzato sia in grado di recuperare le credenziali o che non siano scadute\n \\u2022 Puoi eseguire il seguente comando per verificare: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Si è verificato un problema di configurazione con il profilo '{profile}'.\n\nPer risolvere questo problema:\n\\u2022 Verifica che i file di configurazione e credenziali AWS siano corretti\n \\u2022 Per impostazione predefinita, questi file si trovano in ~/.aws su Linux/MacOS o %USERPROFILE%/.aws su Windows\n\\u2022 Verifica che sia impostata la regione AWS corretta\n \\u2022 Verifica che nessuna variabile d'ambiente come AWS_DEFAULT_REGION sia impostata su una regione errata\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor:\n \\u2022 Verifica che qualsiasi processo di credenziali utilizzato sia in grado di recuperare le credenziali o che non siano scadute\n \\u2022 Puoi eseguire il seguente comando per verificare: aws sts get-caller-identity --profile ", "There was an error with authentication": "Si è verificato un errore con l'autenticazione", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Si è verificato un problema sconosciuto durante il tentativo di autenticazione con il profilo '{profile}'.\n\nControlla i log della console disponibili per errori per provare a diagnosticare il problema.\nI log si trovano comunemente:\n \\u2022 Nel terminale da cui è stata avviata la finestra di dialogo o il software in cui è in esecuzione il submitter \\u2022 Nella console integrata all'interno del software in cui è in esecuzione il submitter", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Si è verificato un problema sconosciuto durante il tentativo di autenticazione con il profilo '{profile}'.\n\nControlla i log della console disponibili per errori per provare a diagnosticare il problema.\nI log si trovano comunemente:\n \\u2022 Nel terminale da cui è stata avviata la finestra di dialogo o il software in cui è in esecuzione il submitter \\u2022 Nella console integrata all'interno del software in cui è in esecuzione il submitter", "Timeouts": "Timeout", "Unknown Issue With Configured Profile": "Problema sconosciuto con il profilo configurato", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versione {latest_version} di Deadline Cloud per il mittente {integration_name} è ora disponibile.", + "View release notes": "Visualizza note di rilascio", "Unrecognized Parameters": "Parametri non riconosciuti", "Upload progress": "Avanzamento caricamento", "Use array parameter": "Usa parametro array", "Value(s)": "Valore/i", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "La versione {latest_version} di Deadline Cloud per il mittente {integration_name} è ora disponibile.", - "View release notes": "Visualizza note di rilascio", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Sei autenticato con il profilo '{profile}', ma questo profilo non è in grado di chiamare AWS Deadline Cloud ListFarms e non può inviare lavori ad AWS Deadline Cloud.\n\nPer risolvere questo problema:\n\\u2022 Verifica che non ci siano variabili d'ambiente che puntano alla regione AWS errata (ad es. AWS_DEFAULT_REGION)\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor, verifica che il profilo disponga delle autorizzazioni per queste API AWS Deadline Cloud necessarie per l'invio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Sei autenticato con il profilo '{profile}', ma questo profilo non è in grado di chiamare AWS Deadline Cloud ListFarms e non può inviare lavori ad AWS Deadline Cloud.\n\nPer risolvere questo problema:\n\\u2022 Verifica che non ci siano variabili d'ambiente che puntano alla regione AWS errata (ad es. AWS_DEFAULT_REGION)\n\\u2022 Se non stai utilizzando un profilo Deadline Cloud Monitor, verifica che il profilo disponga delle autorizzazioni per queste API AWS Deadline Cloud necessarie per l'invio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Hai effettuato il logout.", "{profile} doesn't have access permissions to submit a job.": "{profile} non dispone delle autorizzazioni di accesso per inviare un lavoro.", - "{submitter} job submission": "Invio lavoro {submitter}" + "{submitter} job submission": "Invio lavoro {submitter}", + "Default maximum retries per task": "Numero massimo predefinito di tentativi per attivit\u00e0", + "Default maximum failed tasks count": "Numero massimo predefinito di attivit\u00e0 non riuscite", + "Browse Job Bundles": "Sfoglia bundle di processi", + "History": "Cronologia", + "Job bundle directory": "Directory bundle di processi", + "Local": "Locale", + "Name": "Nome", + "Parameters:": "Parametri:", + "Path:": "Percorso:", + "Select": "Seleziona", + "Select a job bundle to see details": "Seleziona un bundle di processi per visualizzare i dettagli", + "Source:": "Origine:", + "Steps:": "Passaggi:" } diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index 5b8036f56..abe799bde 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud ワークステーション設定", "AWS profile": "AWS プロファイル", "About": "バージョン情報", + "Application Restart Required": "アプリケーションの再起動が必要です", "Add": "追加", "Add amount": "量を追加", "Add attribute": "属性を追加", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "S3 ジョブアタッチメントを常にチェック", "Amount name": "量の名前", "Any": "任意", - "Application Restart Required": "アプリケーションの再起動が必要です", "Apply": "適用", "Array parameter values": "配列パラメータ値", "Attach input directories": "入力ディレクトリを添付", "Attach input files": "入力ファイルを添付", "Attribute name": "属性名", "Auto accept prompt defaults": "デフォルト値を自動的に受け入れる", - "Browse Job Bundles": "ジョブバンドルを参照", "CPU architecture": "CPU アーキテクチャ", "Cancel": "キャンセル", "Canceling submission...": "送信をキャンセルしています...", - "Cannot submit job:\n\n• {issues}": "ジョブを送信できません:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "ジョブを送信できません:\n\n\\u2022 {issues}", "Choose job bundle directory": "ジョブバンドルディレクトリを選択", "Close": "閉じる", "Conflict resolution option": "競合解決オプション", "Copy": "コピー", - "Current logging level": "現在のログレベル", "Current: {current_version} -> New: {latest_version}": "現在: {current_version} -> 新規: {latest_version}", + "Current logging level": "現在のログレベル", "Custom host requirements": "カスタムホスト要件", "Data directory": "データディレクトリ", "Deadline Cloud settings": "Deadline Cloud 設定", "Default farm": "デフォルトファーム", - "Default maximum failed tasks count": "デフォルトの失敗したタスクの最大数", - "Default maximum retries per task": "タスクあたりのデフォルトの最大再試行回数", "Default queue": "デフォルトキュー", "Default storage profile": "デフォルトストレージプロファイル", "Delete": "削除", @@ -61,7 +58,6 @@ "Hardware requirements": "ハードウェア要件", "Hashing progress": "ハッシュ進行状況", "Help": "ヘルプ", - "History": "履歴", "Host requirements": "ホスト要件", "Initial state": "初期状態", "Issue With Profile Configuration": "プロファイル設定の問題", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "ジョブ送信の確認", "Job attachments": "ジョブアタッチメント", "Job attachments filesystem options": "ジョブアタッチメントファイルシステムオプション", - "Job bundle directory": "ジョブバンドルディレクトリ", "Job history directory": "ジョブ履歴ディレクトリ", "Job submission confirmation": "ジョブ送信の確認", "Job-specific settings": "ジョブ固有の設定", @@ -77,7 +72,6 @@ "Language": "言語", "Language will change next time the submitter is opened": "言語は次回サブミッターを開いたときに変更されます", "Load Bundle": "バンドルを読み込む", - "Local": "ローカル", "Log in": "ログイン", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud にログイン", "Logging you in...": "ログイン中...", @@ -89,7 +83,6 @@ "Memory (GiB)": "メモリ (GiB)", "Min": "最小", "More info": "詳細情報", - "Name": "名前", "New version available": "新しいバージョンが利用可能です", "No farm is configured. Click Settings to select a farm for job submission.": "ファームが設定されていません。設定をクリックして、ジョブ送信用のファームを選択してください。", "No max worker count": "最大ワーカー数なし", @@ -97,10 +90,8 @@ "Non valid inputs detected": "無効な入力が検出されました", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud モニターを開いています。ここに戻る前にログインしてください。", - "Operating system": "オペレーティングシステム", - "Parameters:": "パラメータ:", - "Path:": "パス:", "Please run the installer and then restart {integration_name} to use the new version.": "インストーラーを実行してから {integration_name} を再起動して、新しいバージョンをご利用ください。", + "Operating system": "オペレーティングシステム", "Preparing files...": "ファイルを準備しています...", "Preparing for hashing...": "ハッシュの準備をしています...", "Preparing for upload...": "アップロードの準備をしています...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "次の要件を満たすワーカーホストで実行", "Saved the submission as a job bundle:\n{path}": "送信をジョブバンドルとして保存しました:\n{path}", "Scratch space": "一時領域", - "Select": "選択", - "Select a job bundle to see details": "ジョブバンドルを選択して詳細を表示", "Set max worker count": "最大ワーカー数を設定", "Settings...": "設定...", "Shared job settings": "共有ジョブ設定", "Show auto-detected": "自動検出されたものを表示", "Show submitter update notifications": "サブミッターの更新通知を表示", - "Source:": "ソース:", "Specify a job bundle directory or run the bundle command with the --browse flag": "ジョブバンドルディレクトリを指定するか、--browse フラグを使用して bundle コマンドを実行してください", "Specify output directories": "出力ディレクトリを指定", - "Steps:": "ステップ:", "Submission canceled": "送信がキャンセルされました", "Submission complete": "送信が完了しました", "Submission error": "送信エラー", @@ -139,19 +126,32 @@ "Telemetry opt out": "テレメトリをオプトアウト", "Template file format": "テンプレートファイル形式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "次のパラメータはジョブテンプレートまたはキューで認識されません:\n\n{params}\n\nこれらのパラメータはジョブ送信時に無視されます。\n\n続行しますか?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "プロファイル '{profile}' に設定の問題があります。\n\nこの問題を解決するには:\n\\u2022 AWS 設定ファイルと認証情報ファイルが正しいことを確認してください\n \\u2022 デフォルトでは、これらのファイルは Linux/MacOS では ~/.aws、Windows では %USERPROFILE%/.aws にあります\n\\u2022 正しい AWS リージョンが設定されていることを確認してください\n \\u2022 AWS_DEFAULT_REGION などの環境変数が誤ったリージョンに設定されていないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合:\n \\u2022 使用されている認証情報プロセスが認証情報を取得できること、または有効期限が切れていないことを確認してください\n \\u2022 次のコマンドを実行して確認できます: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "プロファイル '{profile}' に設定の問題があります。\n\nこの問題を解決するには:\n\\u2022 AWS 設定ファイルと認証情報ファイルが正しいことを確認してください\n \\u2022 デフォルトでは、これらのファイルは Linux/MacOS では ~/.aws、Windows では %USERPROFILE%/.aws にあります\n\\u2022 正しい AWS リージョンが設定されていることを確認してください\n \\u2022 AWS_DEFAULT_REGION などの環境変数が誤ったリージョンに設定されていないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合:\n \\u2022 使用されている認証情報プロセスが認証情報を取得できること、または有効期限が切れていないことを確認してください\n \\u2022 次のコマンドを実行して確認できます: aws sts get-caller-identity --profile ", "There was an error with authentication": "認証でエラーが発生しました", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "プロファイル '{profile}' で認証しようとしたときに不明な問題が発生しました。\n\n利用可能なコンソールログでエラーを確認して、問題を診断してください。\nログは通常、次の場所にあります:\n \\u2022 ダイアログまたはサブミッターが実行されているソフトウェアが起動されたターミナル \\u2022 サブミッターが実行されているソフトウェア内の組み込みコンソール", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "プロファイル '{profile}' で認証しようとしたときに不明な問題が発生しました。\n\n利用可能なコンソールログでエラーを確認して、問題を診断してください。\nログは通常、次の場所にあります:\n \\u2022 ダイアログまたはサブミッターが実行されているソフトウェアが起動されたターミナル \\u2022 サブミッターが実行されているソフトウェア内の組み込みコンソール", "Timeouts": "タイムアウト", "Unknown Issue With Configured Profile": "設定されたプロファイルに不明な問題があります", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} サブミッターのバージョン {latest_version} が利用可能になりました。", + "View release notes": "リリースノートを表示", "Unrecognized Parameters": "認識されないパラメータ", "Upload progress": "アップロード進行状況", "Use array parameter": "配列パラメータを使用", "Value(s)": "値", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} サブミッターのバージョン {latest_version} が利用可能になりました。", - "View release notes": "リリースノートを表示", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "プロファイル '{profile}' で認証されていますが、このプロファイルは AWS Deadline Cloud ListFarms を呼び出すことができず、AWS Deadline Cloud にジョブを送信できません。\n\nこの問題を解決するには:\n\\u2022 誤った AWS リージョンを指す環境変数 (AWS_DEFAULT_REGION など) がないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合は、プロファイルに送信に必要な次の AWS Deadline Cloud API のアクセス許可があることを確認してください:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "プロファイル '{profile}' で認証されていますが、このプロファイルは AWS Deadline Cloud ListFarms を呼び出すことができず、AWS Deadline Cloud にジョブを送信できません。\n\nこの問題を解決するには:\n\\u2022 誤った AWS リージョンを指す環境変数 (AWS_DEFAULT_REGION など) がないことを確認してください\n\\u2022 Deadline Cloud Monitor プロファイルを使用していない場合は、プロファイルに送信に必要な次の AWS Deadline Cloud API のアクセス許可があることを確認してください:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - ログアウトしています。", "{profile} doesn't have access permissions to submit a job.": "{profile} にはジョブを送信するアクセス許可がありません。", - "{submitter} job submission": "{submitter} ジョブ送信" + "{submitter} job submission": "{submitter} ジョブ送信", + "Default maximum retries per task": "\u30bf\u30b9\u30af\u3042\u305f\u308a\u306e\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u6700\u5927\u518d\u8a66\u884c\u56de\u6570", + "Default maximum failed tasks count": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u5931\u6557\u3057\u305f\u30bf\u30b9\u30af\u306e\u6700\u5927\u6570", + "Browse Job Bundles": "ジョブバンドルを参照", + "History": "履歴", + "Job bundle directory": "ジョブバンドルディレクトリ", + "Local": "ローカル", + "Name": "名前", + "Parameters:": "パラメータ:", + "Path:": "パス:", + "Select": "選択", + "Select a job bundle to see details": "ジョブバンドルを選択して詳細を表示", + "Source:": "ソース:", + "Steps:": "ステップ:" } diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index fdf752f37..e3020a0f6 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 워크스테이션 구성", "AWS profile": "AWS 프로필", "About": "정보", + "Application Restart Required": "애플리케이션 재시작 필요", "Add": "추가", "Add amount": "수량 추가", "Add attribute": "속성 추가", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "S3 작업 첨부 파일 항상 확인", "Amount name": "수량 이름", "Any": "모두", - "Application Restart Required": "애플리케이션 재시작 필요", "Apply": "적용", "Array parameter values": "배열 파라미터 값", "Attach input directories": "입력 디렉터리 연결", "Attach input files": "입력 파일 연결", "Attribute name": "속성 이름", "Auto accept prompt defaults": "기본값 자동 수락", - "Browse Job Bundles": "작업 번들 찾아보기", "CPU architecture": "CPU 아키텍처", "Cancel": "취소", "Canceling submission...": "제출 취소 중...", - "Cannot submit job:\n\n• {issues}": "작업을 제출할 수 없습니다:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "작업을 제출할 수 없습니다:\n\n\\u2022 {issues}", "Choose job bundle directory": "작업 번들 디렉터리 선택", "Close": "닫기", "Conflict resolution option": "충돌 해결 옵션", "Copy": "복사", - "Current logging level": "현재 로깅 수준", "Current: {current_version} -> New: {latest_version}": "현재: {current_version} -> 새 버전: {latest_version}", + "Current logging level": "현재 로깅 수준", "Custom host requirements": "사용자 지정 호스트 요구 사항", "Data directory": "데이터 디렉터리", "Deadline Cloud settings": "Deadline Cloud 설정", "Default farm": "기본 팜", - "Default maximum failed tasks count": "기본 최대 실패 작업 수", - "Default maximum retries per task": "작업당 기본 최대 재시도 횟수", "Default queue": "기본 대기열", "Default storage profile": "기본 스토리지 프로필", "Delete": "삭제", @@ -61,7 +58,6 @@ "Hardware requirements": "하드웨어 요구 사항", "Hashing progress": "해싱 진행률", "Help": "도움말", - "History": "기록", "Host requirements": "호스트 요구 사항", "Initial state": "초기 상태", "Issue With Profile Configuration": "프로필 구성 문제", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "작업 제출 확인", "Job attachments": "작업 첨부 파일", "Job attachments filesystem options": "작업 첨부 파일 파일 시스템 옵션", - "Job bundle directory": "작업 번들 디렉터리", "Job history directory": "작업 기록 디렉터리", "Job submission confirmation": "작업 제출 확인", "Job-specific settings": "작업별 설정", @@ -77,7 +72,6 @@ "Language": "언어", "Language will change next time the submitter is opened": "언어는 다음에 제출자를 열 때 변경됩니다", "Load Bundle": "번들 로드", - "Local": "로컬", "Log in": "로그인", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud에 로그인", "Logging you in...": "로그인 중...", @@ -89,7 +83,6 @@ "Memory (GiB)": "메모리(GiB)", "Min": "최소", "More info": "추가 정보", - "Name": "이름", "New version available": "새 버전 사용 가능", "No farm is configured. Click Settings to select a farm for job submission.": "구성된 팜이 없습니다. 설정을 클릭하여 작업 제출을 위한 팜을 선택하세요.", "No max worker count": "최대 작업자 수 없음", @@ -97,10 +90,8 @@ "Non valid inputs detected": "유효하지 않은 입력이 감지되었습니다", "Ok": "확인", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud 모니터를 여는 중입니다. 여기로 돌아오기 전에 로그인하세요.", - "Operating system": "운영 체제", - "Parameters:": "파라미터:", - "Path:": "경로:", "Please run the installer and then restart {integration_name} to use the new version.": "설치 프로그램을 실행한 후 {integration_name}을(를) 다시 시작하여 새 버전을 사용하세요.", + "Operating system": "운영 체제", "Preparing files...": "파일 준비 중...", "Preparing for hashing...": "해싱 준비 중...", "Preparing for upload...": "업로드 준비 중...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "다음 요구 사항을 충족하는 작업자 호스트에서 실행", "Saved the submission as a job bundle:\n{path}": "제출을 작업 번들로 저장했습니다:\n{path}", "Scratch space": "임시 공간", - "Select": "선택", - "Select a job bundle to see details": "작업 번들을 선택하여 세부 정보 보기", "Set max worker count": "최대 작업자 수 설정", "Settings...": "설정...", "Shared job settings": "공유 작업 설정", "Show auto-detected": "자동 감지된 항목 표시", "Show submitter update notifications": "제출기 업데이트 알림 표시", - "Source:": "소스:", "Specify a job bundle directory or run the bundle command with the --browse flag": "작업 번들 디렉터리를 지정하거나 --browse 플래그와 함께 bundle 명령을 실행하세요", "Specify output directories": "출력 디렉터리 지정", - "Steps:": "단계:", "Submission canceled": "제출이 취소되었습니다", "Submission complete": "제출 완료", "Submission error": "제출 오류", @@ -139,19 +126,32 @@ "Telemetry opt out": "원격 측정 옵트아웃", "Template file format": "템플릿 파일 형식", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "다음 파라미터는 작업 템플릿 또는 대기열에서 인식되지 않습니다:\n\n{params}\n\n이러한 파라미터는 작업 제출 중에 무시됩니다.\n\n계속하시겠습니까?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "프로필 '{profile}'에 구성 문제가 있습니다.\n\n이 문제를 해결하려면:\n\\u2022 AWS 구성 및 자격 증명 파일이 올바른지 확인하세요\n \\u2022 기본적으로 이러한 파일은 Linux/MacOS의 ~/.aws 또는 Windows의 %USERPROFILE%/.aws에서 찾을 수 있습니다\n\\u2022 올바른 AWS 리전이 설정되어 있는지 확인하세요\n \\u2022 AWS_DEFAULT_REGION과 같은 환경 변수가 잘못된 리전으로 설정되어 있지 않은지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우:\n \\u2022 사용 중인 자격 증명 프로세스가 자격 증명을 검색할 수 있는지 또는 만료되지 않았는지 확인하세요\n \\u2022 다음 명령을 실행하여 확인할 수 있습니다: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "프로필 '{profile}'에 구성 문제가 있습니다.\n\n이 문제를 해결하려면:\n\\u2022 AWS 구성 및 자격 증명 파일이 올바른지 확인하세요\n \\u2022 기본적으로 이러한 파일은 Linux/MacOS의 ~/.aws 또는 Windows의 %USERPROFILE%/.aws에서 찾을 수 있습니다\n\\u2022 올바른 AWS 리전이 설정되어 있는지 확인하세요\n \\u2022 AWS_DEFAULT_REGION과 같은 환경 변수가 잘못된 리전으로 설정되어 있지 않은지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우:\n \\u2022 사용 중인 자격 증명 프로세스가 자격 증명을 검색할 수 있는지 또는 만료되지 않았는지 확인하세요\n \\u2022 다음 명령을 실행하여 확인할 수 있습니다: aws sts get-caller-identity --profile ", "There was an error with authentication": "인증 오류가 발생했습니다", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "프로필 '{profile}'로 인증을 시도하는 동안 알 수 없는 문제가 발생했습니다.\n\n사용 가능한 콘솔 로그에서 오류를 확인하여 문제를 진단하세요.\n로그는 일반적으로 다음 위치에서 찾을 수 있습니다:\n \\u2022 대화 상자 또는 제출자가 실행 중인 소프트웨어가 시작된 터미널 \\u2022 제출자가 실행 중인 소프트웨어 내의 기본 제공 콘솔", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "프로필 '{profile}'로 인증을 시도하는 동안 알 수 없는 문제가 발생했습니다.\n\n사용 가능한 콘솔 로그에서 오류를 확인하여 문제를 진단하세요.\n로그는 일반적으로 다음 위치에서 찾을 수 있습니다:\n \\u2022 대화 상자 또는 제출자가 실행 중인 소프트웨어가 시작된 터미널 \\u2022 제출자가 실행 중인 소프트웨어 내의 기본 제공 콘솔", "Timeouts": "제한 시간", "Unknown Issue With Configured Profile": "구성된 프로필의 알 수 없는 문제", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 제출자 버전 {latest_version}을(를) 사용할 수 있습니다.", + "View release notes": "릴리스 노트 보기", "Unrecognized Parameters": "인식되지 않는 파라미터", "Upload progress": "업로드 진행률", "Use array parameter": "배열 파라미터 사용", "Value(s)": "값", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 제출자 버전 {latest_version}을(를) 사용할 수 있습니다.", - "View release notes": "릴리스 노트 보기", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "프로필 '{profile}'로 인증되었지만 이 프로필은 AWS Deadline Cloud ListFarms를 호출할 수 없으며 AWS Deadline Cloud에 작업을 제출할 수 없습니다.\n\n이 문제를 해결하려면:\n\\u2022 잘못된 AWS 리전을 가리키는 환경 변수(예: AWS_DEFAULT_REGION)가 없는지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우 프로필에 제출에 필요한 다음 AWS Deadline Cloud API에 대한 권한이 있는지 확인하세요:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "프로필 '{profile}'로 인증되었지만 이 프로필은 AWS Deadline Cloud ListFarms를 호출할 수 없으며 AWS Deadline Cloud에 작업을 제출할 수 없습니다.\n\n이 문제를 해결하려면:\n\\u2022 잘못된 AWS 리전을 가리키는 환경 변수(예: AWS_DEFAULT_REGION)가 없는지 확인하세요\n\\u2022 Deadline Cloud Monitor 프로필을 사용하지 않는 경우 프로필에 제출에 필요한 다음 AWS Deadline Cloud API에 대한 권한이 있는지 확인하세요:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 로그아웃되었습니다.", "{profile} doesn't have access permissions to submit a job.": "{profile}에 작업을 제출할 액세스 권한이 없습니다.", - "{submitter} job submission": "{submitter} 작업 제출" + "{submitter} job submission": "{submitter} 작업 제출", + "Default maximum retries per task": "\uc791\uc5c5\ub2f9 \uae30\ubcf8 \ucd5c\ub300 \uc7ac\uc2dc\ub3c4 \ud69f\uc218", + "Default maximum failed tasks count": "\uae30\ubcf8 \ucd5c\ub300 \uc2e4\ud328 \uc791\uc5c5 \uc218", + "Browse Job Bundles": "작업 번들 찾아보기", + "History": "기록", + "Job bundle directory": "작업 번들 디렉터리", + "Local": "로컬", + "Name": "이름", + "Parameters:": "파라미터:", + "Path:": "경로:", + "Select": "선택", + "Select a job bundle to see details": "작업 번들을 선택하여 세부 정보 보기", + "Source:": "소스:", + "Steps:": "단계:" } diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 1517082f6..23e8a57de 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "Configuração de estação de trabalho do AWS Deadline Cloud", "AWS profile": "Perfil da AWS", "About": "Sobre", + "Application Restart Required": "Reinicialização do aplicativo necessária", "Add": "Adicionar", "Add amount": "Adicionar quantidade", "Add attribute": "Adicionar atributo", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "Sempre verificar anexos de trabalho do S3", "Amount name": "Nome da quantidade", "Any": "Qualquer", - "Application Restart Required": "Reinicialização do aplicativo necessária", "Apply": "Aplicar", "Array parameter values": "Valores de parâmetros de matriz", "Attach input directories": "Anexar diretórios de entrada", "Attach input files": "Anexar arquivos de entrada", "Attribute name": "Nome do atributo", "Auto accept prompt defaults": "Aceitar automaticamente padrões", - "Browse Job Bundles": "Procurar pacotes de trabalho", "CPU architecture": "Arquitetura da CPU", "Cancel": "Cancelar", "Canceling submission...": "Cancelando envio...", - "Cannot submit job:\n\n• {issues}": "Não é possível enviar o trabalho:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "Não é possível enviar o trabalho:\n\n\\u2022 {issues}", "Choose job bundle directory": "Escolher diretório do pacote de tarefas", "Close": "Fechar", "Conflict resolution option": "Opção de resolução de conflitos", "Copy": "Copiar", - "Current logging level": "Nível de registro atual", "Current: {current_version} -> New: {latest_version}": "Atual: {current_version} -> Novo: {latest_version}", + "Current logging level": "Nível de registro atual", "Custom host requirements": "Requisitos de host personalizados", "Data directory": "Diretório de dados", "Deadline Cloud settings": "Configurações do Deadline Cloud", "Default farm": "Fazenda padrão", - "Default maximum failed tasks count": "Contagem máxima padrão de tarefas com falha", - "Default maximum retries per task": "Máximo padrão de tentativas por tarefa", "Default queue": "Fila padrão", "Default storage profile": "Perfil de armazenamento padrão", "Delete": "Excluir", @@ -61,7 +58,6 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progresso de hash", "Help": "Ajuda", - "History": "Histórico", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema com a configuração do perfil", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "Confirmação de envio de trabalho", "Job attachments": "Anexos de trabalho", "Job attachments filesystem options": "Opções do sistema de arquivos de anexos de tarefas", - "Job bundle directory": "Diretório de pacotes de trabalho", "Job history directory": "Diretório de histórico de trabalhos", "Job submission confirmation": "Confirmação de envio de trabalho", "Job-specific settings": "Configurações específicas do trabalho", @@ -77,7 +72,6 @@ "Language": "Idioma", "Language will change next time the submitter is opened": "O idioma será alterado na próxima vez que o submitter for aberto", "Load Bundle": "Carregar pacote", - "Local": "Local", "Log in": "Fazer login", "Log in to AWS Deadline Cloud": "Fazer login no AWS Deadline Cloud", "Logging you in...": "Fazendo login...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Memória (GiB)", "Min": "Mín", "More info": "Mais informações", - "Name": "Nome", "New version available": "Nova versão disponível", "No farm is configured. Click Settings to select a farm for job submission.": "Nenhuma fazenda está configurada. Clique em Configurações para selecionar uma fazenda para envio de trabalhos.", "No max worker count": "Sem contagem máxima de trabalhadores", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Entradas não válidas detectadas", "Ok": "OK", "Opening Deadline Cloud monitor. Please log in before returning here.": "Abrindo o monitor do Deadline Cloud. Faça login antes de retornar aqui.", - "Operating system": "Sistema operacional", - "Parameters:": "Parâmetros:", - "Path:": "Caminho:", "Please run the installer and then restart {integration_name} to use the new version.": "Execute o instalador e reinicie o {integration_name} para usar a nova versão.", + "Operating system": "Sistema operacional", "Preparing files...": "Preparando arquivos...", "Preparing for hashing...": "Preparando para hash...", "Preparing for upload...": "Preparando para upload...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Executar em hosts de trabalho que atendam aos seguintes requisitos", "Saved the submission as a job bundle:\n{path}": "O envio foi salvo como um pacote de tarefas:\n{path}", "Scratch space": "Espaço temporário", - "Select": "Selecionar", - "Select a job bundle to see details": "Selecione um pacote de trabalho para ver os detalhes", "Set max worker count": "Definir contagem máxima de trabalhadores", "Settings...": "Configurações...", "Shared job settings": "Configurações de trabalho compartilhadas", "Show auto-detected": "Mostrar detectados automaticamente", "Show submitter update notifications": "Mostrar notificações de atualização do submissor", - "Source:": "Origem:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique um diretório de pacote de tarefas ou execute o comando bundle com a flag --browse", "Specify output directories": "Especificar diretórios de saída", - "Steps:": "Etapas:", "Submission canceled": "Envio cancelado", "Submission complete": "Envio concluído", "Submission error": "Erro de envio", @@ -139,19 +126,32 @@ "Telemetry opt out": "Desativar telemetria", "Template file format": "Formato de arquivo de modelo", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Os seguintes parâmetros não são reconhecidos pelo modelo de trabalho ou fila:\n\n{params}\n\nEsses parâmetros serão ignorados durante o envio do trabalho.\n\nDeseja continuar?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "Há um problema de configuração com o perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique se seus arquivos de configuração e credenciais da AWS estão corretos\n \\u2022 Por padrão, esses arquivos podem ser encontrados em ~/.aws no Linux/MacOS ou %USERPROFILE%/.aws no Windows\n\\u2022 Verifique se a região da AWS correta está definida\n \\u2022 Verifique se nenhuma variável de ambiente como AWS_DEFAULT_REGION está definida para uma região incorreta\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor:\n \\u2022 Verifique se qualquer processo de credencial que está sendo usado é capaz de recuperar as credenciais ou se elas não expiraram\n \\u2022 Você pode executar o seguinte comando para verificar: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "Há um problema de configuração com o perfil '{profile}'.\n\nPara resolver este problema:\n\\u2022 Verifique se seus arquivos de configuração e credenciais da AWS estão corretos\n \\u2022 Por padrão, esses arquivos podem ser encontrados em ~/.aws no Linux/MacOS ou %USERPROFILE%/.aws no Windows\n\\u2022 Verifique se a região da AWS correta está definida\n \\u2022 Verifique se nenhuma variável de ambiente como AWS_DEFAULT_REGION está definida para uma região incorreta\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor:\n \\u2022 Verifique se qualquer processo de credencial que está sendo usado é capaz de recuperar as credenciais ou se elas não expiraram\n \\u2022 Você pode executar o seguinte comando para verificar: aws sts get-caller-identity --profile ", "There was an error with authentication": "Ocorreu um erro com a autenticação", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "Ocorreu um problema desconhecido ao tentar autenticar com o perfil '{profile}'.\n\nVerifique os logs do console disponíveis em busca de erros para tentar diagnosticar o problema.\nOs logs geralmente são encontrados:\n \\u2022 No terminal do qual a caixa de diálogo ou o software em que o remetente está sendo executado foi iniciado \\u2022 No console integrado dentro do software em que o remetente está sendo executado", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "Ocorreu um problema desconhecido ao tentar autenticar com o perfil '{profile}'.\n\nVerifique os logs do console disponíveis em busca de erros para tentar diagnosticar o problema.\nOs logs geralmente são encontrados:\n \\u2022 No terminal do qual a caixa de diálogo ou o software em que o remetente está sendo executado foi iniciado \\u2022 No console integrado dentro do software em que o remetente está sendo executado", "Timeouts": "Tempos limite", "Unknown Issue With Configured Profile": "Problema desconhecido com o perfil configurado", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "A versão {latest_version} do Deadline Cloud para o remetente {integration_name} já está disponível.", + "View release notes": "Ver notas de versão", "Unrecognized Parameters": "Parâmetros não reconhecidos", "Upload progress": "Progresso do upload", "Use array parameter": "Usar parâmetro de matriz", "Value(s)": "Valor(es)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "A versão {latest_version} do Deadline Cloud para o remetente {integration_name} já está disponível.", - "View release notes": "Ver notas de versão", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "Você está autenticado com o perfil '{profile}', mas este perfil não consegue chamar o AWS Deadline Cloud ListFarms e não consegue enviar trabalhos para o AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Verifique se não há variáveis de ambiente apontando para a região da AWS errada (por exemplo, AWS_DEFAULT_REGION)\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor, verifique se o perfil tem permissões para essas APIs do AWS Deadline Cloud necessárias para envio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "Você está autenticado com o perfil '{profile}', mas este perfil não consegue chamar o AWS Deadline Cloud ListFarms e não consegue enviar trabalhos para o AWS Deadline Cloud.\n\nPara resolver este problema:\n\\u2022 Verifique se não há variáveis de ambiente apontando para a região da AWS errada (por exemplo, AWS_DEFAULT_REGION)\n\\u2022 Se você não estiver usando um perfil do Deadline Cloud Monitor, verifique se o perfil tem permissões para essas APIs do AWS Deadline Cloud necessárias para envio:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Você está desconectado.", "{profile} doesn't have access permissions to submit a job.": "{profile} não tem permissões de acesso para enviar um trabalho.", - "{submitter} job submission": "Envio de trabalho {submitter}" + "{submitter} job submission": "Envio de trabalho {submitter}", + "Default maximum retries per task": "M\u00e1ximo padr\u00e3o de tentativas por tarefa", + "Default maximum failed tasks count": "Contagem m\u00e1xima padr\u00e3o de tarefas com falha", + "Browse Job Bundles": "Procurar pacotes de trabalho", + "History": "Histórico", + "Job bundle directory": "Diretório de pacotes de trabalho", + "Local": "Local", + "Name": "Nome", + "Parameters:": "Parâmetros:", + "Path:": "Caminho:", + "Select": "Selecionar", + "Select a job bundle to see details": "Selecione um pacote de trabalho para ver os detalhes", + "Source:": "Origem:", + "Steps:": "Etapas:" } diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index c74b1cbe8..26f59fe1a 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud iş istasyonu yapılandırması", "AWS profile": "AWS profili", "About": "Hakkında", + "Application Restart Required": "Uygulama yeniden başlatma gerekli", "Add": "Ekle", "Add amount": "Miktar ekle", "Add attribute": "Öznitelik ekle", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "S3 iş eklerini her zaman kontrol et", "Amount name": "Miktar adı", "Any": "Herhangi", - "Application Restart Required": "Uygulama yeniden başlatma gerekli", "Apply": "Uygula", "Array parameter values": "Dizi parametre değerleri", "Attach input directories": "Giriş dizinlerini ekle", "Attach input files": "Giriş dosyalarını ekle", "Attribute name": "Öznitelik adı", "Auto accept prompt defaults": "Varsayılanları otomatik olarak kabul et", - "Browse Job Bundles": "İş paketlerine göz at", "CPU architecture": "CPU mimarisi", "Cancel": "İptal", "Canceling submission...": "Gönderim iptal ediliyor...", - "Cannot submit job:\n\n• {issues}": "İş gönderilemedi:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "İş gönderilemedi:\n\n\\u2022 {issues}", "Choose job bundle directory": "İş paketi dizini seçin", "Close": "Kapat", "Conflict resolution option": "Çakışma çözümleme seçeneği", "Copy": "Kopyala", - "Current logging level": "Geçerli günlük kaydı düzeyi", "Current: {current_version} -> New: {latest_version}": "Mevcut: {current_version} -> Yeni: {latest_version}", + "Current logging level": "Geçerli günlük kaydı düzeyi", "Custom host requirements": "Özel ana bilgisayar gereksinimleri", "Data directory": "Veri dizini", "Deadline Cloud settings": "Deadline Cloud ayarları", "Default farm": "Varsayılan farm", - "Default maximum failed tasks count": "Varsayılan maksimum başarısız görev sayısı", - "Default maximum retries per task": "Görev başına varsayılan maksimum yeniden deneme", "Default queue": "Varsayılan kuyruk", "Default storage profile": "Varsayılan depolama profili", "Delete": "Sil", @@ -61,7 +58,6 @@ "Hardware requirements": "Donanım gereksinimleri", "Hashing progress": "Karma oluşturma ilerlemesi", "Help": "Yardım", - "History": "Geçmiş", "Host requirements": "Ana bilgisayar gereksinimleri", "Initial state": "Başlangıç durumu", "Issue With Profile Configuration": "Profil yapılandırmasıyla ilgili sorun", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "İş gönderimi onayı", "Job attachments": "İş ekleri", "Job attachments filesystem options": "İş ekleri dosya sistemi seçenekleri", - "Job bundle directory": "İş paketi dizini", "Job history directory": "İş geçmişi dizini", "Job submission confirmation": "İş gönderimi onayı", "Job-specific settings": "İşe özel ayarlar", @@ -77,7 +72,6 @@ "Language": "Dil", "Language will change next time the submitter is opened": "Dil, submitter bir sonraki açılışında değişecektir", "Load Bundle": "Paket yükle", - "Local": "Yerel", "Log in": "Oturum aç", "Log in to AWS Deadline Cloud": "AWS Deadline Cloud'da oturum aç", "Logging you in...": "Oturum açılıyor...", @@ -89,7 +83,6 @@ "Memory (GiB)": "Bellek (GiB)", "Min": "Min", "More info": "Daha fazla bilgi", - "Name": "Ad", "New version available": "Yeni sürüm mevcut", "No farm is configured. Click Settings to select a farm for job submission.": "Yapılandırılmış farm yok. İş gönderimi için bir farm seçmek üzere Ayarlar'a tıklayın.", "No max worker count": "Maksimum çalışan sayısı yok", @@ -97,10 +90,8 @@ "Non valid inputs detected": "Geçersiz girişler algılandı", "Ok": "Tamam", "Opening Deadline Cloud monitor. Please log in before returning here.": "Deadline Cloud monitörü açılıyor. Buraya dönmeden önce lütfen oturum açın.", - "Operating system": "İşletim sistemi", - "Parameters:": "Parametreler:", - "Path:": "Yol:", "Please run the installer and then restart {integration_name} to use the new version.": "Lütfen installer'ı çalıştırın ve ardından yeni sürümü kullanmak için {integration_name} uygulamasını yeniden başlatın.", + "Operating system": "İşletim sistemi", "Preparing files...": "Dosyalar hazırlanıyor...", "Preparing for hashing...": "Karma oluşturma için hazırlanıyor...", "Preparing for upload...": "Yükleme için hazırlanıyor...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "Aşağıdaki gereksinimleri karşılayan çalışan ana bilgisayarlarda çalıştır", "Saved the submission as a job bundle:\n{path}": "Gönderim iş paketi olarak kaydedildi:\n{path}", "Scratch space": "Geçici alan", - "Select": "Seç", - "Select a job bundle to see details": "Ayrıntıları görmek için bir iş paketi seçin", "Set max worker count": "Maksimum çalışan sayısını ayarla", "Settings...": "Ayarlar...", "Shared job settings": "Paylaşılan iş ayarları", "Show auto-detected": "Otomatik algılanmışları göster", "Show submitter update notifications": "Gönderi aracı güncelleme bildirimlerini göster", - "Source:": "Kaynak:", "Specify a job bundle directory or run the bundle command with the --browse flag": "Bir iş paketi dizini belirtin veya bundle komutunu --browse bayrağıyla çalıştırın", "Specify output directories": "Çıkış dizinlerini belirtin", - "Steps:": "Adımlar:", "Submission canceled": "Gönderim iptal edildi", "Submission complete": "Gönderim tamamlandı", "Submission error": "Gönderim hatası", @@ -139,19 +126,32 @@ "Telemetry opt out": "Telemetriyi devre dışı bırak", "Template file format": "Şablon dosya biçimi", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "Aşağıdaki parametreler iş şablonu veya kuyruk tarafından tanınmıyor:\n\n{params}\n\nBu parametreler iş gönderimi sırasında yok sayılacak.\n\nDevam etmek istiyor musunuz?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "'{profile}' profiliyle ilgili bir yapılandırma sorunu var.\n\nBu sorunu çözmek için:\n\\u2022 AWS yapılandırma ve kimlik bilgileri dosyalarınızın doğru olduğunu doğrulayın\n \\u2022 Varsayılan olarak bu dosyalar Linux/MacOS'ta ~/.aws veya Windows'ta %USERPROFILE%/.aws konumunda bulunabilir\n\\u2022 Doğru AWS bölgesinin ayarlandığını doğrulayın\n \\u2022 AWS_DEFAULT_REGION gibi ortam değişkenlerinin yanlış bir bölgeye ayarlanmadığını kontrol edin\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız:\n \\u2022 Kullanılan kimlik bilgisi işleminin kimlik bilgilerini alabileceğini veya sürelerinin dolmadığını doğrulayın\n \\u2022 Kontrol etmek için şu komutu çalıştırabilirsiniz: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "'{profile}' profiliyle ilgili bir yapılandırma sorunu var.\n\nBu sorunu çözmek için:\n\\u2022 AWS yapılandırma ve kimlik bilgileri dosyalarınızın doğru olduğunu doğrulayın\n \\u2022 Varsayılan olarak bu dosyalar Linux/MacOS'ta ~/.aws veya Windows'ta %USERPROFILE%/.aws konumunda bulunabilir\n\\u2022 Doğru AWS bölgesinin ayarlandığını doğrulayın\n \\u2022 AWS_DEFAULT_REGION gibi ortam değişkenlerinin yanlış bir bölgeye ayarlanmadığını kontrol edin\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız:\n \\u2022 Kullanılan kimlik bilgisi işleminin kimlik bilgilerini alabileceğini veya sürelerinin dolmadığını doğrulayın\n \\u2022 Kontrol etmek için şu komutu çalıştırabilirsiniz: aws sts get-caller-identity --profile ", "There was an error with authentication": "Kimlik doğrulamada bir hata oluştu", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "'{profile}' profiliyle kimlik doğrulamaya çalışırken bilinmeyen bir sorun oluştu.\n\nSorunu teşhis etmeye çalışmak için hataları görmek üzere mevcut konsol günlüklerini kontrol edin.\nGünlükler genellikle şurada bulunur:\n \\u2022 Gönderenin çalıştığı iletişim kutusunun veya yazılımın başlatıldığı terminalde \\u2022 Gönderenin çalıştığı yazılımın içindeki yerleşik konsolda", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "'{profile}' profiliyle kimlik doğrulamaya çalışırken bilinmeyen bir sorun oluştu.\n\nSorunu teşhis etmeye çalışmak için hataları görmek üzere mevcut konsol günlüklerini kontrol edin.\nGünlükler genellikle şurada bulunur:\n \\u2022 Gönderenin çalıştığı iletişim kutusunun veya yazılımın başlatıldığı terminalde \\u2022 Gönderenin çalıştığı yazılımın içindeki yerleşik konsolda", "Timeouts": "Zaman aşımları", "Unknown Issue With Configured Profile": "Yapılandırılmış profille ilgili bilinmeyen sorun", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} göndericisi sürüm {latest_version} artık kullanılabilir.", + "View release notes": "Sürüm notlarını görüntüle", "Unrecognized Parameters": "Tanınmayan parametreler", "Upload progress": "Yükleme ilerlemesi", "Use array parameter": "Dizi parametresi kullan", "Value(s)": "Değer(ler)", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} göndericisi sürüm {latest_version} artık kullanılabilir.", - "View release notes": "Sürüm notlarını görüntüle", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "'{profile}' profiliyle kimlik doğrulandınız, ancak bu profil AWS Deadline Cloud ListFarms'ı çağıramıyor ve AWS Deadline Cloud'a iş gönderemiyor.\n\nBu sorunu çözmek için:\n\\u2022 Yanlış AWS bölgesine işaret eden ortam değişkenleri olmadığını kontrol edin (örn. AWS_DEFAULT_REGION)\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız, profilin gönderim için gereken şu AWS Deadline Cloud API'leri için izinlere sahip olduğunu kontrol edin:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "'{profile}' profiliyle kimlik doğrulandınız, ancak bu profil AWS Deadline Cloud ListFarms'ı çağıramıyor ve AWS Deadline Cloud'a iş gönderemiyor.\n\nBu sorunu çözmek için:\n\\u2022 Yanlış AWS bölgesine işaret eden ortam değişkenleri olmadığını kontrol edin (örn. AWS_DEFAULT_REGION)\n\\u2022 Deadline Cloud Monitor profili kullanmıyorsanız, profilin gönderim için gereken şu AWS Deadline Cloud API'leri için izinlere sahip olduğunu kontrol edin:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - Oturumunuz kapatıldı.", "{profile} doesn't have access permissions to submit a job.": "{profile} bir iş göndermek için erişim izinlerine sahip değil.", - "{submitter} job submission": "{submitter} iş gönderimi" + "{submitter} job submission": "{submitter} iş gönderimi", + "Default maximum retries per task": "G\u00f6rev ba\u015f\u0131na varsay\u0131lan maksimum yeniden deneme", + "Default maximum failed tasks count": "Varsay\u0131lan maksimum ba\u015far\u0131s\u0131z g\u00f6rev say\u0131s\u0131", + "Browse Job Bundles": "İş paketlerine göz at", + "History": "Geçmiş", + "Job bundle directory": "İş paketi dizini", + "Local": "Yerel", + "Name": "Ad", + "Parameters:": "Parametreler:", + "Path:": "Yol:", + "Select": "Seç", + "Select a job bundle to see details": "Ayrıntıları görmek için bir iş paketi seçin", + "Source:": "Kaynak:", + "Steps:": "Adımlar:" } diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index d456adfa0..b7561a650 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 工作站配置", "AWS profile": "AWS 配置文件", "About": "关于", + "Application Restart Required": "需要重新启动应用程序", "Add": "添加", "Add amount": "添加数量", "Add attribute": "添加属性", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "始终检查 S3 作业附件", "Amount name": "数量名称", "Any": "任意", - "Application Restart Required": "需要重新启动应用程序", "Apply": "应用", "Array parameter values": "数组参数值", "Attach input directories": "附加输入目录", "Attach input files": "附加输入文件", "Attribute name": "属性名称", "Auto accept prompt defaults": "自动接受默认值", - "Browse Job Bundles": "浏览作业包", "CPU architecture": "CPU 架构", "Cancel": "取消", "Canceling submission...": "正在取消提交...", - "Cannot submit job:\n\n• {issues}": "无法提交作业:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "无法提交作业:\n\n\\u2022 {issues}", "Choose job bundle directory": "选择作业捆绑包目录", "Close": "关闭", "Conflict resolution option": "冲突解决选项", "Copy": "复制", - "Current logging level": "当前日志记录级别", "Current: {current_version} -> New: {latest_version}": "当前: {current_version} -> 新版本: {latest_version}", + "Current logging level": "当前日志记录级别", "Custom host requirements": "自定义主机要求", "Data directory": "数据目录", "Deadline Cloud settings": "Deadline Cloud 设置", "Default farm": "默认服务器农场", - "Default maximum failed tasks count": "默认最大失败任务数", - "Default maximum retries per task": "每个任务的默认最大重试次数", "Default queue": "默认队列", "Default storage profile": "默认存储配置文件", "Delete": "删除", @@ -61,7 +58,6 @@ "Hardware requirements": "硬件要求", "Hashing progress": "哈希进度", "Help": "帮助", - "History": "历史记录", "Host requirements": "主机要求", "Initial state": "初始状态", "Issue With Profile Configuration": "配置文件配置问题", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "作业提交确认", "Job attachments": "作业附件", "Job attachments filesystem options": "作业附件文件系统选项", - "Job bundle directory": "作业包目录", "Job history directory": "作业历史记录目录", "Job submission confirmation": "作业提交确认", "Job-specific settings": "作业特定设置", @@ -77,7 +72,6 @@ "Language": "语言", "Language will change next time the submitter is opened": "语言将在下次打开提交器时更改", "Load Bundle": "加载捆绑包", - "Local": "本地", "Log in": "登录", "Log in to AWS Deadline Cloud": "登录 AWS Deadline Cloud", "Logging you in...": "正在登录...", @@ -89,7 +83,6 @@ "Memory (GiB)": "内存 (GiB)", "Min": "最小", "More info": "更多信息", - "Name": "名称", "New version available": "有新版本可用", "No farm is configured. Click Settings to select a farm for job submission.": "未配置服务器农场。单击设置以选择用于作业提交的服务器农场。", "No max worker count": "无最大工作线程数", @@ -97,10 +90,8 @@ "Non valid inputs detected": "检测到无效输入", "Ok": "确定", "Opening Deadline Cloud monitor. Please log in before returning here.": "正在打开 Deadline Cloud 监控器。请在返回此处之前登录。", - "Operating system": "操作系统", - "Parameters:": "参数:", - "Path:": "路径:", "Please run the installer and then restart {integration_name} to use the new version.": "请运行安装程序,然后重新启动 {integration_name} 以使用新版本。", + "Operating system": "操作系统", "Preparing files...": "正在准备文件...", "Preparing for hashing...": "正在准备哈希...", "Preparing for upload...": "正在准备上传...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "在满足以下要求的工作主机上运行", "Saved the submission as a job bundle:\n{path}": "已将提交保存为作业捆绑包:\n{path}", "Scratch space": "临时空间", - "Select": "选择", - "Select a job bundle to see details": "选择作业包以查看详细信息", "Set max worker count": "设置最大工作线程数", "Settings...": "设置...", "Shared job settings": "共享作业设置", "Show auto-detected": "显示自动检测的", "Show submitter update notifications": "显示提交器更新通知", - "Source:": "来源:", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定作业捆绑包目录或使用 --browse 标志运行 bundle 命令", "Specify output directories": "指定输出目录", - "Steps:": "步骤:", "Submission canceled": "提交已取消", "Submission complete": "提交完成", "Submission error": "提交错误", @@ -139,19 +126,32 @@ "Telemetry opt out": "选择退出遥测", "Template file format": "模板文件格式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "作业模板或队列无法识别以下参数:\n\n{params}\n\n这些参数将在作业提交期间被忽略。\n\n是否要继续?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "配置文件 '{profile}' 存在配置问题。\n\n要解决此问题:\n\\u2022 验证您的 AWS 配置和凭证文件是否正确\n \\u2022 默认情况下,这些文件可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 验证是否设置了正确的 AWS 区域\n \\u2022 检查是否没有将 AWS_DEFAULT_REGION 等环境变量设置为错误的区域\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件:\n \\u2022 验证正在使用的任何凭证进程是否能够检索凭证或凭证是否未过期\n \\u2022 您可以运行以下命令进行检查: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "配置文件 '{profile}' 存在配置问题。\n\n要解决此问题:\n\\u2022 验证您的 AWS 配置和凭证文件是否正确\n \\u2022 默认情况下,这些文件可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 验证是否设置了正确的 AWS 区域\n \\u2022 检查是否没有将 AWS_DEFAULT_REGION 等环境变量设置为错误的区域\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件:\n \\u2022 验证正在使用的任何凭证进程是否能够检索凭证或凭证是否未过期\n \\u2022 您可以运行以下命令进行检查: aws sts get-caller-identity --profile ", "There was an error with authentication": "身份验证出错", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "尝试使用配置文件 '{profile}' 进行身份验证时出现未知问题。\n\n检查任何可用的控制台日志以查找错误,以尝试诊断问题。\n日志通常位于:\n \\u2022 在启动对话框或提交者正在运行的软件的终端中 \\u2022 在提交者正在运行的软件内的内置控制台中", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "尝试使用配置文件 '{profile}' 进行身份验证时出现未知问题。\n\n检查任何可用的控制台日志以查找错误,以尝试诊断问题。\n日志通常位于:\n \\u2022 在启动对话框或提交者正在运行的软件的终端中 \\u2022 在提交者正在运行的软件内的内置控制台中", "Timeouts": "超时", "Unknown Issue With Configured Profile": "配置的配置文件存在未知问题", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 现已可用。", + "View release notes": "查看发行说明", "Unrecognized Parameters": "无法识别的参数", "Upload progress": "上传进度", "Use array parameter": "使用数组参数", "Value(s)": "值", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 现已可用。", - "View release notes": "查看发行说明", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "您已使用配置文件 '{profile}' 进行身份验证,但此配置文件无法调用 AWS Deadline Cloud ListFarms 并且无法向 AWS Deadline Cloud 提交作业。\n\n要解决此问题:\n\\u2022 检查是否没有指向错误 AWS 区域的环境变量 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件,请检查配置文件是否具有提交所需的这些 AWS Deadline Cloud API 的权限:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "您已使用配置文件 '{profile}' 进行身份验证,但此配置文件无法调用 AWS Deadline Cloud ListFarms 并且无法向 AWS Deadline Cloud 提交作业。\n\n要解决此问题:\n\\u2022 检查是否没有指向错误 AWS 区域的环境变量 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 配置文件,请检查配置文件是否具有提交所需的这些 AWS Deadline Cloud API 的权限:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 您已登出。", "{profile} doesn't have access permissions to submit a job.": "{profile} 没有提交作业的访问权限。", - "{submitter} job submission": "{submitter} 作业提交" + "{submitter} job submission": "{submitter} 作业提交", + "Default maximum retries per task": "\u6bcf\u4e2a\u4efb\u52a1\u7684\u9ed8\u8ba4\u6700\u5927\u91cd\u8bd5\u6b21\u6570", + "Default maximum failed tasks count": "\u9ed8\u8ba4\u6700\u5927\u5931\u8d25\u4efb\u52a1\u6570", + "Browse Job Bundles": "浏览作业包", + "History": "历史记录", + "Job bundle directory": "作业包目录", + "Local": "本地", + "Name": "名称", + "Parameters:": "参数:", + "Path:": "路径:", + "Select": "选择", + "Select a job bundle to see details": "选择作业包以查看详细信息", + "Source:": "来源:", + "Steps:": "步骤:" } diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index aaefa1c61..e8f1a6d21 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -6,6 +6,7 @@ "AWS Deadline Cloud workstation configuration": "AWS Deadline Cloud 工作站組態", "AWS profile": "AWS 設定檔", "About": "關於", + "Application Restart Required": "需要重新啟動應用程式", "Add": "新增", "Add amount": "新增數量", "Add attribute": "新增屬性", @@ -15,30 +16,26 @@ "Always check S3 job attachments": "一律檢查 S3 任務附件", "Amount name": "數量名稱", "Any": "任何", - "Application Restart Required": "需要重新啟動應用程式", "Apply": "套用", "Array parameter values": "陣列參數值", "Attach input directories": "附加輸入目錄", "Attach input files": "附加輸入檔案", "Attribute name": "屬性名稱", "Auto accept prompt defaults": "自動接受預設值", - "Browse Job Bundles": "瀏覽工作套件", "CPU architecture": "CPU 架構", "Cancel": "取消", "Canceling submission...": "正在取消提交...", - "Cannot submit job:\n\n• {issues}": "無法提交任務:\n\n\\u2022 {issues}", + "Cannot submit job:\n\n\u2022 {issues}": "無法提交任務:\n\n\\u2022 {issues}", "Choose job bundle directory": "選擇任務套件目錄", "Close": "關閉", "Conflict resolution option": "衝突解決選項", "Copy": "複製", - "Current logging level": "目前的日誌記錄層級", "Current: {current_version} -> New: {latest_version}": "目前: {current_version} -> 新版本: {latest_version}", + "Current logging level": "目前的日誌記錄層級", "Custom host requirements": "自訂主機需求", "Data directory": "資料目錄", "Deadline Cloud settings": "Deadline Cloud 設定", "Default farm": "預設伺服器陣列", - "Default maximum failed tasks count": "預設失敗任務數上限", - "Default maximum retries per task": "每個任務的預設重試次數上限", "Default queue": "預設佇列", "Default storage profile": "預設儲存設定檔", "Delete": "刪除", @@ -61,7 +58,6 @@ "Hardware requirements": "硬體需求", "Hashing progress": "雜湊進度", "Help": "說明", - "History": "歷史記錄", "Host requirements": "主機需求", "Initial state": "初始狀態", "Issue With Profile Configuration": "設定檔組態問題", @@ -69,7 +65,6 @@ "Job Submission Confirmation": "任務提交確認", "Job attachments": "任務附件", "Job attachments filesystem options": "任務附件檔案系統選項", - "Job bundle directory": "工作套件目錄", "Job history directory": "任務歷史記錄目錄", "Job submission confirmation": "任務提交確認", "Job-specific settings": "任務特定設定", @@ -77,7 +72,6 @@ "Language": "語言", "Language will change next time the submitter is opened": "語言將在下次開啟提交器時變更", "Load Bundle": "載入套件", - "Local": "本機", "Log in": "登入", "Log in to AWS Deadline Cloud": "登入 AWS Deadline Cloud", "Logging you in...": "正在登入...", @@ -89,7 +83,6 @@ "Memory (GiB)": "記憶體 (GiB)", "Min": "最小", "More info": "更多資訊", - "Name": "名稱", "New version available": "有新版本可用", "No farm is configured. Click Settings to select a farm for job submission.": "未設定伺服器陣列。按一下設定以選取用於任務提交的伺服器陣列。", "No max worker count": "無工作程序數上限", @@ -97,10 +90,8 @@ "Non valid inputs detected": "偵測到無效的輸入", "Ok": "確定", "Opening Deadline Cloud monitor. Please log in before returning here.": "正在開啟 Deadline Cloud 監視器。請在返回此處之前登入。", - "Operating system": "作業系統", - "Parameters:": "參數:", - "Path:": "路徑:", "Please run the installer and then restart {integration_name} to use the new version.": "請執行安裝程式,然後重新啟動 {integration_name} 以使用新版本。", + "Operating system": "作業系統", "Preparing files...": "正在準備檔案...", "Preparing for hashing...": "正在準備雜湊...", "Preparing for upload...": "正在準備上傳...", @@ -117,17 +108,13 @@ "Run on worker hosts that meet the following requirements": "在符合下列需求的工作者主機上執行", "Saved the submission as a job bundle:\n{path}": "已將提交儲存為任務套件:\n{path}", "Scratch space": "暫存空間", - "Select": "選取", - "Select a job bundle to see details": "選取工作套件以查看詳細資訊", "Set max worker count": "設定工作程序數上限", "Settings...": "設定...", "Shared job settings": "共用任務設定", "Show auto-detected": "顯示自動偵測的", "Show submitter update notifications": "顯示提交器更新通知", - "Source:": "來源:", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定任務套件目錄或使用 --browse 旗標執行 bundle 命令", "Specify output directories": "指定輸出目錄", - "Steps:": "步驟:", "Submission canceled": "提交已取消", "Submission complete": "提交完成", "Submission error": "提交錯誤", @@ -139,19 +126,32 @@ "Telemetry opt out": "選擇退出遙測", "Template file format": "範本檔案格式", "The following parameters are not recognized by the job template or queue:\n\n{params}\n\nThese parameters will be ignored during job submission.\n\nDo you want to continue?": "任務範本或佇列無法識別下列參數:\n\n{params}\n\n這些參數將在任務提交期間被忽略。\n\n是否要繼續?", - "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n• Verify your AWS config and credentials files are correct\n • By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n• Verify that the correct AWS region is set\n • Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n• If you are not using a Deadline Cloud Monitor profile:\n • Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n • You can run the following command to check: aws sts get-caller-identity --profile ": "設定檔 '{profile}' 存在組態問題。\n\n若要解決此問題:\n\\u2022 驗證您的 AWS 組態和憑證檔案是否正確\n \\u2022 根據預設,這些檔案可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 驗證是否設定了正確的 AWS 區域\n \\u2022 檢查是否沒有將 AWS_DEFAULT_REGION 等環境變數設定為錯誤的區域\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔:\n \\u2022 驗證正在使用的任何憑證程序是否能夠擷取憑證或憑證是否未過期\n \\u2022 您可以執行下列命令進行檢查: aws sts get-caller-identity --profile ", + "There is a configuration issue with the profile '{profile}'.\n\nTo resolve this issue:\n\u2022 Verify your AWS config and credentials files are correct\n \u2022 By default these files can be found in ~/.aws on Linux/MacOS or %USERPROFILE%/.aws on Windows\n\u2022 Verify that the correct AWS region is set\n \u2022 Check that no environment variables like AWS_DEFAULT_REGION are set to an incorrect region\n\u2022 If you are not using a Deadline Cloud Monitor profile:\n \u2022 Verify that any credential process being used is able to retrieve the credentials or that they aren't expired\n \u2022 You can run the following command to check: aws sts get-caller-identity --profile ": "設定檔 '{profile}' 存在組態問題。\n\n若要解決此問題:\n\\u2022 驗證您的 AWS 組態和憑證檔案是否正確\n \\u2022 根據預設,這些檔案可以在 Linux/MacOS 上的 ~/.aws 或 Windows 上的 %USERPROFILE%/.aws 中找到\n\\u2022 驗證是否設定了正確的 AWS 區域\n \\u2022 檢查是否沒有將 AWS_DEFAULT_REGION 等環境變數設定為錯誤的區域\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔:\n \\u2022 驗證正在使用的任何憑證程序是否能夠擷取憑證或憑證是否未過期\n \\u2022 您可以執行下列命令進行檢查: aws sts get-caller-identity --profile ", "There was an error with authentication": "身分驗證發生錯誤", - "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n • In the terminal that the dialog or software the submitter is running in was launched from • In the built-in console within the software that the submitter is running in": "嘗試使用設定檔 '{profile}' 進行身分驗證時發生未知問題。\n\n檢查任何可用的主控台日誌以查找錯誤,以嘗試診斷問題。\n日誌通常位於:\n \\u2022 在啟動對話方塊或提交者正在執行的軟體的終端機中 \\u2022 在提交者正在執行的軟體內的內建主控台中", + "There was an unknown issue when trying to authenticate with the profile '{profile}'.\n\nCheck any available console logs for errors to try and diagnose the problem.\nLogs are commonly found:\n \u2022 In the terminal that the dialog or software the submitter is running in was launched from \u2022 In the built-in console within the software that the submitter is running in": "嘗試使用設定檔 '{profile}' 進行身分驗證時發生未知問題。\n\n檢查任何可用的主控台日誌以查找錯誤,以嘗試診斷問題。\n日誌通常位於:\n \\u2022 在啟動對話方塊或提交者正在執行的軟體的終端機中 \\u2022 在提交者正在執行的軟體內的內建主控台中", "Timeouts": "逾時", "Unknown Issue With Configured Profile": "設定的設定檔存在未知問題", + "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 現已可用。", + "View release notes": "檢視版本資訊", "Unrecognized Parameters": "無法識別的參數", "Upload progress": "上傳進度", "Use array parameter": "使用陣列參數", "Value(s)": "值", - "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Deadline Cloud for {integration_name} 提交器版本 {latest_version} 現已可用。", - "View release notes": "檢視版本資訊", - "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n• Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n• If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n • deadline:AssumeQueueRoleForUser\n • deadline:CreateJob\n • deadline:GetJob\n • deadline:GetQueue\n • deadline:GetQueueEnvironment\n • deadline:GetStorageProfileForQueue\n • deadline:GetStorageProfile\n • deadline:ListFarms\n • deadline:ListQueues\n • deadline:ListQueueEnvironments\n • deadline:ListStorageProfilesForQueue": "您已使用設定檔 '{profile}' 進行身分驗證,但此設定檔無法呼叫 AWS Deadline Cloud ListFarms 並且無法向 AWS Deadline Cloud 提交任務。\n\n若要解決此問題:\n\\u2022 檢查是否沒有指向錯誤 AWS 區域的環境變數 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔,請檢查設定檔是否具有提交所需的這些 AWS Deadline Cloud API 的許可:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", + "You are authenticated with the profile '{profile}', but this profile is unable to call AWS Deadline Cloud ListFarms and unable to submit jobs to AWS Deadline Cloud.\n\nTo resolve this issue:\n\u2022 Check that there aren't any environment variables pointing to the wrong AWS region (e.g., AWS_DEFAULT_REGION)\n\u2022 If you are not using a Deadline Cloud Monitor profile, check that the profile has permissions for these AWS Deadline Cloud APIs needed for submitting:\n \u2022 deadline:AssumeQueueRoleForUser\n \u2022 deadline:CreateJob\n \u2022 deadline:GetJob\n \u2022 deadline:GetQueue\n \u2022 deadline:GetQueueEnvironment\n \u2022 deadline:GetStorageProfileForQueue\n \u2022 deadline:GetStorageProfile\n \u2022 deadline:ListFarms\n \u2022 deadline:ListQueues\n \u2022 deadline:ListQueueEnvironments\n \u2022 deadline:ListStorageProfilesForQueue": "您已使用設定檔 '{profile}' 進行身分驗證,但此設定檔無法呼叫 AWS Deadline Cloud ListFarms 並且無法向 AWS Deadline Cloud 提交任務。\n\n若要解決此問題:\n\\u2022 檢查是否沒有指向錯誤 AWS 區域的環境變數 (例如 AWS_DEFAULT_REGION)\n\\u2022 如果您未使用 Deadline Cloud Monitor 設定檔,請檢查設定檔是否具有提交所需的這些 AWS Deadline Cloud API 的許可:\n \\u2022 deadline:AssumeQueueRoleForUser\n \\u2022 deadline:CreateJob\n \\u2022 deadline:GetJob\n \\u2022 deadline:GetQueue\n \\u2022 deadline:GetQueueEnvironment\n \\u2022 deadline:GetStorageProfileForQueue\n \\u2022 deadline:GetStorageProfile\n \\u2022 deadline:ListFarms\n \\u2022 deadline:ListQueues\n \\u2022 deadline:ListQueueEnvironments\n \\u2022 deadline:ListStorageProfilesForQueue", "{profile} - You are logged out.": "{profile} - 您已登出。", "{profile} doesn't have access permissions to submit a job.": "{profile} 沒有提交任務的存取許可。", - "{submitter} job submission": "{submitter} 任務提交" + "{submitter} job submission": "{submitter} 任務提交", + "Default maximum retries per task": "\u6bcf\u500b\u4efb\u52d9\u7684\u9810\u8a2d\u91cd\u8a66\u6b21\u6578\u4e0a\u9650", + "Default maximum failed tasks count": "\u9810\u8a2d\u5931\u6557\u4efb\u52d9\u6578\u4e0a\u9650", + "Browse Job Bundles": "瀏覽工作套件", + "History": "歷史記錄", + "Job bundle directory": "工作套件目錄", + "Local": "本機", + "Name": "名稱", + "Parameters:": "參數:", + "Path:": "路徑:", + "Select": "選取", + "Select a job bundle to see details": "選取工作套件以查看詳細資訊", + "Source:": "來源:", + "Steps:": "步驟:" } diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index b1b0c0356..7165232a9 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -6,7 +6,10 @@ from __future__ import annotations +import atexit import os +import shutil +import tempfile from logging import getLogger from typing import Any, Optional @@ -18,6 +21,7 @@ ) from ..dataclasses import JobBundleSettings +from ...config import get_setting from .openjd_parameters_widget import OpenJDParametersWidget from ...job_bundle.submission import AssetReferences from ...job_bundle.loader import read_yaml_or_json_object, validate_directory_symlink_containment @@ -79,7 +83,6 @@ def on_load_bundle(self): Browse and load the selected submission bundle """ from ..dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog - from ...config import get_setting # Determine the default local browse directory default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") @@ -127,20 +130,12 @@ def on_load_bundle(self): if browser.selected_is_archive: input_job_bundle_dir = browser.s3_repo.resolve_bundle(browser.selected_path, "") else: - import tempfile - import atexit - import shutil - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") atexit.register(shutil.rmtree, temp_dir, True) input_job_bundle_dir = browser.s3_repo.resolve_bundle( browser.selected_path, temp_dir ) elif browser.selected_is_archive: - import tempfile - import atexit - import shutil - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") atexit.register(shutil.rmtree, temp_dir, True) input_job_bundle_dir = browser._local_repo.extract_bundle( diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 954113337..294621634 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -45,8 +45,8 @@ def test_list_local_no_archives(self, tmp_path): bundle.mkdir() (bundle / "template.yaml").write_text("name: Dir\nsteps: []\n") - zip_path = tmp_path / "archive-bundle.zip" - with zipfile.ZipFile(str(zip_path), "w") as zf: + ojd_path = tmp_path / "archive-bundle.ojd" + with zipfile.ZipFile(str(ojd_path), "w") as zf: zf.writestr("template.yaml", "name: Zipped\nsteps: []\n") runner = CliRunner() @@ -98,28 +98,6 @@ def test_upload_creates_zip(self, mock_boto3_client, mock_s3_settings, mock_conf assert "Uploaded bundle to" in result.output mock_s3.upload_fileobj.assert_called_once() - @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") - @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") - @patch("boto3.client") - def test_upload_no_archive(self, mock_boto3_client, mock_s3_settings, mock_config, tmp_path): - bundle = tmp_path / "my-bundle" - bundle.mkdir() - (bundle / "template.yaml").write_text("name: Test\nsteps: []\n") - (bundle / "script.sh").write_text("echo hello") - - mock_s3_settings.return_value = MagicMock( - s3BucketName="test-bucket", rootPrefix="DeadlineCloud" - ) - mock_s3 = MagicMock() - mock_boto3_client.return_value = mock_s3 - - runner = CliRunner() - result = runner.invoke(main, ["bundle", "upload", str(bundle), "--no-archive"]) - - assert result.exit_code == 0, result.output - assert "Uploaded 2 files" in result.output - assert mock_s3.upload_file.call_count == 2 - @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index bc4e7b58d..1762b2a4a 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -2,6 +2,8 @@ """Tests for the job bundle repository module.""" +from __future__ import annotations + import json import os import zipfile diff --git a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py index 4c74af75f..8ef08bf65 100644 --- a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py @@ -8,6 +8,7 @@ try: from deadline.client.ui.dataclasses import JobBundleSettings from deadline.client.ui.widgets.job_bundle_settings_tab import JobBundleSettingsWidget + import deadline.client.ui.dialogs.job_bundle_browser_dialog # noqa: F401 - preload for patching except ImportError: pytest.importorskip("deadline.client.ui.widgets.job_bundle_settings_tab") @@ -44,10 +45,28 @@ def widget(qtbot, temp_job_bundle_dir): return w +BROWSER_DIALOG = "deadline.client.ui.dialogs.job_bundle_browser_dialog.JobBundleBrowserDialog" +WIDGET_MODULE = "deadline.client.ui.widgets.job_bundle_settings_tab" + + +def _patch_browser(selected_path=None, accepted=True): + """Patch JobBundleBrowserDialog with a mock class that returns a configured instance.""" + mock_instance = MagicMock() + mock_instance.exec_.return_value = 1 if accepted else 0 + mock_instance.selected_path = selected_path + mock_instance.selected_is_s3 = False + mock_instance.selected_is_archive = False + mock_instance.s3_repo = None + + mock_cls = MagicMock(return_value=mock_instance) + mock_cls.Accepted = 1 + return patch(BROWSER_DIALOG, mock_cls) + + def test_on_load_bundle_loads_new_bundle_and_refreshes_dialog( widget, qtbot, fresh_deadline_config, tmp_path ): - """Clicking 'Load a different job bundle' opens a file picker, loads the + """Clicking 'Load a different job bundle' opens the browser dialog, loads the chosen bundle, and pushes its settings into the parent dialog via refresh(). """ second_bundle = tmp_path / "second_bundle" @@ -56,36 +75,25 @@ def test_on_load_bundle_loads_new_bundle_and_refreshes_dialog( parent_dialog = MagicMock() - with ( - patch( - "deadline.client.ui.widgets.job_bundle_settings_tab.QFileDialog.getExistingDirectory", - return_value=str(second_bundle), - ), - patch.object(widget, "window", return_value=parent_dialog), + with _patch_browser(selected_path=str(second_bundle)), patch.object( + widget, "window", return_value=parent_dialog ): widget.on_load_bundle() - assert widget.input_job_bundle_dir == str(second_bundle) + assert os.path.realpath(widget.input_job_bundle_dir) == os.path.realpath(str(second_bundle)) parent_dialog.refresh.assert_called_once() kwargs = parent_dialog.refresh.call_args.kwargs assert kwargs["load_new_bundle"] is True - assert kwargs["job_settings"].input_job_bundle_dir == str(second_bundle) assert kwargs["job_settings"].name == "Second Bundle" def test_on_load_bundle_cancelled_dialog_is_noop(widget, qtbot): - """If the user cancels the file picker, nothing changes.""" + """If the user cancels the browser dialog, nothing changes.""" original_dir = widget.input_job_bundle_dir parent_dialog = MagicMock() - with ( - patch( - "deadline.client.ui.widgets.job_bundle_settings_tab.QFileDialog.getExistingDirectory", - return_value="", - ), - patch.object(widget, "window", return_value=parent_dialog), - ): + with _patch_browser(accepted=False), patch.object(widget, "window", return_value=parent_dialog): widget.on_load_bundle() assert widget.input_job_bundle_dir == original_dir @@ -102,16 +110,11 @@ def test_on_load_bundle_invalid_bundle_shows_warning( parent_dialog = MagicMock() - with ( - patch( - "deadline.client.ui.widgets.job_bundle_settings_tab.QFileDialog.getExistingDirectory", - return_value=str(bad_bundle), - ), - patch.object(widget, "window", return_value=parent_dialog), - patch( - "deadline.client.ui.widgets.job_bundle_settings_tab.QMessageBox.warning" - ) as mock_warning, - ): + with _patch_browser(selected_path=str(bad_bundle)), patch.object( + widget, "window", return_value=parent_dialog + ), patch( + "deadline.client.ui.widgets.job_bundle_settings_tab.QMessageBox.warning" + ) as mock_warning: widget.on_load_bundle() mock_warning.assert_called_once() From 035795a12f51afa43dfe021fa59ed34f5fc66a5c Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:18:04 -0500 Subject: [PATCH 12/89] chore: swap from 's3 job bundles', to 'queue job bundles' Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 24 +++++++++---------- .../ui/dialogs/job_bundle_browser_dialog.py | 14 +++++------ .../dialogs/submit_job_to_deadline_dialog.py | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 10cbb34d3..d182d505f 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -562,10 +562,10 @@ def _get_queue_s3_settings(config): @cli_bundle.command(name="list") @click.argument("path", required=False) @click.option( - "--s3", - "use_s3", + "--queue", + "use_queue", is_flag=True, - help="List bundles from the queue's S3 job-bundles folder.", + help="List bundles shared on the queue.", ) @click.option( "--no-archives", @@ -582,7 +582,7 @@ def _get_queue_s3_settings(config): help="Output format. TEXT prints one name per line, JSON prints full details.", ) @_handle_error -def bundle_list(path, use_s3, no_archives, output, **args): +def bundle_list(path, use_queue, no_archives, output, **args): """ List job bundles. @@ -590,10 +590,10 @@ def bundle_list(path, use_s3, no_archives, output, **args): With no arguments, lists bundles in the configured default local directory (settings.job_bundle_default_directory, or home if not set). With PATH, lists bundles in that local directory. - With --s3, lists bundles from the queue's S3 job-bundles folder. + With --queue, lists bundles shared on the queue. """ - if use_s3: + if use_queue: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) repo: BundleRepository = S3BundleRepository( @@ -632,7 +632,7 @@ def bundle_list(path, use_s3, no_archives, output, **args): @cli_bundle.group(name="cache") @_handle_error def cli_bundle_cache(): - """Manage the local cache of S3 job bundles.""" + """Manage the local cache of queue job bundles.""" @cli_bundle_cache.command(name="clean") @@ -640,7 +640,7 @@ def cli_bundle_cache(): @click.option("--dry-run", is_flag=True, help="Show what would be removed without deleting.") @_handle_error def bundle_cache_clean(bundle_name, dry_run): - """Remove cached S3 bundle archives from the local cache.""" + """Remove cached queue bundle archives from the local cache.""" cache_root = _get_bundle_cache_dir() if not os.path.isdir(cache_root): @@ -698,7 +698,7 @@ def bundle_cache_clean(bundle_name, dry_run): @click.option("--queue-id", help="The queue to use.") @_handle_error def bundle_cache_update(bundle_name, **args): - """Re-download any stale cached bundles from S3 by checking ETags.""" + """Re-download any stale cached bundles from the queue by checking ETags.""" config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -773,12 +773,12 @@ def bundle_cache_update(bundle_name, **args): @click.option("--queue-id", help="The queue to use.") @click.option( "--name", - help="Name for the archive in S3. Defaults to the bundle directory name.", + help="Name for the shared archive on the queue. Defaults to the bundle directory name.", ) @_handle_error def bundle_upload(job_bundle_dir, name, **args): """ - Upload a job bundle to the queue's S3 job-bundles folder as an .ojd archive. + Upload a job bundle to share on the queue as an .ojd archive. """ config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) s3_settings = _get_queue_s3_settings(config) @@ -856,7 +856,7 @@ def bundle_upload(job_bundle_dir, name, **args): @_handle_error def bundle_download(bundle_name, output_dir, **args): """ - Download a job bundle from the queue's S3 job-bundles folder. + Download a shared job bundle from the queue. BUNDLE_NAME is the name of the bundle (e.g. 'blender-render'). """ diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 40e204097..6de347741 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -47,7 +47,7 @@ class JobBundleBrowserDialog(QDialog): """ - A dialog for browsing and selecting job bundles from local filesystem or S3. + A dialog for browsing and selecting job bundles from local filesystem or queue. Args: local_root: Default local directory to browse. @@ -192,20 +192,20 @@ def _build_ui(self): bottom_layout = QVBoxLayout() bottom_layout.setContentsMargins(0, 8, 0, 0) - # Source toggle row — S3 first (primary use case), then History, then Local + # Source toggle row — Queue first (primary use case), then History, then Local source_row = QHBoxLayout() source_label = QLabel(tr("Source:")) source_row.addWidget(source_label) if self._s3_repo: - s3_label = f"S3 ({self._s3_repo._bucket})" + queue_label = tr("Queue") elif self._s3_error: - s3_label = "\u26a0 S3" + queue_label = "\u26a0 " + tr("Queue") else: - s3_label = "S3 (not configured)" - self._radio_s3 = QRadioButton(s3_label) + queue_label = tr("Queue") + " (not configured)" + self._radio_s3 = QRadioButton(queue_label) self._radio_s3.setEnabled(self._s3_available) if not self._s3_available and self._s3_error: - self._radio_s3.setToolTip(f"S3 unavailable: {self._s3_error}") + self._radio_s3.setToolTip(f"Queue unavailable: {self._s3_error}") self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) self._radio_history = QRadioButton(tr("History")) 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 7192a7d87..3515a60a2 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 @@ -625,7 +625,7 @@ def on_export_bundle(self): ) # type: ignore[call-arg] def on_share_bundle(self): - """Archive the current bundle and upload it to the queue's S3 job-bundles folder.""" + """Archive the current bundle and share it on the queue.""" import io import zipfile From 4a1d960b321e0b621cd80a841050a1ec454e7b24 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:38:55 -0500 Subject: [PATCH 13/89] fix: use config creds for queue bundles Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 22 +++++++------ .../ui/dialogs/job_bundle_browser_dialog.py | 3 +- .../dialogs/submit_job_to_deadline_dialog.py | 31 +++++++++---------- .../client/ui/job_bundle_submitter.py | 18 ++++++----- .../ui/widgets/job_bundle_settings_tab.py | 11 ++++--- .../cli/test_cli_bundle_repository.py | 15 +++++---- .../widgets/test_job_bundle_settings_tab.py | 17 +++++----- 7 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index d182d505f..ce1c7b33b 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -18,7 +18,6 @@ import os from dataclasses import fields -import boto3 import click from botocore.exceptions import ClientError @@ -41,6 +40,7 @@ AssetSyncCancelledError, MisconfiguredInputsError, ) +from ....job_attachments._aws.deadline import get_queue from ....job_attachments.models import JobAttachmentsFileSystem from ...exceptions import DeadlineOperationError, CreateJobWaiterCanceled @@ -543,20 +543,19 @@ def _print_response( def _get_queue_s3_settings(config): """Get the queue's job attachment S3 settings from config.""" - from ....job_attachments._aws.deadline import get_queue - farm_id = config_file.get_setting("defaults.farm_id", config=config) queue_id = config_file.get_setting("defaults.queue_id", config=config) if not farm_id or not queue_id: raise DeadlineOperationError( "A default farm and queue must be configured. Run 'deadline config set defaults.farm_id ' and 'deadline config set defaults.queue_id '." ) - queue = get_queue(farm_id=farm_id, queue_id=queue_id) + boto3_session = api.get_boto3_session(config=config) + queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) if not queue.jobAttachmentSettings: raise DeadlineOperationError( f"Queue {queue_id} does not have job attachment settings configured." ) - return queue.jobAttachmentSettings + return queue.jobAttachmentSettings, boto3_session @cli_bundle.command(name="list") @@ -595,10 +594,11 @@ def bundle_list(path, use_queue, no_archives, output, **args): if use_queue: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings = _get_queue_s3_settings(config) + s3_settings, boto3_session = _get_queue_s3_settings(config) repo: BundleRepository = S3BundleRepository( bucket_name=s3_settings.s3BucketName, root_prefix=s3_settings.rootPrefix, + session=boto3_session, ) else: if path: @@ -701,11 +701,12 @@ def bundle_cache_update(bundle_name, **args): """Re-download any stale cached bundles from the queue by checking ETags.""" config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings = _get_queue_s3_settings(config) + s3_settings, boto3_session = _get_queue_s3_settings(config) repo = S3BundleRepository( bucket_name=s3_settings.s3BucketName, root_prefix=s3_settings.rootPrefix, + session=boto3_session, ) # List remote bundles to match against cache @@ -781,7 +782,7 @@ def bundle_upload(job_bundle_dir, name, **args): Upload a job bundle to share on the queue as an .ojd archive. """ config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings = _get_queue_s3_settings(config) + s3_settings, boto3_session = _get_queue_s3_settings(config) job_bundle_dir = os.path.abspath(job_bundle_dir) if not is_job_bundle_dir(job_bundle_dir): @@ -819,7 +820,7 @@ def bundle_upload(job_bundle_dir, name, **args): bundle_name = name or os.path.basename(job_bundle_dir) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" - s3 = boto3.client("s3") + s3 = boto3_session.client("s3") # Archive and upload buf = io.BytesIO() @@ -862,11 +863,12 @@ def bundle_download(bundle_name, output_dir, **args): """ config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings = _get_queue_s3_settings(config) + s3_settings, boto3_session = _get_queue_s3_settings(config) repo = S3BundleRepository( bucket_name=s3_settings.s3BucketName, root_prefix=s3_settings.rootPrefix, + session=boto3_session, ) output_dir = os.path.abspath(output_dir) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 6de347741..9e87410e4 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -65,6 +65,7 @@ def __init__( s3_root_prefix: str = "", s3_error: str = "", job_history_dir: str = "", + session=None, parent: Optional[QWidget] = None, ): super().__init__(parent=parent) @@ -78,7 +79,7 @@ def __init__( self._s3_available = bool(s3_bucket_name) if s3_bucket_name: self._s3_repo = S3BundleRepository( - bucket_name=s3_bucket_name, root_prefix=s3_root_prefix + bucket_name=s3_bucket_name, root_prefix=s3_root_prefix, session=session ) self._history_dir = job_history_dir 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 3515a60a2..fd04b49e6 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 @@ -5,10 +5,12 @@ from __future__ import annotations +import io import logging import os import sys import json +import zipfile from typing import Any, Dict, Optional, Protocol import yaml @@ -42,6 +44,13 @@ from ...job_bundle import create_job_history_bundle_dir from ...job_bundle.parameters import JobParameter from ...job_bundle.submission import AssetReferences +from ...job_bundle.repository import ( + S3_JOB_BUNDLES_PREFIX, + LocalBundleRepository, + _extract_bundle_info, + _parse_template, +) +from ....job_attachments._aws.deadline import get_queue from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget from ..widgets.job_attachments_tab import JobAttachmentsWidget from ..widgets.shared_job_settings_tab import SharedJobSettingsWidget @@ -626,19 +635,6 @@ def on_export_bundle(self): def on_share_bundle(self): """Archive the current bundle and share it on the queue.""" - import io - import zipfile - - import boto3 - - from ...config import get_setting - from ....job_attachments._aws.deadline import get_queue - from ...job_bundle.repository import ( - S3_JOB_BUNDLES_PREFIX, - LocalBundleRepository, - _extract_bundle_info, - _parse_template, - ) # First export the bundle locally settings = self.job_settings_type() @@ -678,7 +674,8 @@ def on_share_bundle(self): try: farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") - queue_obj = get_queue(farm_id=farm_id, queue_id=queue_id) + boto3_session = api.get_boto3_session() + queue_obj = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) if not queue_obj.jobAttachmentSettings: QMessageBox.warning( self, "Share failed", "Queue does not have job attachment settings configured." @@ -749,7 +746,7 @@ def on_share_bundle(self): zf.write(local_path, arcname) buf.seek(0) - s3 = boto3.client("s3") + s3 = boto3_session.client("s3") s3.upload_fileobj( buf, s3_settings.s3BucketName, @@ -759,8 +756,8 @@ def on_share_bundle(self): QMessageBox.information( self, - "Shared to S3", - f"Bundle shared to:\ns3://{s3_settings.s3BucketName}/{s3_key}", + "Shared", + f"Bundle shared to queue:\ns3://{s3_settings.s3BucketName}/{s3_key}", ) except Exception as exc: QMessageBox.critical(self, "Share failed", f"Failed to upload bundle:\n{exc}") diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 83bcd94ca..0b9125355 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -47,6 +47,10 @@ from .widgets.job_bundle_settings_tab import JobBundleSettingsWidget from ..job_bundle.submission import AssetReferences from ..api._session import session_context +from .. import api +from ...job_attachments._aws.deadline import get_queue +from ..config import get_setting +from .dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog logger = getLogger(__name__) @@ -214,9 +218,6 @@ def show_job_bundle_submitter( parent = main_windows[0] if not input_job_bundle_dir: - from .dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog - from ..config import get_setting - # Determine the default local browse directory default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") if not default_dir: @@ -224,17 +225,17 @@ def show_job_bundle_submitter( if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue's S3 bucket for S3 browsing + # Try to get the queue's S3 bucket for queue browsing s3_bucket = "" s3_prefix = "" s3_error = "" + boto3_session = None try: farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") if farm_id and queue_id: - from ...job_attachments._aws.deadline import get_queue - - queue = get_queue(farm_id=farm_id, queue_id=queue_id) + boto3_session = api.get_boto3_session() + queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) if queue.jobAttachmentSettings: s3_bucket = queue.jobAttachmentSettings.s3BucketName s3_prefix = queue.jobAttachmentSettings.rootPrefix @@ -243,7 +244,7 @@ def show_job_bundle_submitter( else: s3_error = "No farm or queue configured" except Exception as e: - logger.debug("Could not retrieve queue S3 settings for bundle browser", exc_info=True) + logger.debug("Could not retrieve queue settings for bundle browser", exc_info=True) s3_error = str(e) # Get the job history directory for the current profile @@ -255,6 +256,7 @@ def show_job_bundle_submitter( s3_root_prefix=s3_prefix, s3_error=s3_error, job_history_dir=job_history_dir, + session=boto3_session, parent=parent, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 7165232a9..2d9a5ad96 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -22,6 +22,8 @@ from ..dataclasses import JobBundleSettings from ...config import get_setting +from ... import api +from ....job_attachments._aws.deadline import get_queue from .openjd_parameters_widget import OpenJDParametersWidget from ...job_bundle.submission import AssetReferences from ...job_bundle.loader import read_yaml_or_json_object, validate_directory_symlink_containment @@ -91,17 +93,17 @@ def on_load_bundle(self): if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue's S3 bucket for S3 browsing + # Try to get the queue's S3 bucket for queue browsing s3_bucket = "" s3_prefix = "" s3_error = "" + boto3_session = None try: farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") if farm_id and queue_id: - from ....job_attachments._aws.deadline import get_queue - - queue = get_queue(farm_id=farm_id, queue_id=queue_id) + boto3_session = api.get_boto3_session() + queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) if queue.jobAttachmentSettings: s3_bucket = queue.jobAttachmentSettings.s3BucketName s3_prefix = queue.jobAttachmentSettings.rootPrefix @@ -121,6 +123,7 @@ def on_load_bundle(self): s3_root_prefix=s3_prefix, s3_error=s3_error, job_history_dir=job_history_dir, + session=boto3_session, parent=self, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 294621634..5ddc0d51f 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -85,11 +85,13 @@ def test_upload_creates_zip(self, mock_boto3_client, mock_s3_settings, mock_conf ) ) - mock_s3_settings.return_value = MagicMock( - s3BucketName="test-bucket", rootPrefix="DeadlineCloud" - ) + mock_session = MagicMock() mock_s3 = MagicMock() - mock_boto3_client.return_value = mock_s3 + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DeadlineCloud"), + mock_session, + ) runner = CliRunner() result = runner.invoke(main, ["bundle", "upload", str(bundle)]) @@ -104,8 +106,9 @@ def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): not_bundle = tmp_path / "empty" not_bundle.mkdir() - mock_s3_settings.return_value = MagicMock( - s3BucketName="test-bucket", rootPrefix="DeadlineCloud" + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DeadlineCloud"), + MagicMock(), ) runner = CliRunner() diff --git a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py index 8ef08bf65..d7050718c 100644 --- a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py @@ -75,8 +75,9 @@ def test_on_load_bundle_loads_new_bundle_and_refreshes_dialog( parent_dialog = MagicMock() - with _patch_browser(selected_path=str(second_bundle)), patch.object( - widget, "window", return_value=parent_dialog + with ( + _patch_browser(selected_path=str(second_bundle)), + patch.object(widget, "window", return_value=parent_dialog), ): widget.on_load_bundle() @@ -110,11 +111,13 @@ def test_on_load_bundle_invalid_bundle_shows_warning( parent_dialog = MagicMock() - with _patch_browser(selected_path=str(bad_bundle)), patch.object( - widget, "window", return_value=parent_dialog - ), patch( - "deadline.client.ui.widgets.job_bundle_settings_tab.QMessageBox.warning" - ) as mock_warning: + with ( + _patch_browser(selected_path=str(bad_bundle)), + patch.object(widget, "window", return_value=parent_dialog), + patch( + "deadline.client.ui.widgets.job_bundle_settings_tab.QMessageBox.warning" + ) as mock_warning, + ): widget.on_load_bundle() mock_warning.assert_called_once() From 402d9f595b7ac718387064880fcb437cae8abde7 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:44:04 -0500 Subject: [PATCH 14/89] chore: move source selection to top of browser Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 9e87410e4..a13edef5f 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -118,6 +118,39 @@ def s3_repo(self) -> Optional[S3BundleRepository]: def _build_ui(self): layout = QVBoxLayout(self) + # Source toggle row — at the top so users select source before browsing + source_row = QHBoxLayout() + source_label = QLabel(tr("Source:")) + source_row.addWidget(source_label) + if self._s3_repo: + queue_label = tr("Queue") + elif self._s3_error: + queue_label = "\u26a0 " + tr("Queue") + else: + queue_label = tr("Queue") + " (not configured)" + self._radio_s3 = QRadioButton(queue_label) + self._radio_s3.setEnabled(self._s3_available) + if not self._s3_available and self._s3_error: + self._radio_s3.setToolTip(f"Queue unavailable: {self._s3_error}") + self._radio_s3.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_s3) + self._radio_local = QRadioButton(tr("Local")) + self._radio_local.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_local) + self._radio_history = QRadioButton(tr("History")) + self._radio_history.setEnabled(self._history_repo is not None) + self._radio_history.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_history) + source_row.addStretch() + layout.addLayout(source_row) + + # Default to Queue if available, otherwise Local + if self._s3_available: + self._radio_s3.setChecked(True) + self._current_repo = self._s3_repo + else: + self._radio_local.setChecked(True) + # Main splitter: tree on left, preview on right splitter = QSplitter(Qt.Horizontal) layout.addWidget(splitter, stretch=1) @@ -189,43 +222,10 @@ def _build_ui(self): splitter.addWidget(preview_scroll) splitter.setSizes([350, 350]) - # Bottom: source toggle + path + buttons + # Bottom: path display + buttons bottom_layout = QVBoxLayout() bottom_layout.setContentsMargins(0, 8, 0, 0) - # Source toggle row — Queue first (primary use case), then History, then Local - source_row = QHBoxLayout() - source_label = QLabel(tr("Source:")) - source_row.addWidget(source_label) - if self._s3_repo: - queue_label = tr("Queue") - elif self._s3_error: - queue_label = "\u26a0 " + tr("Queue") - else: - queue_label = tr("Queue") + " (not configured)" - self._radio_s3 = QRadioButton(queue_label) - self._radio_s3.setEnabled(self._s3_available) - if not self._s3_available and self._s3_error: - self._radio_s3.setToolTip(f"Queue unavailable: {self._s3_error}") - self._radio_s3.toggled.connect(self._on_source_changed) - source_row.addWidget(self._radio_s3) - self._radio_history = QRadioButton(tr("History")) - self._radio_history.setEnabled(self._history_repo is not None) - self._radio_history.toggled.connect(self._on_source_changed) - source_row.addWidget(self._radio_history) - self._radio_local = QRadioButton(tr("Local")) - self._radio_local.toggled.connect(self._on_source_changed) - source_row.addWidget(self._radio_local) - source_row.addStretch() - bottom_layout.addLayout(source_row) - - # Default to S3 if available, otherwise Local - if self._s3_available: - self._radio_s3.setChecked(True) - self._current_repo = self._s3_repo - else: - self._radio_local.setChecked(True) - # Path row path_row = QHBoxLayout() path_label = QLabel(tr("Path:")) From 5a2873622095913d5cb350f4818ed8ffed2a43a3 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:52:41 -0500 Subject: [PATCH 15/89] feat: add 'show hidden folders' option Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 22 +++++++++++++++++-- .../client/ui/translations/locales/de_DE.json | 1 + .../client/ui/translations/locales/en_US.json | 1 + .../client/ui/translations/locales/es_ES.json | 1 + .../client/ui/translations/locales/fr_FR.json | 1 + .../client/ui/translations/locales/id_ID.json | 1 + .../client/ui/translations/locales/it_IT.json | 1 + .../client/ui/translations/locales/ja_JP.json | 1 + .../client/ui/translations/locales/ko_KR.json | 1 + .../client/ui/translations/locales/pt_BR.json | 1 + .../client/ui/translations/locales/tr_TR.json | 1 + .../client/ui/translations/locales/zh_CN.json | 1 + .../client/ui/translations/locales/zh_TW.json | 1 + 13 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index a13edef5f..6b6d31cd1 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -14,6 +14,7 @@ from qtpy.QtCore import Qt, QModelIndex, QSortFilterProxyModel, QTimer, Signal # type: ignore from qtpy.QtGui import QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore + QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, @@ -144,6 +145,12 @@ def _build_ui(self): source_row.addStretch() layout.addLayout(source_row) + # Show hidden folders checkbox + self._show_hidden_cb = QCheckBox(tr("Show hidden folders"), parent=self) + self._show_hidden_cb.setChecked(False) + self._show_hidden_cb.toggled.connect(self._on_hidden_toggled) + layout.addWidget(self._show_hidden_cb) + # Default to Queue if available, otherwise Local if self._s3_available: self._radio_s3.setChecked(True) @@ -248,6 +255,12 @@ def _build_ui(self): # ── Tree Population ────────────────────────────────────────── + def _filter_entries(self, entries: list) -> list: + """Filter out hidden entries (names starting with '.') unless show hidden is checked.""" + if self._show_hidden_cb.isChecked(): + return entries + return [e for e in entries if not e.name.startswith(".")] + def _populate_root(self): self._model.clear() self._model.setHorizontalHeaderLabels([tr("Name")]) @@ -260,7 +273,7 @@ def _populate_root(self): self._show_error_preview(f"Failed to list bundles:\n{e}") entries = [] root = self._model.invisibleRootItem() - for entry in entries: + for entry in self._filter_entries(entries): self._add_entry_item(root, entry) def _add_entry_item(self, parent_item: QStandardItem, entry: BrowseEntry): @@ -303,7 +316,7 @@ def _on_expanded(self, proxy_index: QModelIndex): error_item.setEnabled(False) item.appendRow(error_item) return - for entry in entries: + for entry in self._filter_entries(entries): self._add_entry_item(item, entry) def _on_clicked(self, proxy_index: QModelIndex): @@ -393,6 +406,11 @@ def _on_source_changed(self, checked: bool): self._clear_preview() self._populate_root() + def _on_hidden_toggled(self, checked: bool): + if not self._ready: + return + self._populate_root() + # ── Preview ────────────────────────────────────────────────── def _load_preview(self, path: str, item: Optional[QStandardItem] = None): diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index 68a028e50..b5548120f 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -112,6 +112,7 @@ "Settings...": "Einstellungen...", "Shared job settings": "Gemeinsame Jobeinstellungen", "Show auto-detected": "Automatisch erkannte anzeigen", + "Show hidden folders": "Versteckte Ordner anzeigen", "Show submitter update notifications": "Aktualisierungsbenachrichtigungen des Submitters anzeigen", "Specify a job bundle directory or run the bundle command with the --browse flag": "Geben Sie ein Jobpaket-Verzeichnis an oder führen Sie den Bundle-Befehl mit dem Flag --browse aus", "Specify output directories": "Ausgabeverzeichnisse angeben", diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index d975870f4..30739d0b4 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -112,6 +112,7 @@ "Settings...": "Settings...", "Shared job settings": "Shared job settings", "Show auto-detected": "Show auto-detected", + "Show hidden folders": "Show hidden folders", "Show submitter update notifications": "Show submitter update notifications", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specify a job bundle directory or run the bundle command with the --browse flag", "Specify output directories": "Specify output directories", diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index ec2c96b19..e948d49f5 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -112,6 +112,7 @@ "Settings...": "Configuración...", "Shared job settings": "Configuración de trabajo compartida", "Show auto-detected": "Mostrar detectados automáticamente", + "Show hidden folders": "Mostrar carpetas ocultas", "Show submitter update notifications": "Mostrar notificaciones de actualización del remitente", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique un directorio de paquete de trabajos o ejecute el comando bundle con la marca --browse", "Specify output directories": "Especificar directorios de salida", diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index b95a9a0a2..00baf4814 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -112,6 +112,7 @@ "Settings...": "Paramètres...", "Shared job settings": "Paramètres de tâche partagés", "Show auto-detected": "Afficher les éléments détectés automatiquement", + "Show hidden folders": "Afficher les dossiers cachés", "Show submitter update notifications": "Afficher les notifications de mise à jour du soumetteur", "Specify a job bundle directory or run the bundle command with the --browse flag": "Spécifiez un répertoire de lot de tâches ou exécutez la commande bundle avec l'indicateur --browse", "Specify output directories": "Spécifier les répertoires de sortie", diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index e99617c08..600f2c0b0 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -112,6 +112,7 @@ "Settings...": "Pengaturan...", "Shared job settings": "Pengaturan pekerjaan bersama", "Show auto-detected": "Tampilkan yang terdeteksi otomatis", + "Show hidden folders": "Tampilkan folder tersembunyi", "Show submitter update notifications": "Tampilkan notifikasi pembaruan pengirim", "Specify a job bundle directory or run the bundle command with the --browse flag": "Tentukan direktori bundel pekerjaan atau jalankan perintah bundle dengan flag --browse", "Specify output directories": "Tentukan direktori output", diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index 9cb435c52..861303e67 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -112,6 +112,7 @@ "Settings...": "Impostazioni...", "Shared job settings": "Impostazioni lavoro condivise", "Show auto-detected": "Mostra rilevati automaticamente", + "Show hidden folders": "Mostra cartelle nascoste", "Show submitter update notifications": "Mostra notifiche di aggiornamento del submitter", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specifica una directory pacchetto lavoro o esegui il comando bundle con il flag --browse", "Specify output directories": "Specifica directory di output", diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index abe799bde..2c47fcb63 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -112,6 +112,7 @@ "Settings...": "設定...", "Shared job settings": "共有ジョブ設定", "Show auto-detected": "自動検出されたものを表示", + "Show hidden folders": "隠しフォルダーを表示", "Show submitter update notifications": "サブミッターの更新通知を表示", "Specify a job bundle directory or run the bundle command with the --browse flag": "ジョブバンドルディレクトリを指定するか、--browse フラグを使用して bundle コマンドを実行してください", "Specify output directories": "出力ディレクトリを指定", diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index e3020a0f6..8a43e2f96 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -112,6 +112,7 @@ "Settings...": "설정...", "Shared job settings": "공유 작업 설정", "Show auto-detected": "자동 감지된 항목 표시", + "Show hidden folders": "숨겨진 폴더 표시", "Show submitter update notifications": "제출기 업데이트 알림 표시", "Specify a job bundle directory or run the bundle command with the --browse flag": "작업 번들 디렉터리를 지정하거나 --browse 플래그와 함께 bundle 명령을 실행하세요", "Specify output directories": "출력 디렉터리 지정", diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 23e8a57de..7d4c58338 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -112,6 +112,7 @@ "Settings...": "Configurações...", "Shared job settings": "Configurações de trabalho compartilhadas", "Show auto-detected": "Mostrar detectados automaticamente", + "Show hidden folders": "Mostrar pastas ocultas", "Show submitter update notifications": "Mostrar notificações de atualização do submissor", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique um diretório de pacote de tarefas ou execute o comando bundle com a flag --browse", "Specify output directories": "Especificar diretórios de saída", diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index 26f59fe1a..55b2bdc2a 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -112,6 +112,7 @@ "Settings...": "Ayarlar...", "Shared job settings": "Paylaşılan iş ayarları", "Show auto-detected": "Otomatik algılanmışları göster", + "Show hidden folders": "Gizli klasörleri göster", "Show submitter update notifications": "Gönderi aracı güncelleme bildirimlerini göster", "Specify a job bundle directory or run the bundle command with the --browse flag": "Bir iş paketi dizini belirtin veya bundle komutunu --browse bayrağıyla çalıştırın", "Specify output directories": "Çıkış dizinlerini belirtin", diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index b7561a650..80761477b 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -112,6 +112,7 @@ "Settings...": "设置...", "Shared job settings": "共享作业设置", "Show auto-detected": "显示自动检测的", + "Show hidden folders": "显示隐藏文件夹", "Show submitter update notifications": "显示提交器更新通知", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定作业捆绑包目录或使用 --browse 标志运行 bundle 命令", "Specify output directories": "指定输出目录", diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index e8f1a6d21..95159f0e5 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -112,6 +112,7 @@ "Settings...": "設定...", "Shared job settings": "共用任務設定", "Show auto-detected": "顯示自動偵測的", + "Show hidden folders": "顯示隱藏資料夾", "Show submitter update notifications": "顯示提交器更新通知", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定任務套件目錄或使用 --browse 旗標執行 bundle 命令", "Specify output directories": "指定輸出目錄", From 758b25cc071721ea0cf8a032b2e7e4c4dcc6741b Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:00:29 -0500 Subject: [PATCH 16/89] feat: Show bundle parameter preview as table Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 6b6d31cd1..b8cc0fdec 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -18,12 +18,15 @@ QDialog, QDialogButtonBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QPushButton, QRadioButton, QScrollArea, QSplitter, + QTableWidget, + QTableWidgetItem, QTreeView, QVBoxLayout, QWidget, @@ -216,10 +219,15 @@ def _build_ui(self): self._preview_params_label = QLabel(tr("Parameters:")) self._preview_params_label.setStyleSheet("font-weight: bold; margin-top: 8px;") preview_layout.addWidget(self._preview_params_label) - self._preview_params = QLabel() - self._preview_params.setWordWrap(True) - preview_layout.addWidget(self._preview_params) - preview_layout.addStretch(1) + self._preview_params = QTableWidget() + self._preview_params.setColumnCount(3) + self._preview_params.setHorizontalHeaderLabels(["Name", "Type", "Value"]) + self._preview_params.horizontalHeader().setStretchLastSection(True) + self._preview_params.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + self._preview_params.verticalHeader().setVisible(False) + self._preview_params.setEditTriggers(QTableWidget.NoEditTriggers) + self._preview_params.setSelectionMode(QTableWidget.NoSelection) + preview_layout.addWidget(self._preview_params, stretch=1) self._clear_preview() @@ -452,16 +460,12 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): if info.parameters: self._preview_params_label.setVisible(True) - lines = [] - for p in info.parameters: - pname = p.get("name", "?") - ptype = p.get("type", "?") - value = p.get("_display_value") - if value is not None: - lines.append(f" \u2022 {pname} ({ptype}) = {value}") - else: - lines.append(f" \u2022 {pname} ({ptype})") - self._preview_params.setText("\n".join(lines)) + self._preview_params.setRowCount(len(info.parameters)) + for row, p in enumerate(info.parameters): + self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) + self._preview_params.setItem(row, 1, QTableWidgetItem(p.get("type", "?"))) + value = p.get("_display_value", "") + self._preview_params.setItem(row, 2, QTableWidgetItem(str(value) if value else "")) self._preview_params.setVisible(True) else: self._preview_params_label.setVisible(False) @@ -474,6 +478,7 @@ def _clear_preview(self): self._preview_steps_label.setVisible(False) self._preview_steps.setVisible(False) self._preview_params_label.setVisible(False) + self._preview_params.setRowCount(0) self._preview_params.setVisible(False) def _mark_item_error(self, item: QStandardItem) -> None: From 0214c206ce3f6c6eaff962d1ba84851f3767378d Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:18:37 -0500 Subject: [PATCH 17/89] fix: warning label when queue bundles are not available Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index b8cc0fdec..99147cd10 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -126,16 +126,8 @@ def _build_ui(self): source_row = QHBoxLayout() source_label = QLabel(tr("Source:")) source_row.addWidget(source_label) - if self._s3_repo: - queue_label = tr("Queue") - elif self._s3_error: - queue_label = "\u26a0 " + tr("Queue") - else: - queue_label = tr("Queue") + " (not configured)" - self._radio_s3 = QRadioButton(queue_label) + self._radio_s3 = QRadioButton(tr("Queue")) self._radio_s3.setEnabled(self._s3_available) - if not self._s3_available and self._s3_error: - self._radio_s3.setToolTip(f"Queue unavailable: {self._s3_error}") self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) self._radio_local = QRadioButton(tr("Local")) @@ -148,6 +140,24 @@ def _build_ui(self): source_row.addStretch() layout.addLayout(source_row) + # Inline warning when queue source is unavailable + self._queue_warning = QLabel() + self._queue_warning.setWordWrap(True) + self._queue_warning.setStyleSheet( + "QLabel { color: #b35900; background-color: #fff3e0;" + " border: 1px solid #ffcc80; border-radius: 4px;" + " padding: 4px 8px; }" + ) + if not self._s3_available and self._s3_error: + self._queue_warning.setText( + f"\u26a0 Queue browsing unavailable: {self._s3_error}" + ) + self._queue_warning.setTextFormat(Qt.RichText) + self._queue_warning.setVisible(True) + else: + self._queue_warning.setVisible(False) + layout.addWidget(self._queue_warning) + # Show hidden folders checkbox self._show_hidden_cb = QCheckBox(tr("Show hidden folders"), parent=self) self._show_hidden_cb.setChecked(False) From c74047694e952ed2de96f93c4fadf0e2f1b6732f Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:23:43 -0500 Subject: [PATCH 18/89] fix: upload .ojd not .zip Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fd04b49e6..a52354919 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 @@ -735,7 +735,7 @@ def on_share_bundle(self): bundle_name = resolved_name.replace(" ", "_").replace("/", "_") prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" - s3_key = f"{prefix}/{bundle_name}.zip" + s3_key = f"{prefix}/{bundle_name}.ojd" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: From 4b324b07caece084487e6d36ab2565a46edd1fcc Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:27:36 -0500 Subject: [PATCH 19/89] fix: bundle download now respects output path Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/cli/_groups/bundle_group.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index ce1c7b33b..5e5ad0591 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -890,4 +890,9 @@ def bundle_download(bundle_name, output_dir, **args): raise DeadlineOperationError(msg) local_path = repo.download_full_bundle(match.path, output_dir) - click.echo(f"Downloaded bundle to: {local_path}") + # download_full_bundle resolves to cache; copy to user's output_dir + dest_path = os.path.join(output_dir, bundle_name) + if os.path.exists(dest_path): + shutil.rmtree(dest_path) + shutil.copytree(local_path, dest_path) + click.echo(f"Downloaded bundle to: {dest_path}") From d84d027c910ae228a4fa7543b271073b7a57548d Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:12:41 -0500 Subject: [PATCH 20/89] fix: handle s3 user metadata limit Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 43 ++++- src/deadline/client/job_bundle/repository.py | 23 ++- .../ui/dialogs/job_bundle_browser_dialog.py | 18 +- .../dialogs/submit_job_to_deadline_dialog.py | 47 +++++- .../cli/test_cli_bundle_repository.py | 99 +++++++++++ .../job_bundle/test_repository.py | 10 +- .../metadata-limit-test/template.yaml | 158 ++++++++++++++++++ 7 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 test_bundles/metadata-limit-test/template.yaml diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 5e5ad0591..28fa07c5c 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -28,6 +28,14 @@ from ...job_bundle.repository import ( BundleRepository, LocalBundleRepository, + METADATA_KEY_DESC, + METADATA_KEY_NAME, + METADATA_KEY_PARAMS, + METADATA_KEY_STEPS, + METADATA_LIMIT_DESC, + METADATA_LIMIT_NAME, + METADATA_LIMIT_PARAMS, + METADATA_LIMIT_STEPS, S3BundleRepository, S3_JOB_BUNDLES_PREFIX, _extract_bundle_info, @@ -541,6 +549,25 @@ def _print_response( click.echo("Job submission canceled.") +def _truncate_metadata(value: str, limit: int, field: str) -> str: + """Truncate a metadata value, warning if truncation occurs. + + S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values). + We apply conservative per-field limits to stay well within that budget. + See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata + """ + if len(value) > limit: + click.echo( + click.style( + f"Warning: Bundle metadata '{field}' truncated from {len(value)} to {limit} characters", + fg="yellow", + ), + err=True, + ) + return value[: limit - 3] + "..." + return value + + def _get_queue_s3_settings(config): """Get the queue's job attachment S3 settings from config.""" farm_id = config_file.get_setting("defaults.farm_id", config=config) @@ -803,18 +830,26 @@ def bundle_upload(job_bundle_dir, name, **args): job_bundle_dir, LocalBundleRepository._read_parameter_values(job_bundle_dir), ) - bundle_metadata["bundle-name"] = info.name[:256] + bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( + info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) if info.description: # S3 metadata values must be valid HTTP header values (no newlines) desc = " ".join(info.description.split()) - bundle_metadata["bundle-description"] = desc[:512] + bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( + desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC + ) if info.step_names: - bundle_metadata["bundle-steps"] = ",".join(info.step_names)[:512] + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + ",".join(info.step_names), METADATA_LIMIT_STEPS, METADATA_KEY_STEPS + ) if info.parameters: param_strs = [ f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters ] - bundle_metadata["bundle-parameters"] = ",".join(param_strs)[:512] + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + ",".join(param_strs), METADATA_LIMIT_PARAMS, METADATA_KEY_PARAMS + ) break bundle_name = name or os.path.basename(job_bundle_dir) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 9ef7668a0..97d2aede7 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -26,6 +26,19 @@ ARCHIVE_EXTENSION = ".ojd" CACHE_META_FILENAME = ".bundle_cache_meta.json" +# S3 user-defined metadata is limited to 2 KB total (keys + values, UTF-8 encoded). +# Keys include the "x-amz-meta-" prefix (12 bytes) added by S3. +# Budget: 4 keys × (12 + ~9 avg key len) = ~83 bytes for keys, leaving ~1,965 for values. +# See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata +METADATA_KEY_NAME = "ojd-name" +METADATA_KEY_DESC = "ojd-desc" +METADATA_KEY_STEPS = "ojd-steps" +METADATA_KEY_PARAMS = "ojd-params" +METADATA_LIMIT_NAME = 256 +METADATA_LIMIT_DESC = 480 +METADATA_LIMIT_STEPS = 480 +METADATA_LIMIT_PARAMS = 700 + def _is_archive(name: str) -> bool: """Check if a filename is an .ojd archive.""" @@ -312,12 +325,12 @@ def _write_cache_meta(cache_dir: str, etag: str, last_modified: str) -> None: def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleInfo]: """Try to construct BundleInfo from S3 user metadata set during upload. - Returns None if the required 'bundle-name' key is missing.""" - name = metadata.get("bundle-name") + Returns None if the required 'ojd-name' key is missing.""" + name = metadata.get(METADATA_KEY_NAME) if not name: return None params = [] - params_str = metadata.get("bundle-parameters", "") + params_str = metadata.get(METADATA_KEY_PARAMS, "") if params_str: for p in params_str.split(","): parts = p.split(":", 1) @@ -326,8 +339,8 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI return BundleInfo( path=path, name=name, - description=metadata.get("bundle-description", ""), - step_names=[s for s in metadata.get("bundle-steps", "").split(",") if s], + description=metadata.get(METADATA_KEY_DESC, ""), + step_names=[s for s in metadata.get(METADATA_KEY_STEPS, "").split(",") if s], parameters=params, ) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 99147cd10..c6a69747c 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -12,7 +12,7 @@ from typing import Optional from qtpy.QtCore import Qt, QModelIndex, QSortFilterProxyModel, QTimer, Signal # type: ignore -from qtpy.QtGui import QStandardItemModel, QStandardItem # type: ignore +from qtpy.QtGui import QColor, QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore QCheckBox, QDialog, @@ -470,12 +470,24 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): if info.parameters: self._preview_params_label.setVisible(True) - self._preview_params.setRowCount(len(info.parameters)) - for row, p in enumerate(info.parameters): + # Detect if parameters were truncated in metadata + truncated = any( + p.get("name", "").endswith("...") or p.get("type", "").endswith("...") + for p in info.parameters + ) + # Drop the last entry if it's garbled from truncation + params = info.parameters[:-1] if truncated else info.parameters + row_count = len(params) + (1 if truncated else 0) + self._preview_params.setRowCount(row_count) + for row, p in enumerate(params): self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) self._preview_params.setItem(row, 1, QTableWidgetItem(p.get("type", "?"))) value = p.get("_display_value", "") self._preview_params.setItem(row, 2, QTableWidgetItem(str(value) if value else "")) + if truncated: + truncation_item = QTableWidgetItem("\u2026 additional parameters not shown") + truncation_item.setForeground(QColor("gray")) + self._preview_params.setItem(len(params), 0, truncation_item) self._preview_params.setVisible(True) else: self._preview_params_label.setVisible(False) 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 a52354919..65035cf57 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 @@ -47,6 +47,14 @@ from ...job_bundle.repository import ( S3_JOB_BUNDLES_PREFIX, LocalBundleRepository, + METADATA_KEY_DESC, + METADATA_KEY_NAME, + METADATA_KEY_PARAMS, + METADATA_KEY_STEPS, + METADATA_LIMIT_DESC, + METADATA_LIMIT_NAME, + METADATA_LIMIT_PARAMS, + METADATA_LIMIT_STEPS, _extract_bundle_info, _parse_template, ) @@ -61,6 +69,22 @@ logger = logging.getLogger(__name__) + +def _truncate_metadata(value: str, limit: int, field: str) -> str: + """Truncate a metadata value, warning if truncation occurs. + + S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values). + We apply conservative per-field limits to stay well within that budget. + See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata + """ + if len(value) > limit: + logger.warning( + "Bundle metadata '%s' truncated from %d to %d characters", field, len(value), limit + ) + return value[: limit - 3] + "..." + return value + + # initialize early so once the UI opens, things are already initialized DeadlineAuthenticationStatus.getInstance() @@ -697,18 +721,25 @@ def on_share_bundle(self): pv = LocalBundleRepository._read_parameter_values(self.job_history_bundle_dir) info = _extract_bundle_info(template, self.job_history_bundle_dir, pv) # Use settings.name which is already resolved by the UI - bundle_metadata["bundle-name"] = settings.name[:256] + bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( + settings.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) if info.description: - bundle_metadata["bundle-description"] = " ".join(info.description.split())[ - :512 - ] + desc = " ".join(info.description.split()) + bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( + desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC + ) if info.step_names: - bundle_metadata["bundle-steps"] = ",".join(info.step_names)[:512] + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + ",".join(info.step_names), METADATA_LIMIT_STEPS, METADATA_KEY_STEPS + ) if info.parameters: param_strs = [ f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters ] - bundle_metadata["bundle-parameters"] = ",".join(param_strs)[:512] + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + ",".join(param_strs), METADATA_LIMIT_PARAMS, METADATA_KEY_PARAMS + ) break # Archive and upload @@ -731,7 +762,9 @@ def on_share_bundle(self): resolved_name = os.path.basename( settings.input_job_bundle_dir ) # fallback to dir name - bundle_metadata["bundle-name"] = resolved_name[:256] + bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( + resolved_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) bundle_name = resolved_name.replace(" ", "_").replace("/", "_") prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 5ddc0d51f..da1240509 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -10,6 +10,12 @@ from unittest.mock import MagicMock, patch from deadline.client.cli import main +from deadline.client.job_bundle.repository import ( + METADATA_LIMIT_DESC, + METADATA_LIMIT_NAME, + METADATA_LIMIT_PARAMS, + METADATA_LIMIT_STEPS, +) BUNDLE_GROUP = "deadline.client.cli._groups.bundle_group" @@ -170,3 +176,96 @@ def test_clean_specific_bundle(self, tmp_path): assert "Removed cached bundle: bundle-a" in result.output assert not bundle_a.exists() assert bundle_b.exists() + + +class TestMetadataTruncation: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_truncates_metadata_with_warning( + self, mock_s3_settings, mock_config, tmp_path, capsys + ): + bundle = tmp_path / "big-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "A" * 300, + "description": "D" * 600, + "steps": [{"name": f"Step_{i:03d}_Long"} for i in range(40)], + "parameterDefinitions": [ + {"name": f"Param_{i:03d}_Long", "type": "STRING"} for i in range(50) + ], + } + ) + ) + + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), + mock_session, + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle)]) + assert result.exit_code == 0, result.output + + # Verify warnings were emitted + assert "ojd-name" in result.output + assert "ojd-desc" in result.output + assert "ojd-steps" in result.output + assert "ojd-params" in result.output + + # Verify metadata values respect limits + call_args = mock_s3.upload_fileobj.call_args + metadata = call_args[1]["ExtraArgs"]["Metadata"] + assert len(metadata["ojd-name"]) <= METADATA_LIMIT_NAME + assert len(metadata["ojd-desc"]) <= METADATA_LIMIT_DESC + assert len(metadata["ojd-steps"]) <= METADATA_LIMIT_STEPS + assert len(metadata["ojd-params"]) <= METADATA_LIMIT_PARAMS + + # Verify truncated values end with "..." + assert metadata["ojd-name"].endswith("...") + assert metadata["ojd-desc"].endswith("...") + assert metadata["ojd-steps"].endswith("...") + assert metadata["ojd-params"].endswith("...") + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_no_truncation_when_within_limits(self, mock_s3_settings, mock_config, tmp_path): + bundle = tmp_path / "small-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Short Name", + "description": "Brief", + "steps": [{"name": "Render"}], + "parameterDefinitions": [{"name": "Frames", "type": "STRING"}], + } + ) + ) + + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), + mock_session, + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle)]) + assert result.exit_code == 0, result.output + + # No warnings + assert "truncated" not in result.output.lower() + + # Values stored as-is without "..." + call_args = mock_s3.upload_fileobj.call_args + metadata = call_args[1]["ExtraArgs"]["Metadata"] + assert metadata["ojd-name"] == "Short Name" + assert not metadata["ojd-name"].endswith("...") diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 1762b2a4a..ed52f53d4 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -141,10 +141,10 @@ def test_name_not_resolved_from_pv(self): class TestBundleInfoFromS3Metadata: def test_full_metadata(self): metadata = { - "bundle-name": "My Bundle", - "bundle-description": "A description", - "bundle-steps": "Step1,Step2", - "bundle-parameters": "Frames:STRING,Output:PATH", + "ojd-name": "My Bundle", + "ojd-desc": "A description", + "ojd-steps": "Step1,Step2", + "ojd-params": "Frames:STRING,Output:PATH", } info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key") assert info is not None @@ -160,7 +160,7 @@ def test_missing_name_returns_none(self): assert info is None def test_name_only(self): - info = _bundle_info_from_s3_metadata({"bundle-name": "Simple"}, "s3://bucket/key") + info = _bundle_info_from_s3_metadata({"ojd-name": "Simple"}, "s3://bucket/key") assert info is not None assert info.name == "Simple" assert info.step_names == [] diff --git a/test_bundles/metadata-limit-test/template.yaml b/test_bundles/metadata-limit-test/template.yaml new file mode 100644 index 000000000..13337def1 --- /dev/null +++ b/test_bundles/metadata-limit-test/template.yaml @@ -0,0 +1,158 @@ +description: 'This is a very long description. This is a very long description. This + is a very long description. This is a very long description. This is a very long + description. This is a very long description. This is a very long description. This + is a very long description. This is a very long description. This is a very long + description. This is a very long description. This is a very long description. This + is a very long description. This is a very long description. This is a very long + description. This is a very long description. This is a very long description. This + is a very long description. This is a very long description. This is a very long + description. ' +name: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +parameterDefinitions: +- default: value_0 + name: Parameter_000_LongName + type: STRING +- default: value_1 + name: Parameter_001_LongName + type: STRING +- default: value_2 + name: Parameter_002_LongName + type: STRING +- default: value_3 + name: Parameter_003_LongName + type: STRING +- default: value_4 + name: Parameter_004_LongName + type: STRING +- default: value_5 + name: Parameter_005_LongName + type: STRING +- default: value_6 + name: Parameter_006_LongName + type: STRING +- default: value_7 + name: Parameter_007_LongName + type: STRING +- default: value_8 + name: Parameter_008_LongName + type: STRING +- default: value_9 + name: Parameter_009_LongName + type: STRING +- default: value_10 + name: Parameter_010_LongName + type: STRING +- default: value_11 + name: Parameter_011_LongName + type: STRING +- default: value_12 + name: Parameter_012_LongName + type: STRING +- default: value_13 + name: Parameter_013_LongName + type: STRING +- default: value_14 + name: Parameter_014_LongName + type: STRING +- default: value_15 + name: Parameter_015_LongName + type: STRING +- default: value_16 + name: Parameter_016_LongName + type: STRING +- default: value_17 + name: Parameter_017_LongName + type: STRING +- default: value_18 + name: Parameter_018_LongName + type: STRING +- default: value_19 + name: Parameter_019_LongName + type: STRING +- default: value_20 + name: Parameter_020_LongName + type: STRING +- default: value_21 + name: Parameter_021_LongName + type: STRING +- default: value_22 + name: Parameter_022_LongName + type: STRING +- default: value_23 + name: Parameter_023_LongName + type: STRING +- default: value_24 + name: Parameter_024_LongName + type: STRING +- default: value_25 + name: Parameter_025_LongName + type: STRING +- default: value_26 + name: Parameter_026_LongName + type: STRING +- default: value_27 + name: Parameter_027_LongName + type: STRING +- default: value_28 + name: Parameter_028_LongName + type: STRING +- default: value_29 + name: Parameter_029_LongName + type: STRING +- default: value_30 + name: Parameter_030_LongName + type: STRING +- default: value_31 + name: Parameter_031_LongName + type: STRING +- default: value_32 + name: Parameter_032_LongName + type: STRING +- default: value_33 + name: Parameter_033_LongName + type: STRING +- default: value_34 + name: Parameter_034_LongName + type: STRING +- default: value_35 + name: Parameter_035_LongName + type: STRING +- default: value_36 + name: Parameter_036_LongName + type: STRING +- default: value_37 + name: Parameter_037_LongName + type: STRING +- default: value_38 + name: Parameter_038_LongName + type: STRING +- default: value_39 + name: Parameter_039_LongName + type: STRING +specificationVersion: jobtemplate-2023-09 +steps: +- name: Step_000_RenderingProcess +- name: Step_001_RenderingProcess +- name: Step_002_RenderingProcess +- name: Step_003_RenderingProcess +- name: Step_004_RenderingProcess +- name: Step_005_RenderingProcess +- name: Step_006_RenderingProcess +- name: Step_007_RenderingProcess +- name: Step_008_RenderingProcess +- name: Step_009_RenderingProcess +- name: Step_010_RenderingProcess +- name: Step_011_RenderingProcess +- name: Step_012_RenderingProcess +- name: Step_013_RenderingProcess +- name: Step_014_RenderingProcess +- name: Step_015_RenderingProcess +- name: Step_016_RenderingProcess +- name: Step_017_RenderingProcess +- name: Step_018_RenderingProcess +- name: Step_019_RenderingProcess +- name: Step_020_RenderingProcess +- name: Step_021_RenderingProcess +- name: Step_022_RenderingProcess +- name: Step_023_RenderingProcess +- name: Step_024_RenderingProcess From ad37bb170adcf281aa4ae2233c24204fdb31e824 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:23:42 -0500 Subject: [PATCH 21/89] fix: add confirmation for overwriting existing bundle on queue Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 13 ++++- .../dialogs/submit_job_to_deadline_dialog.py | 18 ++++++- .../cli/test_cli_bundle_repository.py | 54 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 28fa07c5c..a79b97b14 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -854,12 +854,22 @@ def bundle_upload(job_bundle_dir, name, **args): bundle_name = name or os.path.basename(job_bundle_dir) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" + s3_key = f"{prefix}/{bundle_name}.ojd" s3 = boto3_session.client("s3") + # Check if bundle already exists + try: + s3.head_object(Bucket=s3_settings.s3BucketName, Key=s3_key) + if not click.confirm(f"Bundle '{bundle_name}' already exists on the queue. Overwrite?"): + click.echo("Upload canceled.") + return + except ClientError as e: + if e.response["Error"]["Code"] != "404": + raise + # Archive and upload buf = io.BytesIO() - ext = ".ojd" with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for root, _dirs, files in os.walk(job_bundle_dir): for fname in files: @@ -867,7 +877,6 @@ def bundle_upload(job_bundle_dir, name, **args): arcname = os.path.relpath(local_path, job_bundle_dir) zf.write(local_path, arcname) - s3_key = f"{prefix}/{bundle_name}{ext}" buf.seek(0) s3.upload_fileobj( buf, 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 65035cf57..ebeefd8a2 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 @@ -770,6 +770,23 @@ def on_share_bundle(self): prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.ojd" + s3 = boto3_session.client("s3") + + # Check if bundle already exists + try: + s3.head_object(Bucket=s3_settings.s3BucketName, Key=s3_key) + reply = QMessageBox.question( + self, + "Overwrite?", + f"Bundle '{bundle_name}' already exists on the queue. Overwrite?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + except Exception: + pass # 404 means it doesn't exist, proceed + buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for root, _dirs, files in os.walk(self.job_history_bundle_dir): @@ -779,7 +796,6 @@ def on_share_bundle(self): zf.write(local_path, arcname) buf.seek(0) - s3 = boto3_session.client("s3") s3.upload_fileobj( buf, s3_settings.s3BucketName, diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index da1240509..95bd67b6d 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -6,6 +6,7 @@ import zipfile import yaml +from botocore.exceptions import ClientError from click.testing import CliRunner from unittest.mock import MagicMock, patch @@ -93,6 +94,7 @@ def test_upload_creates_zip(self, mock_boto3_client, mock_s3_settings, mock_conf mock_session = MagicMock() mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") mock_session.client.return_value = mock_s3 mock_s3_settings.return_value = ( MagicMock(s3BucketName="test-bucket", rootPrefix="DeadlineCloud"), @@ -202,6 +204,7 @@ def test_upload_truncates_metadata_with_warning( mock_session = MagicMock() mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") mock_session.client.return_value = mock_s3 mock_s3_settings.return_value = ( MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), @@ -251,6 +254,7 @@ def test_upload_no_truncation_when_within_limits(self, mock_s3_settings, mock_co mock_session = MagicMock() mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") mock_session.client.return_value = mock_s3 mock_s3_settings.return_value = ( MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), @@ -269,3 +273,53 @@ def test_upload_no_truncation_when_within_limits(self, mock_s3_settings, mock_co metadata = call_args[1]["ExtraArgs"]["Metadata"] assert metadata["ojd-name"] == "Short Name" assert not metadata["ojd-name"].endswith("...") + + +class TestBundleUploadOverwrite: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_prompts_when_bundle_exists_and_user_confirms( + self, mock_s3_settings, mock_config, tmp_path + ): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps:\n- name: S1\n") + + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_s3.head_object.return_value = {} # exists + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), + mock_session, + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle)], input="y\n") + assert result.exit_code == 0, result.output + assert "already exists" in result.output + mock_s3.upload_fileobj.assert_called_once() + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_aborts_when_bundle_exists_and_user_declines( + self, mock_s3_settings, mock_config, tmp_path + ): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps:\n- name: S1\n") + + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_s3.head_object.return_value = {} # exists + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), + mock_session, + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "upload", str(bundle)], input="n\n") + assert result.exit_code == 0, result.output + assert "canceled" in result.output.lower() + mock_s3.upload_fileobj.assert_not_called() From 048e9e04f9f0904d37d9037508bd13fe8e4d0c6e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:33:22 -0500 Subject: [PATCH 22/89] fix: prevent directory traversal with symlinks Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 7 +++- src/deadline/client/job_bundle/repository.py | 10 ++++- .../dialogs/submit_job_to_deadline_dialog.py | 5 ++- .../job_bundle/test_repository.py | 41 +++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index a79b97b14..2edd55b9d 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -871,9 +871,14 @@ def bundle_upload(job_bundle_dir, name, **args): # Archive and upload buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, _dirs, files in os.walk(job_bundle_dir): + for root, dirs, files in os.walk(job_bundle_dir, followlinks=False): + # Skip symlinked directories + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] for fname in files: local_path = os.path.join(root, fname) + if os.path.islink(local_path): + logger.warning("Skipping symlink: %s", local_path) + continue arcname = os.path.relpath(local_path, job_bundle_dir) zf.write(local_path, arcname) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 97d2aede7..8f0dca5fa 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -55,11 +55,17 @@ def _strip_archive_ext(name: str) -> str: def _safe_zip_extract(zf: zipfile.ZipFile, dest_dir: str) -> None: """Extract a zip file, rejecting archives with entries that would escape dest_dir.""" dest = os.path.realpath(dest_dir) + for member in zf.namelist(): if os.path.isabs(member): raise ValueError(f"Archive contains absolute path: {member}") - target = os.path.normpath(os.path.join(dest, member)) - if not (target.startswith(dest + os.sep) or target == dest): + target = os.path.realpath(os.path.join(dest, member)) + try: + common = os.path.commonpath([dest, target]) + except ValueError: + # On Windows, different drives have no common path + raise ValueError(f"Archive entry would extract outside target directory: {member}") + if common != dest: raise ValueError(f"Archive entry would extract outside target directory: {member}") zf.extractall(dest_dir) 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 ebeefd8a2..5c6ff4045 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 @@ -789,9 +789,12 @@ def on_share_bundle(self): buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, _dirs, files in os.walk(self.job_history_bundle_dir): + for root, dirs, files in os.walk(self.job_history_bundle_dir, followlinks=False): + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] for fname in files: local_path = os.path.join(root, fname) + if os.path.islink(local_path): + continue arcname = os.path.relpath(local_path, self.job_history_bundle_dir) zf.write(local_path, arcname) diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index ed52f53d4..c2b00423e 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -17,6 +17,7 @@ _is_archive, _parse_template, _read_template_from_archive_path, + _safe_zip_extract, _strip_archive_ext, ) @@ -463,3 +464,43 @@ def test_read_parameter_values_none(self, tmp_path): bundle_dir.mkdir() result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) assert result is None + +import pytest + + +class TestSafeZipExtract: + def test_rejects_absolute_path(self, tmp_path): + archive = tmp_path / "bad.zip" + with zipfile.ZipFile(str(archive), "w") as zf: + zf.writestr("/etc/passwd", "malicious") + + dest = tmp_path / "out" + dest.mkdir() + with zipfile.ZipFile(str(archive), "r") as zf: + with pytest.raises(ValueError, match="absolute path"): + _safe_zip_extract(zf, str(dest)) + + def test_rejects_parent_directory_traversal(self, tmp_path): + archive = tmp_path / "bad.zip" + with zipfile.ZipFile(str(archive), "w") as zf: + zf.writestr("../../etc/passwd", "malicious") + + dest = tmp_path / "out" + dest.mkdir() + with zipfile.ZipFile(str(archive), "r") as zf: + with pytest.raises(ValueError, match="outside target directory"): + _safe_zip_extract(zf, str(dest)) + + def test_allows_normal_archive(self, tmp_path): + archive = tmp_path / "good.zip" + with zipfile.ZipFile(str(archive), "w") as zf: + zf.writestr("template.yaml", "name: Test\n") + zf.writestr("subdir/file.txt", "hello") + + dest = tmp_path / "out" + dest.mkdir() + with zipfile.ZipFile(str(archive), "r") as zf: + _safe_zip_extract(zf, str(dest)) + + assert (dest / "template.yaml").exists() + assert (dest / "subdir" / "file.txt").exists() From 146069824337f2e7e9b304d781b0c44ce5cac33f Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:52:39 -0500 Subject: [PATCH 23/89] chore: update doc and s3 repo apis Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 84 +++++++++++-------- .../client/cli/_groups/bundle_group.py | 25 +----- src/deadline/client/job_bundle/repository.py | 31 ++++++- .../ui/dialogs/job_bundle_browser_dialog.py | 29 +++---- .../client/ui/job_bundle_submitter.py | 32 ++----- .../ui/widgets/job_bundle_settings_tab.py | 32 ++----- .../job_bundle/test_repository.py | 3 +- 7 files changed, 112 insertions(+), 124 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 5c99f1d4a..6b851576c 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -71,7 +71,7 @@ class BundleRepository(Protocol): Two implementations: - `LocalBundleRepository` — walks the local filesystem. Lists directories and archive files. Directories are bundles if they contain `template.yaml`/`template.json`. Archives are always shown as bundles (validated on preview). Provides `extract_bundle()` for extracting archives to a local directory. -- `S3BundleRepository` — lists objects and prefixes under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Only `.ojd` archives are recognized as bundles; subfolders are shown for navigation only. Provides `resolve_bundle()` which handles archive download+cache+extract. +- `S3BundleRepository` — lists objects and prefixes under the queue's job attachment bucket at `{rootPrefix}/job-bundles/`. Only `.ojd` archives are recognized as bundles; subfolders are shown for navigation only. Provides `resolve_bundle()` which handles archive download+cache+extract. The `from_config()` classmethod encapsulates all initialization logic (session creation, queue lookup, settings extraction) to avoid duplicating this across callers. ### S3 Bucket Convention @@ -131,19 +131,16 @@ Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniquenes When `deadline bundle upload` uploads an archive, it attaches bundle metadata as S3 user metadata on the object: -- `bundle-name`: The template's `name` field -- `bundle-description`: The template's `description` field (newlines collapsed to spaces) -- `bundle-steps`: Comma-separated list of step names -- `bundle-parameters`: Comma-separated `name:type` pairs +- `ojd-name`: The template's `name` field (limit: 256 chars) +- `ojd-desc`: The template's `description` field, newlines collapsed to spaces (limit: 480 chars) +- `ojd-steps`: Comma-separated list of step names (limit: 480 chars) +- `ojd-params`: Comma-separated `name:type` pairs (limit: 700 chars) -This metadata is returned by `head_object`, which is already called for ETag validation. This means preview of uploaded archives requires **zero downloads** — a single `head_object` provides both cache validation and all preview information. +These limits are defined as constants in `repository.py` (`METADATA_LIMIT_NAME`, `METADATA_LIMIT_DESC`, `METADATA_LIMIT_STEPS`, `METADATA_LIMIT_PARAMS`). S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values, including the `x-amz-meta-` prefix). The per-field limits are chosen to stay within this budget even at maximum usage. See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata -The preview priority chain for S3 archives: -1. **S3 user metadata** from `head_object` → instant, no download -2. **Local cache** if ETag matches → read template from disk -3. **Download archive** → parse template, populate cache (fallback for archives not uploaded via the CLI) +When truncation occurs, the CLI emits a yellow warning (e.g. `Warning: Bundle metadata 'ojd-params' truncated from 899 to 700 characters`) and the truncated value ends with `...` to make it visually obvious in the preview that information was cut off. The parameters table in the browser dialog detects truncated metadata and shows an "… additional parameters not shown" row. -S3 user metadata has a 2KB total limit, which is sufficient for typical bundle metadata. Per-field limits: `bundle-name` is truncated to 256 characters, `bundle-description`, `bundle-steps`, and `bundle-parameters` are each truncated to 512 characters. +This metadata is returned by `head_object`, which is already called for ETag validation. This means preview of uploaded archives requires **zero downloads** — a single `head_object` provides both cache validation and all preview information. ### Detection: What Is a Job Bundle? @@ -164,6 +161,9 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b ``` ┌─────────────────────────────────────────────────────────────┐ │ Job Bundle Browser │ +├─────────────────────────────────────────────────────────────┤ +│ Source: (•) Queue ( ) Local ( ) History │ +│ ☐ Show hidden folders │ ├────────────────────────────────┬────────────────────────────┤ │ [Filter bundles... ] │ Name: Blender Render │ │ 📁 my-bundles/ │ Description: Renders a │ @@ -173,43 +173,48 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b │ 📦 experimental-job │ • RenderBlender │ │ 📦 simple-job/ │ │ │ │ Parameters: │ -│ │ • BlenderSceneFile (PATH)│ -│ │ • Frames (STRING) │ -│ │ • OutputDir (PATH) │ -│ │ • Format (STRING) │ +│ │ ┌──────────┬──────┬─────┐ │ +│ │ │ Name │ Type │ Val │ │ +│ │ ├──────────┼──────┼─────┤ │ +│ │ │ Frames │ STR │ │ │ +│ │ │ OutputDir│ PATH │ │ │ +│ │ └──────────┴──────┴─────┘ │ │ │ │ ├────────────────────────────────┴────────────────────────────┤ -│ Source: ( ) Local (•) S3 (my-farm-bucket) ( ) History │ │ Path: [/job-bundles/ ] │ │ [Cancel] [Select] │ └─────────────────────────────────────────────────────────────┘ ``` +**Top bar** — Source selection and options: +- Radio toggle between Queue, Local, and History sources. Queue is selected by default when available; otherwise Local is selected. Queue option is disabled if the queue has no job attachment settings or access fails. When Queue is unavailable, an inline warning label appears below the radio buttons explaining why (e.g. "⚠ **Queue browsing unavailable:** AccessDeniedException..."). +- "Show hidden folders" checkbox — hidden by default, toggling refreshes the tree to include/exclude dot-prefixed directories. + **Left panel** — Filter and navigable tree view: - A text filter at the top that narrows the tree as you type. Case-insensitive, matches against entry names. Uses recursive filtering so parent folders remain visible when a child matches. The tree auto-expands when filtering to show results. - Shows folders (📁) and job bundles (📦) with distinct icons. Both directory bundles and archive bundles use the 📦 icon. - Clicking a folder clears any active filter, expands the folder to show its children, and scrolls it to the top of the view. This makes the search-then-navigate flow natural: search for a folder, click it, see its contents. - Job bundles are leaf nodes (selectable, not expandable). - Non-bundle, non-archive files are hidden. +- Hidden folders (names starting with `.`) are hidden by default; toggled via the checkbox. **Right panel** — Preview (shown when a bundle is selected, scrollable): - **Name**: From the template's `name` field, shown as-is (with `{{Param.X}}` references unresolved). - **Description**: From the template's `description` field, if present. - **Steps**: List of step names from the template, in definition order. -- **Parameters**: Name, type, and value of each parameter definition, in definition order. Values are resolved in priority order: `parameter_values.yaml`/`.json` > template `default` > blank. For S3 archives, values are available once the bundle is cached locally (first click caches, subsequent clicks show values). +- **Parameters**: Rendered as a table with Name, Type, and Value columns. Columns resize to fit content, with the last column stretching. If parameters were truncated in S3 metadata, the last garbled entry is dropped and a gray "… additional parameters not shown" row is appended. **Bottom bar**: -- Radio toggle between Local, S3, and Job History sources. S3 is selected by default when available; otherwise Local is selected. S3 option shows the bucket name from the queue and is disabled if the queue has no job attachment settings or S3 access fails (with a tooltip explaining why). Job History browses the `settings.job_history_dir` for the current AWS profile, showing previously submitted bundles; it is disabled if the job history directory does not exist on disk. - Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. ### Share Button -The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" packages the current job bundle as an `.ojd` archive and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's S3 source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. +The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" packages the current job bundle as an `.ojd` archive and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's Queue source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. -Share is enabled when the API is available and a farm and queue are configured — it does not require valid queue parameters (unlike Submit), since sharing only needs S3 access, not a runnable job configuration. +If a bundle with the same name already exists on the queue, the user is prompted with a confirmation dialog ("Bundle 'name' already exists on the queue. Overwrite?") before proceeding. -Note: uploading a bundle with the same name as an existing one silently overwrites it in S3. +Share is enabled when the API is available and a farm and queue are configured — it does not require valid queue parameters (unlike Submit), since sharing only needs S3 access, not a runnable job configuration. ### Lazy Loading @@ -262,13 +267,13 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again |---|---| | `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | | `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, and `deadline bundle cache` (clean/update) commands | -| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Local/S3/History sources. | +| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Queue/Local/History sources, hidden folder toggle, parameter table preview. Constructor takes keyword-only args: `queue_source`, `queue_error`, `local_source`, `history_source`. | | `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | -| `ui/dialogs/submit_job_to_deadline_dialog.py` | Add "Share" button to upload the current bundle to S3 | +| `ui/dialogs/submit_job_to_deadline_dialog.py` | Add "Share" button to upload the current bundle to queue (with overwrite confirmation) | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | | `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | -| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository`, archive helpers, cache management | +| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository` (with `from_config()` factory), archive helpers, cache management, metadata constants | ### CLI Commands @@ -278,7 +283,7 @@ Lists job bundles in a local directory or the queue's S3 `job-bundles/` folder. - With no arguments, lists bundles in the configured default local directory (`settings.job_bundle_default_directory`, or home if not set). No AWS config needed. - With `path`, lists bundles in that local directory. -- With `--s3`, lists bundles from the queue's S3 job-bundles folder (requires farm and queue). +- With `--queue`, lists bundles shared on the queue (requires farm and queue). - Default output is one bundle name per line, suitable for piping. - `--output json`: JSON array with name, format (archive/folder), and path. @@ -290,12 +295,12 @@ maya-arnold $ deadline bundle list ./my-bundles simple-job -$ deadline bundle list --s3 +$ deadline bundle list --queue blender-render maya-arnold monte_carlo_simulation -$ deadline bundle list --s3 --output json +$ deadline bundle list --queue --output json [{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.ojd", "format": "archive"}, ...] $ deadline bundle list | head -1 | xargs deadline bundle gui-submit --browse @@ -355,10 +360,12 @@ blender-render: up-to-date #### `deadline bundle upload ` -Uploads a local job bundle to the queue's S3 `job-bundles/` folder as an `.ojd` archive. +Uploads a local job bundle to share on the queue as an `.ojd` archive. -- `--name`: Override the bundle name in S3 (defaults to the directory name). +- `--name`: Override the bundle name (defaults to the directory name). - `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. +- If a bundle with the same name already exists, prompts for confirmation before overwriting. +- Symlinks within the bundle directory are skipped (not followed) to prevent unintended file disclosure. ``` $ deadline bundle upload ./my-render-job @@ -366,15 +373,20 @@ Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.o $ deadline bundle upload ./my-render-job --name custom-name Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.ojd + +$ deadline bundle upload ./my-render-job +Bundle 'my-render-job' already exists on the queue. Overwrite? [y/N]: y +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.ojd ``` #### `deadline bundle download ` -Downloads a job bundle from the queue's S3 `job-bundles/` folder. +Downloads a shared job bundle from the queue. - Finds the `.ojd` archive matching the given name. - Uses the ETag cache (same as the browser dialog) — repeated downloads are instant if the archive hasn't changed. -- `-o, --output-dir`: Local directory to extract/download to (defaults to `.`). +- Copies the resolved bundle to the output directory (cache is used internally but the user gets a clean copy at their requested location). +- `-o, --output-dir`: Local directory to download to (defaults to `.`). - `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. ``` @@ -389,7 +401,7 @@ Downloaded bundle to: /tmp/bundles/blender-render Errors are displayed inline rather than as popup dialogs: -- **S3 unavailable** (no farm/queue, no JA settings, auth failure): The S3 radio button shows `⚠ S3` and is disabled. Hovering shows the reason in a tooltip. The label distinguishes "not configured" (expected) from errors (⚠ icon). +- **Queue unavailable** (no farm/queue, no JA settings, auth failure): The Queue radio button is disabled and a styled inline warning label appears below the source selector showing the reason (e.g. "⚠ **Queue browsing unavailable:** AccessDeniedException..."). - **Listing failure** (network error, permissions): The preview panel shows "⚠ Error" in red with the error message. - **Expand failure** (subfolder listing fails): A disabled `⚠ Error: {message}` entry appears in the tree under that folder. - **Preview failure** (malformed template, missing fields): The preview panel shows "⚠ Error" with "Could not read bundle template" and the tree entry icon changes from 📦 to ⚠. @@ -399,11 +411,15 @@ Errors are displayed inline rather than as popup dialogs: Archives are validated before extraction to prevent path traversal attacks: -- All entry paths are checked for absolute paths and `../` traversal before any extraction occurs. The entire archive is rejected if any entry is suspicious. +- All entry paths are checked for absolute paths and `../` traversal using `os.path.commonpath()` with `os.path.realpath()` — this handles mixed path separators on Windows. The entire archive is rejected if any entry would extract outside the target directory. + +Symlink protection during upload: + +- `os.walk(followlinks=False)` is used when archiving bundles. Symlinked files and directories are skipped to prevent unintended inclusion of files outside the bundle directory. ### S3 Considerations -- **Authentication**: S3 browsing and CLI commands use the same boto3 session/profile as the rest of deadline-cloud. No separate auth flow. +- **Authentication**: S3 browsing and CLI commands use `api.get_boto3_session()` which respects the configured AWS profile in `~/.deadline/config`. The `S3BundleRepository.from_config()` factory method encapsulates session creation, queue lookup, and settings extraction in one place. No separate auth flow. - **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. If access is denied, show an error rather than crashing. - **Performance**: Listing is a single paginated `list_objects_v2` call with delimiter. Archive preview with S3 metadata is 1 `head_object` (no download). Cached archive selection is 1 `head_object`. - **S3 object metadata**: `deadline bundle upload` attaches bundle name, description, steps, and parameters as S3 user metadata. This enables zero-download preview via `head_object`. Archives uploaded by other means fall back to downloading the archive for preview. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 2edd55b9d..88e8410cc 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -621,12 +621,7 @@ def bundle_list(path, use_queue, no_archives, output, **args): if use_queue: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings, boto3_session = _get_queue_s3_settings(config) - repo: BundleRepository = S3BundleRepository( - bucket_name=s3_settings.s3BucketName, - root_prefix=s3_settings.rootPrefix, - session=boto3_session, - ) + repo: BundleRepository = S3BundleRepository.from_config(config) else: if path: local_root = os.path.abspath(path) @@ -728,13 +723,7 @@ def bundle_cache_update(bundle_name, **args): """Re-download any stale cached bundles from the queue by checking ETags.""" config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings, boto3_session = _get_queue_s3_settings(config) - - repo = S3BundleRepository( - bucket_name=s3_settings.s3BucketName, - root_prefix=s3_settings.rootPrefix, - session=boto3_session, - ) + repo = S3BundleRepository.from_config(config) # List remote bundles to match against cache entries = repo.list_entries(repo.root_path()) @@ -912,13 +901,7 @@ def bundle_download(bundle_name, output_dir, **args): """ config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) - s3_settings, boto3_session = _get_queue_s3_settings(config) - - repo = S3BundleRepository( - bucket_name=s3_settings.s3BucketName, - root_prefix=s3_settings.rootPrefix, - session=boto3_session, - ) + repo = S3BundleRepository.from_config(config) output_dir = os.path.abspath(output_dir) os.makedirs(output_dir, exist_ok=True) @@ -933,7 +916,7 @@ def bundle_download(bundle_name, output_dir, **args): if not match: available = [e.name for e in entries if e.is_bundle] - msg = f"Bundle '{bundle_name}' not found in s3://{s3_settings.s3BucketName}/{repo._prefix}" + msg = f"Bundle '{bundle_name}' not found in {repo.root_path()}" if available: msg += f"\nAvailable bundles: {', '.join(available)}" raise DeadlineOperationError(msg) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 8f0dca5fa..360306efb 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -19,6 +19,12 @@ import yaml +from ..api import get_boto3_session +from ..config import config_file +from ..config.config_file import get_cache_directory +from ..exceptions import DeadlineOperationError +from ...job_attachments._aws.deadline import get_queue + logger = getLogger(__name__) TEMPLATE_FILENAMES = ("template.yaml", "template.json") @@ -297,8 +303,6 @@ def _is_dir_bundle(path: str) -> bool: def _get_bundle_cache_dir() -> str: """Get the root cache directory for S3 bundle archives.""" - from ..config.config_file import get_cache_directory - return os.path.join(get_cache_directory(), "job-bundles") @@ -365,6 +369,29 @@ def __init__(self, bucket_name: str, root_prefix: str, session=None): self._session = session or _boto3.Session() self._s3 = self._session.client("s3") + @classmethod + def from_config(cls, config=None) -> "S3BundleRepository": + """Create an S3BundleRepository from the user's Deadline Cloud configuration. + + Handles session creation, queue lookup, and attachment settings extraction. + Raises DeadlineOperationError if farm/queue is not configured or has no attachments. + """ + farm_id = config_file.get_setting("defaults.farm_id", config=config) + queue_id = config_file.get_setting("defaults.queue_id", config=config) + if not farm_id or not queue_id: + raise DeadlineOperationError("A default farm and queue must be configured.") + session = get_boto3_session(config=config) + queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=session) + if not queue.jobAttachmentSettings: + raise DeadlineOperationError( + f"Queue {queue_id} does not have job attachment settings configured." + ) + return cls( + bucket_name=queue.jobAttachmentSettings.s3BucketName, + root_prefix=queue.jobAttachmentSettings.rootPrefix, + session=session, + ) + def root_path(self) -> str: return f"s3://{self._bucket}/{self._prefix}" diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index c6a69747c..149589fb0 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -64,12 +64,11 @@ class JobBundleBrowserDialog(QDialog): def __init__( self, - local_root: str = "", - s3_bucket_name: str = "", - s3_root_prefix: str = "", - s3_error: str = "", - job_history_dir: str = "", - session=None, + *, + queue_source: Optional[S3BundleRepository] = None, + queue_error: str = "", + local_source: str = "", + history_source: str = "", parent: Optional[QWidget] = None, ): super().__init__(parent=parent) @@ -77,19 +76,15 @@ def __init__( self.setMinimumSize(750, 550) self.resize(850, 620) - self._local_repo = LocalBundleRepository(root=local_root, include_archives=False) - self._s3_repo: Optional[S3BundleRepository] = None - self._s3_error = s3_error - self._s3_available = bool(s3_bucket_name) - if s3_bucket_name: - self._s3_repo = S3BundleRepository( - bucket_name=s3_bucket_name, root_prefix=s3_root_prefix, session=session - ) + self._s3_repo: Optional[S3BundleRepository] = queue_source + self._s3_error = queue_error + self._s3_available = self._s3_repo is not None + + self._local_repo = LocalBundleRepository(root=local_source, include_archives=False) - self._history_dir = job_history_dir self._history_repo: Optional[LocalBundleRepository] = None - if job_history_dir and os.path.isdir(job_history_dir): - self._history_repo = LocalBundleRepository(root=job_history_dir, include_archives=False) + if history_source and os.path.isdir(history_source): + self._history_repo = LocalBundleRepository(root=history_source, include_archives=False) self._current_repo: BundleRepository = self._local_repo self._selected_path: Optional[str] = None diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 0b9125355..3023c48f7 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -31,6 +31,7 @@ validate_directory_symlink_containment, ) from ..job_bundle.saver import save_yaml_or_json_to_file +from ..job_bundle.repository import S3BundleRepository from ..job_bundle.parameters import ( JobParameter, apply_job_parameters, @@ -47,8 +48,6 @@ from .widgets.job_bundle_settings_tab import JobBundleSettingsWidget from ..job_bundle.submission import AssetReferences from ..api._session import session_context -from .. import api -from ...job_attachments._aws.deadline import get_queue from ..config import get_setting from .dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog @@ -225,24 +224,11 @@ def show_job_bundle_submitter( if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue's S3 bucket for queue browsing - s3_bucket = "" - s3_prefix = "" + # Try to get the queue repo for queue browsing + queue_repo = None s3_error = "" - boto3_session = None try: - farm_id = get_setting("defaults.farm_id") - queue_id = get_setting("defaults.queue_id") - if farm_id and queue_id: - boto3_session = api.get_boto3_session() - queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) - if queue.jobAttachmentSettings: - s3_bucket = queue.jobAttachmentSettings.s3BucketName - s3_prefix = queue.jobAttachmentSettings.rootPrefix - else: - s3_error = "Queue does not have job attachment settings" - else: - s3_error = "No farm or queue configured" + queue_repo = S3BundleRepository.from_config() except Exception as e: logger.debug("Could not retrieve queue settings for bundle browser", exc_info=True) s3_error = str(e) @@ -251,12 +237,10 @@ def show_job_bundle_submitter( job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) browser = JobBundleBrowserDialog( - local_root=default_dir, - s3_bucket_name=s3_bucket, - s3_root_prefix=s3_prefix, - s3_error=s3_error, - job_history_dir=job_history_dir, - session=boto3_session, + queue_source=queue_repo, + queue_error=s3_error, + local_source=default_dir, + history_source=job_history_dir, parent=parent, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 2d9a5ad96..3b7855085 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -22,8 +22,7 @@ from ..dataclasses import JobBundleSettings from ...config import get_setting -from ... import api -from ....job_attachments._aws.deadline import get_queue +from ...job_bundle.repository import S3BundleRepository from .openjd_parameters_widget import OpenJDParametersWidget from ...job_bundle.submission import AssetReferences from ...job_bundle.loader import read_yaml_or_json_object, validate_directory_symlink_containment @@ -93,24 +92,11 @@ def on_load_bundle(self): if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue's S3 bucket for queue browsing - s3_bucket = "" - s3_prefix = "" + # Try to get the queue repo for queue browsing + queue_repo = None s3_error = "" - boto3_session = None try: - farm_id = get_setting("defaults.farm_id") - queue_id = get_setting("defaults.queue_id") - if farm_id and queue_id: - boto3_session = api.get_boto3_session() - queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) - if queue.jobAttachmentSettings: - s3_bucket = queue.jobAttachmentSettings.s3BucketName - s3_prefix = queue.jobAttachmentSettings.rootPrefix - else: - s3_error = "Queue does not have job attachment settings" - else: - s3_error = "No farm or queue configured" + queue_repo = S3BundleRepository.from_config() except Exception as e: s3_error = str(e) @@ -118,12 +104,10 @@ def on_load_bundle(self): job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) browser = JobBundleBrowserDialog( - local_root=default_dir, - s3_bucket_name=s3_bucket, - s3_root_prefix=s3_prefix, - s3_error=s3_error, - job_history_dir=job_history_dir, - session=boto3_session, + queue_source=queue_repo, + queue_error=s3_error, + local_source=default_dir, + history_source=job_history_dir, parent=self, ) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index c2b00423e..73b365bad 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -8,6 +8,7 @@ import os import zipfile +import pytest import yaml from deadline.client.job_bundle.repository import ( @@ -465,8 +466,6 @@ def test_read_parameter_values_none(self, tmp_path): result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) assert result is None -import pytest - class TestSafeZipExtract: def test_rejects_absolute_path(self, tmp_path): From e48498000528e542ceca2137a2b215687bbc8b95 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:22:00 -0500 Subject: [PATCH 24/89] fix: sanitize bundle downloads Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 11 +++++- src/deadline/client/job_bundle/repository.py | 20 +++++++++++ .../dialogs/submit_job_to_deadline_dialog.py | 2 +- .../job_bundle/test_repository.py | 34 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 88e8410cc..8fef9094d 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -42,6 +42,7 @@ _get_bundle_cache_dir, _parse_template, _read_cache_meta, + sanitize_bundle_name, ) from ....job_attachments.exceptions import ( AssetSyncError, @@ -842,8 +843,16 @@ def bundle_upload(job_bundle_dir, name, **args): break bundle_name = name or os.path.basename(job_bundle_dir) + if not bundle_name or not bundle_name.strip("/ \\"): + raise DeadlineOperationError( + "Bundle name is empty or invalid. Use --name to specify a valid name." + ) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.ojd" + if len(s3_key) > 1024: + raise DeadlineOperationError( + f"Bundle name is too long. S3 key would be {len(s3_key)} characters (max 1024)." + ) s3 = boto3_session.client("s3") @@ -923,7 +932,7 @@ def bundle_download(bundle_name, output_dir, **args): local_path = repo.download_full_bundle(match.path, output_dir) # download_full_bundle resolves to cache; copy to user's output_dir - dest_path = os.path.join(output_dir, bundle_name) + dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) if os.path.exists(dest_path): shutil.rmtree(dest_path) shutil.copytree(local_path, dest_path) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 360306efb..5736aeb01 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -11,7 +11,9 @@ import io import json import os +import re import shutil +import sys import zipfile from dataclasses import dataclass, field from logging import getLogger @@ -45,6 +47,24 @@ METADATA_LIMIT_STEPS = 480 METADATA_LIMIT_PARAMS = 700 +# POSIX only forbids / and null; Windows also forbids \ : * ? " < > | +# Control characters (0x00-0x1F, 0x7F) are problematic on all platforms +_WINDOWS_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]+') +_POSIX_UNSAFE_CHARS = re.compile(r"[/\x00-\x1f\x7f]+") + + +def sanitize_bundle_name(name: str) -> str: + """Sanitize a bundle name for use as a local directory name. + + Only replaces characters illegal on the current OS, preserving the + original name as closely as possible. + """ + pattern = _WINDOWS_UNSAFE_CHARS if sys.platform == "win32" else _POSIX_UNSAFE_CHARS + name = pattern.sub("_", name).strip("_") + if not name: + raise ValueError("Bundle name is empty after sanitization") + return name + def _is_archive(name: str) -> bool: """Check if a filename is an .ojd archive.""" 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 5c6ff4045..eeea8cdb4 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 @@ -766,7 +766,7 @@ def on_share_bundle(self): resolved_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME ) - bundle_name = resolved_name.replace(" ", "_").replace("/", "_") + bundle_name = resolved_name.replace("/", "_") prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.ojd" diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 73b365bad..0cf293392 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -6,6 +6,7 @@ import json import os +import sys import zipfile import pytest @@ -20,6 +21,7 @@ _read_template_from_archive_path, _safe_zip_extract, _strip_archive_ext, + sanitize_bundle_name, ) @@ -503,3 +505,35 @@ def test_allows_normal_archive(self, tmp_path): assert (dest / "template.yaml").exists() assert (dest / "subdir" / "file.txt").exists() + + +class TestSanitizeBundleName: + def test_slashes_replaced(self): + assert sanitize_bundle_name("path/to/bundle") == "path_to_bundle" + + def test_backslashes_replaced_on_windows(self): + if sys.platform == "win32": + assert sanitize_bundle_name("path\\to\\bundle") == "path_to_bundle" + + def test_backslashes_preserved_on_posix(self): + if sys.platform != "win32": + assert sanitize_bundle_name("path\\to\\bundle") == "path\\to\\bundle" + + def test_windows_illegal_chars_replaced_on_windows(self): + if sys.platform == "win32": + assert sanitize_bundle_name("file:name*with?bad") == "file_name_with_bad_chars_" + + def test_colons_preserved_on_posix(self): + if sys.platform != "win32": + assert sanitize_bundle_name("my:bundle") == "my:bundle" + + def test_empty_after_sanitization_raises(self): + with pytest.raises(ValueError, match="empty after sanitization"): + sanitize_bundle_name("///") + + def test_long_name_preserved(self): + long_name = "a" * 1000 + assert sanitize_bundle_name(long_name) == long_name + + def test_normal_name_unchanged(self): + assert sanitize_bundle_name("blender-render_v2.1") == "blender-render_v2.1" From 2a2d1b1fb6815eb143d8639bef9af59614cc3d2a Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:27:02 -0500 Subject: [PATCH 25/89] fix: remove access to private _local_repo variable in browser Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 26 ++++++++++++++++ .../client/ui/job_bundle_submitter.py | 31 ++----------------- .../ui/widgets/job_bundle_settings_tab.py | 23 ++------------ 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 149589fb0..7d31243f8 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -7,7 +7,10 @@ from __future__ import annotations +import atexit import os +import shutil +import tempfile from logging import getLogger from typing import Optional @@ -112,6 +115,29 @@ def selected_is_archive(self) -> bool: def s3_repo(self) -> Optional[S3BundleRepository]: return self._s3_repo + def resolve_selection(self) -> Optional[str]: + """Resolve the selected bundle to a local directory path. + + Handles S3 download/cache, archive extraction, and direct directory paths. + Returns None if no selection. + """ + if not self._selected_path: + return None + + if self._selected_is_s3 and self._s3_repo: + if self._selected_is_archive: + return self._s3_repo.resolve_bundle(self._selected_path, "") + else: + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + atexit.register(shutil.rmtree, temp_dir, True) + return self._s3_repo.resolve_bundle(self._selected_path, temp_dir) + elif self._selected_is_archive: + temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") + atexit.register(shutil.rmtree, temp_dir, True) + return self._local_repo.extract_bundle(self._selected_path, temp_dir) + else: + return self._selected_path + # ── UI Construction ────────────────────────────────────────── def _build_ui(self): diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 3023c48f7..a39dc5c2b 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -246,34 +246,9 @@ def show_job_bundle_submitter( if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return None - if browser.selected_is_s3 and browser.s3_repo: - if browser.selected_is_archive: - # Archive bundles are cached locally with ETag validation - input_job_bundle_dir = browser.s3_repo.resolve_bundle(browser.selected_path, "") - else: - # Folder bundles are downloaded to a temp directory - import tempfile - import atexit - import shutil - - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - input_job_bundle_dir = browser.s3_repo.resolve_bundle( - browser.selected_path, temp_dir - ) - elif browser.selected_is_archive: - # Local archive — extract to temp dir - import tempfile - import atexit - import shutil - - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - input_job_bundle_dir = browser._local_repo.extract_bundle( - browser.selected_path, temp_dir - ) - else: - input_job_bundle_dir = browser.selected_path + input_job_bundle_dir = browser.resolve_selection() + if not input_job_bundle_dir: + return None def on_create_job_bundle_callback( widget: SubmitJobToDeadlineDialog, diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 3b7855085..d82b639cb 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -6,10 +6,7 @@ from __future__ import annotations -import atexit import os -import shutil -import tempfile from logging import getLogger from typing import Any, Optional @@ -113,23 +110,9 @@ def on_load_bundle(self): if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return - if browser.selected_is_s3 and browser.s3_repo: - if browser.selected_is_archive: - input_job_bundle_dir = browser.s3_repo.resolve_bundle(browser.selected_path, "") - else: - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - input_job_bundle_dir = browser.s3_repo.resolve_bundle( - browser.selected_path, temp_dir - ) - elif browser.selected_is_archive: - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - input_job_bundle_dir = browser._local_repo.extract_bundle( - browser.selected_path, temp_dir - ) - else: - input_job_bundle_dir = browser.selected_path + input_job_bundle_dir = browser.resolve_selection() + if not input_job_bundle_dir: + return # Update job bundle directory path self.input_job_bundle_dir = input_job_bundle_dir From 143800313900b28c62ec590602fa80cab92f61f6 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:29:52 -0500 Subject: [PATCH 26/89] fix: normalize etag comparison Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 5736aeb01..97dc9ae1b 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -344,6 +344,13 @@ def _read_cache_meta(cache_dir: str) -> Optional[dict]: return None +def _normalize_etag(etag: Optional[str]) -> str: + """Strip surrounding quotes from an ETag for consistent comparison.""" + if not etag: + return "" + return etag.strip('"') + + def _write_cache_meta(cache_dir: str, etag: str, last_modified: str) -> None: meta_path = os.path.join(cache_dir, CACHE_META_FILENAME) with open(meta_path, "w", encoding="utf-8") as f: @@ -484,7 +491,9 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: if head: # Check local cache validity - cache_valid = meta and head.get("ETag") == meta.get("etag") + cache_valid = meta and _normalize_etag(head.get("ETag")) == _normalize_etag( + meta.get("etag") + ) # Try S3 user metadata for preview (set by 'deadline bundle upload') s3_metadata = head.get("Metadata", {}) @@ -539,7 +548,7 @@ def _resolve_archive_bundle(self, path: str) -> str: if meta: try: head = self._s3.head_object(Bucket=self._bucket, Key=key) - if head.get("ETag") == meta.get("etag"): + if _normalize_etag(head.get("ETag")) == _normalize_etag(meta.get("etag")): bundle_path = self._find_bundle_in_cache(cache_dir) if bundle_path: logger.info("Using cached bundle: %s", bundle_path) From 42b9ecf221ddae4a890a8a5d759efc47ebc7f4c2 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:09:17 -0500 Subject: [PATCH 27/89] chore: update docs Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 6b851576c..19b31ef3f 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -417,6 +417,21 @@ Symlink protection during upload: - `os.walk(followlinks=False)` is used when archiving bundles. Symlinked files and directories are skipped to prevent unintended inclusion of files outside the bundle directory. +### Bundle Name Validation + +Upload rejects bundles with invalid names: + +- Empty names or names consisting only of whitespace/slashes are rejected with an error directing the user to `--name`. +- The full S3 key (prefix + name + `.ojd`) is validated against S3's 1024-character key limit. +- Control characters (0x00–0x1F, 0x7F) are considered invalid. + +On download, the bundle name is sanitized for the local filesystem in a platform-specific manner: + +- **POSIX** (macOS/Linux): only `/` and null bytes are replaced with `_`. Characters like `:`, `*`, `?` are preserved since they are valid filenames. +- **Windows**: `\ / : * ? " < > |` and control characters are replaced with `_`. + +This means the S3 key preserves the original name as-is (all characters are valid in S3 keys), and only the local directory name is adjusted for the user's OS. + ### S3 Considerations - **Authentication**: S3 browsing and CLI commands use `api.get_boto3_session()` which respects the configured AWS profile in `~/.deadline/config`. The `S3BundleRepository.from_config()` factory method encapsulates session creation, queue lookup, and settings extraction in one place. No separate auth flow. From a3b1b48ecbbe2702bc4ce4a41862fe73fb7bc7be Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:10:28 -0500 Subject: [PATCH 28/89] feat: merge share and export button Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 40 +++- src/deadline/client/job_bundle/repository.py | 5 +- .../client/ui/dialogs/export_bundle_dialog.py | 142 ++++++++++++ .../dialogs/submit_job_to_deadline_dialog.py | 210 +++++++----------- .../widgets/test_job_bundle_settings_tab.py | 1 + 5 files changed, 260 insertions(+), 138 deletions(-) create mode 100644 src/deadline/client/ui/dialogs/export_bundle_dialog.py diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 19b31ef3f..6005a39a0 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -208,13 +208,42 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. -### Share Button +### Export Bundle -The submitter dialog includes a "Share" button alongside the existing "Export bundle" and "Submit" buttons. Clicking "Share" packages the current job bundle as an `.ojd` archive and uploads it to the queue's S3 `job-bundles/` folder, making it available to the team via the browser's Queue source. The bundle name defaults to the job name, with `{{Param.X}}` references resolved using current parameter values. Spaces and slashes in the resolved name are replaced with underscores. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. +The submitter dialog's "Export bundle" button replaces the previous separate "Export" and "Share" buttons with a unified flow. Clicking it opens an export dialog: -If a bundle with the same name already exists on the queue, the user is prompted with a confirmation dialog ("Bundle 'name' already exists on the queue. Overwrite?") before proceeding. +``` +┌─ Export Bundle ─────────────────────────────┐ +│ │ +│ Name: [blender-render_________] │ +│ │ +│ Save to: │ +│ (•) Queue ( ) Local │ +│ │ +│ ⚠ Queue unavailable: AccessDenied... │ +│ (inline warning, shown only when Queue │ +│ is disabled) │ +│ │ +│ Location: [s3://bucket/DC/job-bundles/] │ +│ (read-only for Queue, editable for Local) │ +│ │ +│ [Cancel] [Export] │ +└─────────────────────────────────────────────┘ +``` + +**Name** — defaults to the job name with `{{Param.X}}` references resolved using current parameter values. Editable. Used as the `.ojd` filename for Queue or the directory name for Local. + +**Save to** — Queue or Local: +- **Queue**: archives the bundle as `.ojd` and uploads to the queue's S3 `job-bundles/` folder. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. If a bundle with the same name already exists, the user is prompted to confirm overwrite. When Queue is unavailable (no permissions, no farm/queue configured, no job attachment settings), the radio button is disabled and an inline warning label explains why. +- **Local**: saves the bundle as a directory to the specified location. Defaults to `settings.job_bundle_default_directory` — the same path the browser's Local source browses. The exported bundle immediately appears when browsing Local. + +**Location** — always visible, updates based on the selected source: +- **Queue selected**: shows the S3 path (e.g. `s3://bucket/DeadlineCloud/job-bundles/`), read-only. +- **Local selected**: shows the local directory path, editable with a folder picker button for override. + +Queue export is enabled when the API is available and a farm and queue are configured. Local export is always available. -Share is enabled when the API is available and a farm and queue are configured — it does not require valid queue parameters (unlike Submit), since sharing only needs S3 access, not a runnable job configuration. +Note: the job history directory (`settings.job_history_dir`) is still used internally during Submit to record what was submitted, but Export now targets user-visible locations (Local browse path or Queue) rather than the history directory. ### Lazy Loading @@ -269,7 +298,8 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again | `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, and `deadline bundle cache` (clean/update) commands | | `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Queue/Local/History sources, hidden folder toggle, parameter table preview. Constructor takes keyword-only args: `queue_source`, `queue_error`, `local_source`, `history_source`. | | `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | -| `ui/dialogs/submit_job_to_deadline_dialog.py` | Add "Share" button to upload the current bundle to queue (with overwrite confirmation) | +| `ui/dialogs/submit_job_to_deadline_dialog.py` | Replace "Export" and "Share" buttons with unified "Export bundle" button that opens the export dialog | +| `ui/dialogs/export_bundle_dialog.py` | **New file.** Export dialog with Queue/Local destination, name override, and location display | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | | `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 97dc9ae1b..3661fba5d 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -21,11 +21,9 @@ import yaml -from ..api import get_boto3_session from ..config import config_file from ..config.config_file import get_cache_directory from ..exceptions import DeadlineOperationError -from ...job_attachments._aws.deadline import get_queue logger = getLogger(__name__) @@ -403,6 +401,9 @@ def from_config(cls, config=None) -> "S3BundleRepository": Handles session creation, queue lookup, and attachment settings extraction. Raises DeadlineOperationError if farm/queue is not configured or has no attachments. """ + from ..api import get_boto3_session + from ...job_attachments._aws.deadline import get_queue + farm_id = config_file.get_setting("defaults.farm_id", config=config) queue_id = config_file.get_setting("defaults.queue_id", config=config) if not farm_id or not queue_id: diff --git a/src/deadline/client/ui/dialogs/export_bundle_dialog.py b/src/deadline/client/ui/dialogs/export_bundle_dialog.py new file mode 100644 index 000000000..1f0058cab --- /dev/null +++ b/src/deadline/client/ui/dialogs/export_bundle_dialog.py @@ -0,0 +1,142 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Dialog for exporting a job bundle to Queue (S3) or a local directory.""" + +from __future__ import annotations + +import os +from typing import Optional + +from qtpy.QtCore import Qt # type: ignore +from qtpy.QtWidgets import ( # type: ignore + QDialog, + QDialogButtonBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QVBoxLayout, +) + +from .._utils import tr +from ...job_bundle.repository import S3BundleRepository + + +class ExportBundleDialog(QDialog): + """Dialog to choose where to export a bundle (Queue or Local) with a name override.""" + + def __init__( + self, + *, + default_name: str = "", + queue_repo: Optional[S3BundleRepository] = None, + queue_error: str = "", + local_dir: str = "", + parent=None, + ): + super().__init__(parent=parent) + self.setWindowTitle(tr("Export bundle")) + self.setMinimumWidth(500) + + self._queue_repo = queue_repo + self._queue_available = queue_repo is not None + self._queue_error = queue_error + self._local_dir = local_dir or os.path.expanduser("~") + + self._build_ui(default_name) + + @property + def bundle_name(self) -> str: + return self._name_edit.text().strip() + + @property + def export_to_queue(self) -> bool: + return self._radio_queue.isChecked() + + @property + def local_directory(self) -> str: + return self._location_edit.text() + + def _build_ui(self, default_name: str): + layout = QVBoxLayout(self) + + # Name + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Name:")) + self._name_edit = QLineEdit(default_name) + name_row.addWidget(self._name_edit) + layout.addLayout(name_row) + + # Save to + source_row = QHBoxLayout() + source_row.addWidget(QLabel("Save to:")) + self._radio_queue = QRadioButton(tr("Queue")) + self._radio_queue.setEnabled(self._queue_available) + self._radio_queue.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_queue) + self._radio_local = QRadioButton(tr("Local")) + self._radio_local.toggled.connect(self._on_source_changed) + source_row.addWidget(self._radio_local) + source_row.addStretch() + layout.addLayout(source_row) + + # Warning label for unavailable queue + self._queue_warning = QLabel() + self._queue_warning.setWordWrap(True) + self._queue_warning.setTextFormat(Qt.RichText) + self._queue_warning.setStyleSheet( + "QLabel { color: #b35900; background-color: #fff3e0;" + " border: 1px solid #ffcc80; border-radius: 4px;" + " padding: 4px 8px; }" + ) + if not self._queue_available and self._queue_error: + self._queue_warning.setText(f"\u26a0 Queue unavailable: {self._queue_error}") + self._queue_warning.setVisible(True) + else: + self._queue_warning.setVisible(False) + layout.addWidget(self._queue_warning) + + # Location + location_row = QHBoxLayout() + location_row.addWidget(QLabel("Location:")) + self._location_edit = QLineEdit() + location_row.addWidget(self._location_edit) + self._browse_button = QPushButton("...") + self._browse_button.setFixedWidth(30) + self._browse_button.clicked.connect(self._on_browse) + location_row.addWidget(self._browse_button) + layout.addLayout(location_row) + + # Buttons + button_box = QDialogButtonBox(QDialogButtonBox.Cancel) + self._export_button = QPushButton(tr("Export bundle")) + button_box.addButton(self._export_button, QDialogButtonBox.AcceptRole) + self._export_button.clicked.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Default selection + if self._queue_available: + self._radio_queue.setChecked(True) + else: + self._radio_local.setChecked(True) + + def _on_source_changed(self): + if self._radio_queue.isChecked() and self._queue_repo: + s3_path = self._queue_repo.root_path() + self._location_edit.setText(s3_path) + self._location_edit.setReadOnly(True) + self._browse_button.setVisible(False) + else: + self._location_edit.setText(self._local_dir) + self._location_edit.setReadOnly(False) + self._browse_button.setVisible(True) + + def _on_browse(self): + directory = QFileDialog.getExistingDirectory( + self, "Select export directory", self._location_edit.text() + ) + if directory: + self._location_edit.setText(directory) 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 eeea8cdb4..fb3870318 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 @@ -6,10 +6,10 @@ from __future__ import annotations import io +import json import logging import os -import sys -import json +import shutil import zipfile from typing import Any, Dict, Optional, Protocol import yaml @@ -45,7 +45,6 @@ from ...job_bundle.parameters import JobParameter from ...job_bundle.submission import AssetReferences from ...job_bundle.repository import ( - S3_JOB_BUNDLES_PREFIX, LocalBundleRepository, METADATA_KEY_DESC, METADATA_KEY_NAME, @@ -55,10 +54,10 @@ METADATA_LIMIT_NAME, METADATA_LIMIT_PARAMS, METADATA_LIMIT_STEPS, + S3BundleRepository, _extract_bundle_info, _parse_template, ) -from ....job_attachments._aws.deadline import get_queue from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget from ..widgets.job_attachments_tab import JobAttachmentsWidget from ..widgets.shared_job_settings_tab import SharedJobSettingsWidget @@ -66,6 +65,7 @@ from . import DeadlineConfigDialog, DeadlineLoginDialog from ._types import JobBundlePurpose from ._help_dialog import _HelpDialog +from .export_bundle_dialog import ExportBundleDialog logger = logging.getLogger(__name__) @@ -299,9 +299,6 @@ def _build_ui( self.export_bundle_button = QPushButton(tr("Export bundle")) self.export_bundle_button.clicked.connect(self.on_export_bundle) self.button_box.addButton(self.export_bundle_button, QDialogButtonBox.AcceptRole) - self.share_bundle_button = QPushButton("Share") - self.share_bundle_button.clicked.connect(self.on_share_bundle) - self.button_box.addButton(self.share_bundle_button, QDialogButtonBox.AcceptRole) self.lyt.addWidget(self.button_box) @@ -316,7 +313,6 @@ def _set_submit_button_state(self): enable = api_available and farm_configured and queue_configured and queue_valid self.submit_button.setEnabled(enable) - self.share_bundle_button.setEnabled(api_available and farm_configured and queue_configured) if not enable: issues = [] @@ -579,37 +575,63 @@ def _on_load_bundle(self): self.job_settings.on_load_bundle() def on_export_bundle(self): - """ - Exports a Job Bundle, but does not submit the job. - """ - # Retrieve all the settings into the dataclass + """Export a job bundle to Queue (S3) or a local directory.""" + # Gather settings settings = self.job_settings_type() self.shared_job_settings.update_settings(settings) self.job_settings.update_settings(settings) - queue_parameters = self.shared_job_settings.get_parameters() - asset_references = self.job_attachments.get_asset_references() + # Default export name is the bundle directory name on disk + resolved_name = ( + os.path.basename(settings.input_job_bundle_dir) + if settings.input_job_bundle_dir + else settings.name + ) - # Save the bundle + # Try to get queue repo for the dialog + queue_repo = None + queue_error = "" + try: + queue_repo = S3BundleRepository.from_config() + except Exception as e: + queue_error = str(e) + + # Get default local directory + local_dir = get_setting("settings.job_bundle_default_directory") + if local_dir: + local_dir = os.path.expanduser(local_dir) + else: + local_dir = os.path.expanduser("~") + + # Show export dialog + dialog = ExportBundleDialog( + default_name=resolved_name, + queue_repo=queue_repo, + queue_error=queue_error, + local_dir=local_dir, + parent=self, + ) + if dialog.exec_() != ExportBundleDialog.Accepted or not dialog.bundle_name: + return + + # Create the bundle locally first + asset_references = self.job_attachments.get_asset_references() try: self.job_history_bundle_dir = create_job_history_bundle_dir( self.submitter_info.submitter_name, settings.name ) - if self.show_host_requirements_tab: - host_requirements = self.host_requirements.get_requirements() parameters_from_callback = self.on_create_job_bundle_callback( self, self.job_history_bundle_dir, settings, queue_parameters, asset_references, - host_requirements, + self.host_requirements.get_requirements(), purpose=JobBundlePurpose.EXPORT, ) else: - # Maintaining backward compatibility for submitters that do not support host_requirements yet parameters_from_callback = self.on_create_job_bundle_callback( self, self.job_history_bundle_dir, @@ -620,98 +642,50 @@ def on_export_bundle(self): ) if parameters_from_callback is None: parameters_from_callback = {} - - # If the callback returned job parameters, update them in the job bundle as well so that - # submission from the job history dir is equivalent. job_parameters = parameters_from_callback.get("job_parameters", []) if job_parameters: self.save_job_parameters_to_job_bundle(self.job_history_bundle_dir, job_parameters) - - logger.info(f"Saved the submission as a job bundle: {self.job_history_bundle_dir}") - if sys.platform == "win32": - # Open the directory in the OS's file explorer - os.startfile(self.job_history_bundle_dir) - QMessageBox.information( - self, - tr("{submitter} job submission").format( - submitter=self.submitter_info.submitter_name - ), - tr("Saved the submission as a job bundle:\n{path}").format( - path=self.job_history_bundle_dir - ), - ) - # Close the submitter window to signal the submission is done - self.close() - except NonValidInputError as nvie: QMessageBox.critical(self, tr("Non valid inputs detected"), str(nvie)) - + return except Exception as exc: - logger.exception("Error saving bundle") - message = str(exc) - QMessageBox.critical( - self, - tr("{submitter} job submission").format( - submitter=self.submitter_info.submitter_name - ), - message, - ) # type: ignore[call-arg] + logger.exception("Error creating bundle") + QMessageBox.critical(self, "Export failed", f"Failed to create bundle:\n{exc}") + return - def on_share_bundle(self): - """Archive the current bundle and share it on the queue.""" + bundle_name = dialog.bundle_name - # First export the bundle locally - settings = self.job_settings_type() - self.shared_job_settings.update_settings(settings) - self.job_settings.update_settings(settings) - queue_parameters = self.shared_job_settings.get_parameters() - asset_references = self.job_attachments.get_asset_references() + if dialog.export_to_queue: + self._export_to_queue(queue_repo, bundle_name) + else: + self._export_to_local(dialog.local_directory, bundle_name) + def _export_to_local(self, dest_dir: str, bundle_name: str): + """Copy the bundle to a local directory.""" + assert self.job_history_bundle_dir is not None + dest_path = os.path.join(dest_dir, bundle_name) try: - self.job_history_bundle_dir = create_job_history_bundle_dir( - self.submitter_info.submitter_name, settings.name + if os.path.exists(dest_path): + shutil.rmtree(dest_path) + shutil.copytree(self.job_history_bundle_dir, dest_path) + QMessageBox.information( + self, + tr("Export bundle"), + f"Bundle exported to:\n{dest_path}", ) - if self.show_host_requirements_tab: - self.on_create_job_bundle_callback( - self, - self.job_history_bundle_dir, - settings, - queue_parameters, - asset_references, - self.host_requirements.get_requirements(), - purpose=JobBundlePurpose.EXPORT, - ) - else: - self.on_create_job_bundle_callback( - self, - self.job_history_bundle_dir, - settings, - queue_parameters, - asset_references, - purpose=JobBundlePurpose.EXPORT, - ) except Exception as exc: - QMessageBox.critical(self, "Share failed", f"Failed to create bundle:\n{exc}") - return + QMessageBox.critical(self, "Export failed", f"Failed to save bundle:\n{exc}") - # Get queue S3 settings - try: - farm_id = get_setting("defaults.farm_id") - queue_id = get_setting("defaults.queue_id") - boto3_session = api.get_boto3_session() - queue_obj = get_queue(farm_id=farm_id, queue_id=queue_id, session=boto3_session) - if not queue_obj.jobAttachmentSettings: - QMessageBox.warning( - self, "Share failed", "Queue does not have job attachment settings configured." - ) - return - s3_settings = queue_obj.jobAttachmentSettings - except Exception as exc: - QMessageBox.critical(self, "Share failed", f"Failed to get queue settings:\n{exc}") + def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name: str): + """Archive and upload the bundle to the queue's S3 job-bundles folder.""" + if not queue_repo: + QMessageBox.critical(self, "Export failed", "Queue is not available.") return - # Build S3 metadata from the template - bundle_metadata = {} + assert self.job_history_bundle_dir is not None + + # Build S3 metadata + bundle_metadata: dict[str, str] = {} for tname in ("template.yaml", "template.json"): tpath = os.path.join(self.job_history_bundle_dir, tname) if os.path.isfile(tpath): @@ -720,9 +694,8 @@ def on_share_bundle(self): if template: pv = LocalBundleRepository._read_parameter_values(self.job_history_bundle_dir) info = _extract_bundle_info(template, self.job_history_bundle_dir, pv) - # Use settings.name which is already resolved by the UI bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - settings.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + bundle_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME ) if info.description: desc = " ".join(info.description.split()) @@ -744,37 +717,12 @@ def on_share_bundle(self): # Archive and upload try: - # Resolve any {{Param.X}} in the name using current parameter values - import re - - param_value_map = { - p["name"]: p.get("value", p.get("default", "")) for p in queue_parameters - } - for p in settings.parameters: - param_value_map[p["name"]] = p.get("value", p.get("default", "")) - - resolved_name = re.sub( - r"\{\{Param\.(\w+)\}\}", - lambda m: str(param_value_map.get(m.group(1), m.group(0))), - settings.name, - ) - if not resolved_name.strip(): - resolved_name = os.path.basename( - settings.input_job_bundle_dir - ) # fallback to dir name - bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - resolved_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME - ) - - bundle_name = resolved_name.replace("/", "_") - prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" - s3_key = f"{prefix}/{bundle_name}.ojd" - - s3 = boto3_session.client("s3") + s3_key = f"{queue_repo._prefix}{bundle_name}.ojd" + s3 = queue_repo._s3 # Check if bundle already exists try: - s3.head_object(Bucket=s3_settings.s3BucketName, Key=s3_key) + s3.head_object(Bucket=queue_repo._bucket, Key=s3_key) reply = QMessageBox.question( self, "Overwrite?", @@ -785,7 +733,7 @@ def on_share_bundle(self): if reply != QMessageBox.Yes: return except Exception: - pass # 404 means it doesn't exist, proceed + pass buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: @@ -801,18 +749,18 @@ def on_share_bundle(self): buf.seek(0) s3.upload_fileobj( buf, - s3_settings.s3BucketName, + queue_repo._bucket, s3_key, ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, ) QMessageBox.information( self, - "Shared", - f"Bundle shared to queue:\ns3://{s3_settings.s3BucketName}/{s3_key}", + tr("Export bundle"), + f"Bundle exported to queue:\ns3://{queue_repo._bucket}/{s3_key}", ) except Exception as exc: - QMessageBox.critical(self, "Share failed", f"Failed to upload bundle:\n{exc}") + QMessageBox.critical(self, "Export failed", f"Failed to upload bundle:\n{exc}") def save_job_parameters_to_job_bundle( self, job_bundle_dir: str, job_parameters: list[JobParameter] diff --git a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py index d7050718c..7d299af11 100644 --- a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py @@ -57,6 +57,7 @@ def _patch_browser(selected_path=None, accepted=True): mock_instance.selected_is_s3 = False mock_instance.selected_is_archive = False mock_instance.s3_repo = None + mock_instance.resolve_selection.return_value = selected_path mock_cls = MagicMock(return_value=mock_instance) mock_cls.Accepted = 1 From e129b114d64db9b17b4586316ef6ea531a92067f Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:24:43 -0500 Subject: [PATCH 29/89] feat: implement show/hide functionality for queue bundles Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 148 +++++++++++++-- .../client/cli/_groups/bundle_group.py | 71 ++++++- src/deadline/client/job_bundle/repository.py | 116 ++++++++++++ .../ui/dialogs/job_bundle_browser_dialog.py | 122 ++++++++++-- .../client/ui/translations/locales/de_DE.json | 4 +- .../client/ui/translations/locales/en_US.json | 4 +- .../client/ui/translations/locales/es_ES.json | 4 +- .../client/ui/translations/locales/fr_FR.json | 4 +- .../client/ui/translations/locales/id_ID.json | 4 +- .../client/ui/translations/locales/it_IT.json | 4 +- .../client/ui/translations/locales/ja_JP.json | 4 +- .../client/ui/translations/locales/ko_KR.json | 4 +- .../client/ui/translations/locales/pt_BR.json | 4 +- .../client/ui/translations/locales/tr_TR.json | 4 +- .../client/ui/translations/locales/zh_CN.json | 4 +- .../client/ui/translations/locales/zh_TW.json | 4 +- .../cli/test_cli_bundle_repository.py | 150 ++++++++++++++- .../job_bundle/test_repository.py | 177 ++++++++++++++++++ 18 files changed, 781 insertions(+), 51 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 6005a39a0..0dd6789d8 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -107,15 +107,19 @@ This is useful for: The Job History source uses the same `LocalBundleRepository` as the Local source, just rooted at the job history directory instead of the user's home or configured default. -### S3 Archive Caching +### Archive Caching -Archive bundles from S3 are cached locally to avoid re-downloading on repeated use. +Archive bundles (both local `.ojd` files and S3 archives) are cached in a unified location to avoid redundant extraction and downloads. **Cache location**: `~/.deadline/cache/job-bundles/{hash}/{bundle-name}/` -Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniqueness. +Where `{hash}` is a truncated SHA-256 of the source identifier: +- **S3 archives**: `hash(bucket/s3-key)` +- **Local archives**: `hash(local-file-path)` -**Cache validation**: On each access, a single `head_object` call retrieves the archive's ETag. If it matches the cached ETag, the local copy is used directly. If it differs (or no cache exists), the archive is re-downloaded and re-extracted. +**Cache validation**: +- **S3**: A `head_object` call retrieves the archive's ETag. If it matches the cached ETag, the local copy is used. If it differs (or no cache exists), the archive is re-downloaded and re-extracted. +- **Local**: The file's mtime is compared to the cached mtime. If it differs, the archive is re-extracted in-place. **Cache metadata** (`.bundle_cache_meta.json`): ```json @@ -125,7 +129,9 @@ Where `{hash}` is a truncated SHA-256 of `{bucket}/{s3-key}` to ensure uniquenes } ``` -**Why only archives are cached**: An archive is a single S3 object with a single ETag — one `head_object` validates the entire bundle. +For S3 archives, `etag` is used for validation. For local archives, `mtime` (float, seconds since epoch) is stored instead. The fields present indicate the source type. + +**Cleanup**: `deadline bundle cache clean` removes cached bundles from this directory. There is no separate temp dir or `atexit` cleanup — all extracted archives live in the cache. ### S3 Object Metadata for Preview @@ -163,7 +169,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b │ Job Bundle Browser │ ├─────────────────────────────────────────────────────────────┤ │ Source: (•) Queue ( ) Local ( ) History │ -│ ☐ Show hidden folders │ +│ ☐ Show hidden │ ├────────────────────────────────┬────────────────────────────┤ │ [Filter bundles... ] │ Name: Blender Render │ │ 📁 my-bundles/ │ Description: Renders a │ @@ -188,7 +194,7 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b **Top bar** — Source selection and options: - Radio toggle between Queue, Local, and History sources. Queue is selected by default when available; otherwise Local is selected. Queue option is disabled if the queue has no job attachment settings or access fails. When Queue is unavailable, an inline warning label appears below the radio buttons explaining why (e.g. "⚠ **Queue browsing unavailable:** AccessDeniedException..."). -- "Show hidden folders" checkbox — hidden by default, toggling refreshes the tree to include/exclude dot-prefixed directories. +- "Show hidden" checkbox — unchecked by default, toggling refreshes the tree to include/exclude hidden items. For Local/History sources, this means dot-prefixed directories. For the Queue source, this means bundles marked as hidden via the visibility manifest (see [Bundle Visibility](#bundle-visibility)). **Left panel** — Filter and navigable tree view: - A text filter at the top that narrows the tree as you type. Case-insensitive, matches against entry names. Uses recursive filtering so parent folders remain visible when a child matches. The tree auto-expands when filtering to show results. @@ -196,7 +202,8 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - Clicking a folder clears any active filter, expands the folder to show its children, and scrolls it to the top of the view. This makes the search-then-navigate flow natural: search for a folder, click it, see its contents. - Job bundles are leaf nodes (selectable, not expandable). - Non-bundle, non-archive files are hidden. -- Hidden folders (names starting with `.`) are hidden by default; toggled via the checkbox. +- Hidden folders (names starting with `.`) and hidden S3 bundles are hidden by default; toggled via the "Show hidden" checkbox. +- **Context menu** (Queue source only): Right-clicking a visible bundle shows "Hide bundle"; right-clicking a hidden bundle (when "Show hidden" is checked) shows "Unhide bundle". Hidden bundles are rendered with a dimmed/grayed icon to distinguish them from visible ones. Hide/unhide operations happen in the background with automatic retry on conflict (see [Bundle Visibility](#bundle-visibility)). **Right panel** — Preview (shown when a bundle is selected, scrollable): - **Name**: From the template's `name` field, shown as-is (with `{{Param.X}}` references unresolved). @@ -234,7 +241,7 @@ The submitter dialog's "Export bundle" button replaces the previous separate "Ex **Name** — defaults to the job name with `{{Param.X}}` references resolved using current parameter values. Editable. Used as the `.ojd` filename for Queue or the directory name for Local. **Save to** — Queue or Local: -- **Queue**: archives the bundle as `.ojd` and uploads to the queue's S3 `job-bundles/` folder. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. If a bundle with the same name already exists, the user is prompted to confirm overwrite. When Queue is unavailable (no permissions, no farm/queue configured, no job attachment settings), the radio button is disabled and an inline warning label explains why. +- **Queue**: archives the bundle as `.ojd` and uploads to the queue's S3 `job-bundles/` folder. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. If a bundle with the same name already exists, the user is prompted to confirm overwrite. If the existing bundle is hidden, the prompt warns "A hidden bundle with this name already exists" with three options: Cancel, Overwrite (keeps it hidden), or Overwrite and unhide. When Queue is unavailable (no permissions, no farm/queue configured, no job attachment settings), the radio button is disabled and an inline warning label explains why. - **Local**: saves the bundle as a directory to the specified location. Defaults to `settings.job_bundle_default_directory` — the same path the browser's Local source browses. The exported bundle immediately appears when browsing Local. **Location** — always visible, updates based on the selected source: @@ -281,8 +288,8 @@ After the user selects a bundle in the browser, it must be resolved to a local d | Source | Format | Resolution | Cleanup | |---|---|---|---| | Local | Directory | Used directly (no copy) | None needed | -| Local | Archive (.ojd) | Extracted to temp dir | atexit cleanup | -| S3 | Archive (.ojd) | Downloaded, cached with ETag, extracted to cache dir | Persists in cache | +| Local | Archive (.ojd) | Extracted to cache dir (`hash(path)` + mtime validation) | `deadline bundle cache clean` | +| S3 | Archive (.ojd) | Downloaded to cache dir (`hash(bucket/key)` + ETag validation), extracted | `deadline bundle cache clean` | The CLI `deadline bundle download` command downloads the `.ojd` archive, caches it locally with ETag validation, and extracts it to the output directory. @@ -295,15 +302,15 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again | File | Change | |---|---| | `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | -| `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, and `deadline bundle cache` (clean/update) commands | -| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Queue/Local/History sources, hidden folder toggle, parameter table preview. Constructor takes keyword-only args: `queue_source`, `queue_error`, `local_source`, `history_source`. | +| `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, `deadline bundle hide`, `deadline bundle unhide`, and `deadline bundle cache` (clean/update) commands | +| `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Queue/Local/History sources, "Show hidden" toggle, parameter table preview, and right-click context menu for hide/unhide (Queue source). Constructor takes keyword-only args: `queue_source`, `queue_error`, `local_source`, `history_source`. | | `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | | `ui/dialogs/submit_job_to_deadline_dialog.py` | Replace "Export" and "Share" buttons with unified "Export bundle" button that opens the export dialog | | `ui/dialogs/export_bundle_dialog.py` | **New file.** Export dialog with Queue/Local destination, name override, and location display | | `ui/widgets/job_bundle_settings_tab.py` | `on_load_bundle` opens the new browser dialog instead of `QFileDialog` | | `ui/job_bundle_submitter.py` | `show_job_bundle_submitter` uses the new browser dialog when `browse=True`; handles archive extraction and S3 resolution | | `job_bundle/loader.py` | Add `is_job_bundle_dir(path) -> bool` helper for quick detection | -| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository` (with `from_config()` factory), archive helpers, cache management, metadata constants | +| `job_bundle/repository.py` | **New file.** `BundleRepository` protocol, `LocalBundleRepository`, `S3BundleRepository` (with `from_config()` factory), archive helpers, cache management, metadata constants, visibility manifest read/write with optimistic concurrency | ### CLI Commands @@ -313,7 +320,8 @@ Lists job bundles in a local directory or the queue's S3 `job-bundles/` folder. - With no arguments, lists bundles in the configured default local directory (`settings.job_bundle_default_directory`, or home if not set). No AWS config needed. - With `path`, lists bundles in that local directory. -- With `--queue`, lists bundles shared on the queue (requires farm and queue). +- With `--queue`, lists bundles shared on the queue (requires farm and queue). Hidden bundles are excluded by default. +- With `--queue --show-hidden`, includes hidden bundles in the output (marked with `(hidden)` in plain text, `"hidden": true` in JSON). - Default output is one bundle name per line, suitable for piping. - `--output json`: JSON array with name, format (archive/folder), and path. @@ -330,6 +338,12 @@ blender-render maya-arnold monte_carlo_simulation +$ deadline bundle list --queue --show-hidden +blender-render +maya-arnold +monte_carlo_simulation +old-maya-job (hidden) + $ deadline bundle list --queue --output json [{"name": "blender-render", "path": "s3://bucket/prefix/job-bundles/blender-render.ojd", "format": "archive"}, ...] @@ -427,6 +441,97 @@ $ deadline bundle download blender-render -o /tmp/bundles Downloaded bundle to: /tmp/bundles/blender-render ``` +#### `deadline bundle hide ` + +Hides a shared bundle on the queue. The bundle remains in S3 but is no longer shown in the browser or `deadline bundle list` by default. + +- Updates the `.bundle-visibility.json` manifest using conditional writes (automatic retry on conflict). +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. +- No-op if the bundle is already hidden. + +``` +$ deadline bundle hide blender-render +Hidden bundle: blender-render + +$ deadline bundle hide blender-render +Bundle already hidden: blender-render +``` + +#### `deadline bundle unhide ` + +Unhides a previously hidden bundle, making it visible again in the browser and `deadline bundle list`. + +- Updates the `.bundle-visibility.json` manifest using conditional writes (automatic retry on conflict). +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. +- No-op if the bundle is not hidden. + +``` +$ deadline bundle unhide blender-render +Unhidden bundle: blender-render + +$ deadline bundle unhide blender-render +Bundle is not hidden: blender-render +``` + +### Bundle Visibility + +Bundles shared on S3 can be "hidden" without deleting them. This is the S3 equivalent of dot-prefixed hidden folders on local filesystems — bundles remain accessible but are not shown in the browser by default. + +**Mechanism — sidecar manifest with optimistic concurrency:** + +A single JSON file stores the list of hidden bundle names: + +``` +s3://{bucket}/{rootPrefix}/job-bundles/.bundle-visibility.json +``` + +```json +{ + "version": 1, + "hidden": ["blender-render", "old-maya-job"] +} +``` + +The `version` field allows evolving the format in the future (e.g. adding per-entry comments or timestamps) without breaking older clients. The `hidden` array contains bundle names (without the `.ojd` extension) — these correspond 1:1 with object keys since duplicate names are not allowed in the same folder. In Python, the array is deserialized into a `set` for O(1) lookup, and serialized back as a sorted list for stable output. + +**Scope:** A single manifest exists at the `job-bundles/` root and covers all bundles including those in subfolders (stored as relative paths, e.g. `"rendering/custom-renderer"`). This avoids additional S3 calls to fetch per-subfolder manifests. + +**Re-upload behavior:** If a hidden bundle is re-uploaded (overwritten), it remains hidden. The hide is "sticky" to the name. This is intentional — hiding expresses "this name shouldn't clutter the default view" regardless of the object's content. The export dialog handles this explicitly (see [Export Bundle](#export-bundle)). + +**Pruning:** When the manifest is read during listing, any entries that don't match an existing bundle are silently removed. The pruned manifest is written back (using the same conditional write) only if entries were actually removed. This prevents unbounded growth from bundles that were deleted directly via S3. + +**Sync behavior:** The manifest is fetched fresh (single `GetObject`) alongside `list_objects_v2` every time the Queue source is loaded or refreshed. There is no persistent local copy or background polling. If a teammate hides a bundle, the change is visible next time the browser loads the listing. + +**Why a sidecar manifest?** `list_objects_v2` does not return per-object user metadata or tags. A per-object approach would require a `head_object` call for every bundle during listing. The sidecar is fetched with a single `GetObject` during listing, making hidden-bundle filtering zero-cost per bundle. + +**Concurrency control:** Updates use S3 conditional writes (`If-Match` on ETag) to prevent lost updates when multiple users hide/unhide simultaneously: + +1. `GetObject` on `.bundle-visibility.json` → get contents + ETag (or handle `NoSuchKey` for first use). +2. Modify the hidden set locally. +3. `PutObject` with `If-Match: ` (or `If-None-Match: *` for creation). +4. On `412 Precondition Failed`, retry from step 1 (up to 3 attempts). + +Retries are transparent to the user — the operation either succeeds silently or shows an inline error after exhausting retries. + +**Browser behavior:** + +- When "Show hidden" is unchecked (default), bundles in the hidden list are excluded from the tree. +- When "Show hidden" is checked, hidden bundles appear with a dimmed/grayed 📦 icon. +- Right-click context menu on bundles (Queue source only): + - Visible bundle → "Hide bundle" + - Hidden bundle → "Unhide bundle" +- After hide/unhide, the tree refreshes to reflect the change. + +**Permissions:** Hiding/unhiding requires `s3:GetObject` and `s3:PutObject` on the `.bundle-visibility.json` key. Users with read-only access can still browse (the manifest is read during listing) but cannot hide/unhide. + +### Progress Indication + +Operations that involve network I/O show progress to the user: + +- **Browser dialog**: When selecting an S3 bundle and clicking "Select", a progress spinner replaces the Select button label while the archive is downloaded/resolved. The dialog remains responsive (download happens on a background thread). +- **Export dialog**: When uploading to Queue, a progress bar appears below the Export button showing upload progress. Cancel is available during upload. +- **CLI**: `deadline bundle upload` and `deadline bundle download` show a progress bar (using the same style as job attachment uploads). `deadline bundle hide`/`unhide` complete fast enough to not need progress. + ### Error Handling Errors are displayed inline rather than as popup dialogs: @@ -436,6 +541,7 @@ Errors are displayed inline rather than as popup dialogs: - **Expand failure** (subfolder listing fails): A disabled `⚠ Error: {message}` entry appears in the tree under that folder. - **Preview failure** (malformed template, missing fields): The preview panel shows "⚠ Error" with "Could not read bundle template" and the tree entry icon changes from 📦 to ⚠. - **Double-click**: Double-clicking a bundle selects it and accepts the dialog. Double-clicking a folder does nothing. +- **Hide/unhide failure** (insufficient permissions or conflict): The context menu action is always shown. If the operation fails (e.g. `AccessDeniedException`), an inline warning appears below the tree: "⚠ Could not hide bundle: AccessDeniedException". ### Archive Safety @@ -465,11 +571,21 @@ This means the S3 key preserves the original name as-is (all characters are vali ### S3 Considerations - **Authentication**: S3 browsing and CLI commands use `api.get_boto3_session()` which respects the configured AWS profile in `~/.deadline/config`. The `S3BundleRepository.from_config()` factory method encapsulates session creation, queue lookup, and settings extraction in one place. No separate auth flow. -- **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. If access is denied, show an error rather than crashing. +- **Permissions**: Requires `s3:ListBucket` and `s3:GetObject` on the queue's attachment bucket for browsing/download. Upload additionally requires `s3:PutObject`. Hiding/unhiding bundles requires `s3:GetObject` and `s3:PutObject` on the `.bundle-visibility.json` key. If access is denied, show an error rather than crashing. - **Performance**: Listing is a single paginated `list_objects_v2` call with delimiter. Archive preview with S3 metadata is 1 `head_object` (no download). Cached archive selection is 1 `head_object`. - **S3 object metadata**: `deadline bundle upload` attaches bundle name, description, steps, and parameters as S3 user metadata. This enables zero-download preview via `head_object`. Archives uploaded by other means fall back to downloading the archive for preview. - **Bundled assets**: Scripts, data files, and other assets within the bundle are included in the archive. Relative PATH parameters resolve against the extracted copy. +### MCP Server Integration + +The MCP server exposes bundle sharing operations as tools for AI assistants: + +- **list_shared_bundles** — Lists bundles on the queue (respects visibility, supports `show_hidden`). +- **upload_bundle** — Uploads a local job bundle to the queue as an `.ojd` archive. +- **download_bundle** — Downloads a shared bundle from the queue to a local directory. + +These tools use the same `S3BundleRepository` as the CLI and GUI, so behavior is consistent. Hide/unhide is not exposed via MCP — it's a management action better suited to direct user intent via CLI or GUI. + ## Out of Scope (Future) - Favoriting or pinning frequently used bundles. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 8fef9094d..e22aef89c 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -594,6 +594,11 @@ def _get_queue_s3_settings(config): is_flag=True, help="List bundles shared on the queue.", ) +@click.option( + "--show-hidden", + is_flag=True, + help="Include hidden bundles in the output (queue only).", +) @click.option( "--no-archives", is_flag=True, @@ -609,7 +614,7 @@ def _get_queue_s3_settings(config): help="Output format. TEXT prints one name per line, JSON prints full details.", ) @_handle_error -def bundle_list(path, use_queue, no_archives, output, **args): +def bundle_list(path, use_queue, show_hidden, no_archives, output, **args): """ List job bundles. @@ -620,9 +625,11 @@ def bundle_list(path, use_queue, no_archives, output, **args): With --queue, lists bundles shared on the queue. """ + hidden_set: set[str] = set() if use_queue: config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) repo: BundleRepository = S3BundleRepository.from_config(config) + hidden_set = repo.get_hidden_set() # type: ignore[attr-defined] else: if path: local_root = os.path.abspath(path) @@ -637,19 +644,30 @@ def bundle_list(path, use_queue, no_archives, output, **args): entries = repo.list_entries(repo.root_path()) bundles = [e for e in entries if e.is_bundle] + # Filter hidden bundles unless --show-hidden + if use_queue and not show_hidden: + bundles = [e for e in bundles if e.name not in hidden_set] + + # Prune stale hidden entries + if use_queue and hidden_set: + existing_names = {e.name for e in entries if e.is_bundle} + repo.prune_hidden_set(existing_names) # type: ignore[attr-defined] + if output == "json": result = [ { "name": e.name, "path": e.path, "format": "archive" if e.is_archive else "folder", + **({"hidden": True} if e.name in hidden_set else {}), } for e in bundles ] click.echo(json.dumps(result, indent=2)) else: for e in bundles: - click.echo(e.name) + suffix = " (hidden)" if e.name in hidden_set else "" + click.echo(f"{e.name}{suffix}") @cli_bundle.group(name="cache") @@ -937,3 +955,52 @@ def bundle_download(bundle_name, output_dir, **args): shutil.rmtree(dest_path) shutil.copytree(local_path, dest_path) click.echo(f"Downloaded bundle to: {dest_path}") + + +@cli_bundle.command(name="hide") +@click.argument("bundle_name") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def bundle_hide(bundle_name, **args): + """ + Hide a shared bundle on the queue. + + The bundle remains in S3 but is no longer shown in the browser or + `deadline bundle list` by default. Use --show-hidden to see it. + """ + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + repo = S3BundleRepository.from_config(config) + + hidden_set = repo.get_hidden_set() + if bundle_name in hidden_set: + click.echo(f"Bundle already hidden: {bundle_name}") + return + + repo.set_bundle_visibility(bundle_name, hidden=True) + click.echo(f"Hidden bundle: {bundle_name}") + + +@cli_bundle.command(name="unhide") +@click.argument("bundle_name") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def bundle_unhide(bundle_name, **args): + """ + Unhide a previously hidden bundle on the queue. + + Makes the bundle visible again in the browser and `deadline bundle list`. + """ + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + repo = S3BundleRepository.from_config(config) + + hidden_set = repo.get_hidden_set() + if bundle_name not in hidden_set: + click.echo(f"Bundle is not hidden: {bundle_name}") + return + + repo.set_bundle_visibility(bundle_name, hidden=False) + click.echo(f"Unhidden bundle: {bundle_name}") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 3661fba5d..7f14bd966 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -21,6 +21,8 @@ import yaml +from botocore.exceptions import ClientError + from ..config import config_file from ..config.config_file import get_cache_directory from ..exceptions import DeadlineOperationError @@ -31,6 +33,9 @@ S3_JOB_BUNDLES_PREFIX = "job-bundles" ARCHIVE_EXTENSION = ".ojd" CACHE_META_FILENAME = ".bundle_cache_meta.json" +VISIBILITY_MANIFEST_FILENAME = ".bundle-visibility.json" +VISIBILITY_MANIFEST_VERSION = 1 +VISIBILITY_MAX_RETRIES = 3 # S3 user-defined metadata is limited to 2 KB total (keys + values, UTF-8 encoded). # Keys include the "x-amz-meta-" prefix (12 bytes) added by S3. @@ -632,3 +637,114 @@ def _to_s3_prefix(self, path: str) -> str: """Convert an s3:// URI or prefix to a raw S3 prefix ending with /.""" key = self._to_s3_key(path) return key if key.endswith("/") else key + "/" + + # ── Visibility manifest ────────────────────────────────── + + def _visibility_key(self) -> str: + """S3 key for the visibility manifest.""" + return f"{self._prefix}{VISIBILITY_MANIFEST_FILENAME}" + + def get_hidden_set(self) -> set[str]: + """Fetch the set of hidden bundle names from the visibility manifest.""" + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=self._visibility_key()) + data = json.loads(resp["Body"].read()) + return set(data.get("hidden", [])) + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchKey": + return set() + raise + + def set_bundle_visibility(self, bundle_name: str, *, hidden: bool) -> None: + """Hide or unhide a bundle using optimistic concurrency on the manifest. + + Retries transparently on conflict (up to VISIBILITY_MAX_RETRIES attempts). + """ + key = self._visibility_key() + for _ in range(VISIBILITY_MAX_RETRIES): + etag = None + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + data = json.loads(resp["Body"].read()) + etag = resp["ETag"] + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchKey": + data = {"version": VISIBILITY_MANIFEST_VERSION, "hidden": []} + else: + raise + + hidden_set = set(data.get("hidden", [])) + if hidden and bundle_name in hidden_set: + return # Already hidden + if not hidden and bundle_name not in hidden_set: + return # Already visible + + if hidden: + hidden_set.add(bundle_name) + else: + hidden_set.discard(bundle_name) + + data["hidden"] = sorted(hidden_set) + data["version"] = VISIBILITY_MANIFEST_VERSION + body = json.dumps(data, indent=2) + + put_kwargs: dict = { + "Bucket": self._bucket, + "Key": key, + "Body": body, + "ContentType": "application/json", + } + if etag: + put_kwargs["IfMatch"] = etag + else: + put_kwargs["IfNoneMatch"] = "*" + + try: + self._s3.put_object(**put_kwargs) + return + except ClientError as e: + code = e.response["Error"]["Code"] + if code in ("PreconditionFailed", "ConditionalCheckFailed"): + continue # Retry + raise + + raise DeadlineOperationError( + f"Failed to update bundle visibility after {VISIBILITY_MAX_RETRIES} retries " + f"(concurrent modifications). Try again." + ) + + def prune_hidden_set(self, existing_names: set[str]) -> None: + """Remove entries from the hidden manifest that no longer exist in S3. + + Called during listing to keep the manifest tidy. Only writes if entries + were actually pruned. + """ + key = self._visibility_key() + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + data = json.loads(resp["Body"].read()) + etag = resp["ETag"] + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchKey": + return + raise + + hidden_set = set(data.get("hidden", [])) + pruned = hidden_set - existing_names + if not pruned: + return + + data["hidden"] = sorted(hidden_set & existing_names) + body = json.dumps(data, indent=2) + try: + self._s3.put_object( + Bucket=self._bucket, + Key=key, + Body=body, + ContentType="application/json", + IfMatch=etag, + ) + logger.debug("Pruned %d stale entries from visibility manifest", len(pruned)) + except ClientError: + # Best-effort pruning — if it fails (conflict), skip silently + logger.debug("Pruning visibility manifest failed (concurrent write), skipping") diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 7d31243f8..4a3128df8 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -24,6 +24,7 @@ QHeaderView, QLabel, QLineEdit, + QMenu, QPushButton, QRadioButton, QScrollArea, @@ -50,6 +51,25 @@ ROLE_IS_BUNDLE = Qt.UserRole + 2 ROLE_LOADED = Qt.UserRole + 3 ROLE_IS_ARCHIVE = Qt.UserRole + 4 +ROLE_IS_HIDDEN = Qt.UserRole + 5 + + +class _BundleFilterProxy(QSortFilterProxyModel): + """Proxy that filters by text and optionally hides items marked as hidden.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._show_hidden = False + + def set_show_hidden(self, show: bool): + self._show_hidden = show + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent) -> bool: # type: ignore[override] + index = self.sourceModel().index(source_row, 0, source_parent) + if not self._show_hidden and index.data(ROLE_IS_HIDDEN): + return False + return super().filterAcceptsRow(source_row, source_parent) class JobBundleBrowserDialog(QDialog): @@ -93,6 +113,9 @@ def __init__( self._selected_path: Optional[str] = None self._selected_is_s3 = False self._selected_is_archive = False + self._cached_root_entries: list[BrowseEntry] = [] + self._hidden_set: set[str] = set() + self._last_preview_path: Optional[str] = None self._ready = False self._build_ui() @@ -179,8 +202,8 @@ def _build_ui(self): self._queue_warning.setVisible(False) layout.addWidget(self._queue_warning) - # Show hidden folders checkbox - self._show_hidden_cb = QCheckBox(tr("Show hidden folders"), parent=self) + # Show hidden checkbox + self._show_hidden_cb = QCheckBox(tr("Show hidden"), parent=self) self._show_hidden_cb.setChecked(False) self._show_hidden_cb.toggled.connect(self._on_hidden_toggled) layout.addWidget(self._show_hidden_cb) @@ -210,7 +233,7 @@ def _build_ui(self): self._model = QStandardItemModel() self._model.setHorizontalHeaderLabels([tr("Name")]) - self._proxy = QSortFilterProxyModel() + self._proxy = _BundleFilterProxy() self._proxy.setSourceModel(self._model) self._proxy.setRecursiveFilteringEnabled(True) self._proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) @@ -219,6 +242,8 @@ def _build_ui(self): self._tree.setModel(self._proxy) self._tree.setHeaderHidden(True) self._tree.setEditTriggers(QTreeView.NoEditTriggers) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) self._tree.expanded.connect(self._on_expanded) self._tree.clicked.connect(self._on_clicked) self._tree.doubleClicked.connect(self._on_double_clicked) @@ -294,36 +319,46 @@ def _build_ui(self): # ── Tree Population ────────────────────────────────────────── - def _filter_entries(self, entries: list) -> list: - """Filter out hidden entries (names starting with '.') unless show hidden is checked.""" - if self._show_hidden_cb.isChecked(): - return entries - return [e for e in entries if not e.name.startswith(".")] - def _populate_root(self): self._model.clear() self._model.setHorizontalHeaderLabels([tr("Name")]) root_path = self._current_repo.root_path() self._path_display.setText(root_path) try: - entries = self._current_repo.list_entries(root_path) + self._cached_root_entries = self._current_repo.list_entries(root_path) except Exception as e: logger.warning("Failed to list bundles: %s", e, exc_info=True) self._show_error_preview(f"Failed to list bundles:\n{e}") - entries = [] + self._cached_root_entries = [] + + # Fetch S3 hidden set for Queue source + self._hidden_set = set() + if isinstance(self._current_repo, S3BundleRepository): + try: + self._hidden_set = self._current_repo.get_hidden_set() + except Exception: + logger.debug("Failed to fetch visibility manifest", exc_info=True) + root = self._model.invisibleRootItem() - for entry in self._filter_entries(entries): - self._add_entry_item(root, entry) + for entry in self._cached_root_entries: + is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set + self._add_entry_item(root, entry, is_hidden=is_hidden) - def _add_entry_item(self, parent_item: QStandardItem, entry: BrowseEntry): + def _add_entry_item( + self, parent_item: QStandardItem, entry: BrowseEntry, *, is_hidden: bool = False + ): item = QStandardItem(self._entry_display(entry)) item.setData(entry.path, ROLE_PATH) item.setData(entry.is_bundle, ROLE_IS_BUNDLE) item.setData(False, ROLE_LOADED) item.setData(entry.is_archive, ROLE_IS_ARCHIVE) + item.setData(is_hidden, ROLE_IS_HIDDEN) + if is_hidden: + item.setForeground(QColor(150, 150, 150)) if not entry.is_bundle: # Add a placeholder child so the expand arrow shows placeholder = QStandardItem() + placeholder.setData(is_hidden, ROLE_IS_HIDDEN) item.appendRow(placeholder) parent_item.appendRow(item) @@ -355,8 +390,12 @@ def _on_expanded(self, proxy_index: QModelIndex): error_item.setEnabled(False) item.appendRow(error_item) return - for entry in self._filter_entries(entries): - self._add_entry_item(item, entry) + for entry in entries: + is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set + # If parent is hidden, children inherit hidden state + if item.data(ROLE_IS_HIDDEN): + is_hidden = True + self._add_entry_item(item, entry, is_hidden=is_hidden) def _on_clicked(self, proxy_index: QModelIndex): self._update_selection(proxy_index) @@ -392,7 +431,8 @@ def _update_selection(self, proxy_index: QModelIndex): self._selected_is_s3 = self._radio_s3.isChecked() self._selected_is_archive = bool(item.data(ROLE_IS_ARCHIVE)) self._select_button.setEnabled(True) - self._load_preview(path, item) + if path != self._last_preview_path: + self._load_preview(path, item) else: self._selected_path = None self._select_button.setEnabled(False) @@ -448,11 +488,56 @@ def _on_source_changed(self, checked: bool): def _on_hidden_toggled(self, checked: bool): if not self._ready: return - self._populate_root() + self._proxy.set_show_hidden(checked) + + def _on_context_menu(self, position): + """Show hide/unhide context menu for Queue source bundles.""" + if not isinstance(self._current_repo, S3BundleRepository): + return + proxy_index = self._tree.indexAt(position) + if not proxy_index.isValid(): + return + source_index = self._proxy.mapToSource(proxy_index) + item = self._model.itemFromIndex(source_index) + if not item or not item.data(ROLE_IS_BUNDLE): + return + + path = item.data(ROLE_PATH) + name = path.rsplit("/", 1)[-1] + if name.endswith(".ojd"): + name = name[:-4] + is_hidden = bool(item.data(ROLE_IS_HIDDEN)) + + menu = QMenu(self) + if is_hidden: + action = menu.addAction(tr("Unhide bundle")) + else: + action = menu.addAction(tr("Hide bundle")) + + chosen = menu.exec_(self._tree.viewport().mapToGlobal(position)) + if chosen != action: + return + + try: + self._current_repo.set_bundle_visibility(name, hidden=not is_hidden) + item.setData(not is_hidden, ROLE_IS_HIDDEN) + if not is_hidden: + item.setForeground(QColor(150, 150, 150)) + self._hidden_set.add(name) + else: + item.setForeground(QColor(0, 0, 0)) + self._hidden_set.discard(name) + self._proxy.invalidateFilter() + except Exception as e: + logger.warning("Failed to update visibility: %s", e, exc_info=True) + self._show_error_preview( + f"\u26a0 Could not {'hide' if not is_hidden else 'unhide'} bundle: {e}" + ) # ── Preview ────────────────────────────────────────────────── def _load_preview(self, path: str, item: Optional[QStandardItem] = None): + self._last_preview_path = path try: info = self._current_repo.get_bundle_info(path) except Exception as e: @@ -515,6 +600,7 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): self._preview_params.setVisible(False) def _clear_preview(self): + self._last_preview_path = None self._preview_name.setText(tr("Select a job bundle to see details")) self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: gray;") self._preview_desc.setVisible(False) diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index b5548120f..716a7b43b 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -58,6 +58,8 @@ "Hardware requirements": "Hardwareanforderungen", "Hashing progress": "Hashing-Fortschritt", "Help": "Hilfe", + "Hide bundle": "Bundle ausblenden", + "Unhide bundle": "Bundle einblenden", "Host requirements": "Host-Anforderungen", "Initial state": "Anfangszustand", "Issue With Profile Configuration": "Problem mit Profilkonfiguration", @@ -112,7 +114,7 @@ "Settings...": "Einstellungen...", "Shared job settings": "Gemeinsame Jobeinstellungen", "Show auto-detected": "Automatisch erkannte anzeigen", - "Show hidden folders": "Versteckte Ordner anzeigen", + "Show hidden": "Versteckte anzeigen", "Show submitter update notifications": "Aktualisierungsbenachrichtigungen des Submitters anzeigen", "Specify a job bundle directory or run the bundle command with the --browse flag": "Geben Sie ein Jobpaket-Verzeichnis an oder führen Sie den Bundle-Befehl mit dem Flag --browse aus", "Specify output directories": "Ausgabeverzeichnisse angeben", diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index 30739d0b4..3f29f186c 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -58,6 +58,7 @@ "Hardware requirements": "Hardware requirements", "Hashing progress": "Hashing progress", "Help": "Help", + "Hide bundle": "Hide bundle", "Host requirements": "Host requirements", "Initial state": "Initial state", "Issue With Profile Configuration": "Issue With Profile Configuration", @@ -112,7 +113,7 @@ "Settings...": "Settings...", "Shared job settings": "Shared job settings", "Show auto-detected": "Show auto-detected", - "Show hidden folders": "Show hidden folders", + "Show hidden": "Show hidden", "Show submitter update notifications": "Show submitter update notifications", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specify a job bundle directory or run the bundle command with the --browse flag", "Specify output directories": "Specify output directories", @@ -134,6 +135,7 @@ "Unknown Issue With Configured Profile": "Unknown Issue With Configured Profile", "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.": "Version {latest_version} of Deadline Cloud for {integration_name} submitter is now available.", "View release notes": "View release notes", + "Unhide bundle": "Unhide bundle", "Unrecognized Parameters": "Unrecognized Parameters", "Upload progress": "Upload progress", "Use array parameter": "Use array parameter", diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index e948d49f5..be6bd6132 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -58,6 +58,8 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progreso de hash", "Help": "Ayuda", + "Hide bundle": "Ocultar bundle", + "Unhide bundle": "Mostrar bundle", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema con la configuración del perfil", @@ -112,7 +114,7 @@ "Settings...": "Configuración...", "Shared job settings": "Configuración de trabajo compartida", "Show auto-detected": "Mostrar detectados automáticamente", - "Show hidden folders": "Mostrar carpetas ocultas", + "Show hidden": "Mostrar ocultos", "Show submitter update notifications": "Mostrar notificaciones de actualización del remitente", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique un directorio de paquete de trabajos o ejecute el comando bundle con la marca --browse", "Specify output directories": "Especificar directorios de salida", diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index 00baf4814..f0c821730 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -58,6 +58,8 @@ "Hardware requirements": "Exigences matérielles", "Hashing progress": "Progression du hachage", "Help": "Aide", + "Hide bundle": "Masquer le bundle", + "Unhide bundle": "Afficher le bundle", "Host requirements": "Exigences d'hôte", "Initial state": "État initial", "Issue With Profile Configuration": "Problème avec la configuration du profil", @@ -112,7 +114,7 @@ "Settings...": "Paramètres...", "Shared job settings": "Paramètres de tâche partagés", "Show auto-detected": "Afficher les éléments détectés automatiquement", - "Show hidden folders": "Afficher les dossiers cachés", + "Show hidden": "Afficher les éléments masqués", "Show submitter update notifications": "Afficher les notifications de mise à jour du soumetteur", "Specify a job bundle directory or run the bundle command with the --browse flag": "Spécifiez un répertoire de lot de tâches ou exécutez la commande bundle avec l'indicateur --browse", "Specify output directories": "Spécifier les répertoires de sortie", diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index 600f2c0b0..a9f600db7 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -58,6 +58,8 @@ "Hardware requirements": "Persyaratan perangkat keras", "Hashing progress": "Kemajuan hashing", "Help": "Bantuan", + "Hide bundle": "Sembunyikan bundle", + "Unhide bundle": "Tampilkan bundle", "Host requirements": "Persyaratan host", "Initial state": "Status awal", "Issue With Profile Configuration": "Masalah dengan konfigurasi profil", @@ -112,7 +114,7 @@ "Settings...": "Pengaturan...", "Shared job settings": "Pengaturan pekerjaan bersama", "Show auto-detected": "Tampilkan yang terdeteksi otomatis", - "Show hidden folders": "Tampilkan folder tersembunyi", + "Show hidden": "Tampilkan tersembunyi", "Show submitter update notifications": "Tampilkan notifikasi pembaruan pengirim", "Specify a job bundle directory or run the bundle command with the --browse flag": "Tentukan direktori bundel pekerjaan atau jalankan perintah bundle dengan flag --browse", "Specify output directories": "Tentukan direktori output", diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index 861303e67..0bde4ce80 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -58,6 +58,8 @@ "Hardware requirements": "Requisiti hardware", "Hashing progress": "Avanzamento hashing", "Help": "Aiuto", + "Hide bundle": "Nascondi bundle", + "Unhide bundle": "Mostra bundle", "Host requirements": "Requisiti host", "Initial state": "Stato iniziale", "Issue With Profile Configuration": "Problema con la configurazione del profilo", @@ -112,7 +114,7 @@ "Settings...": "Impostazioni...", "Shared job settings": "Impostazioni lavoro condivise", "Show auto-detected": "Mostra rilevati automaticamente", - "Show hidden folders": "Mostra cartelle nascoste", + "Show hidden": "Mostra nascosti", "Show submitter update notifications": "Mostra notifiche di aggiornamento del submitter", "Specify a job bundle directory or run the bundle command with the --browse flag": "Specifica una directory pacchetto lavoro o esegui il comando bundle con il flag --browse", "Specify output directories": "Specifica directory di output", diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index 2c47fcb63..8c10ff332 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -58,6 +58,8 @@ "Hardware requirements": "ハードウェア要件", "Hashing progress": "ハッシュ進行状況", "Help": "ヘルプ", + "Hide bundle": "バンドルを非表示", + "Unhide bundle": "バンドルを表示", "Host requirements": "ホスト要件", "Initial state": "初期状態", "Issue With Profile Configuration": "プロファイル設定の問題", @@ -112,7 +114,7 @@ "Settings...": "設定...", "Shared job settings": "共有ジョブ設定", "Show auto-detected": "自動検出されたものを表示", - "Show hidden folders": "隠しフォルダーを表示", + "Show hidden": "非表示を表示", "Show submitter update notifications": "サブミッターの更新通知を表示", "Specify a job bundle directory or run the bundle command with the --browse flag": "ジョブバンドルディレクトリを指定するか、--browse フラグを使用して bundle コマンドを実行してください", "Specify output directories": "出力ディレクトリを指定", diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index 8a43e2f96..f54f8d52c 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -58,6 +58,8 @@ "Hardware requirements": "하드웨어 요구 사항", "Hashing progress": "해싱 진행률", "Help": "도움말", + "Hide bundle": "번들 숨기기", + "Unhide bundle": "번들 표시", "Host requirements": "호스트 요구 사항", "Initial state": "초기 상태", "Issue With Profile Configuration": "프로필 구성 문제", @@ -112,7 +114,7 @@ "Settings...": "설정...", "Shared job settings": "공유 작업 설정", "Show auto-detected": "자동 감지된 항목 표시", - "Show hidden folders": "숨겨진 폴더 표시", + "Show hidden": "숨겨진 항목 표시", "Show submitter update notifications": "제출기 업데이트 알림 표시", "Specify a job bundle directory or run the bundle command with the --browse flag": "작업 번들 디렉터리를 지정하거나 --browse 플래그와 함께 bundle 명령을 실행하세요", "Specify output directories": "출력 디렉터리 지정", diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 7d4c58338..0fad0c2d8 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -58,6 +58,8 @@ "Hardware requirements": "Requisitos de hardware", "Hashing progress": "Progresso de hash", "Help": "Ajuda", + "Hide bundle": "Ocultar bundle", + "Unhide bundle": "Exibir bundle", "Host requirements": "Requisitos de host", "Initial state": "Estado inicial", "Issue With Profile Configuration": "Problema com a configuração do perfil", @@ -112,7 +114,7 @@ "Settings...": "Configurações...", "Shared job settings": "Configurações de trabalho compartilhadas", "Show auto-detected": "Mostrar detectados automaticamente", - "Show hidden folders": "Mostrar pastas ocultas", + "Show hidden": "Mostrar ocultos", "Show submitter update notifications": "Mostrar notificações de atualização do submissor", "Specify a job bundle directory or run the bundle command with the --browse flag": "Especifique um diretório de pacote de tarefas ou execute o comando bundle com a flag --browse", "Specify output directories": "Especificar diretórios de saída", diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index 55b2bdc2a..aacde9bc9 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -58,6 +58,8 @@ "Hardware requirements": "Donanım gereksinimleri", "Hashing progress": "Karma oluşturma ilerlemesi", "Help": "Yardım", + "Hide bundle": "Bundle'ı gizle", + "Unhide bundle": "Bundle'ı göster", "Host requirements": "Ana bilgisayar gereksinimleri", "Initial state": "Başlangıç durumu", "Issue With Profile Configuration": "Profil yapılandırmasıyla ilgili sorun", @@ -112,7 +114,7 @@ "Settings...": "Ayarlar...", "Shared job settings": "Paylaşılan iş ayarları", "Show auto-detected": "Otomatik algılanmışları göster", - "Show hidden folders": "Gizli klasörleri göster", + "Show hidden": "Gizlileri göster", "Show submitter update notifications": "Gönderi aracı güncelleme bildirimlerini göster", "Specify a job bundle directory or run the bundle command with the --browse flag": "Bir iş paketi dizini belirtin veya bundle komutunu --browse bayrağıyla çalıştırın", "Specify output directories": "Çıkış dizinlerini belirtin", diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index 80761477b..16ec0dd2b 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -58,6 +58,8 @@ "Hardware requirements": "硬件要求", "Hashing progress": "哈希进度", "Help": "帮助", + "Hide bundle": "隐藏 Bundle", + "Unhide bundle": "显示 Bundle", "Host requirements": "主机要求", "Initial state": "初始状态", "Issue With Profile Configuration": "配置文件配置问题", @@ -112,7 +114,7 @@ "Settings...": "设置...", "Shared job settings": "共享作业设置", "Show auto-detected": "显示自动检测的", - "Show hidden folders": "显示隐藏文件夹", + "Show hidden": "显示隐藏项", "Show submitter update notifications": "显示提交器更新通知", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定作业捆绑包目录或使用 --browse 标志运行 bundle 命令", "Specify output directories": "指定输出目录", diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index 95159f0e5..5acd8585c 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -58,6 +58,8 @@ "Hardware requirements": "硬體需求", "Hashing progress": "雜湊進度", "Help": "說明", + "Hide bundle": "隱藏 Bundle", + "Unhide bundle": "顯示 Bundle", "Host requirements": "主機需求", "Initial state": "初始狀態", "Issue With Profile Configuration": "設定檔組態問題", @@ -112,7 +114,7 @@ "Settings...": "設定...", "Shared job settings": "共用任務設定", "Show auto-detected": "顯示自動偵測的", - "Show hidden folders": "顯示隱藏資料夾", + "Show hidden": "顯示隱藏項目", "Show submitter update notifications": "顯示提交器更新通知", "Specify a job bundle directory or run the bundle command with the --browse flag": "指定任務套件目錄或使用 --browse 旗標執行 bundle 命令", "Specify output directories": "指定輸出目錄", diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 95bd67b6d..e6eb57951 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -12,6 +12,7 @@ from deadline.client.cli import main from deadline.client.job_bundle.repository import ( + BrowseEntry, METADATA_LIMIT_DESC, METADATA_LIMIT_NAME, METADATA_LIMIT_PARAMS, @@ -125,9 +126,6 @@ def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): assert "not appear to be a job bundle" in result.output -REPO_MODULE = "deadline.client.job_bundle.repository" - - class TestBundleCacheClean: def test_clean_no_cache(self, tmp_path): with patch( @@ -323,3 +321,149 @@ def test_upload_aborts_when_bundle_exists_and_user_declines( assert result.exit_code == 0, result.output assert "canceled" in result.output.lower() mock_s3.upload_fileobj.assert_not_called() + + +class TestBundleHide: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_hide_bundle(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = set() + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "hide", "blender-render"]) + + assert result.exit_code == 0, result.output + assert "Hidden bundle: blender-render" in result.output + mock_repo.set_bundle_visibility.assert_called_once_with("blender-render", hidden=True) + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_hide_already_hidden(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = {"blender-render"} + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "hide", "blender-render"]) + + assert result.exit_code == 0, result.output + assert "already hidden" in result.output + mock_repo.set_bundle_visibility.assert_not_called() + + +class TestBundleUnhide: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_unhide_bundle(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = {"blender-render"} + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "unhide", "blender-render"]) + + assert result.exit_code == 0, result.output + assert "Unhidden bundle: blender-render" in result.output + mock_repo.set_bundle_visibility.assert_called_once_with("blender-render", hidden=False) + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_unhide_not_hidden(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = set() + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "unhide", "blender-render"]) + + assert result.exit_code == 0, result.output + assert "not hidden" in result.output + mock_repo.set_bundle_visibility.assert_not_called() + + +class TestBundleListShowHidden: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_list_queue_hides_hidden_by_default(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = {"old-job"} + mock_repo.root_path.return_value = "s3://bucket/prefix/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="blender-render", + path="s3://b/p/blender-render.ojd", + is_bundle=True, + is_archive=True, + ), + BrowseEntry( + name="old-job", path="s3://b/p/old-job.ojd", is_bundle=True, is_archive=True + ), + ] + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "list", "--queue"]) + + assert result.exit_code == 0, result.output + assert "blender-render" in result.output + assert "old-job" not in result.output + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_list_queue_show_hidden(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = {"old-job"} + mock_repo.root_path.return_value = "s3://bucket/prefix/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="blender-render", + path="s3://b/p/blender-render.ojd", + is_bundle=True, + is_archive=True, + ), + BrowseEntry( + name="old-job", path="s3://b/p/old-job.ojd", is_bundle=True, is_archive=True + ), + ] + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "list", "--queue", "--show-hidden"]) + + assert result.exit_code == 0, result.output + assert "blender-render" in result.output + assert "old-job (hidden)" in result.output + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_list_queue_show_hidden_json(self, mock_from_config, mock_config): + mock_repo = MagicMock() + mock_repo.get_hidden_set.return_value = {"old-job"} + mock_repo.root_path.return_value = "s3://bucket/prefix/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="blender-render", + path="s3://b/p/blender-render.ojd", + is_bundle=True, + is_archive=True, + ), + BrowseEntry( + name="old-job", path="s3://b/p/old-job.ojd", is_bundle=True, is_archive=True + ), + ] + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke( + main, ["bundle", "list", "--queue", "--show-hidden", "--output", "json"] + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert len(data) == 2 + hidden_entry = next(e for e in data if e["name"] == "old-job") + visible_entry = next(e for e in data if e["name"] == "blender-render") + assert hidden_entry["hidden"] is True + assert "hidden" not in visible_entry diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 0cf293392..d3de2c5a3 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -12,8 +12,13 @@ import pytest import yaml +from unittest.mock import MagicMock, patch +from botocore.exceptions import ClientError + from deadline.client.job_bundle.repository import ( LocalBundleRepository, + S3BundleRepository, + VISIBILITY_MAX_RETRIES, _bundle_info_from_s3_metadata, _extract_bundle_info, _is_archive, @@ -537,3 +542,175 @@ def test_long_name_preserved(self): def test_normal_name_unchanged(self): assert sanitize_bundle_name("blender-render_v2.1") == "blender-render_v2.1" + + +class TestS3BundleVisibility: + def _make_repo(self): + """Create an S3BundleRepository with a mocked S3 client.""" + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", + root_prefix="DeadlineCloud", + session=MagicMock(), + ) + repo._s3 = MagicMock() + return repo + + def test_get_hidden_set_empty_when_no_manifest(self): + repo = self._make_repo() + repo._s3.get_object.side_effect = ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") + assert repo.get_hidden_set() == set() + + def test_get_hidden_set_returns_names(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["bundle-a", "bundle-b"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())) + } + assert repo.get_hidden_set() == {"bundle-a", "bundle-b"} + + def test_set_bundle_visibility_hide(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["existing"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"abc123"', + } + + repo.set_bundle_visibility("new-bundle", hidden=True) + + repo._s3.put_object.assert_called_once() + call_kwargs = repo._s3.put_object.call_args[1] + written = json.loads(call_kwargs["Body"]) + assert "new-bundle" in written["hidden"] + assert "existing" in written["hidden"] + assert call_kwargs["IfMatch"] == '"abc123"' + + def test_set_bundle_visibility_unhide(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["bundle-a", "bundle-b"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"abc123"', + } + + repo.set_bundle_visibility("bundle-a", hidden=False) + + repo._s3.put_object.assert_called_once() + call_kwargs = repo._s3.put_object.call_args[1] + written = json.loads(call_kwargs["Body"]) + assert "bundle-a" not in written["hidden"] + assert "bundle-b" in written["hidden"] + + def test_set_bundle_visibility_noop_already_hidden(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["bundle-a"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"abc123"', + } + + repo.set_bundle_visibility("bundle-a", hidden=True) + repo._s3.put_object.assert_not_called() + + def test_set_bundle_visibility_noop_already_visible(self): + repo = self._make_repo() + repo._s3.get_object.side_effect = ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") + + repo.set_bundle_visibility("bundle-a", hidden=False) + repo._s3.put_object.assert_not_called() + + def test_set_bundle_visibility_creates_manifest_on_first_hide(self): + repo = self._make_repo() + repo._s3.get_object.side_effect = ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") + + repo.set_bundle_visibility("new-bundle", hidden=True) + + repo._s3.put_object.assert_called_once() + call_kwargs = repo._s3.put_object.call_args[1] + written = json.loads(call_kwargs["Body"]) + assert written == {"version": 1, "hidden": ["new-bundle"]} + assert call_kwargs["IfNoneMatch"] == "*" + + def test_set_bundle_visibility_retries_on_conflict(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": []}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"etag1"', + } + # First put fails with PreconditionFailed, second succeeds + repo._s3.put_object.side_effect = [ + ClientError({"Error": {"Code": "PreconditionFailed"}}, "PutObject"), + {}, + ] + + repo.set_bundle_visibility("bundle-a", hidden=True) + + assert repo._s3.put_object.call_count == 2 + assert repo._s3.get_object.call_count == 2 + + def test_set_bundle_visibility_raises_after_max_retries(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": []}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"etag1"', + } + repo._s3.put_object.side_effect = ClientError( + {"Error": {"Code": "PreconditionFailed"}}, "PutObject" + ) + + from deadline.client.exceptions import DeadlineOperationError + + with pytest.raises(DeadlineOperationError, match="Failed to update bundle visibility"): + repo.set_bundle_visibility("bundle-a", hidden=True) + + assert repo._s3.put_object.call_count == VISIBILITY_MAX_RETRIES + + def test_set_bundle_visibility_hidden_list_sorted(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["z-bundle", "a-bundle"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"etag1"', + } + + repo.set_bundle_visibility("m-bundle", hidden=True) + + call_kwargs = repo._s3.put_object.call_args[1] + written = json.loads(call_kwargs["Body"]) + assert written["hidden"] == ["a-bundle", "m-bundle", "z-bundle"] + + def test_prune_hidden_set_removes_stale_entries(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["exists", "deleted"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"etag1"', + } + + repo.prune_hidden_set(existing_names={"exists", "other"}) + + repo._s3.put_object.assert_called_once() + call_kwargs = repo._s3.put_object.call_args[1] + written = json.loads(call_kwargs["Body"]) + assert written["hidden"] == ["exists"] + + def test_prune_hidden_set_noop_when_nothing_to_prune(self): + repo = self._make_repo() + manifest = json.dumps({"version": 1, "hidden": ["exists"]}) + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=manifest.encode())), + "ETag": '"etag1"', + } + + repo.prune_hidden_set(existing_names={"exists", "other"}) + repo._s3.put_object.assert_not_called() + + def test_prune_hidden_set_noop_when_no_manifest(self): + repo = self._make_repo() + repo._s3.get_object.side_effect = ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") + + repo.prune_hidden_set(existing_names={"anything"}) + repo._s3.put_object.assert_not_called() From 602b1d12c29a6d6d2a4d57df685615db1027308e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:21:48 -0500 Subject: [PATCH 30/89] fix: use queue user boto3 session for bundle browsing Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 7f14bd966..0a5193919 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -404,9 +404,10 @@ def from_config(cls, config=None) -> "S3BundleRepository": """Create an S3BundleRepository from the user's Deadline Cloud configuration. Handles session creation, queue lookup, and attachment settings extraction. + Uses queue role credentials for S3 access (required for DCM profiles). Raises DeadlineOperationError if farm/queue is not configured or has no attachments. """ - from ..api import get_boto3_session + from ..api import get_boto3_session, get_queue_user_boto3_session from ...job_attachments._aws.deadline import get_queue farm_id = config_file.get_setting("defaults.farm_id", config=config) @@ -419,10 +420,20 @@ def from_config(cls, config=None) -> "S3BundleRepository": raise DeadlineOperationError( f"Queue {queue_id} does not have job attachment settings configured." ) + + # Use queue role credentials for S3 operations + deadline_client = session.client("deadline") + s3_session = get_queue_user_boto3_session( + deadline=deadline_client, + config=config, + farm_id=farm_id, + queue_id=queue_id, + ) + return cls( bucket_name=queue.jobAttachmentSettings.s3BucketName, root_prefix=queue.jobAttachmentSettings.rootPrefix, - session=session, + session=s3_session, ) def root_path(self) -> str: From 4841239b117f9cff664755c2f5abbfdb5376a957 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:54:19 -0500 Subject: [PATCH 31/89] fix: download/upload to use queue role Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/cli/_groups/bundle_group.py | 7 ++++++- src/deadline/client/job_bundle/repository.py | 5 +++-- .../client/ui/dialogs/job_bundle_browser_dialog.py | 3 ++- .../client/ui/dialogs/submit_job_to_deadline_dialog.py | 9 ++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index e22aef89c..c7538b91a 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -583,7 +583,12 @@ def _get_queue_s3_settings(config): raise DeadlineOperationError( f"Queue {queue_id} does not have job attachment settings configured." ) - return queue.jobAttachmentSettings, boto3_session + # Use queue role credentials for S3 access (required for DCM profiles) + deadline_client = api.get_boto3_client("deadline", config=config) + s3_session = api.get_queue_user_boto3_session( + deadline=deadline_client, config=config, farm_id=farm_id, queue_id=queue_id + ) + return queue.jobAttachmentSettings, s3_session @cli_bundle.command(name="list") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 0a5193919..b94cc9c6f 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -407,7 +407,7 @@ def from_config(cls, config=None) -> "S3BundleRepository": Uses queue role credentials for S3 access (required for DCM profiles). Raises DeadlineOperationError if farm/queue is not configured or has no attachments. """ - from ..api import get_boto3_session, get_queue_user_boto3_session + from ..api import get_boto3_client, get_boto3_session, get_queue_user_boto3_session from ...job_attachments._aws.deadline import get_queue farm_id = config_file.get_setting("defaults.farm_id", config=config) @@ -422,12 +422,13 @@ def from_config(cls, config=None) -> "S3BundleRepository": ) # Use queue role credentials for S3 operations - deadline_client = session.client("deadline") + deadline_client = get_boto3_client("deadline", config=config) s3_session = get_queue_user_boto3_session( deadline=deadline_client, config=config, farm_id=farm_id, queue_id=queue_id, + queue_display_name=queue.displayName, ) return cls( diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 4a3128df8..add6e2607 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -283,7 +283,8 @@ def _build_ui(self): self._preview_params.verticalHeader().setVisible(False) self._preview_params.setEditTriggers(QTableWidget.NoEditTriggers) self._preview_params.setSelectionMode(QTableWidget.NoSelection) - preview_layout.addWidget(self._preview_params, stretch=1) + preview_layout.addWidget(self._preview_params) + preview_layout.addStretch(1) self._clear_preview() 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 fb3870318..1067e063b 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 @@ -760,7 +760,14 @@ def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name f"Bundle exported to queue:\ns3://{queue_repo._bucket}/{s3_key}", ) except Exception as exc: - QMessageBox.critical(self, "Export failed", f"Failed to upload bundle:\n{exc}") + from botocore.exceptions import ClientError + + logger.error("Failed to export bundle: %s", exc, exc_info=True) + if isinstance(exc, ClientError) and exc.response["Error"]["Code"] == "AccessDenied": + msg = "You don't have permission to share bundles on this queue." + else: + msg = f"Failed to upload bundle:\n{exc}" + QMessageBox.critical(self, "Export failed", msg) def save_job_parameters_to_job_bundle( self, job_bundle_dir: str, job_parameters: list[JobParameter] From 0fa2c7cef2acbb831b7da9f91d8393b7ddbece17 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:28:15 -0500 Subject: [PATCH 32/89] feat: add deadline bundle info command Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 46 ++++++++++- .../client/cli/_groups/bundle_group.py | 80 +++++++++++++++++++ src/deadline/client/job_bundle/repository.py | 30 +++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 0dd6789d8..ce6d44725 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -302,7 +302,7 @@ Bundled assets (scripts, data files) with relative paths resolve correctly again | File | Change | |---|---| | `config/config_file.py` | Add `settings.job_bundle_default_directory` to `SETTINGS` | -| `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, `deadline bundle hide`, `deadline bundle unhide`, and `deadline bundle cache` (clean/update) commands | +| `cli/_groups/bundle_group.py` | Add `deadline bundle list`, `deadline bundle upload`, `deadline bundle download`, `deadline bundle info`, `deadline bundle hide`, `deadline bundle unhide`, and `deadline bundle cache` (clean/update) commands | | `ui/dialogs/job_bundle_browser_dialog.py` | **New file.** The browser dialog with filter, Queue/Local/History sources, "Show hidden" toggle, parameter table preview, and right-click context menu for hide/unhide (Queue source). Constructor takes keyword-only args: `queue_source`, `queue_error`, `local_source`, `history_source`. | | `ui/dialogs/deadline_config_dialog.py` | Add "Job bundle directory" picker to the settings dialog | | `ui/dialogs/submit_job_to_deadline_dialog.py` | Replace "Export" and "Share" buttons with unified "Export bundle" button that opens the export dialog | @@ -473,6 +473,50 @@ $ deadline bundle unhide blender-render Bundle is not hidden: blender-render ``` +#### `deadline bundle info ` + +Shows detailed information about a job bundle — the same data shown in the browser's preview panel. + +- Without `--queue`, `bundle_name` is treated as a local path to a job bundle directory. If the path doesn't exist, searches by name in the current directory and then the configured `settings.job_bundle_default_directory`. +- With `--queue`, looks up the named bundle on the queue (uses S3 metadata for zero-download preview when available). +- Output always includes the resolved path so the user knows where the bundle was found. +- `--output json`: JSON object with path, name, description, steps, and parameters. +- `--profile`, `--farm-id`, `--queue-id`: Standard config overrides (with `--queue`). + +``` +$ deadline bundle info ./my-render-job +Path: /home/user/my-render-job +Name: Blender Render +Description: Renders a Blender scene file using Cycles +Steps: + • RenderBlender +Parameters: + Frames (STRING) = 1-100 + OutputDir (PATH) = /tmp/output + +$ deadline bundle info blender-render --queue +Path: s3://my-farm-bucket/DeadlineCloud/job-bundles/blender-render.ojd +Name: Blender Render +Description: Renders a Blender scene file using Cycles +Steps: + • RenderBlender +Parameters: + Frames (STRING) = 1-100 + OutputDir (PATH) = /tmp/output + +$ deadline bundle info blender-render --queue --output json +{ + "path": "s3://my-farm-bucket/DeadlineCloud/job-bundles/blender-render.ojd", + "name": "Blender Render", + "description": "Renders a Blender scene file using Cycles", + "steps": ["RenderBlender"], + "parameters": [ + {"name": "Frames", "type": "STRING", "_display_value": "1-100"}, + {"name": "OutputDir", "type": "PATH", "_display_value": "/tmp/output"} + ] +} +``` + ### Bundle Visibility Bundles shared on S3 can be "hidden" without deleting them. This is the S3 equivalent of dot-prefixed hidden folders on local filesystems — bundles remain accessible but are not shown in the browser by default. diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index c7538b91a..a0638f058 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -644,6 +644,7 @@ def bundle_list(path, use_queue, show_hidden, no_archives, output, **args): local_root = config_file.get_setting("settings.job_bundle_default_directory") if not local_root: local_root = os.path.expanduser("~") + local_root = os.path.expanduser(local_root) repo = LocalBundleRepository(root=local_root, include_archives=not no_archives) entries = repo.list_entries(repo.root_path()) @@ -1009,3 +1010,82 @@ def bundle_unhide(bundle_name, **args): repo.set_bundle_visibility(bundle_name, hidden=False) click.echo(f"Unhidden bundle: {bundle_name}") + + +@cli_bundle.command(name="info") +@click.argument("bundle_name") +@click.option( + "--queue", + "use_queue", + is_flag=True, + help="Inspect a bundle shared on the queue.", +) +@click.option( + "--output", + type=click.Choice(["verbose", "json"], case_sensitive=False), + default="verbose", + help="Output format.", +) +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def bundle_info(bundle_name, use_queue, output, **args): + """ + Show details about a job bundle (name, description, steps, parameters). + + BUNDLE_NAME is either a local path to a job bundle directory, or the name + of a shared bundle on the queue (when used with --queue). For local bundles, + if the path doesn't exist, searches by name in the current directory and then + the configured job bundle default directory. + """ + if use_queue: + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + repo: BundleRepository = S3BundleRepository.from_config(config) + # Find the bundle by name in the listing + entries = repo.list_entries(repo.root_path()) + match = next((e for e in entries if e.name == bundle_name and e.is_bundle), None) + if not match: + available = [e.name for e in entries if e.is_bundle] + msg = f"Bundle '{bundle_name}' not found on queue." + if available: + msg += f"\nAvailable bundles: {', '.join(available)}" + raise DeadlineOperationError(msg) + info = repo.get_bundle_info(match.path) + else: + bundle_path = os.path.abspath(bundle_name) + if not os.path.isdir(bundle_path): + # Search by name in cwd, then configured default directory + for search_dir in [ + os.getcwd(), + os.path.expanduser( + config_file.get_setting("settings.job_bundle_default_directory") or "" + ), + ]: + if not search_dir: + continue + candidate = os.path.join(search_dir, bundle_name) + if os.path.isdir(candidate) and is_job_bundle_dir(candidate): + bundle_path = candidate + break + else: + raise DeadlineOperationError( + f"Bundle '{bundle_name}' not found as a path, in current directory, " + "or in the configured job bundle default directory." + ) + repo = LocalBundleRepository(root=os.path.dirname(bundle_path)) + info = repo.get_bundle_info(bundle_path) + + if not info: + raise DeadlineOperationError( + f"Could not read bundle template for '{bundle_name}'. " + "The template may be missing or malformed." + ) + + if output == "json": + result = info.to_dict() + result["path"] = info.path + click.echo(json.dumps(result, indent=2)) + else: + click.echo(f"Path: {info.path}") + click.echo(info.format_text()) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index b94cc9c6f..20d47e81d 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -151,6 +151,36 @@ class BundleInfo: step_names: list[str] = field(default_factory=list) parameters: list[dict] = field(default_factory=list) + def to_dict(self) -> dict: + """Serialize to a dict suitable for JSON output.""" + return { + "name": self.name, + "description": self.description, + "steps": self.step_names, + "parameters": self.parameters, + } + + def format_text(self) -> str: + """Format as human-readable text.""" + lines = [f"Name: {self.name}"] + if self.description: + lines.append(f"Description: {self.description}") + if self.step_names: + lines.append("Steps:") + for step in self.step_names: + lines.append(f" \u2022 {step}") + if self.parameters: + lines.append("Parameters:") + for p in self.parameters: + name = p.get("name", "?") + ptype = p.get("type", "?") + value = p.get("_display_value", "") + line = f" {name} ({ptype})" + if value: + line += f" = {value}" + lines.append(line) + return "\n".join(lines) + @dataclass class BrowseEntry: From a0f87430204587971eb2aad7c7d374e815209e9a Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:38:05 -0500 Subject: [PATCH 33/89] fix: show .ojd in Local source, and only use s3 metadata if bundle not cached locally Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 2 +- src/deadline/client/job_bundle/repository.py | 18 ++++++++---------- .../ui/dialogs/job_bundle_browser_dialog.py | 7 ++++--- .../dialogs/submit_job_to_deadline_dialog.py | 9 +++++++++ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index ce6d44725..d2ddbbbab 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -156,7 +156,7 @@ This metadata is returned by `head_object`, which is already called for ETag val For `list_entries`, detection is kept fast: - **Local directories**: stat check for template file existence (no parsing). -- **Local archives**: matched by `.ojd` extension, then validated by checking for a template inside the archive. This prevents random files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used by the browser for Local/History sources, and via `--no-archives` in the CLI). The browser dialog disables local archives because the primary use case for archives is S3-shared bundles — local users work with directory bundles directly. +- **Local archives**: matched by `.ojd` extension, then validated by checking for a template inside the archive. This prevents random files from appearing as bundles. Archive scanning can be disabled via `include_archives=False` on `LocalBundleRepository` (used via `--no-archives` in the CLI). The browser dialog shows both directory bundles and `.ojd` archives for a consistent experience across Local and Queue sources. - **S3 folders**: shown for navigation only (expandable in the tree), never treated as bundles. - **S3 archives**: matched by `.ojd` extension only (no API call). diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 20d47e81d..d505ea7c9 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -543,21 +543,19 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: meta.get("etag") ) - # Try S3 user metadata for preview (set by 'deadline bundle upload') + # Prefer cached template (source of truth) over S3 metadata (fast hint) + if cache_valid: + cached_info = self._read_info_from_cache(cache_dir, path) + if cached_info: + return cached_info + + # No valid cache — use S3 user metadata as a fast preview hint + # (avoids downloading the archive just for preview) s3_metadata = head.get("Metadata", {}) info = _bundle_info_from_s3_metadata(s3_metadata, path) if info: - # If cache is valid, enrich with parameter values from the cached bundle - if cache_valid: - cached_info = self._read_info_from_cache(cache_dir, path) - if cached_info: - info.parameters = cached_info.parameters return info - # No S3 metadata — fall back to cache - if cache_valid: - return self._read_info_from_cache(cache_dir, path) - # Cache miss or stale — download, cache, and parse try: resp = self._s3.get_object(Bucket=self._bucket, Key=key) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index add6e2607..033f5dd48 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -103,11 +103,11 @@ def __init__( self._s3_error = queue_error self._s3_available = self._s3_repo is not None - self._local_repo = LocalBundleRepository(root=local_source, include_archives=False) + self._local_repo = LocalBundleRepository(root=local_source, include_archives=True) self._history_repo: Optional[LocalBundleRepository] = None if history_source and os.path.isdir(history_source): - self._history_repo = LocalBundleRepository(root=history_source, include_archives=False) + self._history_repo = LocalBundleRepository(root=history_source, include_archives=True) self._current_repo: BundleRepository = self._local_repo self._selected_path: Optional[str] = None @@ -366,7 +366,8 @@ def _add_entry_item( @staticmethod def _entry_display(entry: BrowseEntry) -> str: icon = "\U0001f4e6" if entry.is_bundle else "\U0001f4c1" # 📦 or 📁 - return f"{icon} {entry.name}" + suffix = ".ojd" if entry.is_archive and not entry.path.startswith("s3://") else "" + return f"{icon} {entry.name}{suffix}" # ── Event Handlers ─────────────────────────────────────────── 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 1067e063b..1432e8512 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 @@ -666,6 +666,15 @@ def _export_to_local(self, dest_dir: str, bundle_name: str): dest_path = os.path.join(dest_dir, bundle_name) try: if os.path.exists(dest_path): + reply = QMessageBox.question( + self, + tr("Export bundle"), + f"Bundle '{bundle_name}' already exists at:\n{dest_path}\n\nOverwrite?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return shutil.rmtree(dest_path) shutil.copytree(self.job_history_bundle_dir, dest_path) QMessageBox.information( From 7e3017ef2e7e0854f46b11bbd45aecbe9540fe0d Mon Sep 17 00:00:00 2001 From: phil-IO-p <259470369+phil-IO-p@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:39:09 -0700 Subject: [PATCH 34/89] feat(ui): theme-aware, WCAG-AA contrast warning banner The inline 'Queue unavailable' warning used hardcoded amber (#b35900 on adapt to the OS/Qt theme the submitter inherits, so it looked out of place in dark mode. Add a shared warning_banner_qss() helper in ui/_utils.py that derives AA-passing, theme-aware colors (light 7.35:1, dark 9.35:1) and use it from both the export dialog and the job bundle browser. Signed-off-by: phil-IO-p <259470369+phil-IO-p@users.noreply.github.com> --- src/deadline/client/ui/_utils.py | 22 +++++++++++++++++++ .../client/ui/dialogs/export_bundle_dialog.py | 8 ++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/deadline/client/ui/_utils.py b/src/deadline/client/ui/_utils.py index 148234055..cbf07744d 100644 --- a/src/deadline/client/ui/_utils.py +++ b/src/deadline/client/ui/_utils.py @@ -49,6 +49,28 @@ def tr(text: TranslationKey) -> str: return _get_translations().get(text, text) +def warning_banner_qss(widget) -> str: + """Stylesheet for an inline warning banner that adapts to the active theme. + + The submitter has no in-app theme toggle — it inherits the OS/Qt palette — so a + hardcoded light-amber banner looks out of place in dark mode. This derives a + light- or dark-amber treatment from the widget's palette. Both pass WCAG AA + contrast for the body text (light 7.35:1, dark 9.35:1). + """ + from qtpy.QtGui import QPalette # type: ignore + + is_dark = widget.palette().color(QPalette.Window).lightness() < 128 + if is_dark: + text, bg, border = "#ffcc80", "#3a2a10", "#8a5a20" + else: + text, bg, border = "#7a4200", "#fff3e0", "#e0a040" + return ( + f"QLabel {{ color: {text}; background-color: {bg};" + f" border: 1px solid {border}; border-radius: 4px;" + " padding: 4px 8px; }" + ) + + @contextmanager def block_signals(element): """ diff --git a/src/deadline/client/ui/dialogs/export_bundle_dialog.py b/src/deadline/client/ui/dialogs/export_bundle_dialog.py index 1f0058cab..54bede30b 100644 --- a/src/deadline/client/ui/dialogs/export_bundle_dialog.py +++ b/src/deadline/client/ui/dialogs/export_bundle_dialog.py @@ -20,7 +20,7 @@ QVBoxLayout, ) -from .._utils import tr +from .._utils import tr, warning_banner_qss from ...job_bundle.repository import S3BundleRepository @@ -86,11 +86,7 @@ def _build_ui(self, default_name: str): self._queue_warning = QLabel() self._queue_warning.setWordWrap(True) self._queue_warning.setTextFormat(Qt.RichText) - self._queue_warning.setStyleSheet( - "QLabel { color: #b35900; background-color: #fff3e0;" - " border: 1px solid #ffcc80; border-radius: 4px;" - " padding: 4px 8px; }" - ) + self._queue_warning.setStyleSheet(warning_banner_qss(self)) if not self._queue_available and self._queue_error: self._queue_warning.setText(f"\u26a0 Queue unavailable: {self._queue_error}") self._queue_warning.setVisible(True) From 2cb11bace79c79b4594ac55eb0c6c5c9bd246a88 Mon Sep 17 00:00:00 2001 From: phil-IO-p <259470369+phil-IO-p@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:39:26 -0700 Subject: [PATCH 35/89] feat(ui): polish job bundle browser preview panel, tree, and layout Preview panel: - Switch empty/detail views via QStackedWidget; centered empty state with a bundle glyph + two-line prompt (fixes a bug where the placeholder could inherit the error state's red styling). - Size the parameters table to its content (no large empty void); wrap long Name/Value text instead of eliding; cap the Name column. - Friendly parameter type labels (Text/Path/Number) and a '(required)' flag for params with no default. - Section labels (Description/Steps/Parameters) with item counts, consistent type scale and spacing, theme-aware (palette-derived) colors, left-aligned table headers, and alternating row tint. - Normalize multi-line template descriptions so they wrap to the panel width. - Raised, rounded panel surface with a subtle gradient, distinct from the tree. Tree & layout: - Order folders before bundles at each level. - Move the read-only Path field up under the source selector, and group the 'Show hidden' toggle with the filter as a view control. Signed-off-by: phil-IO-p <259470369+phil-IO-p@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 336 +++++++++++++++--- 1 file changed, 280 insertions(+), 56 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 033f5dd48..c73339c9a 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -9,17 +9,19 @@ import atexit import os +import re import shutil import tempfile from logging import getLogger from typing import Optional from qtpy.QtCore import Qt, QModelIndex, QSortFilterProxyModel, QTimer, Signal # type: ignore -from qtpy.QtGui import QColor, QStandardItemModel, QStandardItem # type: ignore +from qtpy.QtGui import QColor, QPalette, QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore QCheckBox, QDialog, QDialogButtonBox, + QFrame, QHBoxLayout, QHeaderView, QLabel, @@ -27,8 +29,10 @@ QMenu, QPushButton, QRadioButton, + QGraphicsOpacityEffect, QScrollArea, QSplitter, + QStackedWidget, QTableWidget, QTableWidgetItem, QTreeView, @@ -36,7 +40,7 @@ QWidget, ) -from .._utils import tr +from .._utils import tr, warning_banner_qss from ...job_bundle.repository import ( BrowseEntry, BundleRepository, @@ -53,6 +57,51 @@ ROLE_IS_ARCHIVE = Qt.UserRole + 4 ROLE_IS_HIDDEN = Qt.UserRole + 5 +# Semantic warning/accent color, matched to the queue-unavailable banner used +# elsewhere in this dialog so the panel reads as part of the same UI. Legible on +# both light and dark themes. +REQUIRED_COLOR = QColor("#b35900") + +# Shared "quiet section label" style — small, bold, sentence-case, muted. +# Color is set per-widget from the palette so it adapts to the theme. +_SECTION_LABEL_QSS = "font-size: 13px; font-weight: bold;" + +# Friendly, artist-facing labels for the OpenJD parameter type enums. +_FRIENDLY_PARAM_TYPES = { + "STRING": "Text", + "PATH": "Path", + "INT": "Number", + "FLOAT": "Number", +} + + +def _friendly_param_type(raw_type: str) -> str: + """Map an OpenJD parameter type enum to an artist-facing label.""" + return _FRIENDLY_PARAM_TYPES.get(raw_type.upper(), raw_type.title() if raw_type else "?") + + +def _folders_first(entries: list) -> list: + """Order entries folders-first, preserving the repo's name-sort within each group.""" + folders = [e for e in entries if not e.is_bundle] + bundles = [e for e in entries if e.is_bundle] + return folders + bundles + + +def _steps_list_text(step_names: list[str]) -> str: + """Render step names as a plain bulleted list.""" + return "\n".join(f" • {name}" for name in step_names if name) + + +def _normalize_description(text: str) -> str: + """Collapse hard line breaks within a paragraph so the label can word-wrap to + the panel width, while preserving intentional blank-line paragraph breaks. + + Template descriptions are often authored as multi-line YAML blocks, which would + otherwise display with awkward breaks mid-sentence. + """ + paragraphs = re.split(r"\n\s*\n", text.strip()) + return "\n\n".join(" ".join(p.split()) for p in paragraphs if p.strip()) + class _BundleFilterProxy(QSortFilterProxyModel): """Proxy that filters by text and optionally hides items marked as hidden.""" @@ -187,11 +236,7 @@ def _build_ui(self): # Inline warning when queue source is unavailable self._queue_warning = QLabel() self._queue_warning.setWordWrap(True) - self._queue_warning.setStyleSheet( - "QLabel { color: #b35900; background-color: #fff3e0;" - " border: 1px solid #ffcc80; border-radius: 4px;" - " padding: 4px 8px; }" - ) + self._queue_warning.setStyleSheet(warning_banner_qss(self)) if not self._s3_available and self._s3_error: self._queue_warning.setText( f"\u26a0 Queue browsing unavailable: {self._s3_error}" @@ -202,11 +247,14 @@ def _build_ui(self): self._queue_warning.setVisible(False) layout.addWidget(self._queue_warning) - # Show hidden checkbox - self._show_hidden_cb = QCheckBox(tr("Show hidden"), parent=self) - self._show_hidden_cb.setChecked(False) - self._show_hidden_cb.toggled.connect(self._on_hidden_toggled) - layout.addWidget(self._show_hidden_cb) + # #7 — Path display sits just under the source selection (both describe + # "where am I browsing"), rather than at the bottom by the buttons. + path_row = QHBoxLayout() + path_row.addWidget(QLabel(tr("Path:"))) + self._path_display = QLineEdit() + self._path_display.setReadOnly(True) + path_row.addWidget(self._path_display) + layout.addLayout(path_row) # Default to Queue if available, otherwise Local if self._s3_available: @@ -224,11 +272,21 @@ def _build_ui(self): left_layout = QVBoxLayout(left_widget) left_layout.setContentsMargins(0, 0, 0, 0) + # Row 1: filter + "Show hidden" (both are list-view controls, grouped above + # the tree). Show hidden is a filter toggle, so it lives with the filter. + filter_row = QHBoxLayout() self._filter_edit = QLineEdit() - self._filter_edit.setPlaceholderText("Filter bundles...") + self._filter_edit.setPlaceholderText(tr("Filter bundles...")) self._filter_edit.setClearButtonEnabled(True) self._filter_edit.textChanged.connect(self._on_filter_changed) - left_layout.addWidget(self._filter_edit) + filter_row.addWidget(self._filter_edit, stretch=1) + + self._show_hidden_cb = QCheckBox(tr("Show hidden"), parent=self) + self._show_hidden_cb.setChecked(False) + self._show_hidden_cb.toggled.connect(self._on_hidden_toggled) + filter_row.addSpacing(8) + filter_row.addWidget(self._show_hidden_cb) + left_layout.addLayout(filter_row) self._model = QStandardItemModel() self._model.setHorizontalHeaderLabels([tr("Name")]) @@ -252,63 +310,165 @@ def _build_ui(self): splitter.addWidget(left_widget) - # Right: preview panel in a scroll area + # Right: preview panel. A QStackedWidget switches between an empty-state + # page (centered prompt) and the detail page (scrollable bundle info). + self._muted_hex = self.palette().color(QPalette.PlaceholderText).name() + muted_qss = f"color: {self._muted_hex};" + + self._preview_stack = QStackedWidget() + + # Empty-state page — icon + prompt + hint, centered both ways. + empty_page = QWidget() + empty_layout = QVBoxLayout(empty_page) + empty_layout.setSpacing(4) + + # Large, low-opacity bundle glyph as a backdrop (matches the tree's 📦). + empty_icon = QLabel("\U0001f4e6") + empty_icon.setAlignment(Qt.AlignCenter) + empty_icon.setStyleSheet("font-size: 44px;") + empty_icon.setGraphicsEffect(self._make_opacity(0.35)) + + empty_prompt = QLabel(tr("Select a job bundle")) + empty_prompt.setAlignment(Qt.AlignCenter) + empty_prompt.setStyleSheet("font-size: 15px; font-weight: bold;") + + self._empty_label = QLabel(tr("Choose one from the list to preview its details")) + self._empty_label.setAlignment(Qt.AlignCenter) + self._empty_label.setWordWrap(True) + self._empty_label.setStyleSheet(f"font-size: 12px; {muted_qss}") + + empty_layout.addStretch(1) + empty_layout.addWidget(empty_icon) + empty_layout.addWidget(empty_prompt) + empty_layout.addWidget(self._empty_label) + empty_layout.addStretch(1) + self._preview_stack.addWidget(empty_page) # index 0 + + # Detail page — scrollable bundle info. preview_widget = QWidget() preview_layout = QVBoxLayout(preview_widget) + # One consistent vertical rhythm instead of scattered per-widget margins. + preview_layout.setSpacing(6) + # Title — top of a 3-step scale (title / body / section-label). self._preview_name = QLabel() self._preview_name.setWordWrap(True) - self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px;") + self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") preview_layout.addWidget(self._preview_name) + # Muted subline: bundle type + source (e.g. "📦 Folder · Local") + self._preview_subline = QLabel() + self._preview_subline.setStyleSheet(f"font-size: 11px; {muted_qss}") + preview_layout.addWidget(self._preview_subline) + + # Extra breathing room between the title/subline block and the description. + preview_layout.addSpacing(12) + + # Description — with a section label to match Steps/Parameters. + self._preview_desc_label = QLabel(tr("Description")) + self._preview_desc_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) + preview_layout.addWidget(self._preview_desc_label) self._preview_desc = QLabel() self._preview_desc.setWordWrap(True) preview_layout.addWidget(self._preview_desc) - self._preview_steps_label = QLabel(tr("Steps:")) - self._preview_steps_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + preview_layout.addSpacing(8) + self._preview_steps_label = QLabel(tr("Steps")) + self._preview_steps_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) preview_layout.addWidget(self._preview_steps_label) + # Steps as a plain bulleted list — no background fill (it drew the eye + # more than it should). self._preview_steps = QLabel() self._preview_steps.setWordWrap(True) preview_layout.addWidget(self._preview_steps) - self._preview_params_label = QLabel(tr("Parameters:")) - self._preview_params_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + preview_layout.addSpacing(8) + self._preview_params_label = QLabel(tr("Parameters")) + self._preview_params_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) preview_layout.addWidget(self._preview_params_label) self._preview_params = QTableWidget() self._preview_params.setColumnCount(3) - self._preview_params.setHorizontalHeaderLabels(["Name", "Type", "Value"]) - self._preview_params.horizontalHeader().setStretchLastSection(True) - self._preview_params.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + self._preview_params.setHorizontalHeaderLabels([tr("Name"), tr("Type"), tr("Value")]) + header = self._preview_params.horizontalHeader() + # Name/Type size to content but are capped (see _size_params_table below); + # Value takes the rest and wraps long content rather than eliding/scrolling. + header.setSectionResizeMode(0, QHeaderView.Interactive) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setHighlightSections(False) + # Left-align the column headers to match the cell text below them. + header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) self._preview_params.verticalHeader().setVisible(False) self._preview_params.setEditTriggers(QTableWidget.NoEditTriggers) self._preview_params.setSelectionMode(QTableWidget.NoSelection) + # #2 — subtle alternating row tint so rows are easier to scan. + self._preview_params.setAlternatingRowColors(True) + # Wrap long Name/Value text onto multiple lines instead of eliding it. + self._preview_params.setWordWrap(True) + self._preview_params.setTextElideMode(Qt.ElideNone) + self._preview_params.setShowGrid(False) + self._preview_params.setFocusPolicy(Qt.NoFocus) + # No frame on the table itself — the panel outline already contains it. + self._preview_params.setFrameShape(QFrame.NoFrame) + # #5 — header divider; header is transparent so it sits on the panel surface. + hdr_line = self._muted_hex + header.setStyleSheet( + "QHeaderView::section {" + " background: transparent;" + f" border: none; border-bottom: 1px solid {hdr_line};" + " padding: 4px 6px; font-weight: bold; }" + ) + # Table is transparent (inherits the panel surface); alternating rows provide + # the only fill, so they read as subtle stripes on the panel. + self._preview_params.setStyleSheet( + "QTableWidget { background: transparent; }" + ) + # Let the panel's own scroll area handle overflow; the table sizes to its rows. + self._preview_params.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._preview_params.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._preview_params.setSizeAdjustPolicy(QTableWidget.AdjustToContents) preview_layout.addWidget(self._preview_params) preview_layout.addStretch(1) self._clear_preview() + # Inner padding so content doesn't crowd the panel edges. + preview_layout.setContentsMargins(14, 14, 14, 14) + preview_scroll = QScrollArea() preview_scroll.setWidget(preview_widget) preview_scroll.setWidgetResizable(True) - splitter.addWidget(preview_scroll) + preview_scroll.setFrameShape(QFrame.NoFrame) + # Make the detail page transparent so the panel's gradient shows through here + # too (a QScrollArea + its content otherwise paint an opaque Base background). + preview_scroll.setStyleSheet("QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; }") + preview_widget.setAttribute(Qt.WA_TranslucentBackground, False) + preview_widget.setStyleSheet("background: transparent;") + self._preview_stack.addWidget(preview_scroll) # index 1 + + # #4 + #5 — the whole panel is a raised, rounded surface distinct from the + # tree, with a subtle top-to-bottom gradient for a bit of depth. Both stops + # are derived from QPalette.Base so it adapts to the theme. objectName + # scoping keeps the styling off child widgets. + base = self.palette().color(QPalette.Base) + is_dark = base.lightness() < 128 + # Lighten the top / darken the bottom. Kept subtle: enough to add depth but + # gentle enough not to compete with the dense detail content for readability. + # (Near-black Base needs a larger % shift than a light Base to be visible.) + top = base.lighter(128) if is_dark else base.lighter(104) + bottom = base.darker(110) if is_dark else base.darker(106) + tc = self.palette().color(QPalette.WindowText) + border = f"rgba({tc.red()}, {tc.green()}, {tc.blue()}, 70)" + self._preview_stack.setObjectName("previewPanel") + self._preview_stack.setStyleSheet( + "#previewPanel {" + " background: qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 {top.name()}, stop:1 {bottom.name()});" + f" border: 1px solid {border}; border-radius: 8px; }}" + ) + splitter.addWidget(self._preview_stack) splitter.setSizes([350, 350]) - # Bottom: path display + buttons - bottom_layout = QVBoxLayout() - bottom_layout.setContentsMargins(0, 8, 0, 0) - - # Path row - path_row = QHBoxLayout() - path_label = QLabel(tr("Path:")) - path_row.addWidget(path_label) - self._path_display = QLineEdit() - self._path_display.setReadOnly(True) - path_row.addWidget(self._path_display) - bottom_layout.addLayout(path_row) - - layout.addLayout(bottom_layout) - # Dialog buttons self._button_box = QDialogButtonBox(QDialogButtonBox.Cancel) self._select_button = QPushButton(tr("Select")) @@ -340,6 +500,9 @@ def _populate_root(self): except Exception: logger.debug("Failed to fetch visibility manifest", exc_info=True) + # Folders first, then bundles — each group already name-sorted by the repo. + self._cached_root_entries = _folders_first(self._cached_root_entries) + root = self._model.invisibleRootItem() for entry in self._cached_root_entries: is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set @@ -392,7 +555,7 @@ def _on_expanded(self, proxy_index: QModelIndex): error_item.setEnabled(False) item.appendRow(error_item) return - for entry in entries: + for entry in _folders_first(entries): is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set # If parent is hidden, children inherit hidden state if item.data(ROLE_IS_HIDDEN): @@ -558,26 +721,42 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): self._select_button.setEnabled(False) return + self._preview_stack.setCurrentIndex(1) # show detail page self._preview_name.setText(info.name) - self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px;") + self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") self._preview_name.setVisible(True) + # Subline: bundle type + source + type_label = tr("Archive") if self._selected_is_archive else tr("Folder") + type_icon = "\U0001f4e6" # \ud83d\udce6 + if self._radio_s3.isChecked(): + source_label = tr("Queue") + elif self._radio_history.isChecked(): + source_label = tr("History") + else: + source_label = tr("Local") + self._preview_subline.setText(f"{type_icon} {type_label} \u00b7 {source_label}") + self._preview_subline.setVisible(True) + if info.description: - self._preview_desc.setText(info.description) + self._preview_desc_label.setVisible(True) + self._preview_desc.setText(_normalize_description(info.description)) self._preview_desc.setVisible(True) else: + self._preview_desc_label.setVisible(False) self._preview_desc.setVisible(False) if info.step_names: + self._preview_steps_label.setText(tr("Steps") + f" ({len(info.step_names)})") self._preview_steps_label.setVisible(True) - self._preview_steps.setText("\n".join(f" \u2022 {name}" for name in info.step_names)) + self._preview_steps.setText(_steps_list_text(info.step_names)) self._preview_steps.setVisible(True) else: self._preview_steps_label.setVisible(False) self._preview_steps.setVisible(False) if info.parameters: - self._preview_params_label.setVisible(True) + muted_color = self._preview_params.palette().color(QPalette.PlaceholderText) # Detect if parameters were truncated in metadata truncated = any( p.get("name", "").endswith("...") or p.get("type", "").endswith("...") @@ -585,32 +764,74 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): ) # Drop the last entry if it's garbled from truncation params = info.parameters[:-1] if truncated else info.parameters + # Count reflects shown params; "+" hints there may be more when truncated. + count_str = f"{len(params)}+" if truncated else str(len(params)) + self._preview_params_label.setText(tr("Parameters") + f" ({count_str})") + self._preview_params_label.setVisible(True) row_count = len(params) + (1 if truncated else 0) self._preview_params.setRowCount(row_count) for row, p in enumerate(params): - self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) - self._preview_params.setItem(row, 1, QTableWidgetItem(p.get("type", "?"))) value = p.get("_display_value", "") - self._preview_params.setItem(row, 2, QTableWidgetItem(str(value) if value else "")) + # "Required" = the artist must supply it because the bundle gives no + # default/value. (Metadata-only previews have no value info, so we can + # only flag this reliably when default/value keys are present.) + is_required = not value and ("default" not in p and "value" not in p) + + # Name is plain; the "(required)" value cell carries the signal. + self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) + + self._preview_params.setItem( + row, 1, QTableWidgetItem(_friendly_param_type(p.get("type", ""))) + ) + + if value: + value_item = QTableWidgetItem(str(value)) + elif is_required: + value_item = QTableWidgetItem(tr("(required)")) + value_item.setForeground(REQUIRED_COLOR) # warm = needs input + else: + value_item = QTableWidgetItem(tr("(no default)")) + value_item.setForeground(muted_color) + self._preview_params.setItem(row, 2, value_item) if truncated: truncation_item = QTableWidgetItem("\u2026 additional parameters not shown") - truncation_item.setForeground(QColor("gray")) + truncation_item.setForeground(muted_color) self._preview_params.setItem(len(params), 0, truncation_item) self._preview_params.setVisible(True) + self._size_params_table_to_contents() else: self._preview_params_label.setVisible(False) self._preview_params.setVisible(False) + def _size_params_table_to_contents(self) -> None: + """Fix the table's height to exactly its rows + header so it doesn't leave + a large empty body, and cap the Name column so a long name can't squeeze + the Value column. Value wraps within its remaining width.""" + # Cap the Name column at ~40% of the table width so it can't dominate. + table_width = self._preview_params.viewport().width() + if table_width > 0: + name_w = self._preview_params.columnWidth(0) + self._preview_params.setColumnWidth(0, min(name_w, int(table_width * 0.4))) + # Recompute row heights now that wrapping/column widths are settled. + self._preview_params.resizeRowsToContents() + total = self._preview_params.horizontalHeader().height() + for row in range(self._preview_params.rowCount()): + total += self._preview_params.rowHeight(row) + # +2 for the frame border + self._preview_params.setFixedHeight(total + 2) + + @staticmethod + def _make_opacity(value: float) -> QGraphicsOpacityEffect: + """Opacity effect for a widget (QLabel CSS 'opacity' has no effect).""" + eff = QGraphicsOpacityEffect() + eff.setOpacity(value) + return eff + def _clear_preview(self): + # Switch to the centered empty-state page. (The empty prompt is its own + # widget, so it can't be polluted by the error state's red styling.) self._last_preview_path = None - self._preview_name.setText(tr("Select a job bundle to see details")) - self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: gray;") - self._preview_desc.setVisible(False) - self._preview_steps_label.setVisible(False) - self._preview_steps.setVisible(False) - self._preview_params_label.setVisible(False) - self._preview_params.setRowCount(0) - self._preview_params.setVisible(False) + self._preview_stack.setCurrentIndex(0) def _mark_item_error(self, item: QStandardItem) -> None: """Replace the bundle/folder icon with a warning icon.""" @@ -624,9 +845,12 @@ def _mark_item_error(self, item: QStandardItem) -> None: def _show_error_preview(self, message: str): """Show an error message in the preview panel.""" + self._preview_stack.setCurrentIndex(1) # show detail page self._preview_name.setText("\u26a0 Error") self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: red;") self._preview_name.setVisible(True) + self._preview_subline.setVisible(False) + self._preview_desc_label.setVisible(False) self._preview_desc.setText(message) self._preview_desc.setVisible(True) self._preview_steps_label.setVisible(False) From ba4b24d8305710c6318e20992bb6d3d4f6d63fa9 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:02:39 -0500 Subject: [PATCH 36/89] fix: can't determine (required) from only metadata Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 43 ++++++++++--------- src/deadline/client/job_bundle/repository.py | 2 +- .../ui/dialogs/job_bundle_browser_dialog.py | 19 +++++--- .../client/ui/translations/locales/de_DE.json | 13 +++++- .../client/ui/translations/locales/en_US.json | 13 +++++- .../client/ui/translations/locales/es_ES.json | 13 +++++- .../client/ui/translations/locales/fr_FR.json | 13 +++++- .../client/ui/translations/locales/id_ID.json | 13 +++++- .../client/ui/translations/locales/it_IT.json | 13 +++++- .../client/ui/translations/locales/ja_JP.json | 13 +++++- .../client/ui/translations/locales/ko_KR.json | 13 +++++- .../client/ui/translations/locales/pt_BR.json | 13 +++++- .../client/ui/translations/locales/tr_TR.json | 13 +++++- .../client/ui/translations/locales/zh_CN.json | 13 +++++- .../client/ui/translations/locales/zh_TW.json | 13 +++++- 15 files changed, 180 insertions(+), 40 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index d2ddbbbab..054cedac6 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -169,36 +169,38 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b │ Job Bundle Browser │ ├─────────────────────────────────────────────────────────────┤ │ Source: (•) Queue ( ) Local ( ) History │ -│ ☐ Show hidden │ +│ Path: [/job-bundles/ ] │ ├────────────────────────────────┬────────────────────────────┤ -│ [Filter bundles... ] │ Name: Blender Render │ -│ 📁 my-bundles/ │ Description: Renders a │ -│ 📦 blender-render │ Blender scene file... │ -│ 📦 maya-arnold │ │ -│ 📁 wip/ │ Steps: │ -│ 📦 experimental-job │ • RenderBlender │ -│ 📦 simple-job/ │ │ -│ │ Parameters: │ +│ [Filter bundles... ] ☐ Hidden│ Name: Blender Render │ +│ 📁 my-bundles/ │ Description (1): │ +│ 📦 blender-render │ Renders a Blender scene │ +│ 📦 maya-arnold │ file... │ +│ 📁 wip/ │ │ +│ 📦 experimental-job │ Steps (1): │ +│ 📦 simple-job/ │ • RenderBlender │ +│ │ │ +│ │ Parameters (2): │ │ │ ┌──────────┬──────┬─────┐ │ │ │ │ Name │ Type │ Val │ │ │ │ ├──────────┼──────┼─────┤ │ -│ │ │ Frames │ STR │ │ │ -│ │ │ OutputDir│ PATH │ │ │ +│ │ │ Frames │ Text │ │ │ +│ │ │ OutputDir│ Path │ │ │ │ │ └──────────┴──────┴─────┘ │ │ │ │ ├────────────────────────────────┴────────────────────────────┤ -│ Path: [/job-bundles/ ] │ │ [Cancel] [Select] │ └─────────────────────────────────────────────────────────────┘ ``` -**Top bar** — Source selection and options: -- Radio toggle between Queue, Local, and History sources. Queue is selected by default when available; otherwise Local is selected. Queue option is disabled if the queue has no job attachment settings or access fails. When Queue is unavailable, an inline warning label appears below the radio buttons explaining why (e.g. "⚠ **Queue browsing unavailable:** AccessDeniedException..."). -- "Show hidden" checkbox — unchecked by default, toggling refreshes the tree to include/exclude hidden items. For Local/History sources, this means dot-prefixed directories. For the Queue source, this means bundles marked as hidden via the visibility manifest (see [Bundle Visibility](#bundle-visibility)). +**Top bar** — Source selection, path, and options: +- Radio toggle between Queue, Local, and History sources. Queue is selected by default when available; otherwise Local is selected. Queue option is disabled if the queue has no job attachment settings or access fails. When Queue is unavailable, an inline warning label appears below the radio buttons explaining why (e.g. "⚠ **Queue browsing unavailable:** AccessDeniedException..."). The warning uses theme-aware, WCAG-AA contrast colors derived from the Qt palette. +- Path display showing the current browse location (read-only, positioned under the source selector). +- "Show hidden" checkbox — grouped with the filter as a view control. Unchecked by default, toggling refreshes the tree to include/exclude hidden items. For Local/History sources, this means dot-prefixed directories. For the Queue source, this means bundles marked as hidden via the visibility manifest (see [Bundle Visibility](#bundle-visibility)). **Left panel** — Filter and navigable tree view: - A text filter at the top that narrows the tree as you type. Case-insensitive, matches against entry names. Uses recursive filtering so parent folders remain visible when a child matches. The tree auto-expands when filtering to show results. -- Shows folders (📁) and job bundles (📦) with distinct icons. Both directory bundles and archive bundles use the 📦 icon. +- Shows folders (📁) and job bundles (📦) with distinct icons. Both directory bundles and archive bundles use the 📦 icon. Local archives show the `.ojd` extension to distinguish them from directory bundles. +- Folders are listed before bundles at each level; both sorted alphabetically within their group. - Clicking a folder clears any active filter, expands the folder to show its children, and scrolls it to the top of the view. This makes the search-then-navigate flow natural: search for a folder, click it, see its contents. - Job bundles are leaf nodes (selectable, not expandable). - Non-bundle, non-archive files are hidden. @@ -206,13 +208,14 @@ Full template parsing happens only in `get_bundle_info` when the user clicks a b - **Context menu** (Queue source only): Right-clicking a visible bundle shows "Hide bundle"; right-clicking a hidden bundle (when "Show hidden" is checked) shows "Unhide bundle". Hidden bundles are rendered with a dimmed/grayed icon to distinguish them from visible ones. Hide/unhide operations happen in the background with automatic retry on conflict (see [Bundle Visibility](#bundle-visibility)). **Right panel** — Preview (shown when a bundle is selected, scrollable): +- When no bundle is selected, a centered empty state is shown with a bundle glyph and prompt. - **Name**: From the template's `name` field, shown as-is (with `{{Param.X}}` references unresolved). -- **Description**: From the template's `description` field, if present. -- **Steps**: List of step names from the template, in definition order. -- **Parameters**: Rendered as a table with Name, Type, and Value columns. Columns resize to fit content, with the last column stretching. If parameters were truncated in S3 metadata, the last garbled entry is dropped and a gray "… additional parameters not shown" row is appended. +- **Description**: From the template's `description` field, if present. Multi-line descriptions are normalized to wrap to the panel width. Section label shows item count. +- **Steps**: List of step names from the template, in definition order. Section label shows item count. +- **Parameters**: Rendered as a table with Name, Type, and Value columns. Type uses friendly labels (Text, Path, Number instead of STRING, PATH, INT). Parameters with no default are marked `(required)`. Section label shows item count. Table is sized to content (no large empty void). If parameters were truncated in S3 metadata, the last garbled entry is dropped and a gray "… additional parameters not shown" row is appended. +- The panel uses a raised, rounded surface with subtle gradient, visually distinct from the tree. Colors are theme-aware (derived from the Qt palette). **Bottom bar**: -- Path display showing the current browse location. - Cancel and Select buttons. Select is enabled only when a valid bundle is highlighted. ### Export Bundle diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index d505ea7c9..a59c9125c 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -405,7 +405,7 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI for p in params_str.split(","): parts = p.split(":", 1) if len(parts) == 2: - params.append({"name": parts[0], "type": parts[1]}) + params.append({"name": parts[0], "type": parts[1], "_from_metadata": True}) return BundleInfo( path=path, name=name, diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index c73339c9a..d83d5631c 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -420,9 +420,7 @@ def _build_ui(self): ) # Table is transparent (inherits the panel surface); alternating rows provide # the only fill, so they read as subtle stripes on the panel. - self._preview_params.setStyleSheet( - "QTableWidget { background: transparent; }" - ) + self._preview_params.setStyleSheet("QTableWidget { background: transparent; }") # Let the panel's own scroll area handle overflow; the table sizes to its rows. self._preview_params.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._preview_params.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -441,7 +439,9 @@ def _build_ui(self): preview_scroll.setFrameShape(QFrame.NoFrame) # Make the detail page transparent so the panel's gradient shows through here # too (a QScrollArea + its content otherwise paint an opaque Base background). - preview_scroll.setStyleSheet("QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; }") + preview_scroll.setStyleSheet( + "QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; }" + ) preview_widget.setAttribute(Qt.WA_TranslucentBackground, False) preview_widget.setStyleSheet("background: transparent;") self._preview_stack.addWidget(preview_scroll) # index 1 @@ -773,9 +773,14 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): for row, p in enumerate(params): value = p.get("_display_value", "") # "Required" = the artist must supply it because the bundle gives no - # default/value. (Metadata-only previews have no value info, so we can - # only flag this reliably when default/value keys are present.) - is_required = not value and ("default" not in p and "value" not in p) + # default/value. Skip this inference for metadata-only params where we + # simply don't have default info. + is_required = ( + not value + and not p.get("_from_metadata") + and "default" not in p + and "value" not in p + ) # Name is plain; the "(required)" value cell carries the signal. self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index 716a7b43b..a7df08371 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -156,5 +156,16 @@ "Select": "Auswählen", "Select a job bundle to see details": "Job-Bundle auswählen, um Details anzuzeigen", "Source:": "Quelle:", - "Steps:": "Schritte:" + "Steps:": "Schritte:", + "(no default)": "(kein Standard)", + "(required)": "(erforderlich)", + "Archive": "Archiv", + "Choose one from the list to preview its details": "Wählen Sie aus der Liste, um Details anzuzeigen", + "Filter bundles...": "Bundles filtern...", + "Folder": "Ordner", + "Parameters": "Parameter", + "Select a job bundle": "Job-Bundle auswählen", + "Steps": "Schritte", + "Type": "Typ", + "Value": "Wert" } diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index 3f29f186c..0291a61a7 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -156,5 +156,16 @@ "Select": "Select", "Select a job bundle to see details": "Select a job bundle to see details", "Source:": "Source:", - "Steps:": "Steps:" + "Steps:": "Steps:", + "(no default)": "(no default)", + "(required)": "(required)", + "Archive": "Archive", + "Choose one from the list to preview its details": "Choose one from the list to preview its details", + "Filter bundles...": "Filter bundles...", + "Folder": "Folder", + "Parameters": "Parameters", + "Select a job bundle": "Select a job bundle", + "Steps": "Steps", + "Type": "Type", + "Value": "Value" } diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index be6bd6132..955ffa424 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -156,5 +156,16 @@ "Select": "Seleccionar", "Select a job bundle to see details": "Seleccione un paquete de trabajo para ver los detalles", "Source:": "Origen:", - "Steps:": "Pasos:" + "Steps:": "Pasos:", + "(no default)": "(sin valor predeterminado)", + "(required)": "(obligatorio)", + "Archive": "Archivo", + "Choose one from the list to preview its details": "Elija uno de la lista para ver sus detalles", + "Filter bundles...": "Filtrar bundles...", + "Folder": "Carpeta", + "Parameters": "Parámetros", + "Select a job bundle": "Seleccionar un job bundle", + "Steps": "Pasos", + "Type": "Tipo", + "Value": "Valor" } diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index f0c821730..ecf64ea1e 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -156,5 +156,16 @@ "Select": "Sélectionner", "Select a job bundle to see details": "Sélectionnez un lot de tâches pour voir les détails", "Source:": "Source :", - "Steps:": "Étapes :" + "Steps:": "Étapes :", + "(no default)": "(pas de défaut)", + "(required)": "(obligatoire)", + "Archive": "Archive", + "Choose one from the list to preview its details": "Choisissez dans la liste pour afficher les détails", + "Filter bundles...": "Filtrer les bundles...", + "Folder": "Dossier", + "Parameters": "Paramètres", + "Select a job bundle": "Sélectionner un job bundle", + "Steps": "Étapes", + "Type": "Type", + "Value": "Valeur" } diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index a9f600db7..811e884aa 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -156,5 +156,16 @@ "Select": "Pilih", "Select a job bundle to see details": "Pilih bundel tugas untuk melihat detail", "Source:": "Sumber:", - "Steps:": "Langkah:" + "Steps:": "Langkah:", + "(no default)": "(tanpa default)", + "(required)": "(wajib)", + "Archive": "Arsip", + "Choose one from the list to preview its details": "Pilih satu dari daftar untuk melihat detailnya", + "Filter bundles...": "Filter bundel...", + "Folder": "Folder", + "Parameters": "Parameter", + "Select a job bundle": "Pilih job bundle", + "Steps": "Langkah", + "Type": "Tipe", + "Value": "Nilai" } diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index 0bde4ce80..3c476cbd0 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -156,5 +156,16 @@ "Select": "Seleziona", "Select a job bundle to see details": "Seleziona un bundle di processi per visualizzare i dettagli", "Source:": "Origine:", - "Steps:": "Passaggi:" + "Steps:": "Passaggi:", + "(no default)": "(nessun valore predefinito)", + "(required)": "(obbligatorio)", + "Archive": "Archivio", + "Choose one from the list to preview its details": "Scegline uno dall'elenco per visualizzarne i dettagli", + "Filter bundles...": "Filtra bundle...", + "Folder": "Cartella", + "Parameters": "Parametri", + "Select a job bundle": "Seleziona un job bundle", + "Steps": "Passaggi", + "Type": "Tipo", + "Value": "Valore" } diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index 8c10ff332..dc4f922c8 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -156,5 +156,16 @@ "Select": "選択", "Select a job bundle to see details": "ジョブバンドルを選択して詳細を表示", "Source:": "ソース:", - "Steps:": "ステップ:" + "Steps:": "ステップ:", + "(no default)": "(デフォルトなし)", + "(required)": "(必須)", + "Archive": "アーカイブ", + "Choose one from the list to preview its details": "リストから選択して詳細をプレビュー", + "Filter bundles...": "バンドルを検索...", + "Folder": "フォルダー", + "Parameters": "パラメータ", + "Select a job bundle": "ジョブバンドルを選択", + "Steps": "ステップ", + "Type": "タイプ", + "Value": "値" } diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index f54f8d52c..42c0d083c 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -156,5 +156,16 @@ "Select": "선택", "Select a job bundle to see details": "작업 번들을 선택하여 세부 정보 보기", "Source:": "소스:", - "Steps:": "단계:" + "Steps:": "단계:", + "(no default)": "(기본값 없음)", + "(required)": "(필수)", + "Archive": "아카이브", + "Choose one from the list to preview its details": "목록에서 선택하여 세부 정보를 미리 봅니다", + "Filter bundles...": "번들 필터...", + "Folder": "폴더", + "Parameters": "파라미터", + "Select a job bundle": "작업 번들 선택", + "Steps": "단계", + "Type": "유형", + "Value": "값" } diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 0fad0c2d8..e8303cbb3 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -156,5 +156,16 @@ "Select": "Selecionar", "Select a job bundle to see details": "Selecione um pacote de trabalho para ver os detalhes", "Source:": "Origem:", - "Steps:": "Etapas:" + "Steps:": "Etapas:", + "(no default)": "(sem padrão)", + "(required)": "(obrigatório)", + "Archive": "Arquivo", + "Choose one from the list to preview its details": "Escolha um da lista para visualizar seus detalhes", + "Filter bundles...": "Filtrar bundles...", + "Folder": "Pasta", + "Parameters": "Parâmetros", + "Select a job bundle": "Selecionar um job bundle", + "Steps": "Etapas", + "Type": "Tipo", + "Value": "Valor" } diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index aacde9bc9..5a9bc879d 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -156,5 +156,16 @@ "Select": "Seç", "Select a job bundle to see details": "Ayrıntıları görmek için bir iş paketi seçin", "Source:": "Kaynak:", - "Steps:": "Adımlar:" + "Steps:": "Adımlar:", + "(no default)": "(varsayılan yok)", + "(required)": "(zorunlu)", + "Archive": "Arşiv", + "Choose one from the list to preview its details": "Ayrıntılarını önizlemek için listeden birini seçin", + "Filter bundles...": "Paketleri filtrele...", + "Folder": "Klasör", + "Parameters": "Parametreler", + "Select a job bundle": "İş paketi seçin", + "Steps": "Adımlar", + "Type": "Tür", + "Value": "Değer" } diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index 16ec0dd2b..829a12f2e 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -156,5 +156,16 @@ "Select": "选择", "Select a job bundle to see details": "选择作业包以查看详细信息", "Source:": "来源:", - "Steps:": "步骤:" + "Steps:": "步骤:", + "(no default)": "(无默认值)", + "(required)": "(必填)", + "Archive": "归档", + "Choose one from the list to preview its details": "从列表中选择以预览其详细信息", + "Filter bundles...": "筛选包...", + "Folder": "文件夹", + "Parameters": "参数", + "Select a job bundle": "选择作业包", + "Steps": "步骤", + "Type": "类型", + "Value": "值" } diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index 5acd8585c..6a4585a4b 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -156,5 +156,16 @@ "Select": "選取", "Select a job bundle to see details": "選取工作套件以查看詳細資訊", "Source:": "來源:", - "Steps:": "步驟:" + "Steps:": "步驟:", + "(no default)": "(無預設值)", + "(required)": "(必填)", + "Archive": "封存", + "Choose one from the list to preview its details": "從清單中選擇以預覽其詳細資訊", + "Filter bundles...": "篩選套件...", + "Folder": "資料夾", + "Parameters": "參數", + "Select a job bundle": "選擇工作套件", + "Steps": "步驟", + "Type": "類型", + "Value": "值" } From 50804313484465480f7b369e3c8785a91060ee09 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:41:43 -0500 Subject: [PATCH 37/89] fix: dynamically allocate metadata limit Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 22 ++++--- .../client/cli/_groups/bundle_group.py | 55 +++++++++++++---- src/deadline/client/job_bundle/repository.py | 19 ++++-- .../ui/dialogs/job_bundle_browser_dialog.py | 60 ++++++++++++++----- .../dialogs/submit_job_to_deadline_dialog.py | 49 ++++++++++++--- .../cli/test_cli_bundle_repository.py | 12 ++-- 6 files changed, 164 insertions(+), 53 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 054cedac6..ce7f860fc 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -137,16 +137,24 @@ For S3 archives, `etag` is used for validation. For local archives, `mtime` (flo When `deadline bundle upload` uploads an archive, it attaches bundle metadata as S3 user metadata on the object: -- `ojd-name`: The template's `name` field (limit: 256 chars) -- `ojd-desc`: The template's `description` field, newlines collapsed to spaces (limit: 480 chars) -- `ojd-steps`: Comma-separated list of step names (limit: 480 chars) -- `ojd-params`: Comma-separated `name:type` pairs (limit: 700 chars) +- `ojd-name`: The template's `name` field (hard cap: 256 chars) +- `ojd-desc`: The template's `description` field, newlines collapsed to spaces (hard cap: 600 chars) +- `ojd-steps`: Comma-separated list of step names (dynamically allocated) +- `ojd-params`: Comma-separated `name:type` pairs (dynamically allocated) +- `ojd-step-count`: Total number of steps in the template (always included, enables accurate count display when steps are truncated) +- `ojd-param-count`: Total number of parameters in the template (always included, enables accurate count display when params are truncated) -These limits are defined as constants in `repository.py` (`METADATA_LIMIT_NAME`, `METADATA_LIMIT_DESC`, `METADATA_LIMIT_STEPS`, `METADATA_LIMIT_PARAMS`). S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values, including the `x-amz-meta-` prefix). The per-field limits are chosen to stay within this budget even at maximum usage. See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata +**Budget allocation**: S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values, including the `x-amz-meta-` prefix). We reserve 256 bytes for customer-defined metadata on the same object, leaving 1,792 bytes for bundle metadata. The budget is allocated dynamically with the following priority: -When truncation occurs, the CLI emits a yellow warning (e.g. `Warning: Bundle metadata 'ojd-params' truncated from 899 to 700 characters`) and the truncated value ends with `...` to make it visually obvious in the preview that information was cut off. The parameters table in the browser dialog detects truncated metadata and shows an "… additional parameters not shown" row. +1. **Name** (hard cap 256 chars) and **Description** (hard cap 600 chars) — always allocated first. +2. **Step count** and **Param count** — always included (tiny, ~10 bytes total). +3. **Steps** and **Params** — split the remaining budget evenly between them. If only one is present, it gets the full remainder. -This metadata is returned by `head_object`, which is already called for ETag validation. This means preview of uploaded archives requires **zero downloads** — a single `head_object` provides both cache validation and all preview information. +This means bundles with short names and descriptions get more space for steps/params, while bundles with long descriptions still get a fair split. Constants are defined in `repository.py` (`METADATA_LIMIT_NAME`, `METADATA_LIMIT_DESC`, `S3_METADATA_TOTAL_BUDGET`). See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata + +When truncation occurs, the CLI emits a yellow warning (e.g. `Warning: Bundle metadata 'ojd-params' truncated from 899 to 520 characters`) and the truncated value ends with `...` to make it visually obvious in the preview that information was cut off. The browser dialog detects truncation and uses the count metadata to show accurate totals (e.g. "Parameters (12)" even when only 8 fit in the metadata). When count metadata is unavailable (older uploads), the count displays as "N+" to indicate more exist. + +This metadata is returned by `head_object`, which is already called for ETag validation. This means preview of uploaded archives requires **zero downloads** — a single `head_object` provides both cache validation and all preview information. When a valid local cache exists (ETag matches), the cached template is preferred over metadata as it includes full parameter values and defaults. ### Detection: What Is a Job Bundle? diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index a0638f058..e0b8bdf94 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -30,12 +30,13 @@ LocalBundleRepository, METADATA_KEY_DESC, METADATA_KEY_NAME, + METADATA_KEY_PARAM_COUNT, METADATA_KEY_PARAMS, + METADATA_KEY_STEP_COUNT, METADATA_KEY_STEPS, METADATA_LIMIT_DESC, METADATA_LIMIT_NAME, - METADATA_LIMIT_PARAMS, - METADATA_LIMIT_STEPS, + S3_METADATA_TOTAL_BUDGET, S3BundleRepository, S3_JOB_BUNDLES_PREFIX, _extract_bundle_info, @@ -854,16 +855,48 @@ def bundle_upload(job_bundle_dir, name, **args): desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC ) if info.step_names: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - ",".join(info.step_names), METADATA_LIMIT_STEPS, METADATA_KEY_STEPS - ) + bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) if info.parameters: - param_strs = [ - f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters - ] - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - ",".join(param_strs), METADATA_LIMIT_PARAMS, METADATA_KEY_PARAMS - ) + bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) + + # Dynamically allocate remaining budget to steps and params + steps_str = ",".join(info.step_names) if info.step_names else "" + param_strs = ( + ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) + if info.parameters + else "" + ) + + # Calculate bytes used so far (key overhead = "x-amz-meta-" prefix + key name) + used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) + remaining = S3_METADATA_TOTAL_BUDGET - used + # Reserve key overhead for steps + params if they have content + keys_needed = 0 + if steps_str: + keys_needed += 12 + len(METADATA_KEY_STEPS) + if param_strs: + keys_needed += 12 + len(METADATA_KEY_PARAMS) + remaining -= keys_needed + + if remaining > 0: + if steps_str and param_strs: + # Split remaining budget evenly + steps_budget = remaining // 2 + params_budget = remaining - steps_budget + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, steps_budget, METADATA_KEY_STEPS + ) + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, params_budget, METADATA_KEY_PARAMS + ) + elif steps_str: + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, remaining, METADATA_KEY_STEPS + ) + elif param_strs: + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, remaining, METADATA_KEY_PARAMS + ) break bundle_name = name or os.path.basename(job_bundle_dir) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index a59c9125c..0c9c3e895 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -39,16 +39,19 @@ # S3 user-defined metadata is limited to 2 KB total (keys + values, UTF-8 encoded). # Keys include the "x-amz-meta-" prefix (12 bytes) added by S3. -# Budget: 4 keys × (12 + ~9 avg key len) = ~83 bytes for keys, leaving ~1,965 for values. +# Total S3 user metadata limit: 2048 bytes (keys + values, UTF-8 encoded). +# We reserve 256 bytes for customer-defined metadata on the same object. # See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata METADATA_KEY_NAME = "ojd-name" METADATA_KEY_DESC = "ojd-desc" METADATA_KEY_STEPS = "ojd-steps" METADATA_KEY_PARAMS = "ojd-params" +METADATA_KEY_STEP_COUNT = "ojd-step-count" +METADATA_KEY_PARAM_COUNT = "ojd-param-count" +# Hard caps to prevent any single field from consuming the entire budget METADATA_LIMIT_NAME = 256 -METADATA_LIMIT_DESC = 480 -METADATA_LIMIT_STEPS = 480 -METADATA_LIMIT_PARAMS = 700 +METADATA_LIMIT_DESC = 600 +S3_METADATA_TOTAL_BUDGET = 2048 - 256 # Reserve 256 bytes for customer metadata # POSIX only forbids / and null; Windows also forbids \ : * ? " < > | # Control characters (0x00-0x1F, 0x7F) are problematic on all platforms @@ -150,6 +153,8 @@ class BundleInfo: description: str = "" step_names: list[str] = field(default_factory=list) parameters: list[dict] = field(default_factory=list) + total_steps: Optional[int] = None # Actual count (when metadata was truncated) + total_parameters: Optional[int] = None # Actual count (when metadata was truncated) def to_dict(self) -> dict: """Serialize to a dict suitable for JSON output.""" @@ -406,12 +411,18 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI parts = p.split(":", 1) if len(parts) == 2: params.append({"name": parts[0], "type": parts[1], "_from_metadata": True}) + + step_count_str = metadata.get(METADATA_KEY_STEP_COUNT) + param_count_str = metadata.get(METADATA_KEY_PARAM_COUNT) + return BundleInfo( path=path, name=name, description=metadata.get(METADATA_KEY_DESC, ""), step_names=[s for s in metadata.get(METADATA_KEY_STEPS, "").split(",") if s], parameters=params, + total_steps=int(step_count_str) if step_count_str else None, + total_parameters=int(param_count_str) if param_count_str else None, ) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index d83d5631c..0d80afa65 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -747,9 +747,26 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): self._preview_desc.setVisible(False) if info.step_names: - self._preview_steps_label.setText(tr("Steps") + f" ({len(info.step_names)})") + # Detect if steps were truncated in S3 metadata + steps_truncated = info.step_names and info.step_names[-1].endswith("...") + steps = info.step_names[:-1] if steps_truncated else info.step_names + # Use total_steps from metadata count if available + if info.total_steps and info.total_steps > len(steps): + count_str = str(info.total_steps) + elif steps_truncated: + count_str = f"{len(steps)}+" + else: + count_str = str(len(steps)) + self._preview_steps_label.setText(tr("Steps") + f" ({count_str})") self._preview_steps_label.setVisible(True) - self._preview_steps.setText(_steps_list_text(info.step_names)) + steps_text = _steps_list_text(steps) + if steps_truncated or (info.total_steps and info.total_steps > len(steps)): + hidden_count = (info.total_steps - len(steps)) if info.total_steps else None + if hidden_count: + steps_text += f"\n \u2026 {hidden_count} more" + else: + steps_text += "\n \u2026 more" + self._preview_steps.setText(steps_text) self._preview_steps.setVisible(True) else: self._preview_steps_label.setVisible(False) @@ -758,14 +775,23 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): if info.parameters: muted_color = self._preview_params.palette().color(QPalette.PlaceholderText) # Detect if parameters were truncated in metadata - truncated = any( + garbled = any( p.get("name", "").endswith("...") or p.get("type", "").endswith("...") for p in info.parameters ) # Drop the last entry if it's garbled from truncation - params = info.parameters[:-1] if truncated else info.parameters - # Count reflects shown params; "+" hints there may be more when truncated. - count_str = f"{len(params)}+" if truncated else str(len(params)) + params = info.parameters[:-1] if garbled else info.parameters + # Truncation: either garbled suffix detected, or count metadata says more exist + truncated = garbled or ( + info.total_parameters is not None and info.total_parameters > len(params) + ) + # Use total_parameters from metadata count if available + if info.total_parameters and info.total_parameters > len(params): + count_str = str(info.total_parameters) + elif truncated: + count_str = f"{len(params)}+" + else: + count_str = str(len(params)) self._preview_params_label.setText(tr("Parameters") + f" ({count_str})") self._preview_params_label.setVisible(True) row_count = len(params) + (1 if truncated else 0) @@ -773,14 +799,8 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): for row, p in enumerate(params): value = p.get("_display_value", "") # "Required" = the artist must supply it because the bundle gives no - # default/value. Skip this inference for metadata-only params where we - # simply don't have default info. - is_required = ( - not value - and not p.get("_from_metadata") - and "default" not in p - and "value" not in p - ) + # default/value. Only meaningful for full template info (not S3 metadata). + is_required = not value and "default" not in p and "value" not in p # Name is plain; the "(required)" value cell carries the signal. self._preview_params.setItem(row, 0, QTableWidgetItem(p.get("name", "?"))) @@ -791,6 +811,8 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): if value: value_item = QTableWidgetItem(str(value)) + elif p.get("_from_metadata"): + value_item = QTableWidgetItem("") elif is_required: value_item = QTableWidgetItem(tr("(required)")) value_item.setForeground(REQUIRED_COLOR) # warm = needs input @@ -799,9 +821,17 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): value_item.setForeground(muted_color) self._preview_params.setItem(row, 2, value_item) if truncated: - truncation_item = QTableWidgetItem("\u2026 additional parameters not shown") + hidden_count = ( + (info.total_parameters - len(params)) if info.total_parameters else None + ) + if hidden_count: + msg = f"\u2026 {hidden_count} more not shown" + else: + msg = "\u2026 more not shown" + truncation_item = QTableWidgetItem(msg) truncation_item.setForeground(muted_color) self._preview_params.setItem(len(params), 0, truncation_item) + self._preview_params.setSpan(len(params), 0, 1, 3) self._preview_params.setVisible(True) self._size_params_table_to_contents() else: 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 1432e8512..fe2fd2b3a 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 @@ -48,12 +48,13 @@ LocalBundleRepository, METADATA_KEY_DESC, METADATA_KEY_NAME, + METADATA_KEY_PARAM_COUNT, METADATA_KEY_PARAMS, + METADATA_KEY_STEP_COUNT, METADATA_KEY_STEPS, METADATA_LIMIT_DESC, METADATA_LIMIT_NAME, - METADATA_LIMIT_PARAMS, - METADATA_LIMIT_STEPS, + S3_METADATA_TOTAL_BUDGET, S3BundleRepository, _extract_bundle_info, _parse_template, @@ -712,16 +713,46 @@ def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC ) if info.step_names: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - ",".join(info.step_names), METADATA_LIMIT_STEPS, METADATA_KEY_STEPS - ) + bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) if info.parameters: - param_strs = [ + bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) + + # Dynamically allocate remaining budget to steps and params + steps_str = ",".join(info.step_names) if info.step_names else "" + param_strs_joined = ( + ",".join( f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters - ] - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - ",".join(param_strs), METADATA_LIMIT_PARAMS, METADATA_KEY_PARAMS ) + if info.parameters + else "" + ) + used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) + remaining = S3_METADATA_TOTAL_BUDGET - used + keys_needed = 0 + if steps_str: + keys_needed += 12 + len(METADATA_KEY_STEPS) + if param_strs_joined: + keys_needed += 12 + len(METADATA_KEY_PARAMS) + remaining -= keys_needed + + if remaining > 0: + if steps_str and param_strs_joined: + steps_budget = remaining // 2 + params_budget = remaining - steps_budget + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, steps_budget, METADATA_KEY_STEPS + ) + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs_joined, params_budget, METADATA_KEY_PARAMS + ) + elif steps_str: + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, remaining, METADATA_KEY_STEPS + ) + elif param_strs_joined: + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs_joined, remaining, METADATA_KEY_PARAMS + ) break # Archive and upload diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index e6eb57951..57598ffa9 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -13,10 +13,8 @@ from deadline.client.cli import main from deadline.client.job_bundle.repository import ( BrowseEntry, - METADATA_LIMIT_DESC, METADATA_LIMIT_NAME, - METADATA_LIMIT_PARAMS, - METADATA_LIMIT_STEPS, + S3_METADATA_TOTAL_BUDGET, ) BUNDLE_GROUP = "deadline.client.cli._groups.bundle_group" @@ -191,7 +189,7 @@ def test_upload_truncates_metadata_with_warning( { "specificationVersion": "jobtemplate-2023-09", "name": "A" * 300, - "description": "D" * 600, + "description": "D" * 1200, "steps": [{"name": f"Step_{i:03d}_Long"} for i in range(40)], "parameterDefinitions": [ {"name": f"Param_{i:03d}_Long", "type": "STRING"} for i in range(50) @@ -223,9 +221,9 @@ def test_upload_truncates_metadata_with_warning( call_args = mock_s3.upload_fileobj.call_args metadata = call_args[1]["ExtraArgs"]["Metadata"] assert len(metadata["ojd-name"]) <= METADATA_LIMIT_NAME - assert len(metadata["ojd-desc"]) <= METADATA_LIMIT_DESC - assert len(metadata["ojd-steps"]) <= METADATA_LIMIT_STEPS - assert len(metadata["ojd-params"]) <= METADATA_LIMIT_PARAMS + # Total metadata must stay within S3's 2KB budget + total = sum(12 + len(k) + len(v) for k, v in metadata.items()) + assert total <= S3_METADATA_TOTAL_BUDGET # Verify truncated values end with "..." assert metadata["ojd-name"].endswith("...") From 25604843bbeb980117923e0ad2e979e55788f419 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:04:28 -0500 Subject: [PATCH 38/89] feat: expansdble parameterrs/steps in preview panel Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 86 +++++++++++-------- src/deadline/client/ui/widgets/__init__.py | 3 + .../client/ui/widgets/expandable_section.py | 83 ++++++++++++++++++ 3 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 src/deadline/client/ui/widgets/expandable_section.py diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 0d80afa65..9d4fdcd7f 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -15,7 +15,7 @@ from logging import getLogger from typing import Optional -from qtpy.QtCore import Qt, QModelIndex, QSortFilterProxyModel, QTimer, Signal # type: ignore +from qtpy.QtCore import Qt, QModelIndex, QSize, QSortFilterProxyModel, QTimer, Signal # type: ignore from qtpy.QtGui import QColor, QPalette, QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore QCheckBox, @@ -41,6 +41,7 @@ ) from .._utils import tr, warning_banner_qss +from ..widgets.expandable_section import ExpandableSection from ...job_bundle.repository import ( BrowseEntry, BundleRepository, @@ -63,6 +64,20 @@ REQUIRED_COLOR = QColor("#b35900") # Shared "quiet section label" style — small, bold, sentence-case, muted. + + +class _WrappingLabel(QLabel): + """QLabel that word-wraps without expanding its parent's width. + + Standard QLabel with wordWrap reports a minimumSizeHint equal to the full + single-line width, which pushes splitter panes and scroll areas wider. + This subclass overrides that to allow shrinking. + """ + + def minimumSizeHint(self): + return QSize(0, 0) + + # Color is set per-widget from the palette so it adapts to the theme. _SECTION_LABEL_QSS = "font-size: 13px; font-weight: bold;" @@ -351,7 +366,7 @@ def _build_ui(self): preview_layout.setSpacing(6) # Title — top of a 3-step scale (title / body / section-label). - self._preview_name = QLabel() + self._preview_name = _WrappingLabel() self._preview_name.setWordWrap(True) self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") preview_layout.addWidget(self._preview_name) @@ -364,28 +379,25 @@ def _build_ui(self): # Extra breathing room between the title/subline block and the description. preview_layout.addSpacing(12) - # Description — with a section label to match Steps/Parameters. - self._preview_desc_label = QLabel(tr("Description")) - self._preview_desc_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) - preview_layout.addWidget(self._preview_desc_label) - self._preview_desc = QLabel() + # Description — expandable, default expanded. + self._desc_section = ExpandableSection(expanded=True, disable_content_paddings=True) + self._desc_section.set_header_style(f"{_SECTION_LABEL_QSS} {muted_qss}") + self._preview_desc = _WrappingLabel() self._preview_desc.setWordWrap(True) - preview_layout.addWidget(self._preview_desc) + self._desc_section.set_content(self._preview_desc) + preview_layout.addWidget(self._desc_section) preview_layout.addSpacing(8) - self._preview_steps_label = QLabel(tr("Steps")) - self._preview_steps_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) - preview_layout.addWidget(self._preview_steps_label) - # Steps as a plain bulleted list — no background fill (it drew the eye - # more than it should). + self._steps_section = ExpandableSection(expanded=False, disable_content_paddings=True) + self._steps_section.set_header_style(f"{_SECTION_LABEL_QSS} {muted_qss}") self._preview_steps = QLabel() self._preview_steps.setWordWrap(True) - preview_layout.addWidget(self._preview_steps) + self._steps_section.set_content(self._preview_steps) + preview_layout.addWidget(self._steps_section) preview_layout.addSpacing(8) - self._preview_params_label = QLabel(tr("Parameters")) - self._preview_params_label.setStyleSheet(_SECTION_LABEL_QSS + muted_qss) - preview_layout.addWidget(self._preview_params_label) + self._params_section = ExpandableSection(expanded=False, disable_content_paddings=True) + self._params_section.set_header_style(f"{_SECTION_LABEL_QSS} {muted_qss}") self._preview_params = QTableWidget() self._preview_params.setColumnCount(3) self._preview_params.setHorizontalHeaderLabels([tr("Name"), tr("Type"), tr("Value")]) @@ -425,7 +437,8 @@ def _build_ui(self): self._preview_params.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._preview_params.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._preview_params.setSizeAdjustPolicy(QTableWidget.AdjustToContents) - preview_layout.addWidget(self._preview_params) + self._params_section.set_content(self._preview_params) + preview_layout.addWidget(self._params_section) preview_layout.addStretch(1) self._clear_preview() @@ -436,6 +449,7 @@ def _build_ui(self): preview_scroll = QScrollArea() preview_scroll.setWidget(preview_widget) preview_scroll.setWidgetResizable(True) + preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) preview_scroll.setFrameShape(QFrame.NoFrame) # Make the detail page transparent so the panel's gradient shows through here # too (a QScrollArea + its content otherwise paint an opaque Base background). @@ -722,7 +736,7 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): return self._preview_stack.setCurrentIndex(1) # show detail page - self._preview_name.setText(info.name) + self._preview_name.setText(f'

{info.name}

') self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") self._preview_name.setVisible(True) @@ -739,12 +753,12 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): self._preview_subline.setVisible(True) if info.description: - self._preview_desc_label.setVisible(True) + self._desc_section.set_title(tr("Description")) + self._desc_section.setVisible(True) self._preview_desc.setText(_normalize_description(info.description)) - self._preview_desc.setVisible(True) + self._preview_desc.setVisible(self._desc_section.is_expanded()) else: - self._preview_desc_label.setVisible(False) - self._preview_desc.setVisible(False) + self._desc_section.setVisible(False) if info.step_names: # Detect if steps were truncated in S3 metadata @@ -757,8 +771,8 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): count_str = f"{len(steps)}+" else: count_str = str(len(steps)) - self._preview_steps_label.setText(tr("Steps") + f" ({count_str})") - self._preview_steps_label.setVisible(True) + self._steps_section.set_title(f"{tr('Steps')} ({count_str})") + self._steps_section.setVisible(True) steps_text = _steps_list_text(steps) if steps_truncated or (info.total_steps and info.total_steps > len(steps)): hidden_count = (info.total_steps - len(steps)) if info.total_steps else None @@ -767,9 +781,9 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): else: steps_text += "\n \u2026 more" self._preview_steps.setText(steps_text) - self._preview_steps.setVisible(True) + self._preview_steps.setVisible(self._steps_section.is_expanded()) else: - self._preview_steps_label.setVisible(False) + self._steps_section.setVisible(False) self._preview_steps.setVisible(False) if info.parameters: @@ -792,8 +806,8 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): count_str = f"{len(params)}+" else: count_str = str(len(params)) - self._preview_params_label.setText(tr("Parameters") + f" ({count_str})") - self._preview_params_label.setVisible(True) + self._params_section.set_title(f"{tr('Parameters')} ({count_str})") + self._params_section.setVisible(True) row_count = len(params) + (1 if truncated else 0) self._preview_params.setRowCount(row_count) for row, p in enumerate(params): @@ -825,17 +839,17 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): (info.total_parameters - len(params)) if info.total_parameters else None ) if hidden_count: - msg = f"\u2026 {hidden_count} more not shown" + msg = f"\u2026 {hidden_count} more" else: - msg = "\u2026 more not shown" + msg = "\u2026 more" truncation_item = QTableWidgetItem(msg) truncation_item.setForeground(muted_color) self._preview_params.setItem(len(params), 0, truncation_item) self._preview_params.setSpan(len(params), 0, 1, 3) - self._preview_params.setVisible(True) + self._preview_params.setVisible(self._params_section.is_expanded()) self._size_params_table_to_contents() else: - self._preview_params_label.setVisible(False) + self._params_section.setVisible(False) self._preview_params.setVisible(False) def _size_params_table_to_contents(self) -> None: @@ -885,10 +899,10 @@ def _show_error_preview(self, message: str): self._preview_name.setStyleSheet("font-weight: bold; font-size: 14px; color: red;") self._preview_name.setVisible(True) self._preview_subline.setVisible(False) - self._preview_desc_label.setVisible(False) + self._desc_section.setVisible(False) self._preview_desc.setText(message) self._preview_desc.setVisible(True) - self._preview_steps_label.setVisible(False) + self._steps_section.setVisible(False) self._preview_steps.setVisible(False) - self._preview_params_label.setVisible(False) + self._params_section.setVisible(False) self._preview_params.setVisible(False) diff --git a/src/deadline/client/ui/widgets/__init__.py b/src/deadline/client/ui/widgets/__init__.py index 9955001c2..1c229d429 100644 --- a/src/deadline/client/ui/widgets/__init__.py +++ b/src/deadline/client/ui/widgets/__init__.py @@ -25,9 +25,12 @@ "DeadlineFarmListComboBoxController", "DeadlineQueueListComboBoxController", "DeadlineStorageProfileListComboBoxController", + # Expandable section + "ExpandableSection", ] from .deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget +from .expandable_section import ExpandableSection from .host_requirements_tab import ( CustomAmountWidget, CustomAttributeValueWidget, diff --git a/src/deadline/client/ui/widgets/expandable_section.py b/src/deadline/client/ui/widgets/expandable_section.py new file mode 100644 index 000000000..5f3ed4a0e --- /dev/null +++ b/src/deadline/client/ui/widgets/expandable_section.py @@ -0,0 +1,83 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Expandable section widget — a clickable header that toggles content visibility. +Similar to Cloudscape's ExpandableSection component. +""" + +from qtpy.QtCore import Qt, QSize, Signal # type: ignore +from qtpy.QtWidgets import QToolButton, QVBoxLayout, QWidget # type: ignore + + +class ExpandableSection(QWidget): + """A collapsible section with a toggle arrow and title. + + The arrow and title act as a single clickable header. Clicking toggles + the visibility of the content widget. + + Example:: + + section = ExpandableSection("Parameters (5)") + section.set_content(my_table_widget) + layout.addWidget(section) + """ + + toggled = Signal(bool) + + def __init__( + self, + title: str = "", + expanded: bool = False, + disable_content_paddings: bool = False, + parent=None, + ): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(0) + self._disable_content_paddings = disable_content_paddings + + self._header = QToolButton() + self._header.setStyleSheet("QToolButton { border: none; padding: 0px; }") + self._header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self._header.setIconSize(QSize(7, 7)) + self._header.setCheckable(True) + self._header.setChecked(expanded) + self._header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow) + if title: + self._header.setText(f" {title}") + self._header.toggled.connect(self._on_toggled) + self._layout.addWidget(self._header) + + self._content: QWidget | None = None + + def set_title(self, title: str): + """Update the section header text.""" + self._header.setText(f" {title}") + + def set_header_style(self, qss: str): + """Set custom stylesheet on the header button.""" + self._header.setStyleSheet(f"QToolButton {{ border: none; padding: 0px; {qss} }}") + + def set_content(self, widget: QWidget): + """Set the content widget that will be shown/hidden.""" + old = self._content + if old is not None: + self._layout.removeWidget(old) + self._content = widget + if not self._disable_content_paddings: + widget.setContentsMargins(16, 4, 0, 0) + widget.setVisible(self._header.isChecked()) + self._layout.addWidget(widget) + + def is_expanded(self) -> bool: + return self._header.isChecked() + + def set_expanded(self, expanded: bool): + self._header.setChecked(expanded) + + def _on_toggled(self, checked: bool): + self._header.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) + if self._content is not None: + self._content.setVisible(checked) + self.toggled.emit(checked) From 8e22c75e2a7ac0e6653cdb9eebb9861d862ea3e3 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:20:02 -0500 Subject: [PATCH 39/89] feat: change export bundle to save bundle as Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/ui/dialogs/export_bundle_dialog.py | 4 ++-- .../ui/dialogs/submit_job_to_deadline_dialog.py | 14 +++++++------- .../client/ui/translations/locales/de_DE.json | 2 +- .../client/ui/translations/locales/en_US.json | 2 +- .../client/ui/translations/locales/es_ES.json | 2 +- .../client/ui/translations/locales/fr_FR.json | 2 +- .../client/ui/translations/locales/id_ID.json | 2 +- .../client/ui/translations/locales/it_IT.json | 2 +- .../client/ui/translations/locales/ja_JP.json | 2 +- .../client/ui/translations/locales/ko_KR.json | 2 +- .../client/ui/translations/locales/pt_BR.json | 2 +- .../client/ui/translations/locales/tr_TR.json | 2 +- .../client/ui/translations/locales/zh_CN.json | 2 +- .../client/ui/translations/locales/zh_TW.json | 2 +- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/deadline/client/ui/dialogs/export_bundle_dialog.py b/src/deadline/client/ui/dialogs/export_bundle_dialog.py index 54bede30b..e1d27a445 100644 --- a/src/deadline/client/ui/dialogs/export_bundle_dialog.py +++ b/src/deadline/client/ui/dialogs/export_bundle_dialog.py @@ -37,7 +37,7 @@ def __init__( parent=None, ): super().__init__(parent=parent) - self.setWindowTitle(tr("Export bundle")) + self.setWindowTitle(tr("Save bundle as")) self.setMinimumWidth(500) self._queue_repo = queue_repo @@ -107,7 +107,7 @@ def _build_ui(self, default_name: str): # Buttons button_box = QDialogButtonBox(QDialogButtonBox.Cancel) - self._export_button = QPushButton(tr("Export bundle")) + self._export_button = QPushButton(tr("Save bundle as")) button_box.addButton(self._export_button, QDialogButtonBox.AcceptRole) self._export_button.clicked.connect(self.accept) button_box.rejected.connect(self.reject) 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 fe2fd2b3a..359a59efd 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 @@ -297,7 +297,7 @@ def _build_ui( self.load_bundle_button = QPushButton(tr("Load Bundle")) self.load_bundle_button.clicked.connect(self._on_load_bundle) self.button_box.addButton(self.load_bundle_button, QDialogButtonBox.AcceptRole) - self.export_bundle_button = QPushButton(tr("Export bundle")) + self.export_bundle_button = QPushButton(tr("Save bundle as")) self.export_bundle_button.clicked.connect(self.on_export_bundle) self.button_box.addButton(self.export_bundle_button, QDialogButtonBox.AcceptRole) @@ -669,7 +669,7 @@ def _export_to_local(self, dest_dir: str, bundle_name: str): if os.path.exists(dest_path): reply = QMessageBox.question( self, - tr("Export bundle"), + tr("Save bundle as"), f"Bundle '{bundle_name}' already exists at:\n{dest_path}\n\nOverwrite?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, @@ -680,8 +680,8 @@ def _export_to_local(self, dest_dir: str, bundle_name: str): shutil.copytree(self.job_history_bundle_dir, dest_path) QMessageBox.information( self, - tr("Export bundle"), - f"Bundle exported to:\n{dest_path}", + tr("Save bundle as"), + f"Bundle saved to:\n{dest_path}", ) except Exception as exc: QMessageBox.critical(self, "Export failed", f"Failed to save bundle:\n{exc}") @@ -796,13 +796,13 @@ def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name QMessageBox.information( self, - tr("Export bundle"), - f"Bundle exported to queue:\ns3://{queue_repo._bucket}/{s3_key}", + tr("Save bundle as"), + "Bundle saved to queue.", ) except Exception as exc: from botocore.exceptions import ClientError - logger.error("Failed to export bundle: %s", exc, exc_info=True) + logger.error("Failed to save bundle: %s", exc, exc_info=True) if isinstance(exc, ClientError) and exc.response["Error"]["Code"] == "AccessDenied": msg = "You don't have permission to share bundles on this queue." else: diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index a7df08371..41258527a 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -46,7 +46,7 @@ "Download installer": "Installer herunterladen", "Edit...": "Bearbeiten...", "Error: Timeout cannot be set to zero.": "Fehler: Timeout kann nicht auf null gesetzt werden.", - "Export bundle": "Paket exportieren", + "Save bundle as": "Bundle speichern unter", "Failed to log in to AWS Deadline Cloud:

{error}": "Anmeldung bei AWS Deadline Cloud fehlgeschlagen:

{error}", "Farm": "Farm", "Farm settings": "Farm-Einstellungen", diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index 0291a61a7..fbe65eb5a 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -46,7 +46,7 @@ "Download installer": "Download installer", "Edit...": "Edit...", "Error: Timeout cannot be set to zero.": "Error: Timeout cannot be set to zero.", - "Export bundle": "Export bundle", + "Save bundle as": "Save bundle as", "Failed to log in to AWS Deadline Cloud:

{error}": "Failed to log in to AWS Deadline Cloud:

{error}", "Farm": "Farm", "Farm settings": "Farm settings", diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index 955ffa424..cde8ab888 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -46,7 +46,7 @@ "Download installer": "Descargar instalador", "Edit...": "Editar...", "Error: Timeout cannot be set to zero.": "Error: El tiempo de espera no puede establecerse en cero.", - "Export bundle": "Exportar paquete", + "Save bundle as": "Guardar paquete como", "Failed to log in to AWS Deadline Cloud:

{error}": "Error al iniciar sesión en AWS Deadline Cloud:

{error}", "Farm": "Granja", "Farm settings": "Configuración de granja", diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index ecf64ea1e..27d5c267a 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -46,7 +46,7 @@ "Download installer": "Télécharger l'installateur", "Edit...": "Modifier...", "Error: Timeout cannot be set to zero.": "Erreur : Le délai d'expiration ne peut pas être défini sur zéro.", - "Export bundle": "Exporter le lot", + "Save bundle as": "Enregistrer le lot sous", "Failed to log in to AWS Deadline Cloud:

{error}": "Échec de la connexion à AWS Deadline Cloud :

{error}", "Farm": "Ferme", "Farm settings": "Paramètres de ferme", diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index 811e884aa..5f82153e6 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -46,7 +46,7 @@ "Download installer": "Unduh installer", "Edit...": "Edit...", "Error: Timeout cannot be set to zero.": "Kesalahan: Timeout tidak dapat diatur ke nol.", - "Export bundle": "Ekspor bundel", + "Save bundle as": "Simpan bundel sebagai", "Failed to log in to AWS Deadline Cloud:

{error}": "Gagal masuk ke AWS Deadline Cloud:

{error}", "Farm": "Peternakan", "Farm settings": "Pengaturan peternakan", diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index 3c476cbd0..1c0199479 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -46,7 +46,7 @@ "Download installer": "Scarica installer", "Edit...": "Modifica...", "Error: Timeout cannot be set to zero.": "Errore: il timeout non può essere impostato su zero.", - "Export bundle": "Esporta pacchetto", + "Save bundle as": "Salva pacchetto come", "Failed to log in to AWS Deadline Cloud:

{error}": "Accesso ad AWS Deadline Cloud non riuscito:

{error}", "Farm": "Farm", "Farm settings": "Impostazioni farm", diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index dc4f922c8..ae7a561ad 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -46,7 +46,7 @@ "Download installer": "インストーラーをダウンロード", "Edit...": "編集...", "Error: Timeout cannot be set to zero.": "エラー: タイムアウトをゼロに設定できません。", - "Export bundle": "バンドルをエクスポート", + "Save bundle as": "バンドルを保存", "Failed to log in to AWS Deadline Cloud:

{error}": "AWS Deadline Cloud へのログインに失敗しました:

{error}", "Farm": "ファーム", "Farm settings": "ファーム設定", diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index 42c0d083c..8c87afbe3 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -46,7 +46,7 @@ "Download installer": "설치 프로그램 다운로드", "Edit...": "편집...", "Error: Timeout cannot be set to zero.": "오류: 제한 시간을 0으로 설정할 수 없습니다.", - "Export bundle": "번들 내보내기", + "Save bundle as": "번들 다른 이름으로 저장", "Failed to log in to AWS Deadline Cloud:

{error}": "AWS Deadline Cloud에 로그인하지 못했습니다:

{error}", "Farm": "팜", "Farm settings": "팜 설정", diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index e8303cbb3..1d2d3bf98 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -46,7 +46,7 @@ "Download installer": "Baixar instalador", "Edit...": "Editar...", "Error: Timeout cannot be set to zero.": "Erro: o tempo limite não pode ser definido como zero.", - "Export bundle": "Exportar pacote", + "Save bundle as": "Salvar pacote como", "Failed to log in to AWS Deadline Cloud:

{error}": "Falha ao fazer login no AWS Deadline Cloud:

{error}", "Farm": "Fazenda", "Farm settings": "Configurações da fazenda", diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index 5a9bc879d..143dc5baa 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -46,7 +46,7 @@ "Download installer": "Installer'ı indir", "Edit...": "Düzenle...", "Error: Timeout cannot be set to zero.": "Hata: Zaman aşımı sıfıra ayarlanamaz.", - "Export bundle": "Paketi dışa aktar", + "Save bundle as": "Paketi farklı kaydet", "Failed to log in to AWS Deadline Cloud:

{error}": "AWS Deadline Cloud'da oturum açılamadı:

{error}", "Farm": "Farm", "Farm settings": "Farm ayarları", diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index 829a12f2e..89ab6c138 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -46,7 +46,7 @@ "Download installer": "下载安装程序", "Edit...": "编辑...", "Error: Timeout cannot be set to zero.": "错误: 超时不能设置为零。", - "Export bundle": "导出捆绑包", + "Save bundle as": "另存包为", "Failed to log in to AWS Deadline Cloud:

{error}": "登录 AWS Deadline Cloud 失败:

{error}", "Farm": "服务器农场", "Farm settings": "服务器农场设置", diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index 6a4585a4b..de2272023 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -46,7 +46,7 @@ "Download installer": "下載安裝程式", "Edit...": "編輯...", "Error: Timeout cannot be set to zero.": "錯誤: 逾時不能設定為零。", - "Export bundle": "匯出套件", + "Save bundle as": "另存套件為", "Failed to log in to AWS Deadline Cloud:

{error}": "登入 AWS Deadline Cloud 失敗:

{error}", "Farm": "伺服器陣列", "Farm settings": "伺服器陣列設定", From 84f06fbaca7e4edd8470477cd1255c08312edb88 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:58 -0500 Subject: [PATCH 40/89] fix: let user know that no bundle were found Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 9d4fdcd7f..c63205cba 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -321,6 +321,15 @@ def _build_ui(self): self._tree.clicked.connect(self._on_clicked) self._tree.doubleClicked.connect(self._on_double_clicked) self._tree.selectionModel().currentChanged.connect(self._on_selection_changed) + # Overlay label for empty tree state (no bundles or no filter match) + self._tree_empty_label = QLabel("No bundles found", self._tree.viewport()) + self._tree_empty_label.setAlignment(Qt.AlignCenter) + self._tree_empty_label.setStyleSheet( + "color: palette(text); font-size: 13px; background: transparent;" + ) + self._tree_empty_label.setAttribute(Qt.WA_TransparentForMouseEvents) + self._tree_empty_label.setVisible(False) + self._tree.viewport().installEventFilter(self) left_layout.addWidget(self._tree) splitter.addWidget(left_widget) @@ -522,6 +531,8 @@ def _populate_root(self): is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set self._add_entry_item(root, entry, is_hidden=is_hidden) + self._update_tree_empty_state() + def _add_entry_item( self, parent_item: QStandardItem, entry: BrowseEntry, *, is_hidden: bool = False ): @@ -592,6 +603,19 @@ def _on_filter_changed(self, text: str): self._proxy.setFilterFixedString(text) if text: self._tree.expandAll() + self._update_tree_empty_state() + + def _update_tree_empty_state(self): + """Show/hide the 'No bundles found' overlay based on visible row count.""" + has_rows = self._proxy.rowCount() > 0 + self._tree_empty_label.setVisible(not has_rows) + if not has_rows: + self._tree_empty_label.resize(self._tree.viewport().size()) + + def eventFilter(self, obj, event): # type: ignore[override] + if obj is self._tree.viewport() and event.type() == event.Type.Resize: + self._tree_empty_label.resize(event.size()) + return super().eventFilter(obj, event) def _update_selection(self, proxy_index: QModelIndex): item = self._source_item(proxy_index) From 4dadbd1b3fbd30c7474590cbeb138b1fef0a8f0f Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:18:51 -0500 Subject: [PATCH 41/89] fix: translate save as dialog Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/ui/dialogs/export_bundle_dialog.py | 8 ++++---- src/deadline/client/ui/translations/locales/de_DE.json | 5 ++++- src/deadline/client/ui/translations/locales/en_US.json | 5 ++++- src/deadline/client/ui/translations/locales/es_ES.json | 5 ++++- src/deadline/client/ui/translations/locales/fr_FR.json | 5 ++++- src/deadline/client/ui/translations/locales/id_ID.json | 5 ++++- src/deadline/client/ui/translations/locales/it_IT.json | 5 ++++- src/deadline/client/ui/translations/locales/ja_JP.json | 5 ++++- src/deadline/client/ui/translations/locales/ko_KR.json | 5 ++++- src/deadline/client/ui/translations/locales/pt_BR.json | 5 ++++- src/deadline/client/ui/translations/locales/tr_TR.json | 5 ++++- src/deadline/client/ui/translations/locales/zh_CN.json | 5 ++++- src/deadline/client/ui/translations/locales/zh_TW.json | 5 ++++- 13 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/deadline/client/ui/dialogs/export_bundle_dialog.py b/src/deadline/client/ui/dialogs/export_bundle_dialog.py index e1d27a445..57e5921a2 100644 --- a/src/deadline/client/ui/dialogs/export_bundle_dialog.py +++ b/src/deadline/client/ui/dialogs/export_bundle_dialog.py @@ -64,14 +64,14 @@ def _build_ui(self, default_name: str): # Name name_row = QHBoxLayout() - name_row.addWidget(QLabel("Name:")) + name_row.addWidget(QLabel(f"{tr('Name')}:")) self._name_edit = QLineEdit(default_name) name_row.addWidget(self._name_edit) layout.addLayout(name_row) # Save to source_row = QHBoxLayout() - source_row.addWidget(QLabel("Save to:")) + source_row.addWidget(QLabel(f"{tr('Save to')}:")) self._radio_queue = QRadioButton(tr("Queue")) self._radio_queue.setEnabled(self._queue_available) self._radio_queue.toggled.connect(self._on_source_changed) @@ -96,7 +96,7 @@ def _build_ui(self, default_name: str): # Location location_row = QHBoxLayout() - location_row.addWidget(QLabel("Location:")) + location_row.addWidget(QLabel(f"{tr('Location')}:")) self._location_edit = QLineEdit() location_row.addWidget(self._location_edit) self._browse_button = QPushButton("...") @@ -132,7 +132,7 @@ def _on_source_changed(self): def _on_browse(self): directory = QFileDialog.getExistingDirectory( - self, "Select export directory", self._location_edit.text() + self, tr("Select directory"), self._location_edit.text() ) if directory: self._location_edit.setText(directory) diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index 41258527a..626dd4af7 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -167,5 +167,8 @@ "Select a job bundle": "Job-Bundle auswählen", "Steps": "Schritte", "Type": "Typ", - "Value": "Wert" + "Value": "Wert", + "Save to": "Speichern in", + "Location": "Speicherort", + "Select directory": "Verzeichnis auswählen" } diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index fbe65eb5a..4a3c39976 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -167,5 +167,8 @@ "Select a job bundle": "Select a job bundle", "Steps": "Steps", "Type": "Type", - "Value": "Value" + "Value": "Value", + "Save to": "Save to", + "Location": "Location", + "Select directory": "Select directory" } diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index cde8ab888..e44679635 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -167,5 +167,8 @@ "Select a job bundle": "Seleccionar un job bundle", "Steps": "Pasos", "Type": "Tipo", - "Value": "Valor" + "Value": "Valor", + "Save to": "Guardar en", + "Location": "Ubicación", + "Select directory": "Seleccionar directorio" } diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index 27d5c267a..5b4b07a3c 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -167,5 +167,8 @@ "Select a job bundle": "Sélectionner un job bundle", "Steps": "Étapes", "Type": "Type", - "Value": "Valeur" + "Value": "Valeur", + "Save to": "Enregistrer dans", + "Location": "Emplacement", + "Select directory": "Sélectionner le répertoire" } diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index 5f82153e6..1cb4da685 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -167,5 +167,8 @@ "Select a job bundle": "Pilih job bundle", "Steps": "Langkah", "Type": "Tipe", - "Value": "Nilai" + "Value": "Nilai", + "Save to": "Simpan ke", + "Location": "Lokasi", + "Select directory": "Pilih direktori" } diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index 1c0199479..46f8b037f 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -167,5 +167,8 @@ "Select a job bundle": "Seleziona un job bundle", "Steps": "Passaggi", "Type": "Tipo", - "Value": "Valore" + "Value": "Valore", + "Save to": "Salva in", + "Location": "Posizione", + "Select directory": "Seleziona directory" } diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index ae7a561ad..49592116b 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -167,5 +167,8 @@ "Select a job bundle": "ジョブバンドルを選択", "Steps": "ステップ", "Type": "タイプ", - "Value": "値" + "Value": "値", + "Save to": "保存先", + "Location": "場所", + "Select directory": "ディレクトリを選択" } diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index 8c87afbe3..214e56d38 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -167,5 +167,8 @@ "Select a job bundle": "작업 번들 선택", "Steps": "단계", "Type": "유형", - "Value": "값" + "Value": "값", + "Save to": "저장 위치", + "Location": "위치", + "Select directory": "디렉터리 선택" } diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 1d2d3bf98..4975f63ae 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -167,5 +167,8 @@ "Select a job bundle": "Selecionar um job bundle", "Steps": "Etapas", "Type": "Tipo", - "Value": "Valor" + "Value": "Valor", + "Save to": "Salvar em", + "Location": "Local", + "Select directory": "Selecionar diretório" } diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index 143dc5baa..4f0b77ffd 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -167,5 +167,8 @@ "Select a job bundle": "İş paketi seçin", "Steps": "Adımlar", "Type": "Tür", - "Value": "Değer" + "Value": "Değer", + "Save to": "Kaydet", + "Location": "Konum", + "Select directory": "Dizin seçin" } diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index 89ab6c138..65bde4ec1 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -167,5 +167,8 @@ "Select a job bundle": "选择作业包", "Steps": "步骤", "Type": "类型", - "Value": "值" + "Value": "值", + "Save to": "保存到", + "Location": "位置", + "Select directory": "选择目录" } diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index de2272023..e135824a3 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -167,5 +167,8 @@ "Select a job bundle": "選擇工作套件", "Steps": "步驟", "Type": "類型", - "Value": "值" + "Value": "值", + "Save to": "儲存到", + "Location": "位置", + "Select directory": "選擇目錄" } From 0f8070a7a957f4f706a4fdd3cc52eb50b58d3199 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:24:55 -0500 Subject: [PATCH 42/89] feat: remember bundle selection per source Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index c63205cba..23e08b265 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -180,6 +180,8 @@ def __init__( self._cached_root_entries: list[BrowseEntry] = [] self._hidden_set: set[str] = set() self._last_preview_path: Optional[str] = None + self._tree_states: dict[str, set[str]] = {} # repo root -> expanded paths + self._tree_selections: dict[str, str] = {} # repo root -> selected path self._ready = False self._build_ui() @@ -677,6 +679,9 @@ def _search(parent_item): def _on_source_changed(self, checked: bool): if not self._ready: return + # Save expanded paths for the current source before switching + self._save_tree_state() + if self._radio_local.isChecked(): self._current_repo = self._local_repo elif self._radio_s3.isChecked() and self._s3_repo: @@ -688,6 +693,66 @@ def _on_source_changed(self, checked: bool): self._clear_preview() self._populate_root() + # Restore expanded paths for the new source + self._restore_tree_state() + + def _save_tree_state(self): + """Save expanded folder paths and selection for the current source.""" + key = self._current_repo.root_path() + + # Save selection + if self._selected_path: + self._tree_selections[key] = self._selected_path + elif key in self._tree_selections: + del self._tree_selections[key] + + # Save expanded folders + expanded: set[str] = set() + + def _collect(parent_index): + for row in range(self._proxy.rowCount(parent_index)): + idx = self._proxy.index(row, 0, parent_index) + if self._tree.isExpanded(idx): + source_idx = self._proxy.mapToSource(idx) + item = self._model.itemFromIndex(source_idx) + if item: + path = item.data(ROLE_PATH) + if path: + expanded.add(path) + _collect(idx) + + _collect(self._tree.rootIndex()) + key = self._current_repo.root_path() + if expanded: + self._tree_states[key] = expanded + elif key in self._tree_states: + del self._tree_states[key] + + def _restore_tree_state(self): + """Restore previously expanded folders and selection for the current source.""" + key = self._current_repo.root_path() + expanded = self._tree_states.get(key) + if expanded: + + def _expand(parent_index): + for row in range(self._proxy.rowCount(parent_index)): + idx = self._proxy.index(row, 0, parent_index) + source_idx = self._proxy.mapToSource(idx) + item = self._model.itemFromIndex(source_idx) + if item and item.data(ROLE_PATH) in expanded: + self._tree.expand(idx) + _expand(idx) + + _expand(self._tree.rootIndex()) + + # Restore selection + saved_path = self._tree_selections.get(key) + if saved_path: + proxy_index = self._find_proxy_index_by_path(saved_path) + if proxy_index and proxy_index.isValid(): + self._tree.setCurrentIndex(proxy_index) + self._tree.scrollTo(proxy_index) + def _on_hidden_toggled(self, checked: bool): if not self._ready: return From 0a9f93124b41aea0afa292057f098143b65b27fd Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:32:39 -0500 Subject: [PATCH 43/89] fix: reject control characters in bundle upload Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 4 ++++ .../cli/test_cli_bundle_repository.py | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index e0b8bdf94..80c5eea9b 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -904,6 +904,10 @@ def bundle_upload(job_bundle_dir, name, **args): raise DeadlineOperationError( "Bundle name is empty or invalid. Use --name to specify a valid name." ) + if re.search(r"[\x00-\x1f\x7f]", bundle_name): + raise DeadlineOperationError( + "Bundle name contains control characters. Use --name to specify a valid name." + ) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.ojd" if len(s3_key) > 1024: diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 57598ffa9..3adc5a2dc 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -270,6 +270,27 @@ def test_upload_no_truncation_when_within_limits(self, mock_s3_settings, mock_co assert metadata["ojd-name"] == "Short Name" assert not metadata["ojd-name"].endswith("...") + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_rejects_control_characters_in_name( + self, mock_s3_settings, mock_config, tmp_path + ): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps:\n- name: S1\n") + + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="bucket", rootPrefix="DC"), + MagicMock(), + ) + + runner = CliRunner() + result = runner.invoke( + main, ["bundle", "upload", str(bundle), "--name", "bad\x01name"] + ) + assert result.exit_code != 0 + assert "control characters" in result.output + class TestBundleUploadOverwrite: @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") From 22fb173a4d25f726e1eb90a2ee71ba43d5157151 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:43:47 -0500 Subject: [PATCH 44/89] fix: modify job bundle directory actually takes effect Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 2 +- src/deadline/client/ui/dialogs/deadline_config_dialog.py | 7 +++++-- .../unit/deadline_client/cli/test_cli_bundle_repository.py | 4 +--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index ce7f860fc..39590974b 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -618,7 +618,7 @@ Upload rejects bundles with invalid names: On download, the bundle name is sanitized for the local filesystem in a platform-specific manner: -- **POSIX** (macOS/Linux): only `/` and null bytes are replaced with `_`. Characters like `:`, `*`, `?` are preserved since they are valid filenames. +- **POSIX** (macOS/Linux): `/` and control characters (0x00–0x1F, 0x7F) are replaced with `_`. While most control characters are technically valid in POSIX filenames, they cause issues in terminals and scripts. - **Windows**: `\ / : * ? " < > |` and control characters are replaced with `_`. This means the S3 key preserves the original name as-is (all characters are valid in S3 keys), and only the local directory name is adjusted for the user's OS. diff --git a/src/deadline/client/ui/dialogs/deadline_config_dialog.py b/src/deadline/client/ui/dialogs/deadline_config_dialog.py index fdad816e8..5b2824841 100644 --- a/src/deadline/client/ui/dialogs/deadline_config_dialog.py +++ b/src/deadline/client/ui/dialogs/deadline_config_dialog.py @@ -863,8 +863,11 @@ def refresh(self): self.job_history_dir_edit.setText(job_history_dir) with block_signals(self.job_bundle_dir_edit): - job_bundle_dir = config_file.get_setting( - "settings.job_bundle_default_directory", config=self.config + job_bundle_dir = self.changes.get( + "settings.job_bundle_default_directory", + config_file.get_setting( + "settings.job_bundle_default_directory", config=self.config + ), ) self.job_bundle_dir_edit.setText(job_bundle_dir) diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 3adc5a2dc..728f9236a 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -285,9 +285,7 @@ def test_upload_rejects_control_characters_in_name( ) runner = CliRunner() - result = runner.invoke( - main, ["bundle", "upload", str(bundle), "--name", "bad\x01name"] - ) + result = runner.invoke(main, ["bundle", "upload", str(bundle), "--name", "bad\x01name"]) assert result.exit_code != 0 assert "control characters" in result.output From 7de931d3abbb5e6f39102abd91fa8f4adcc89fce Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:01:50 -0500 Subject: [PATCH 45/89] feat: upload .ojd files directly Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 14 +- src/deadline/_mcp/registry.py | 15 +- src/deadline/_mcp/tools/bundles.py | 132 ++++++++++++++++++ .../client/cli/_groups/bundle_group.py | 122 ++++++++++++---- .../cli/test_cli_bundle_repository.py | 64 +++++++++ 5 files changed, 315 insertions(+), 32 deletions(-) create mode 100644 src/deadline/_mcp/tools/bundles.py diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 39590974b..3e8a0cda0 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -413,19 +413,23 @@ $ deadline bundle cache update blender-render blender-render: up-to-date ``` -#### `deadline bundle upload ` +#### `deadline bundle upload ` -Uploads a local job bundle to share on the queue as an `.ojd` archive. +Uploads a local job bundle to share on the queue as an `.ojd` archive. Accepts either a bundle directory or an existing `.ojd` archive file. -- `--name`: Override the bundle name (defaults to the directory name). +- When given a directory, archives it as `.ojd` and uploads. Symlinks are skipped (not followed). +- When given an `.ojd` file, uploads it directly without re-archiving. +- `--name`: Override the bundle name (defaults to the directory/file name). - `--profile`, `--farm-id`, `--queue-id`: Standard config overrides. - If a bundle with the same name already exists, prompts for confirmation before overwriting. -- Symlinks within the bundle directory are skipped (not followed) to prevent unintended file disclosure. ``` $ deadline bundle upload ./my-render-job Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.ojd +$ deadline bundle upload ./my-render-job.ojd +Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/my-render-job.ojd + $ deadline bundle upload ./my-render-job --name custom-name Uploaded bundle to s3://my-farm-bucket/DeadlineCloud/job-bundles/custom-name.ojd @@ -636,7 +640,7 @@ This means the S3 key preserves the original name as-is (all characters are vali The MCP server exposes bundle sharing operations as tools for AI assistants: - **list_shared_bundles** — Lists bundles on the queue (respects visibility, supports `show_hidden`). -- **upload_bundle** — Uploads a local job bundle to the queue as an `.ojd` archive. +- **upload_bundle** — Uploads a local job bundle (directory or `.ojd` archive) to the queue. Archives are uploaded directly without re-archiving. - **download_bundle** — Downloads a shared bundle from the queue to a local directory. These tools use the same `S3BundleRepository` as the CLI and GUI, so behavior is consistent. Hide/unhide is not exposed via MCP — it's a management action better suited to direct user intent via CLI or GUI. diff --git a/src/deadline/_mcp/registry.py b/src/deadline/_mcp/registry.py index 83dce456e..25085c16e 100644 --- a/src/deadline/_mcp/registry.py +++ b/src/deadline/_mcp/registry.py @@ -5,7 +5,7 @@ from typing import Any, Callable, List, Optional, TypedDict, Dict from ..client import api -from .tools import job, logs +from .tools import job, logs, bundles class ToolDefinition(TypedDict): @@ -115,4 +115,17 @@ def get_all_tool_names() -> List[str]: "item_offset", ], }, + # Bundle sharing tools + "list_shared_bundles": { + "func": bundles.list_shared_bundles, + "param_names": ["farm_id", "queue_id", "show_hidden"], + }, + "upload_bundle": { + "func": bundles.upload_bundle, + "param_names": ["job_bundle", "name", "farm_id", "queue_id"], + }, + "download_bundle": { + "func": bundles.download_bundle, + "param_names": ["bundle_name", "output_dir", "farm_id", "queue_id"], + }, } diff --git a/src/deadline/_mcp/tools/bundles.py b/src/deadline/_mcp/tools/bundles.py new file mode 100644 index 000000000..66a035687 --- /dev/null +++ b/src/deadline/_mcp/tools/bundles.py @@ -0,0 +1,132 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Deadline Cloud Bundle sharing tools for MCP. +""" + +import os +import shutil +from typing import Any, Dict, Optional + +from click.testing import CliRunner + +from ...client.cli import main +from ...client.config import config_file +from ...client.job_bundle.repository import S3BundleRepository, sanitize_bundle_name + + +def list_shared_bundles( + farm_id: Optional[str] = None, + queue_id: Optional[str] = None, + show_hidden: bool = False, +) -> Dict[str, Any]: + """List job bundles shared on the queue. + + Returns a list of bundles with their name and format. + Hidden bundles are excluded by default unless show_hidden is True. + """ + config = None + if farm_id: + config = config_file.read_config() + config_file.set_setting("defaults.farm_id", farm_id, config) + if queue_id: + if config is None: + config = config_file.read_config() + config_file.set_setting("defaults.queue_id", queue_id, config) + + repo = S3BundleRepository.from_config(config) + entries = repo.list_entries(repo.root_path()) + bundles = [e for e in entries if e.is_bundle] + + hidden_set: set[str] = set() + if not show_hidden: + hidden_set = repo.get_hidden_set() + bundles = [e for e in bundles if e.name not in hidden_set] + + return { + "bundles": [ + { + "name": e.name, + "path": e.path, + "format": "archive", + **({"hidden": True} if e.name in hidden_set else {}), + } + for e in bundles + ] + } + + +def upload_bundle( + job_bundle: str, + name: Optional[str] = None, + farm_id: Optional[str] = None, + queue_id: Optional[str] = None, +) -> Dict[str, Any]: + """Upload a local job bundle to the queue as a shared .ojd archive. + + Args: + job_bundle: Path to a job bundle directory or .ojd archive file. + name: Override the bundle name (defaults to directory/file name). + farm_id: The farm ID (uses default if not specified). + queue_id: The queue ID (uses default if not specified). + """ + args = ["bundle", "upload", job_bundle] + if name: + args.extend(["--name", name]) + if farm_id: + args.extend(["--farm-id", farm_id]) + if queue_id: + args.extend(["--queue-id", queue_id]) + + runner = CliRunner() + result = runner.invoke(main, args, input="y\n") + + if result.exit_code != 0: + return {"success": False, "error": result.output.strip()} + return {"success": True, "message": result.output.strip()} + + +def download_bundle( + bundle_name: str, + output_dir: Optional[str] = None, + farm_id: Optional[str] = None, + queue_id: Optional[str] = None, +) -> Dict[str, Any]: + """Download a shared bundle from the queue to a local directory. + + Args: + bundle_name: Name of the bundle to download. + output_dir: Local directory to download to (defaults to current directory). + farm_id: The farm ID (uses default if not specified). + queue_id: The queue ID (uses default if not specified). + """ + config = None + if farm_id: + config = config_file.read_config() + config_file.set_setting("defaults.farm_id", farm_id, config) + if queue_id: + if config is None: + config = config_file.read_config() + config_file.set_setting("defaults.queue_id", queue_id, config) + + repo = S3BundleRepository.from_config(config) + output_dir = output_dir or os.getcwd() + os.makedirs(output_dir, exist_ok=True) + + entries = repo.list_entries(repo.root_path()) + match = next((e for e in entries if e.name == bundle_name and e.is_bundle), None) + if not match: + available = [e.name for e in entries if e.is_bundle] + return { + "success": False, + "error": f"Bundle '{bundle_name}' not found on queue.", + "available": available, + } + + local_path = repo.download_full_bundle(match.path, output_dir) + dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) + if os.path.exists(dest_path): + shutil.rmtree(dest_path) + shutil.copytree(local_path, dest_path) + + return {"success": True, "path": dest_path} diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 80c5eea9b..9741f25c0 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -43,6 +43,7 @@ _get_bundle_cache_dir, _parse_template, _read_cache_meta, + _read_template_from_archive_path, sanitize_bundle_name, ) from ....job_attachments.exceptions import ( @@ -827,18 +828,75 @@ def bundle_upload(job_bundle_dir, name, **args): s3_settings, boto3_session = _get_queue_s3_settings(config) job_bundle_dir = os.path.abspath(job_bundle_dir) - if not is_job_bundle_dir(job_bundle_dir): + + # Determine if input is an .ojd archive or a directory + is_archive_input = os.path.isfile(job_bundle_dir) and job_bundle_dir.endswith(".ojd") + + if not is_archive_input and not is_job_bundle_dir(job_bundle_dir): raise DeadlineOperationError( f"Directory does not appear to be a job bundle (no template.yaml or template.json): {job_bundle_dir}" ) # Parse the template to extract metadata for S3 object metadata bundle_metadata = {} - for tname in ("template.yaml", "template.json"): - tpath = os.path.join(job_bundle_dir, tname) - if os.path.isfile(tpath): - with open(tpath, encoding="utf-8") as f: - template = _parse_template(f.read(), tname) + if is_archive_input: + result = _read_template_from_archive_path(job_bundle_dir) + if result: + raw, fname = result + template = _parse_template(raw, fname) + if template: + info = _extract_bundle_info(template, job_bundle_dir) + bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( + info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) + if info.description: + desc = " ".join(info.description.split()) + bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( + desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC + ) + if info.step_names: + bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) + if info.parameters: + bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) + + steps_str = ",".join(info.step_names) if info.step_names else "" + param_strs = ( + ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) + if info.parameters + else "" + ) + used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) + remaining = S3_METADATA_TOTAL_BUDGET - used + keys_needed = 0 + if steps_str: + keys_needed += 12 + len(METADATA_KEY_STEPS) + if param_strs: + keys_needed += 12 + len(METADATA_KEY_PARAMS) + remaining -= keys_needed + if remaining > 0: + if steps_str and param_strs: + steps_budget = remaining // 2 + params_budget = remaining - steps_budget + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, steps_budget, METADATA_KEY_STEPS + ) + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, params_budget, METADATA_KEY_PARAMS + ) + elif steps_str: + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, remaining, METADATA_KEY_STEPS + ) + elif param_strs: + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, remaining, METADATA_KEY_PARAMS + ) + else: + for tname in ("template.yaml", "template.json"): + tpath = os.path.join(job_bundle_dir, tname) + if os.path.isfile(tpath): + with open(tpath, encoding="utf-8") as f: + template = _parse_template(f.read(), tname) if template: info = _extract_bundle_info( template, @@ -900,6 +958,8 @@ def bundle_upload(job_bundle_dir, name, **args): break bundle_name = name or os.path.basename(job_bundle_dir) + if is_archive_input and bundle_name.endswith(".ojd"): + bundle_name = bundle_name[:-4] if not bundle_name or not bundle_name.strip("/ \\"): raise DeadlineOperationError( "Bundle name is empty or invalid. Use --name to specify a valid name." @@ -928,26 +988,36 @@ def bundle_upload(job_bundle_dir, name, **args): raise # Archive and upload - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, dirs, files in os.walk(job_bundle_dir, followlinks=False): - # Skip symlinked directories - dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] - for fname in files: - local_path = os.path.join(root, fname) - if os.path.islink(local_path): - logger.warning("Skipping symlink: %s", local_path) - continue - arcname = os.path.relpath(local_path, job_bundle_dir) - zf.write(local_path, arcname) - - buf.seek(0) - s3.upload_fileobj( - buf, - s3_settings.s3BucketName, - s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, - ) + if is_archive_input: + # Already an .ojd — upload directly + with open(job_bundle_dir, "rb") as f: + s3.upload_fileobj( + f, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ) + else: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(job_bundle_dir, followlinks=False): + # Skip symlinked directories + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] + for fname in files: + local_path = os.path.join(root, fname) + if os.path.islink(local_path): + logger.warning("Skipping symlink: %s", local_path) + continue + arcname = os.path.relpath(local_path, job_bundle_dir) + zf.write(local_path, arcname) + + buf.seek(0) + s3.upload_fileobj( + buf, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ) click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 728f9236a..919d1302d 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -289,6 +289,70 @@ def test_upload_rejects_control_characters_in_name( assert result.exit_code != 0 assert "control characters" in result.output + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_archive_produces_same_metadata_as_directory( + self, mock_s3_settings, mock_config, tmp_path + ): + """Uploading a .ojd archive should produce the same S3 metadata as uploading + the equivalent directory bundle.""" + import zipfile + + template_content = yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Metadata Test", + "description": "A bundle for testing metadata parity", + "steps": [{"name": "StepOne"}, {"name": "StepTwo"}], + "parameterDefinitions": [ + {"name": "Frames", "type": "STRING", "default": "1-10"}, + {"name": "OutputDir", "type": "PATH"}, + ], + } + ) + + # Create directory bundle + bundle_dir = tmp_path / "my-bundle" + bundle_dir.mkdir() + (bundle_dir / "template.yaml").write_text(template_content) + + # Create .ojd archive with same content + archive_path = tmp_path / "my-bundle.ojd" + with zipfile.ZipFile(archive_path, "w") as zf: + zf.writestr("template.yaml", template_content) + + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DC"), + mock_session, + ) + + runner = CliRunner() + + # Upload directory + result_dir = runner.invoke(main, ["bundle", "upload", str(bundle_dir)]) + assert result_dir.exit_code == 0, result_dir.output + dir_metadata = mock_s3.upload_fileobj.call_args[1]["ExtraArgs"]["Metadata"] + + mock_s3.reset_mock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") + + # Upload archive + result_ojd = runner.invoke(main, ["bundle", "upload", str(archive_path)]) + assert result_ojd.exit_code == 0, result_ojd.output + ojd_metadata = mock_s3.upload_fileobj.call_args[1]["ExtraArgs"]["Metadata"] + + # Metadata should be identical + assert dir_metadata == ojd_metadata + assert dir_metadata["ojd-name"] == "Metadata Test" + assert "ojd-step-count" in dir_metadata + assert dir_metadata["ojd-step-count"] == "2" + assert "ojd-param-count" in dir_metadata + assert dir_metadata["ojd-param-count"] == "2" + class TestBundleUploadOverwrite: @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") From 0aca56ef1127808e9958fca4ea195a10198f23b7 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:40:06 -0500 Subject: [PATCH 46/89] feat: re-use already extracted local bundles based on mtime Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 2 +- src/deadline/client/job_bundle/repository.py | 45 ++++++++++-- .../cli/test_cli_bundle_repository.py | 70 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 3e8a0cda0..d62f078b9 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -252,7 +252,7 @@ The submitter dialog's "Export bundle" button replaces the previous separate "Ex **Name** — defaults to the job name with `{{Param.X}}` references resolved using current parameter values. Editable. Used as the `.ojd` filename for Queue or the directory name for Local. **Save to** — Queue or Local: -- **Queue**: archives the bundle as `.ojd` and uploads to the queue's S3 `job-bundles/` folder. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. If a bundle with the same name already exists, the user is prompted to confirm overwrite. If the existing bundle is hidden, the prompt warns "A hidden bundle with this name already exists" with three options: Cancel, Overwrite (keeps it hidden), or Overwrite and unhide. When Queue is unavailable (no permissions, no farm/queue configured, no job attachment settings), the radio button is disabled and an inline warning label explains why. +- **Queue**: archives the bundle as `.ojd` and uploads to the queue's S3 `job-bundles/` folder. S3 user metadata (name, description, steps, parameters) is attached for zero-download preview. If a bundle with the same name already exists, the user is prompted to confirm overwrite. When Queue is unavailable (no permissions, no farm/queue configured, no job attachment settings), the radio button is disabled and an inline warning label explains why. - **Local**: saves the bundle as a directory to the specified location. Defaults to `settings.job_bundle_default_directory` — the same path the browser's Local source browses. The exported bundle immediately appears when browsing Local. **Location** — always visible, updates based on the selected source: diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 0c9c3e895..086e09426 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -300,13 +300,37 @@ def get_bundle_info(self, path: str) -> Optional[BundleInfo]: return self._get_dir_bundle_info(path) def extract_bundle(self, path: str, dest_dir: str) -> str: - """Extract an archive bundle to dest_dir. Returns path to the extracted bundle.""" - bundle_name = _strip_archive_ext(os.path.basename(path)) - extract_dir = os.path.join(dest_dir, bundle_name) - os.makedirs(extract_dir, exist_ok=True) - _extract_archive(path, extract_dir) - # If the archive contains a single top-level directory, use that - contents = os.listdir(extract_dir) + """Extract an archive bundle, using mtime-based cache to avoid redundant extraction. + + Returns path to the extracted bundle directory.""" + cache_dir = os.path.join(_get_bundle_cache_dir(), _local_cache_key(path)) + meta = _read_cache_meta(cache_dir) + current_mtime = os.path.getmtime(path) + + # Cache hit — mtime unchanged + if meta and meta.get("mtime") == current_mtime and os.path.isdir(cache_dir): + bundle_dir = self._find_bundle_root(cache_dir) + if bundle_dir: + logger.info("Using cached bundle: %s", cache_dir) + return bundle_dir + + # Cache miss or stale — extract + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + os.makedirs(cache_dir, exist_ok=True) + _extract_archive(path, cache_dir) + + # Write mtime to cache meta + meta_path = os.path.join(cache_dir, CACHE_META_FILENAME) + with open(meta_path, "w", encoding="utf-8") as f: + json.dump({"mtime": current_mtime}, f) + + return self._find_bundle_root(cache_dir) or cache_dir + + @staticmethod + def _find_bundle_root(extract_dir: str) -> Optional[str]: + """If the archive has a single top-level wrapper dir, return it; otherwise extract_dir.""" + contents = [c for c in os.listdir(extract_dir) if c != CACHE_META_FILENAME] if len(contents) == 1 and os.path.isdir(os.path.join(extract_dir, contents[0])): return os.path.join(extract_dir, contents[0]) return extract_dir @@ -371,6 +395,13 @@ def _cache_key(bucket: str, s3_key: str) -> str: return os.path.join(h, name) +def _local_cache_key(path: str) -> str: + """Deterministic cache subdirectory from a local file path.""" + h = hashlib.sha256(os.path.abspath(path).encode()).hexdigest()[:16] + name = _strip_archive_ext(os.path.basename(path)) + return os.path.join(h, name) + + def _read_cache_meta(cache_dir: str) -> Optional[dict]: meta_path = os.path.join(cache_dir, CACHE_META_FILENAME) if os.path.isfile(meta_path): diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 919d1302d..8246b400f 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -3,6 +3,8 @@ """Tests for the bundle CLI commands (list, upload, download, cache).""" import json +import os +import time import zipfile import yaml @@ -13,6 +15,7 @@ from deadline.client.cli import main from deadline.client.job_bundle.repository import ( BrowseEntry, + LocalBundleRepository, METADATA_LIMIT_NAME, S3_METADATA_TOTAL_BUDGET, ) @@ -548,3 +551,70 @@ def test_list_queue_show_hidden_json(self, mock_from_config, mock_config): visible_entry = next(e for e in data if e["name"] == "blender-render") assert hidden_entry["hidden"] is True assert "hidden" not in visible_entry + + +class TestLocalArchiveCache: + """Tests for LocalBundleRepository.extract_bundle mtime-based caching.""" + + def test_extract_caches_and_reuses(self, tmp_path): + """Second extraction of unchanged archive uses the cache.""" + # Create an .ojd archive + archive = tmp_path / "my-bundle.ojd" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template.yaml", "name: Test\nsteps:\n- name: S1\n") + zf.writestr("script.sh", "echo hello") + + repo = LocalBundleRepository(root=str(tmp_path)) + + # First extraction + result1 = repo.extract_bundle(str(archive), str(tmp_path / "out")) + assert os.path.isfile(os.path.join(result1, "template.yaml")) + assert os.path.isfile(os.path.join(result1, "script.sh")) + + # Record the extraction time by checking a file's mtime in cache + template_mtime1 = os.path.getmtime(os.path.join(result1, "template.yaml")) + + # Second extraction — should reuse cache (same path returned) + result2 = repo.extract_bundle(str(archive), str(tmp_path / "out")) + assert result2 == result1 + + # File mtime should be unchanged (no re-extraction happened) + template_mtime2 = os.path.getmtime(os.path.join(result2, "template.yaml")) + assert template_mtime1 == template_mtime2 + + def test_extract_invalidates_on_mtime_change(self, tmp_path): + """Modified archive triggers re-extraction.""" + # Create initial archive + archive = tmp_path / "my-bundle.ojd" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template.yaml", "name: V1\nsteps:\n- name: S1\n") + + repo = LocalBundleRepository(root=str(tmp_path)) + + # First extraction + result1 = repo.extract_bundle(str(archive), str(tmp_path / "out")) + with open(os.path.join(result1, "template.yaml")) as f: + assert "V1" in f.read() + + # Modify the archive (ensure different mtime) + time.sleep(0.05) + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template.yaml", "name: V2\nsteps:\n- name: S2\n") + + # Second extraction — should detect mtime change and re-extract + result2 = repo.extract_bundle(str(archive), str(tmp_path / "out")) + with open(os.path.join(result2, "template.yaml")) as f: + assert "V2" in f.read() + + def test_extract_handles_wrapper_directory(self, tmp_path): + """Archive with a single wrapper dir returns the inner path.""" + archive = tmp_path / "wrapped.ojd" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("inner/template.yaml", "name: Wrapped\nsteps:\n- name: S1\n") + + repo = LocalBundleRepository(root=str(tmp_path)) + result = repo.extract_bundle(str(archive), str(tmp_path / "out")) + + # Should return the inner directory, not the extraction root + assert os.path.basename(result) == "inner" + assert os.path.isfile(os.path.join(result, "template.yaml")) From 80ec9787103f5b0ac55eebc8da730037cc122471 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:21:40 -0500 Subject: [PATCH 47/89] feat: add progress bar for archive/upload Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 9 +- .../client/cli/_groups/bundle_group.py | 30 ++- src/deadline/client/job_bundle/repository.py | 52 ++-- .../ui/dialogs/bundle_progress_dialog.py | 120 +++++++++ .../ui/dialogs/job_bundle_browser_dialog.py | 115 +++++++- .../dialogs/submit_job_to_deadline_dialog.py | 249 +++++++++++++----- .../client/ui/job_bundle_submitter.py | 10 +- .../ui/widgets/job_bundle_settings_tab.py | 9 +- .../cli/test_cli_bundle_repository.py | 38 +++ 9 files changed, 519 insertions(+), 113 deletions(-) create mode 100644 src/deadline/client/ui/dialogs/bundle_progress_dialog.py diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index d62f078b9..2af8efb22 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -587,9 +587,12 @@ Retries are transparent to the user — the operation either succeeds silently o Operations that involve network I/O show progress to the user: -- **Browser dialog**: When selecting an S3 bundle and clicking "Select", a progress spinner replaces the Select button label while the archive is downloaded/resolved. The dialog remains responsive (download happens on a background thread). -- **Export dialog**: When uploading to Queue, a progress bar appears below the Export button showing upload progress. Cancel is available during upload. -- **CLI**: `deadline bundle upload` and `deadline bundle download` show a progress bar (using the same style as job attachment uploads). `deadline bundle hide`/`unhide` complete fast enough to not need progress. +- **Browser dialog**: When selecting an S3 bundle and clicking "Select", a progress dialog shows download progress (bytes transferred / total). The dialog uses `QProgressDialog` with the S3 `download_fileobj` callback delivering updates via Qt signals from the background transfer threads. +- **Save to Queue (GUI)**: Archiving and upload run on a background `QThread`. A `QProgressDialog` shows two phases: + 1. "Archiving bundle... X MB / Y MB" — progress updates per file (small files use fast `zf.write()`, files >8MB use chunked 4MB writes for smoother progress). + 2. "Uploading bundle... X MB / Y MB" — byte-level progress via `upload_fileobj` callback. + On completion, the dialog shows "Bundle saved to queue." with a Close button. All values are scaled to KB to avoid 32-bit int overflow for large bundles. +- **CLI**: `deadline bundle upload` shows a click progress bar with bytes uploaded. `deadline bundle download` shows a progress bar with bytes downloaded. `deadline bundle hide`/`unhide` complete fast enough to not need progress. ### Error Handling diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 9741f25c0..319bfb498 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -990,12 +990,17 @@ def bundle_upload(job_bundle_dir, name, **args): # Archive and upload if is_archive_input: # Already an .ojd — upload directly - with open(job_bundle_dir, "rb") as f: + file_size = os.path.getsize(job_bundle_dir) + with ( + open(job_bundle_dir, "rb") as f, + click.progressbar(length=file_size, label="Uploading") as bar, + ): s3.upload_fileobj( f, s3_settings.s3BucketName, s3_key, ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + Callback=lambda bytes_sent: bar.update(bytes_sent), ) else: buf = io.BytesIO() @@ -1011,13 +1016,16 @@ def bundle_upload(job_bundle_dir, name, **args): arcname = os.path.relpath(local_path, job_bundle_dir) zf.write(local_path, arcname) + file_size = buf.tell() buf.seek(0) - s3.upload_fileobj( - buf, - s3_settings.s3BucketName, - s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, - ) + with click.progressbar(length=file_size, label="Uploading") as bar: + s3.upload_fileobj( + buf, + s3_settings.s3BucketName, + s3_key, + ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + Callback=lambda bytes_sent: bar.update(bytes_sent), + ) click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") @@ -1061,7 +1069,13 @@ def bundle_download(bundle_name, output_dir, **args): msg += f"\nAvailable bundles: {', '.join(available)}" raise DeadlineOperationError(msg) - local_path = repo.download_full_bundle(match.path, output_dir) + # Get file size for progress bar + file_size = repo.get_bundle_size(match.path) + + with click.progressbar(length=file_size, label="Downloading") as bar: + local_path = repo.download_full_bundle( + match.path, output_dir, progress_callback=lambda n: bar.update(n) + ) # download_full_bundle resolves to cache; copy to user's output_dir dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) if os.path.exists(dest_path): diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 086e09426..4b6f57bfd 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -559,10 +559,18 @@ def resolve_bundle(self, path: str, dest_dir: str) -> str: Returns the local path to the usable bundle directory.""" return self._resolve_archive_bundle(path) - def download_full_bundle(self, path: str, dest_dir: str) -> str: + def download_full_bundle(self, path: str, dest_dir: str, progress_callback=None) -> str: """Download a complete S3 .ojd bundle to a local directory. Uses the ETag cache for repeated access.""" - return self._resolve_archive_bundle(path) + return self._resolve_archive_bundle(path, progress_callback=progress_callback) + + def get_bundle_size(self, path: str) -> int: + """Get the size in bytes of a bundle archive on S3. + Caches the result so a subsequent download_full_bundle doesn't repeat the call.""" + key = self._to_s3_key(path) + head = self._s3.head_object(Bucket=self._bucket, Key=key) + self._last_head = (key, head) + return head.get("ContentLength", 0) # ── Archive bundles ────────────────────────────────────── @@ -627,28 +635,40 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: return _extract_bundle_info(template, path) return None - def _resolve_archive_bundle(self, path: str) -> str: + def _resolve_archive_bundle(self, path: str, progress_callback=None) -> str: key = self._to_s3_key(path) cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) - # Check if cache is valid - meta = _read_cache_meta(cache_dir) - if meta: + # Single head_object for both cache validation and metadata + head = None + if hasattr(self, "_last_head") and self._last_head[0] == key: + head = self._last_head[1] + self._last_head = None # type: ignore[assignment] + else: try: head = self._s3.head_object(Bucket=self._bucket, Key=key) - if _normalize_etag(head.get("ETag")) == _normalize_etag(meta.get("etag")): - bundle_path = self._find_bundle_in_cache(cache_dir) - if bundle_path: - logger.info("Using cached bundle: %s", bundle_path) - return bundle_path except Exception: - pass # Cache validation failed; re-download below + pass + + # Check if cache is valid + meta = _read_cache_meta(cache_dir) + if meta and head: + if _normalize_etag(head.get("ETag")) == _normalize_etag(meta.get("etag")): + bundle_path = self._find_bundle_in_cache(cache_dir) + if bundle_path: + logger.info("Using cached bundle: %s", bundle_path) + return bundle_path # Download, extract, and cache - resp = self._s3.get_object(Bucket=self._bucket, Key=key) - data = resp["Body"].read() - etag = resp.get("ETag", "") - last_modified = str(resp.get("LastModified", "")) + etag = head.get("ETag", "") if head else "" + last_modified = str(head.get("LastModified", "")) if head else "" + + buf = io.BytesIO() + download_kwargs: dict = {"Bucket": self._bucket, "Key": key} + if progress_callback: + download_kwargs["Callback"] = progress_callback + self._s3.download_fileobj(Fileobj=buf, **download_kwargs) + data = buf.getvalue() # Clear old cache and extract if os.path.exists(cache_dir): diff --git a/src/deadline/client/ui/dialogs/bundle_progress_dialog.py b/src/deadline/client/ui/dialogs/bundle_progress_dialog.py new file mode 100644 index 000000000..a3d100906 --- /dev/null +++ b/src/deadline/client/ui/dialogs/bundle_progress_dialog.py @@ -0,0 +1,120 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Two-phase progress dialog for bundle operations (archive/upload, download/extract). +Matches the visual style of the job attachments progress in SubmitJobProgressDialog. +""" + +from qtpy.QtCore import Qt # type: ignore +from qtpy.QtWidgets import ( # type: ignore + QDialog, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QLabel, + QProgressBar, + QVBoxLayout, + QWidget, +) + + +class _PhaseWidget(QGroupBox): + """A single phase with a progress bar and status message.""" + + def __init__(self, title: str, parent=None): + super().__init__(title=title, parent=parent) + layout = QFormLayout(self) + layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) + self.progress_bar = QProgressBar() + self.status_label = QLabel("") + layout.addWidget(self.progress_bar) + layout.addWidget(self.status_label) + + +class BundleProgressDialog(QDialog): + """A modal dialog showing two-phase progress for bundle operations. + + Each phase has a titled group box with a progress bar and status label, + matching the style of the job attachments submission progress. + + Example:: + + dialog = BundleProgressDialog( + "Saving bundle to queue", + phase1_title="Archiving", + phase2_title="Uploading", + parent=self, + ) + dialog.show() + dialog.set_phase1_progress(50, 100, "50 MB / 100 MB") + dialog.set_phase2_progress(0, 100, "Waiting...") + dialog.set_complete("Bundle saved to queue.") + """ + + def __init__( + self, + window_title: str, + *, + phase1_title: str = "Phase 1", + phase2_title: str = "Phase 2", + parent: QWidget | None = None, + ): + super().__init__(parent=parent) + self.setWindowTitle(window_title) + self.setWindowModality(Qt.WindowModal) + self.setMinimumWidth(450) + self.setWindowFlags( + (self.windowFlags() & ~Qt.WindowContextHelpButtonHint) | Qt.WindowCloseButtonHint + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + + self._phase1 = _PhaseWidget(phase1_title) + layout.addWidget(self._phase1) + + self._phase2 = _PhaseWidget(phase2_title) + layout.addWidget(self._phase2) + + self._button_box = QDialogButtonBox(QDialogButtonBox.Cancel) + self._button_box.rejected.connect(self.reject) + layout.addWidget(self._button_box) + + self._layout = layout + + def set_phase1_progress(self, value: int, maximum: int, message: str = ""): + self._phase1.progress_bar.setMaximum(maximum) + self._phase1.progress_bar.setValue(value) + if message: + self._phase1.status_label.setText(message) + + def set_phase2_progress(self, value: int, maximum: int, message: str = ""): + self._phase2.progress_bar.setMaximum(maximum) + self._phase2.progress_bar.setValue(value) + if message: + self._phase2.status_label.setText(message) + + def set_complete(self, message: str = "Complete"): + """Mark operation as complete — hide progress, show checkmark, change Cancel to Close.""" + self._phase1.setVisible(False) + self._phase2.setVisible(False) + + if not hasattr(self, "_complete_label"): + self._complete_label = QLabel() + self._complete_label.setAlignment(Qt.AlignCenter) + self._complete_label.setStyleSheet("font-size: 14px; padding: 20px;") + self._layout.insertWidget(0, self._complete_label) + + self._complete_label.setText(f"\u2705 {message}") + self._complete_label.setVisible(True) + + self._button_box.clear() + self._button_box.addButton(QDialogButtonBox.Close) + self._button_box.rejected.connect(self.accept) + + def set_error(self, message: str): + """Show error state.""" + self._phase2.status_label.setText(f"\u26a0 {message}") + self._button_box.clear() + self._button_box.addButton(QDialogButtonBox.Close) + self._button_box.rejected.connect(self.reject) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 23e08b265..8e7677758 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -7,17 +7,15 @@ from __future__ import annotations -import atexit import os import re -import shutil -import tempfile from logging import getLogger from typing import Optional -from qtpy.QtCore import Qt, QModelIndex, QSize, QSortFilterProxyModel, QTimer, Signal # type: ignore +from qtpy.QtCore import Qt, QModelIndex, QSize, QSortFilterProxyModel, QThread, QTimer, Signal # type: ignore from qtpy.QtGui import QColor, QPalette, QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore + QApplication, QCheckBox, QDialog, QDialogButtonBox, @@ -27,6 +25,7 @@ QLabel, QLineEdit, QMenu, + QProgressBar, QPushButton, QRadioButton, QGraphicsOpacityEffect, @@ -214,16 +213,106 @@ def resolve_selection(self) -> Optional[str]: return None if self._selected_is_s3 and self._s3_repo: - if self._selected_is_archive: - return self._s3_repo.resolve_bundle(self._selected_path, "") - else: - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - return self._s3_repo.resolve_bundle(self._selected_path, temp_dir) + file_size = self._s3_repo.get_bundle_size(self._selected_path) + + class _DownloadWorker(QThread): + progress = Signal(int) + finished = Signal(str) + error = Signal(str) + + def __init__(self, repo, path): + super().__init__() + self._repo = repo + self._path = path + self._sent = 0 + + def run(self): + try: + + def _cb(n): + self._sent += n + self.progress.emit(self._sent // 1024) + + result = self._repo._resolve_archive_bundle( + self._path, progress_callback=_cb + ) + self.finished.emit(result) + except Exception as e: + self.error.emit(str(e)) + + progress = QDialog(self) + progress.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint) + progress.setWindowTitle("Downloading Bundle") + progress.setWindowModality(Qt.ApplicationModal) + progress.setMinimumWidth(350) + _dlg_layout = QVBoxLayout(progress) + _progress_label = QLabel("Downloading bundle...") + _progress_label.setAlignment(Qt.AlignCenter) + _progress_bar = QProgressBar() + _progress_bar.setRange(0, max(1, file_size // 1024)) + _cancel_btn = QPushButton("Cancel") + _cancel_btn.clicked.connect(progress.reject) + _dlg_layout.addWidget(_progress_label) + _dlg_layout.addWidget(_progress_bar) + _dlg_layout.addWidget(_cancel_btn, alignment=Qt.AlignRight) + + worker = _DownloadWorker(self._s3_repo, self._selected_path) + download_result = [None] + download_error = [] + + def _on_progress(n): + _progress_bar.setValue(n) + total = _progress_bar.maximum() * 1024 + current = n * 1024 + if total > 0: + if total >= 1024 * 1024 * 1024: + label = f"Downloading bundle... {current / (1024**3):.1f} / {total / (1024**3):.1f} GB" + elif total >= 1024 * 1024: + label = f"Downloading bundle... {current / (1024**2):.1f} / {total / (1024**2):.1f} MB" + else: + label = ( + f"Downloading bundle... {current / 1024:.0f} / {total / 1024:.0f} KB" + ) + _progress_label.setText(label) + + def _on_finished(path): + download_result[0] = path + progress.close() + + def _on_error(msg): + download_error.append(msg) + progress.close() + + worker.progress.connect(_on_progress, Qt.QueuedConnection) + worker.finished.connect(_on_finished, Qt.QueuedConnection) + worker.error.connect(_on_error, Qt.QueuedConnection) + worker.start() + progress.exec_() + + if not download_result[0]: + # Cancelled or error — terminate worker and clean up partial cache + worker.terminate() + worker.wait() + # Clean partial cache for this bundle + from ...job_bundle.repository import _cache_key, _get_bundle_cache_dir + import shutil + + key = self._s3_repo._to_s3_key(self._selected_path) + cache_dir = os.path.join( + _get_bundle_cache_dir(), _cache_key(self._s3_repo._bucket, key) + ) + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir, ignore_errors=True) + return None + + worker.wait() + return download_result[0] elif self._selected_is_archive: - temp_dir = tempfile.mkdtemp(prefix="deadline-bundle-") - atexit.register(shutil.rmtree, temp_dir, True) - return self._local_repo.extract_bundle(self._selected_path, temp_dir) + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + return self._local_repo.extract_bundle(self._selected_path, "") + finally: + QApplication.restoreOverrideCursor() else: return self._selected_path 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 359a59efd..80054a90a 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 @@ -14,14 +14,16 @@ from typing import Any, Dict, Optional, Protocol import yaml -from qtpy.QtCore import QSize, Qt, Signal as _Signal # pylint: disable=import-error +from qtpy.QtCore import QSize, Qt, QThread, QTimer, 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, QDialog, QDialogButtonBox, QFormLayout, + QLabel, QMessageBox, + QProgressBar, QPushButton, QScrollArea, QTabWidget, @@ -581,7 +583,6 @@ def on_export_bundle(self): settings = self.job_settings_type() self.shared_job_settings.update_settings(settings) self.job_settings.update_settings(settings) - queue_parameters = self.shared_job_settings.get_parameters() # Default export name is the bundle directory name on disk resolved_name = ( @@ -616,54 +617,17 @@ def on_export_bundle(self): if dialog.exec_() != ExportBundleDialog.Accepted or not dialog.bundle_name: return - # Create the bundle locally first - asset_references = self.job_attachments.get_asset_references() - try: - self.job_history_bundle_dir = create_job_history_bundle_dir( - self.submitter_info.submitter_name, settings.name - ) - if self.show_host_requirements_tab: - parameters_from_callback = self.on_create_job_bundle_callback( - self, - self.job_history_bundle_dir, - settings, - queue_parameters, - asset_references, - self.host_requirements.get_requirements(), - purpose=JobBundlePurpose.EXPORT, - ) - else: - parameters_from_callback = self.on_create_job_bundle_callback( - self, - self.job_history_bundle_dir, - settings, - queue_parameters, - asset_references, - purpose=JobBundlePurpose.EXPORT, - ) - if parameters_from_callback is None: - parameters_from_callback = {} - job_parameters = parameters_from_callback.get("job_parameters", []) - if job_parameters: - self.save_job_parameters_to_job_bundle(self.job_history_bundle_dir, job_parameters) - except NonValidInputError as nvie: - QMessageBox.critical(self, tr("Non valid inputs detected"), str(nvie)) - return - except Exception as exc: - logger.exception("Error creating bundle") - QMessageBox.critical(self, "Export failed", f"Failed to create bundle:\n{exc}") - return - bundle_name = dialog.bundle_name if dialog.export_to_queue: - self._export_to_queue(queue_repo, bundle_name) + self._export_to_queue(queue_repo, bundle_name, settings.input_job_bundle_dir) else: - self._export_to_local(dialog.local_directory, bundle_name) + self._export_to_local( + dialog.local_directory, bundle_name, settings.input_job_bundle_dir + ) - def _export_to_local(self, dest_dir: str, bundle_name: str): + def _export_to_local(self, dest_dir: str, bundle_name: str, source_dir: str): """Copy the bundle to a local directory.""" - assert self.job_history_bundle_dir is not None dest_path = os.path.join(dest_dir, bundle_name) try: if os.path.exists(dest_path): @@ -677,7 +641,7 @@ def _export_to_local(self, dest_dir: str, bundle_name: str): if reply != QMessageBox.Yes: return shutil.rmtree(dest_path) - shutil.copytree(self.job_history_bundle_dir, dest_path) + shutil.copytree(source_dir, dest_path) QMessageBox.information( self, tr("Save bundle as"), @@ -686,24 +650,24 @@ def _export_to_local(self, dest_dir: str, bundle_name: str): except Exception as exc: QMessageBox.critical(self, "Export failed", f"Failed to save bundle:\n{exc}") - def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name: str): + def _export_to_queue( + self, queue_repo: Optional[S3BundleRepository], bundle_name: str, source_dir: str + ): """Archive and upload the bundle to the queue's S3 job-bundles folder.""" if not queue_repo: QMessageBox.critical(self, "Export failed", "Queue is not available.") return - assert self.job_history_bundle_dir is not None - # Build S3 metadata bundle_metadata: dict[str, str] = {} for tname in ("template.yaml", "template.json"): - tpath = os.path.join(self.job_history_bundle_dir, tname) + tpath = os.path.join(source_dir, tname) if os.path.isfile(tpath): with open(tpath, encoding="utf-8") as f: template = _parse_template(f.read(), tname) if template: - pv = LocalBundleRepository._read_parameter_values(self.job_history_bundle_dir) - info = _extract_bundle_info(template, self.job_history_bundle_dir, pv) + pv = LocalBundleRepository._read_parameter_values(source_dir) + info = _extract_bundle_info(template, source_dir, pv) bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( bundle_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME ) @@ -775,30 +739,173 @@ def _export_to_queue(self, queue_repo: Optional[S3BundleRepository], bundle_name except Exception: pass - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, dirs, files in os.walk(self.job_history_bundle_dir, followlinks=False): - dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] - for fname in files: - local_path = os.path.join(root, fname) - if os.path.islink(local_path): - continue - arcname = os.path.relpath(local_path, self.job_history_bundle_dir) - zf.write(local_path, arcname) - - buf.seek(0) - s3.upload_fileobj( - buf, + # Archive and upload on a background thread with progress + class _UploadWorker(QThread): + progress = _Signal(int, int) # (current_bytes, total_bytes) + status = _Signal(str) + finished = _Signal() + error = _Signal(str) + + def __init__(self, s3, bucket, key, source_dir, extra_args): + super().__init__() + self._s3 = s3 + self._bucket = bucket + self._key = key + self._source_dir = source_dir + self._extra_args = extra_args + + def run(self): + try: + self.status.emit("Archiving bundle...") + # First pass: total size for progress + total_size = 0 + for root, dirs, files in os.walk(self._source_dir, followlinks=False): + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] + for fname in files: + fpath = os.path.join(root, fname) + if not os.path.islink(fpath): + total_size += os.path.getsize(fpath) + self.progress.emit(0, max(1, total_size // 1024)) + + # Second pass: archive with progress + archived = 0 + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + for root, dirs, files in os.walk(self._source_dir, followlinks=False): + dirs[:] = [ + d for d in dirs if not os.path.islink(os.path.join(root, d)) + ] + for fname in files: + local_path = os.path.join(root, fname) + if os.path.islink(local_path): + continue + arcname = os.path.relpath(local_path, self._source_dir) + fsize = os.path.getsize(local_path) + if fsize <= 8 * 1024 * 1024: # ≤8MB: write whole file + zf.write(local_path, arcname) + archived += fsize + self.progress.emit(archived // 1024, 0) + else: # >8MB: chunk for progress + with ( + zf.open(arcname, "w", force_zip64=True) as dest, + open(local_path, "rb") as src, + ): + while True: + chunk = src.read(4 * 1024 * 1024) # 4MB + if not chunk: + break + dest.write(chunk) + archived += len(chunk) + self.progress.emit(archived // 1024, 0) + + total = buf.tell() + buf.seek(0) + self.status.emit("Uploading bundle...") + self.progress.emit(0, max(1, total // 1024)) + + _sent = [0] + + def _upload_cb(n): + _sent[0] += n + self.progress.emit(_sent[0] // 1024, 0) + + self._s3.upload_fileobj( + buf, + self._bucket, + self._key, + ExtraArgs=self._extra_args, + Callback=_upload_cb, + ) + self.finished.emit() + except Exception as e: + self.error.emit(str(e)) + + progress_dialog = QDialog(self) + progress_dialog.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint) + progress_dialog.setWindowTitle("Save Bundle to Queue") + progress_dialog.setWindowModality(Qt.ApplicationModal) + progress_dialog.setMinimumWidth(350) + _dlg_layout = QVBoxLayout(progress_dialog) + _progress_label = QLabel("Archiving bundle...") + _progress_label.setAlignment(Qt.AlignCenter) + _progress_bar = QProgressBar() + _progress_bar.setRange(0, 0) + _cancel_btn = QPushButton("Cancel") + _cancel_btn.clicked.connect(progress_dialog.reject) + _dlg_layout.addWidget(_progress_label) + _dlg_layout.addWidget(_progress_bar) + _dlg_layout.addWidget(_cancel_btn, alignment=Qt.AlignRight) + + worker = _UploadWorker( + s3, queue_repo._bucket, s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + source_dir, + {"Metadata": bundle_metadata} if bundle_metadata else None, ) - QMessageBox.information( - self, - tr("Save bundle as"), - "Bundle saved to queue.", - ) + upload_error = [] + _uploaded_bytes = [0] + _total_bytes = [0] + _phase = ["Archiving"] + _finished = [False] + + def _format_size(b): + if b >= 1024 * 1024 * 1024: + return f"{b / (1024**3):.1f} GB" + elif b >= 1024 * 1024: + return f"{b / (1024**2):.1f} MB" + elif b >= 1024: + return f"{b / 1024:.1f} KB" + return f"{b} B" + + def _on_status(msg): + _progress_label.setText(msg) + if "Upload" in msg: + _phase[0] = "Uploading" + + def _on_progress(n, total): + if _finished[0]: + return + if total > 0: + _total_bytes[0] = total * 1024 + _progress_bar.setMaximum(max(1, total)) + _progress_bar.setValue(0) + _uploaded_bytes[0] = 0 + else: + _uploaded_bytes[0] = n * 1024 + _progress_bar.setValue(n) + _progress_label.setText( + f"{_phase[0]} bundle... {_format_size(_uploaded_bytes[0])} / {_format_size(_total_bytes[0])}" + ) + + def _on_finished(): + _finished[0] = True + # Defer so queued progress signals are processed first + QTimer.singleShot(0, _show_complete) + + def _show_complete(): + _progress_bar.setVisible(False) + _progress_label.setText("\u2705 Bundle saved to queue") + _cancel_btn.setText("Close") + _cancel_btn.clicked.disconnect() + _cancel_btn.clicked.connect(progress_dialog.accept) + + def _on_error(msg): + upload_error.append(msg) + progress_dialog.close() + + worker.status.connect(_on_status, Qt.QueuedConnection) + worker.progress.connect(_on_progress, Qt.QueuedConnection) + worker.finished.connect(_on_finished, Qt.QueuedConnection) + worker.error.connect(_on_error, Qt.QueuedConnection) + worker.start() + + progress_dialog.exec_() + worker.wait() + + if upload_error: + raise RuntimeError(upload_error[0]) except Exception as exc: from botocore.exceptions import ClientError diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index a39dc5c2b..cd3c03a77 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -246,9 +246,17 @@ def show_job_bundle_submitter( if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return None + browser.hide() input_job_bundle_dir = browser.resolve_selection() if not input_job_bundle_dir: - return None + browser.show() + # Re-run the dialog if download was cancelled + if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: + return None + browser.hide() + input_job_bundle_dir = browser.resolve_selection() + if not input_job_bundle_dir: + return None def on_create_job_bundle_callback( widget: SubmitJobToDeadlineDialog, diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index d82b639cb..e032c6862 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -110,9 +110,16 @@ def on_load_bundle(self): if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return + browser.hide() input_job_bundle_dir = browser.resolve_selection() if not input_job_bundle_dir: - return + browser.show() + if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: + return + browser.hide() + input_job_bundle_dir = browser.resolve_selection() + if not input_job_bundle_dir: + return # Update job bundle directory path self.input_job_bundle_dir = input_job_bundle_dir diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 8246b400f..1c14bb4fc 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -2,6 +2,7 @@ """Tests for the bundle CLI commands (list, upload, download, cache).""" +import io import json import os import time @@ -618,3 +619,40 @@ def test_extract_handles_wrapper_directory(self, tmp_path): # Should return the inner directory, not the extraction root assert os.path.basename(result) == "inner" assert os.path.isfile(os.path.join(result, "template.yaml")) + + +class TestDownloadProgressHeadObjectReuse: + """Verify that get_bundle_size + download_full_bundle reuses the head_object call.""" + + def test_get_bundle_size_caches_head_for_download(self): + from deadline.client.job_bundle.repository import S3BundleRepository + + mock_s3 = MagicMock() + mock_s3.head_object.return_value = { + "ETag": '"abc123"', + "ContentLength": 4096, + "LastModified": "2026-01-01T00:00:00Z", + } + # download_fileobj writes bytes into the buffer + mock_s3.download_fileobj.side_effect = lambda Fileobj, **kwargs: Fileobj.write( + zipfile.ZipFile(io.BytesIO(), "w").fp.read() if False else b"PK\x03\x04" + b"\x00" * 100 + ) + + repo = S3BundleRepository(bucket_name="bucket", root_prefix="DC", session=MagicMock()) + repo._s3 = mock_s3 + + # get_bundle_size does head_object + size = repo.get_bundle_size("s3://bucket/DC/job-bundles/test.ojd") + assert size == 4096 + assert mock_s3.head_object.call_count == 1 + + # download_full_bundle should reuse the cached head — no additional head_object + # (It will fail on extraction since our fake data isn't a real zip, but + # we only care about the head_object count) + try: + repo.download_full_bundle("s3://bucket/DC/job-bundles/test.ojd", "/tmp") + except Exception: + pass # Expected — fake zip data + + # Still only 1 head_object call total + assert mock_s3.head_object.call_count == 1 From 15f4a3127ae3c847c5a2366ebe63726f80e66ee6 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:24:08 -0500 Subject: [PATCH 48/89] chore: remove env variable for bundle repo Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 2 -- src/deadline/client/cli/_groups/bundle_group.py | 4 +--- src/deadline/client/ui/job_bundle_submitter.py | 4 +--- src/deadline/client/ui/widgets/job_bundle_settings_tab.py | 4 +--- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 2af8efb22..2dfa6a96c 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -282,8 +282,6 @@ Add a new setting for the default local browse directory: } ``` -Environment variable override: `DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY` - This setting is also exposed in the Deadline Cloud settings dialog (Settings → General settings) as a "Job bundle directory" picker, alongside the existing "Job history directory" setting. ### CLI Integration diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 319bfb498..de31aa198 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -641,9 +641,7 @@ def bundle_list(path, use_queue, show_hidden, no_archives, output, **args): if path: local_root = os.path.abspath(path) else: - local_root = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") - if not local_root: - local_root = config_file.get_setting("settings.job_bundle_default_directory") + local_root = config_file.get_setting("settings.job_bundle_default_directory") if not local_root: local_root = os.path.expanduser("~") local_root = os.path.expanduser(local_root) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index cd3c03a77..36d8300f6 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -218,9 +218,7 @@ def show_job_bundle_submitter( if not input_job_bundle_dir: # Determine the default local browse directory - default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") - if not default_dir: - default_dir = get_setting("settings.job_bundle_default_directory") + default_dir = get_setting("settings.job_bundle_default_directory") if default_dir: default_dir = os.path.expanduser(default_dir) diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index e032c6862..0ca0a0c26 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -83,9 +83,7 @@ def on_load_bundle(self): from ..dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog # Determine the default local browse directory - default_dir = os.environ.get("DEADLINE_JOB_BUNDLE_DEFAULT_DIRECTORY", "") - if not default_dir: - default_dir = get_setting("settings.job_bundle_default_directory") + default_dir = get_setting("settings.job_bundle_default_directory") if default_dir: default_dir = os.path.expanduser(default_dir) From 8f598b32fc66df7caf1c1d7593da0f23ab4d9286 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:49:31 -0500 Subject: [PATCH 49/89] fix: upload/download CLI now report progress Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 86 +++++++++------ src/deadline/client/job_bundle/repository.py | 103 ++++++++++++++++-- .../dialogs/submit_job_to_deadline_dialog.py | 50 ++------- 3 files changed, 160 insertions(+), 79 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index de31aa198..96f9c4dfd 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -10,8 +10,6 @@ import logging import sys import re -import io -import zipfile from typing import Any, Optional import tempfile import shutil @@ -44,6 +42,8 @@ _parse_template, _read_cache_meta, _read_template_from_archive_path, + archive_bundle_dir, + get_bundle_dir_size, sanitize_bundle_name, ) from ....job_attachments.exceptions import ( @@ -1001,21 +1001,11 @@ def bundle_upload(job_bundle_dir, name, **args): Callback=lambda bytes_sent: bar.update(bytes_sent), ) else: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for root, dirs, files in os.walk(job_bundle_dir, followlinks=False): - # Skip symlinked directories - dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] - for fname in files: - local_path = os.path.join(root, fname) - if os.path.islink(local_path): - logger.warning("Skipping symlink: %s", local_path) - continue - arcname = os.path.relpath(local_path, job_bundle_dir) - zf.write(local_path, arcname) - - file_size = buf.tell() - buf.seek(0) + total_size = get_bundle_dir_size(job_bundle_dir) + with click.progressbar(length=total_size, label="Archiving") as bar: + buf = archive_bundle_dir(job_bundle_dir, progress_callback=lambda n: bar.update(n)) + + file_size = buf.getbuffer().nbytes with click.progressbar(length=file_size, label="Uploading") as bar: s3.upload_fileobj( buf, @@ -1035,8 +1025,8 @@ def bundle_upload(job_bundle_dir, name, **args): @click.option( "-o", "--output-dir", - default=".", - help="Local directory to download the bundle to. Defaults to current directory.", + default=None, + help="Local directory to copy the bundle to. If not specified, uses the local cache.", ) @_handle_error def bundle_download(bundle_name, output_dir, **args): @@ -1049,8 +1039,9 @@ def bundle_download(bundle_name, output_dir, **args): config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) repo = S3BundleRepository.from_config(config) - output_dir = os.path.abspath(output_dir) - os.makedirs(output_dir, exist_ok=True) + if output_dir: + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) # List entries to find the bundle by name entries = repo.list_entries(repo.root_path()) @@ -1070,16 +1061,49 @@ def bundle_download(bundle_name, output_dir, **args): # Get file size for progress bar file_size = repo.get_bundle_size(match.path) - with click.progressbar(length=file_size, label="Downloading") as bar: - local_path = repo.download_full_bundle( - match.path, output_dir, progress_callback=lambda n: bar.update(n) - ) - # download_full_bundle resolves to cache; copy to user's output_dir - dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) - if os.path.exists(dest_path): - shutil.rmtree(dest_path) - shutil.copytree(local_path, dest_path) - click.echo(f"Downloaded bundle to: {dest_path}") + # Download and extract are sequential inside download_full_bundle. + _bars: dict = {} + + def _dl_callback(n): + if "dl" not in _bars: + _bars["dl"] = click.progressbar(length=file_size, label="Downloading") + _bars["dl_ctx"] = _bars["dl"].__enter__() + _bars["dl_ctx"].update(n) + + def _ex_callback(n): + if "dl" in _bars and "dl_closed" not in _bars: + _bars["dl_closed"] = True + _bars["dl"].__exit__(None, None, None) + if "ex" not in _bars: + _bars["ex"] = click.progressbar( + length=_bars.get("ex_size", file_size), label="Extracting" + ) + _bars["ex_ctx"] = _bars["ex"].__enter__() + _bars["ex_ctx"].update(n) + + def _ex_size_callback(total): + _bars["ex_size"] = total + + local_path = repo.download_full_bundle( + match.path, + output_dir, + progress_callback=_dl_callback, + extract_callback=_ex_callback, + extract_size_callback=_ex_size_callback, + ) + if "dl" in _bars and "dl_closed" not in _bars: + _bars["dl"].__exit__(None, None, None) + if "ex" in _bars: + _bars["ex"].__exit__(None, None, None) + # download_full_bundle resolves to cache; copy to user's output_dir if specified + if output_dir: + dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) + if os.path.exists(dest_path): + shutil.rmtree(dest_path) + shutil.copytree(local_path, dest_path) + click.echo(f"Downloaded bundle to: {dest_path}") + else: + click.echo(f"Downloaded bundle to: {local_path}") @cli_bundle.command(name="hide") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 4b6f57bfd..feaca40c1 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -84,7 +84,9 @@ def _strip_archive_ext(name: str) -> str: return name -def _safe_zip_extract(zf: zipfile.ZipFile, dest_dir: str) -> None: +def _safe_zip_extract( + zf: zipfile.ZipFile, dest_dir: str, progress_callback=None, size_callback=None +) -> None: """Extract a zip file, rejecting archives with entries that would escape dest_dir.""" dest = os.path.realpath(dest_dir) @@ -99,7 +101,15 @@ def _safe_zip_extract(zf: zipfile.ZipFile, dest_dir: str) -> None: raise ValueError(f"Archive entry would extract outside target directory: {member}") if common != dest: raise ValueError(f"Archive entry would extract outside target directory: {member}") - zf.extractall(dest_dir) + + if progress_callback: + if size_callback: + size_callback(sum(info.file_size for info in zf.infolist())) + for info in zf.infolist(): + zf.extract(info, dest_dir) + progress_callback(info.file_size) + else: + zf.extractall(dest_dir) def _extract_archive(archive_path: str, dest_dir: str) -> None: @@ -138,10 +148,71 @@ def _read_template_from_bytes(data: bytes) -> Optional[tuple[str, str]]: return None -def _extract_archive_from_bytes(data: bytes, dest_dir: str) -> None: +def _extract_archive_from_bytes( + data: bytes, dest_dir: str, progress_callback=None, size_callback=None +) -> None: """Extract an .ojd archive from bytes in memory to dest_dir.""" with zipfile.ZipFile(io.BytesIO(data), "r") as zf: - _safe_zip_extract(zf, dest_dir) + _safe_zip_extract( + zf, dest_dir, progress_callback=progress_callback, size_callback=size_callback + ) + + +_LARGE_FILE_THRESHOLD = 8 * 1024 * 1024 # 8MB +_CHUNK_SIZE = 4 * 1024 * 1024 # 4MB + + +def archive_bundle_dir(source_dir: str, progress_callback=None) -> io.BytesIO: + """Archive a job bundle directory into an in-memory .ojd (zip) buffer. + + Args: + source_dir: Path to the bundle directory to archive. + progress_callback: Optional callable(bytes_archived) called after each file/chunk. + + Returns: + A BytesIO buffer positioned at the start, containing the zip archive. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + for root, dirs, files in os.walk(source_dir, followlinks=False): + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] + for fname in files: + local_path = os.path.join(root, fname) + if os.path.islink(local_path): + logger.warning("Skipping symlink: %s", local_path) + continue + arcname = os.path.relpath(local_path, source_dir) + fsize = os.path.getsize(local_path) + if fsize <= _LARGE_FILE_THRESHOLD: + zf.write(local_path, arcname) + if progress_callback: + progress_callback(fsize) + else: + with ( + zf.open(arcname, "w", force_zip64=True) as dest, + open(local_path, "rb") as src, + ): + while True: + chunk = src.read(_CHUNK_SIZE) + if not chunk: + break + dest.write(chunk) + if progress_callback: + progress_callback(len(chunk)) + buf.seek(0) + return buf + + +def get_bundle_dir_size(source_dir: str) -> int: + """Calculate total size of archivable files in a bundle directory.""" + total = 0 + for root, dirs, files in os.walk(source_dir, followlinks=False): + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] + for fname in files: + fpath = os.path.join(root, fname) + if not os.path.islink(fpath): + total += os.path.getsize(fpath) + return total @dataclass @@ -559,10 +630,22 @@ def resolve_bundle(self, path: str, dest_dir: str) -> str: Returns the local path to the usable bundle directory.""" return self._resolve_archive_bundle(path) - def download_full_bundle(self, path: str, dest_dir: str, progress_callback=None) -> str: + def download_full_bundle( + self, + path: str, + dest_dir: str, + progress_callback=None, + extract_callback=None, + extract_size_callback=None, + ) -> str: """Download a complete S3 .ojd bundle to a local directory. Uses the ETag cache for repeated access.""" - return self._resolve_archive_bundle(path, progress_callback=progress_callback) + return self._resolve_archive_bundle( + path, + progress_callback=progress_callback, + extract_callback=extract_callback, + extract_size_callback=extract_size_callback, + ) def get_bundle_size(self, path: str) -> int: """Get the size in bytes of a bundle archive on S3. @@ -635,7 +718,9 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: return _extract_bundle_info(template, path) return None - def _resolve_archive_bundle(self, path: str, progress_callback=None) -> str: + def _resolve_archive_bundle( + self, path: str, progress_callback=None, extract_callback=None, extract_size_callback=None + ) -> str: key = self._to_s3_key(path) cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) @@ -675,7 +760,9 @@ def _resolve_archive_bundle(self, path: str, progress_callback=None) -> str: shutil.rmtree(cache_dir) os.makedirs(cache_dir, exist_ok=True) - _extract_archive_from_bytes(data, cache_dir) + _extract_archive_from_bytes( + data, cache_dir, progress_callback=extract_callback, size_callback=extract_size_callback + ) _write_cache_meta(cache_dir, etag, last_modified) bundle_path = self._find_bundle_in_cache(cache_dir) 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 80054a90a..7f713bf58 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 @@ -5,12 +5,10 @@ from __future__ import annotations -import io import json import logging import os import shutil -import zipfile from typing import Any, Dict, Optional, Protocol import yaml @@ -60,6 +58,8 @@ S3BundleRepository, _extract_bundle_info, _parse_template, + archive_bundle_dir, + get_bundle_dir_size, ) from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget from ..widgets.job_attachments_tab import JobAttachmentsWidget @@ -757,46 +757,16 @@ def __init__(self, s3, bucket, key, source_dir, extra_args): def run(self): try: self.status.emit("Archiving bundle...") - # First pass: total size for progress - total_size = 0 - for root, dirs, files in os.walk(self._source_dir, followlinks=False): - dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] - for fname in files: - fpath = os.path.join(root, fname) - if not os.path.islink(fpath): - total_size += os.path.getsize(fpath) + total_size = get_bundle_dir_size(self._source_dir) self.progress.emit(0, max(1, total_size // 1024)) - # Second pass: archive with progress - archived = 0 - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf: - for root, dirs, files in os.walk(self._source_dir, followlinks=False): - dirs[:] = [ - d for d in dirs if not os.path.islink(os.path.join(root, d)) - ] - for fname in files: - local_path = os.path.join(root, fname) - if os.path.islink(local_path): - continue - arcname = os.path.relpath(local_path, self._source_dir) - fsize = os.path.getsize(local_path) - if fsize <= 8 * 1024 * 1024: # ≤8MB: write whole file - zf.write(local_path, arcname) - archived += fsize - self.progress.emit(archived // 1024, 0) - else: # >8MB: chunk for progress - with ( - zf.open(arcname, "w", force_zip64=True) as dest, - open(local_path, "rb") as src, - ): - while True: - chunk = src.read(4 * 1024 * 1024) # 4MB - if not chunk: - break - dest.write(chunk) - archived += len(chunk) - self.progress.emit(archived // 1024, 0) + archived = [0] + + def _on_archived(n): + archived[0] += n + self.progress.emit(archived[0] // 1024, 0) + + buf = archive_bundle_dir(self._source_dir, progress_callback=_on_archived) total = buf.tell() buf.seek(0) From 24fa862d705013eaecd48279e6c23719e57ebbb1 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:55:39 -0500 Subject: [PATCH 50/89] fix: isolate bundle tests from user's cache Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../deadline_client/cli/test_cli_bundle_repository.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 1c14bb4fc..c4e9a8945 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -557,7 +557,7 @@ def test_list_queue_show_hidden_json(self, mock_from_config, mock_config): class TestLocalArchiveCache: """Tests for LocalBundleRepository.extract_bundle mtime-based caching.""" - def test_extract_caches_and_reuses(self, tmp_path): + def test_extract_caches_and_reuses(self, fresh_deadline_config, tmp_path): """Second extraction of unchanged archive uses the cache.""" # Create an .ojd archive archive = tmp_path / "my-bundle.ojd" @@ -583,7 +583,7 @@ def test_extract_caches_and_reuses(self, tmp_path): template_mtime2 = os.path.getmtime(os.path.join(result2, "template.yaml")) assert template_mtime1 == template_mtime2 - def test_extract_invalidates_on_mtime_change(self, tmp_path): + def test_extract_invalidates_on_mtime_change(self, fresh_deadline_config, tmp_path): """Modified archive triggers re-extraction.""" # Create initial archive archive = tmp_path / "my-bundle.ojd" @@ -607,7 +607,7 @@ def test_extract_invalidates_on_mtime_change(self, tmp_path): with open(os.path.join(result2, "template.yaml")) as f: assert "V2" in f.read() - def test_extract_handles_wrapper_directory(self, tmp_path): + def test_extract_handles_wrapper_directory(self, fresh_deadline_config, tmp_path): """Archive with a single wrapper dir returns the inner path.""" archive = tmp_path / "wrapped.ojd" with zipfile.ZipFile(archive, "w") as zf: @@ -624,7 +624,7 @@ def test_extract_handles_wrapper_directory(self, tmp_path): class TestDownloadProgressHeadObjectReuse: """Verify that get_bundle_size + download_full_bundle reuses the head_object call.""" - def test_get_bundle_size_caches_head_for_download(self): + def test_get_bundle_size_caches_head_for_download(self, fresh_deadline_config): from deadline.client.job_bundle.repository import S3BundleRepository mock_s3 = MagicMock() From 55b31651bab324b6b774958f4cc325b63f68fff4 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:02:57 -0500 Subject: [PATCH 51/89] fix: have cancelled loads reshow browser Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py | 5 ++++- src/deadline/client/ui/job_bundle_submitter.py | 5 +---- src/deadline/client/ui/widgets/job_bundle_settings_tab.py | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 8e7677758..89399d157 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -261,7 +261,10 @@ def _cb(n): download_error = [] def _on_progress(n): - _progress_bar.setValue(n) + try: + _progress_bar.setValue(n) + except RuntimeError: + return total = _progress_bar.maximum() * 1024 current = n * 1024 if total > 0: diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 36d8300f6..25582751c 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -246,15 +246,12 @@ def show_job_bundle_submitter( browser.hide() input_job_bundle_dir = browser.resolve_selection() - if not input_job_bundle_dir: + while not input_job_bundle_dir: browser.show() - # Re-run the dialog if download was cancelled if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return None browser.hide() input_job_bundle_dir = browser.resolve_selection() - if not input_job_bundle_dir: - return None def on_create_job_bundle_callback( widget: SubmitJobToDeadlineDialog, diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 0ca0a0c26..045114eae 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -110,14 +110,12 @@ def on_load_bundle(self): browser.hide() input_job_bundle_dir = browser.resolve_selection() - if not input_job_bundle_dir: + while not input_job_bundle_dir: browser.show() if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: return browser.hide() input_job_bundle_dir = browser.resolve_selection() - if not input_job_bundle_dir: - return # Update job bundle directory path self.input_job_bundle_dir = input_job_bundle_dir From b9373c1ff4eb4677449b428865d32e23216fa3a0 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:37:53 -0500 Subject: [PATCH 52/89] feat: speed up browser loading Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 7 ++ src/deadline/client/job_bundle/repository.py | 49 ++++++++++---- .../ui/dialogs/job_bundle_browser_dialog.py | 66 +++++++++++++++++-- .../dialogs/submit_job_to_deadline_dialog.py | 2 +- .../client/ui/job_bundle_submitter.py | 54 +++++++++++---- .../ui/widgets/job_bundle_settings_tab.py | 65 +++++++++++++----- 6 files changed, 194 insertions(+), 49 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 96f9c4dfd..59d1d9a30 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -492,6 +492,13 @@ def bundle_gui_submit( from ...ui import gui_context_for_cli from ...ui._utils import tr + # Pre-warm boto3 session + Deadline client (lru_cached, reused by background thread) + if browse: + from ...api import get_boto3_client, get_boto3_session + + get_boto3_session() + get_boto3_client("deadline") + with gui_context_for_cli(automatically_install_dependencies=install_gui) as app: from ...ui.job_bundle_submitter import show_job_bundle_submitter diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index feaca40c1..c4a6ce4d4 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -550,35 +550,56 @@ def from_config(cls, config=None) -> "S3BundleRepository": Uses queue role credentials for S3 access (required for DCM profiles). Raises DeadlineOperationError if farm/queue is not configured or has no attachments. """ + from concurrent.futures import ThreadPoolExecutor, Future + from ..api import get_boto3_client, get_boto3_session, get_queue_user_boto3_session - from ...job_attachments._aws.deadline import get_queue farm_id = config_file.get_setting("defaults.farm_id", config=config) queue_id = config_file.get_setting("defaults.queue_id", config=config) if not farm_id or not queue_id: raise DeadlineOperationError("A default farm and queue must be configured.") - session = get_boto3_session(config=config) - queue = get_queue(farm_id=farm_id, queue_id=queue_id, session=session) - if not queue.jobAttachmentSettings: - raise DeadlineOperationError( - f"Queue {queue_id} does not have job attachment settings configured." - ) - # Use queue role credentials for S3 operations + # Ensure session is cached (used internally by get_boto3_client and get_queue_user_boto3_session) + get_boto3_session(config=config) + + # Create one Deadline client — reused for both get_queue and AssumeQueueRole deadline_client = get_boto3_client("deadline", config=config) + + # Set up queue role session (just wires up credential provider, no API call) s3_session = get_queue_user_boto3_session( deadline=deadline_client, config=config, farm_id=farm_id, queue_id=queue_id, - queue_display_name=queue.displayName, + queue_display_name=None, ) - return cls( - bucket_name=queue.jobAttachmentSettings.s3BucketName, - root_prefix=queue.jobAttachmentSettings.rootPrefix, - session=s3_session, - ) + # Run get_queue API call and S3 client creation in parallel. + # get_queue gives us the bucket name; S3 client creation triggers AssumeRole. + def _call_get_queue(): + return deadline_client.get_queue(farmId=farm_id, queueId=queue_id) + + with ThreadPoolExecutor(max_workers=2) as executor: + queue_future: Future = executor.submit(_call_get_queue) + s3_client_future: Future = executor.submit(s3_session.client, "s3") + + s3_client = s3_client_future.result() + queue_response = queue_future.result() + + # Extract job attachment settings + ja_settings = queue_response.get("jobAttachmentSettings") + if not ja_settings or not ja_settings.get("s3BucketName"): + raise DeadlineOperationError( + f"Queue {queue_id} does not have job attachment settings configured." + ) + + repo = cls.__new__(cls) + base = ja_settings["rootPrefix"].rstrip("/") + repo._bucket = ja_settings["s3BucketName"] + repo._prefix = f"{base}/{S3_JOB_BUNDLES_PREFIX}/" + repo._session = s3_session + repo._s3 = s3_client + return repo def root_path(self) -> str: return f"s3://{self._bucket}/{self._prefix}" diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 89399d157..d67345487 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -153,6 +153,7 @@ def __init__( *, queue_source: Optional[S3BundleRepository] = None, queue_error: str = "", + queue_loading: bool = False, local_source: str = "", history_source: str = "", parent: Optional[QWidget] = None, @@ -165,6 +166,7 @@ def __init__( self._s3_repo: Optional[S3BundleRepository] = queue_source self._s3_error = queue_error self._s3_available = self._s3_repo is not None + self._s3_loading = queue_loading self._local_repo = LocalBundleRepository(root=local_source, include_archives=True) @@ -203,6 +205,33 @@ def selected_is_archive(self) -> bool: def s3_repo(self) -> Optional[S3BundleRepository]: return self._s3_repo + def set_queue_source(self, repo, error: str, entries: list = None, hidden_set: set = None): + """Called when background S3 initialization completes.""" + self._s3_loading = False + if repo: + self._s3_repo = repo + self._s3_available = True + self._s3_error = "" + else: + self._s3_available = False + self._s3_error = error + self._radio_s3.setEnabled(False) + # Switch to Local if Queue was selected but failed + if self._radio_s3.isChecked(): + self._radio_local.setChecked(True) + return + + # If Queue is currently selected, populate now + if self._radio_s3.isChecked(): + self._current_repo = self._s3_repo + if entries is not None: + # Use pre-fetched data to avoid blocking the main thread + self._cached_root_entries = _folders_first(entries) + self._hidden_set = hidden_set or set() + self._populate_tree_from_cache() + else: + self._populate_root() + def resolve_selection(self) -> Optional[str]: """Resolve the selected bundle to a local directory path. @@ -329,7 +358,7 @@ def _build_ui(self): source_label = QLabel(tr("Source:")) source_row.addWidget(source_label) self._radio_s3 = QRadioButton(tr("Queue")) - self._radio_s3.setEnabled(self._s3_available) + self._radio_s3.setEnabled(self._s3_available or self._s3_loading) self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) self._radio_local = QRadioButton(tr("Local")) @@ -365,10 +394,13 @@ def _build_ui(self): path_row.addWidget(self._path_display) layout.addLayout(path_row) - # Default to Queue if available, otherwise Local - if self._s3_available: + # Default to Queue if available or loading, otherwise Local + if self._s3_available or self._s3_loading: self._radio_s3.setChecked(True) - self._current_repo = self._s3_repo + if self._s3_repo: + self._current_repo = self._s3_repo + else: + self._current_repo = None # Will be set by set_queue_source else: self._radio_local.setChecked(True) @@ -600,6 +632,14 @@ def _build_ui(self): def _populate_root(self): self._model.clear() self._model.setHorizontalHeaderLabels([tr("Name")]) + if self._current_repo is None: + # Queue source still loading + self._path_display.setText("") + self._cached_root_entries = [] + if hasattr(self, "_tree_empty_label"): + self._tree_empty_label.setText("Loading...") + self._tree_empty_label.setVisible(True) + return root_path = self._current_repo.root_path() self._path_display.setText(root_path) try: @@ -627,6 +667,17 @@ def _populate_root(self): self._update_tree_empty_state() + def _populate_tree_from_cache(self): + """Populate tree from pre-fetched _cached_root_entries and _hidden_set.""" + self._model.clear() + self._model.setHorizontalHeaderLabels([tr("Name")]) + self._path_display.setText(self._current_repo.root_path()) + root = self._model.invisibleRootItem() + for entry in self._cached_root_entries: + is_hidden = entry.name.startswith(".") or entry.name in self._hidden_set + self._add_entry_item(root, entry, is_hidden=is_hidden) + self._update_tree_empty_state() + def _add_entry_item( self, parent_item: QStandardItem, entry: BrowseEntry, *, is_hidden: bool = False ): @@ -704,6 +755,7 @@ def _update_tree_empty_state(self): has_rows = self._proxy.rowCount() > 0 self._tree_empty_label.setVisible(not has_rows) if not has_rows: + self._tree_empty_label.setText("No bundles found") self._tree_empty_label.resize(self._tree.viewport().size()) def eventFilter(self, obj, event): # type: ignore[override] @@ -778,6 +830,8 @@ def _on_source_changed(self, checked: bool): self._current_repo = self._local_repo elif self._radio_s3.isChecked() and self._s3_repo: self._current_repo = self._s3_repo + elif self._radio_s3.isChecked() and self._s3_loading: + self._current_repo = None # Will be populated when set_queue_source arrives elif self._radio_history.isChecked() and self._history_repo: self._current_repo = self._history_repo self._selected_path = None @@ -790,6 +844,8 @@ def _on_source_changed(self, checked: bool): def _save_tree_state(self): """Save expanded folder paths and selection for the current source.""" + if self._current_repo is None: + return key = self._current_repo.root_path() # Save selection @@ -822,6 +878,8 @@ def _collect(parent_index): def _restore_tree_state(self): """Restore previously expanded folders and selection for the current source.""" + if self._current_repo is None: + return key = self._current_repo.root_path() expanded = self._tree_states.get(key) if expanded: 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 7f713bf58..d65182437 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 @@ -575,7 +575,7 @@ def _on_help_button_clicked(self): def _on_load_bundle(self): """Delegates to the job_settings widget's on_load_bundle method.""" if hasattr(self.job_settings, "on_load_bundle"): - self.job_settings.on_load_bundle() + self.job_settings.on_load_bundle(s3_repo=getattr(self, "_s3_repo", None)) def on_export_bundle(self): """Export a job bundle to Queue (S3) or a local directory.""" diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 25582751c..5c54afed7 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -6,7 +6,7 @@ from logging import getLogger from typing import Any, Optional, Dict -from qtpy.QtCore import Qt # pylint: disable=import-error +from qtpy.QtCore import Qt, QThread, Signal # pylint: disable=import-error from ._utils import tr from qtpy.QtWidgets import ( # pylint: disable=import-error; type: ignore QApplication, @@ -216,34 +216,59 @@ def show_job_bundle_submitter( if main_windows: parent = main_windows[0] + _s3_repo_for_reuse = None + if not input_job_bundle_dir: - # Determine the default local browse directory + # Start S3 initialization in background immediately (before any other work) + class _S3InitWorker(QThread): + finished = Signal(object, str, list, set) # (repo, error, entries, hidden_set) + + def run(self): + try: + from concurrent.futures import ThreadPoolExecutor + + repo = S3BundleRepository.from_config() + with ThreadPoolExecutor(max_workers=2) as ex: + entries_f = ex.submit(repo.list_entries, repo.root_path()) + hidden_f = ex.submit(repo.get_hidden_set) + entries = entries_f.result() + hidden = hidden_f.result() + self.finished.emit(repo, "", entries, hidden) + except Exception as e: + logger.debug( + "Could not retrieve queue settings for bundle browser", exc_info=True + ) + self.finished.emit(None, str(e), [], set()) + + s3_worker = _S3InitWorker() + s3_worker.start() + + # While background runs, do config + dialog setup on main thread default_dir = get_setting("settings.job_bundle_default_directory") if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue repo for queue browsing - queue_repo = None - s3_error = "" - try: - queue_repo = S3BundleRepository.from_config() - except Exception as e: - logger.debug("Could not retrieve queue settings for bundle browser", exc_info=True) - s3_error = str(e) - # Get the job history directory for the current profile job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) + # Show the browser immediately — Queue source will populate when ready browser = JobBundleBrowserDialog( - queue_source=queue_repo, - queue_error=s3_error, + queue_source=None, + queue_error="", + queue_loading=True, local_source=default_dir, history_source=job_history_dir, parent=parent, ) + s3_worker.finished.connect(browser.set_queue_source) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: + s3_worker.wait() return None + s3_worker.wait() + + _s3_repo_for_reuse = browser.s3_repo + browser.hide() input_job_bundle_dir = browser.resolve_selection() while not input_job_bundle_dir: @@ -471,6 +496,9 @@ def on_create_job_bundle_callback( known_asset_paths=known_asset_paths, ) + # Store S3 repo for reuse by "Load Bundle" button (avoids re-creating from scratch) + submitter_dialog._s3_repo = _s3_repo_for_reuse + if job_parameters: # We want to validate the job parameters after the queue parameters are loaded. # Connect a parameter validation function to the queue parameter loading completion diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 045114eae..a7cfca5d3 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -10,7 +10,7 @@ from logging import getLogger from typing import Any, Optional -from qtpy.QtCore import Signal # type: ignore +from qtpy.QtCore import QThread, Signal # type: ignore from qtpy.QtWidgets import ( # type: ignore QVBoxLayout, QWidget, @@ -76,7 +76,7 @@ def refresh_ui(self, settings: JobBundleSettings): lambda message: self.parameter_changed.emit(message) ) - def on_load_bundle(self): + def on_load_bundle(self, s3_repo=None): """ Browse and load the selected submission bundle """ @@ -87,25 +87,54 @@ def on_load_bundle(self): if default_dir: default_dir = os.path.expanduser(default_dir) - # Try to get the queue repo for queue browsing - queue_repo = None - s3_error = "" - try: - queue_repo = S3BundleRepository.from_config() - except Exception as e: - s3_error = str(e) - # Get the job history directory for the current profile job_history_dir = os.path.expanduser(get_setting("settings.job_history_dir")) - browser = JobBundleBrowserDialog( - queue_source=queue_repo, - queue_error=s3_error, - local_source=default_dir, - history_source=job_history_dir, - parent=self, - ) + s3_worker = None + if s3_repo: + # Reuse existing repo — no background init needed + browser = JobBundleBrowserDialog( + queue_source=s3_repo, + queue_error="", + local_source=default_dir, + history_source=job_history_dir, + parent=self, + ) + else: + # Start S3 initialization in background + class _S3InitWorker(QThread): + finished = Signal(object, str, list, set) + + def run(self): + try: + from concurrent.futures import ThreadPoolExecutor + + repo = S3BundleRepository.from_config() + with ThreadPoolExecutor(max_workers=2) as ex: + entries_f = ex.submit(repo.list_entries, repo.root_path()) + hidden_f = ex.submit(repo.get_hidden_set) + entries = entries_f.result() + hidden = hidden_f.result() + self.finished.emit(repo, "", entries, hidden) + except Exception as e: + self.finished.emit(None, str(e), [], set()) + + s3_worker = _S3InitWorker() + s3_worker.start() + + browser = JobBundleBrowserDialog( + queue_source=None, + queue_error="", + queue_loading=True, + local_source=default_dir, + history_source=job_history_dir, + parent=self, + ) + s3_worker.finished.connect(browser.set_queue_source) + if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: + if s3_worker: + s3_worker.wait() return browser.hide() @@ -113,6 +142,8 @@ def on_load_bundle(self): while not input_job_bundle_dir: browser.show() if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: + if s3_worker: + s3_worker.wait() return browser.hide() input_job_bundle_dir = browser.resolve_selection() From 7ffe93de518b8b9255a59496afc1619b942874c8 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:33:08 -0500 Subject: [PATCH 53/89] feat: add tab navigation to browser windows Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index d67345487..4955434a0 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -183,12 +183,19 @@ def __init__( self._last_preview_path: Optional[str] = None self._tree_states: dict[str, set[str]] = {} # repo root -> expanded paths self._tree_selections: dict[str, str] = {} # repo root -> selected path + self._s3_refresh_worker: Optional[QThread] = None self._ready = False self._build_ui() self._ready = True self._populate_root() + # Set initial focus to the source radio group + if self._radio_s3.isChecked(): + self._radio_s3.setFocus() + elif self._radio_local.isChecked(): + self._radio_local.setFocus() + @property def selected_path(self) -> Optional[str]: return self._selected_path @@ -359,12 +366,15 @@ def _build_ui(self): source_row.addWidget(source_label) self._radio_s3 = QRadioButton(tr("Queue")) self._radio_s3.setEnabled(self._s3_available or self._s3_loading) + self._radio_s3.setFocusPolicy(Qt.StrongFocus) self._radio_s3.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_s3) self._radio_local = QRadioButton(tr("Local")) + self._radio_local.setFocusPolicy(Qt.StrongFocus) self._radio_local.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_local) self._radio_history = QRadioButton(tr("History")) + self._radio_history.setFocusPolicy(Qt.StrongFocus) self._radio_history.setEnabled(self._history_repo is not None) self._radio_history.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_history) @@ -391,6 +401,7 @@ def _build_ui(self): path_row.addWidget(QLabel(tr("Path:"))) self._path_display = QLineEdit() self._path_display.setReadOnly(True) + self._path_display.setFocusPolicy(Qt.NoFocus) path_row.addWidget(self._path_display) layout.addLayout(path_row) @@ -456,6 +467,7 @@ def _build_ui(self): self._tree_empty_label.setAttribute(Qt.WA_TransparentForMouseEvents) self._tree_empty_label.setVisible(False) self._tree.viewport().installEventFilter(self) + self._tree.installEventFilter(self) left_layout.addWidget(self._tree) splitter.addWidget(left_widget) @@ -621,14 +633,53 @@ def _build_ui(self): # Dialog buttons self._button_box = QDialogButtonBox(QDialogButtonBox.Cancel) self._select_button = QPushButton(tr("Select")) + self._select_button.setDefault(True) self._select_button.setEnabled(False) self._button_box.addButton(self._select_button, QDialogButtonBox.AcceptRole) self._select_button.clicked.connect(self.accept) self._button_box.rejected.connect(self.reject) layout.addWidget(self._button_box) + # Tab order: Source radios → Filter → Tree → Select + self.setTabOrder(self._radio_s3, self._filter_edit) + self.setTabOrder(self._filter_edit, self._tree) + self.setTabOrder(self._tree, self._select_button) + # ── Tree Population ────────────────────────────────────────── + def _refresh_s3_async(self): + """Refresh S3 listing in a background thread.""" + from concurrent.futures import ThreadPoolExecutor + + # Wait for any previous refresh to finish + if hasattr(self, "_s3_refresh_worker") and self._s3_refresh_worker is not None: + self._s3_refresh_worker.wait() + + assert self._s3_repo is not None + repo = self._s3_repo + + class _Worker(QThread): + finished = Signal(list, set) + + def run(self): + with ThreadPoolExecutor(max_workers=2) as ex: + entries_f = ex.submit(repo.list_entries, repo.root_path()) + hidden_f = ex.submit(repo.get_hidden_set) + self.finished.emit(entries_f.result(), hidden_f.result()) + + self._s3_refresh_worker = _Worker() + self._s3_refresh_worker.finished.connect(self._on_s3_refresh_done) + self._s3_refresh_worker.start() + + def _on_s3_refresh_done(self, entries, hidden_set): + """Handle background S3 refresh completion.""" + if not self._radio_s3.isChecked(): + return # User switched away + self._cached_root_entries = _folders_first(entries) + self._hidden_set = hidden_set + self._populate_tree_from_cache() + self._restore_tree_state() + def _populate_root(self): self._model.clear() self._model.setHorizontalHeaderLabels([tr("Name")]) @@ -761,6 +812,9 @@ def _update_tree_empty_state(self): def eventFilter(self, obj, event): # type: ignore[override] if obj is self._tree.viewport() and event.type() == event.Type.Resize: self._tree_empty_label.resize(event.size()) + elif obj is self._tree and event.type() == event.Type.FocusIn: + if not self._tree.currentIndex().isValid() and self._proxy.rowCount() > 0: + self._tree.setCurrentIndex(self._proxy.index(0, 0)) return super().eventFilter(obj, event) def _update_selection(self, proxy_index: QModelIndex): @@ -837,7 +891,17 @@ def _on_source_changed(self, checked: bool): self._selected_path = None self._select_button.setEnabled(False) self._clear_preview() - self._populate_root() + + # For S3, refresh in background to avoid blocking the UI + if isinstance(self._current_repo, S3BundleRepository): + self._model.clear() + self._model.setHorizontalHeaderLabels([tr("Name")]) + self._path_display.setText(self._current_repo.root_path()) + self._tree_empty_label.setText("Loading...") + self._tree_empty_label.setVisible(True) + self._refresh_s3_async() + else: + self._populate_root() # Restore expanded paths for the new source self._restore_tree_state() From bbab02ac11f4bc5ce3b8e50137350b2cf0204e09 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:38:37 -0500 Subject: [PATCH 54/89] feat: add tab navigation to save dialog Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/ui/dialogs/export_bundle_dialog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/deadline/client/ui/dialogs/export_bundle_dialog.py b/src/deadline/client/ui/dialogs/export_bundle_dialog.py index 57e5921a2..c1327052b 100644 --- a/src/deadline/client/ui/dialogs/export_bundle_dialog.py +++ b/src/deadline/client/ui/dialogs/export_bundle_dialog.py @@ -74,9 +74,11 @@ def _build_ui(self, default_name: str): source_row.addWidget(QLabel(f"{tr('Save to')}:")) self._radio_queue = QRadioButton(tr("Queue")) self._radio_queue.setEnabled(self._queue_available) + self._radio_queue.setFocusPolicy(Qt.StrongFocus) self._radio_queue.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_queue) self._radio_local = QRadioButton(tr("Local")) + self._radio_local.setFocusPolicy(Qt.StrongFocus) self._radio_local.toggled.connect(self._on_source_changed) source_row.addWidget(self._radio_local) source_row.addStretch() From 8687d994c36de594b16d98f1306eb16fae1351aa Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:17:22 -0500 Subject: [PATCH 55/89] test: fix test after rebase Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/cli/_groups/bundle_group.py | 15 +++++++++------ .../deadline_client/job_bundle/test_repository.py | 4 ++-- .../ui/gui/test_gui_submitter_bundles.py | 2 +- .../ui/widgets/test_job_bundle_settings_tab.py | 6 +++--- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 59d1d9a30..fd1185c9b 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -492,14 +492,17 @@ def bundle_gui_submit( from ...ui import gui_context_for_cli from ...ui._utils import tr - # Pre-warm boto3 session + Deadline client (lru_cached, reused by background thread) - if browse: - from ...api import get_boto3_client, get_boto3_session + with gui_context_for_cli(automatically_install_dependencies=install_gui) as app: + # Pre-warm boto3 session + Deadline client (lru_cached, reused by background thread) + if browse: + try: + from ...api import get_boto3_session, get_boto3_client - get_boto3_session() - get_boto3_client("deadline") + get_boto3_session() + get_boto3_client("deadline") + except Exception: + pass # Non-fatal — background thread will handle it - with gui_context_for_cli(automatically_install_dependencies=install_gui) as app: from ...ui.job_bundle_submitter import show_job_bundle_submitter if not job_bundle_dir and not browse: diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index d3de2c5a3..9ea181e98 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -161,8 +161,8 @@ def test_full_metadata(self): assert info.description == "A description" assert info.step_names == ["Step1", "Step2"] assert len(info.parameters) == 2 - assert info.parameters[0] == {"name": "Frames", "type": "STRING"} - assert info.parameters[1] == {"name": "Output", "type": "PATH"} + assert info.parameters[0] == {"name": "Frames", "type": "STRING", "_from_metadata": True} + assert info.parameters[1] == {"name": "Output", "type": "PATH", "_from_metadata": True} def test_missing_name_returns_none(self): info = _bundle_info_from_s3_metadata({}, "s3://bucket/key") diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py index c1c2df848..dc3ca3418 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py @@ -146,7 +146,7 @@ def test_submit_button_tooltip_when_disabled(self, submitter_dialog): def test_export_bundle_button_exists(self, submitter_dialog): """Verify the Export bundle button is present.""" - assert submitter_dialog.export_bundle_button.text() == "Export bundle" + assert submitter_dialog.export_bundle_button.text() == "Save bundle as" def test_settings_button_exists(self, submitter_dialog): """Verify the Settings button is present.""" diff --git a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py index 7d299af11..368d208b0 100644 --- a/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_job_bundle_settings_tab.py @@ -80,7 +80,7 @@ def test_on_load_bundle_loads_new_bundle_and_refreshes_dialog( _patch_browser(selected_path=str(second_bundle)), patch.object(widget, "window", return_value=parent_dialog), ): - widget.on_load_bundle() + widget.on_load_bundle(s3_repo=MagicMock()) assert os.path.realpath(widget.input_job_bundle_dir) == os.path.realpath(str(second_bundle)) parent_dialog.refresh.assert_called_once() @@ -96,7 +96,7 @@ def test_on_load_bundle_cancelled_dialog_is_noop(widget, qtbot): parent_dialog = MagicMock() with _patch_browser(accepted=False), patch.object(widget, "window", return_value=parent_dialog): - widget.on_load_bundle() + widget.on_load_bundle(s3_repo=MagicMock()) assert widget.input_job_bundle_dir == original_dir parent_dialog.refresh.assert_not_called() @@ -119,7 +119,7 @@ def test_on_load_bundle_invalid_bundle_shows_warning( "deadline.client.ui.widgets.job_bundle_settings_tab.QMessageBox.warning" ) as mock_warning, ): - widget.on_load_bundle() + widget.on_load_bundle(s3_repo=MagicMock()) mock_warning.assert_called_once() parent_dialog.refresh.assert_not_called() From 7addbae0dc7984869f5dd48da31c3b65c98c82c8 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:00:29 -0500 Subject: [PATCH 56/89] test: add missing tests Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../cli/test_cli_bundle_repository.py | 162 +++++++- .../job_bundle/test_repository.py | 367 +++++++++++++++++- 2 files changed, 523 insertions(+), 6 deletions(-) diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index c4e9a8945..edd0cdd05 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -19,6 +19,7 @@ LocalBundleRepository, METADATA_LIMIT_NAME, S3_METADATA_TOTAL_BUDGET, + S3BundleRepository, ) BUNDLE_GROUP = "deadline.client.cli._groups.bundle_group" @@ -300,8 +301,6 @@ def test_upload_archive_produces_same_metadata_as_directory( ): """Uploading a .ojd archive should produce the same S3 metadata as uploading the equivalent directory bundle.""" - import zipfile - template_content = yaml.dump( { "specificationVersion": "jobtemplate-2023-09", @@ -621,12 +620,167 @@ def test_extract_handles_wrapper_directory(self, fresh_deadline_config, tmp_path assert os.path.isfile(os.path.join(result, "template.yaml")) +class TestBundleDownload: + """Tests for `deadline bundle download` — validates the full download + extract + copy flow.""" + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_download_to_output_dir( + self, mock_from_config, mock_config, tmp_path, fresh_deadline_config + ): + """Download extracts bundle to output dir.""" + # Create a real .ojd archive to serve as the download + bundle_content = {"template.yaml": "name: DownloadTest\nsteps:\n- name: S1\n"} + archive_buf = io.BytesIO() + with zipfile.ZipFile(archive_buf, "w") as zf: + for name, content in bundle_content.items(): + zf.writestr(name, content) + archive_bytes = archive_buf.getvalue() + + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="test-bundle", + path="s3://bucket/DC/job-bundles/test-bundle.ojd", + is_bundle=True, + is_archive=True, + ), + ] + mock_repo.get_bundle_size.return_value = len(archive_bytes) + + # Make download_full_bundle extract to a real temp dir + extract_dir = tmp_path / "cache" / "test-bundle" + extract_dir.mkdir(parents=True) + with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf: + zf.extractall(str(extract_dir)) + + mock_repo.download_full_bundle.return_value = str(extract_dir) + mock_from_config.return_value = mock_repo + + output_dir = tmp_path / "output" + runner = CliRunner() + result = runner.invoke(main, ["bundle", "download", "test-bundle", "-o", str(output_dir)]) + + assert result.exit_code == 0, result.output + assert "Downloaded bundle to:" in result.output + assert os.path.isfile(str(output_dir / "test-bundle" / "template.yaml")) + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_download_no_output_dir_prints_cache_path( + self, mock_from_config, mock_config, tmp_path, fresh_deadline_config + ): + """Without -o, prints the cache path.""" + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="my-bundle", + path="s3://bucket/DC/job-bundles/my-bundle.ojd", + is_bundle=True, + is_archive=True, + ), + ] + mock_repo.get_bundle_size.return_value = 1024 + cache_path = str(tmp_path / "cached-bundle") + mock_repo.download_full_bundle.return_value = cache_path + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "download", "my-bundle"]) + + assert result.exit_code == 0, result.output + assert cache_path in result.output + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_download_not_found(self, mock_from_config, mock_config, fresh_deadline_config): + """Bundle not found shows error with available bundles.""" + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="other-bundle", + path="s3://b/k/other-bundle.ojd", + is_bundle=True, + is_archive=True, + ), + ] + mock_from_config.return_value = mock_repo + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "download", "nonexistent"]) + + assert result.exit_code == 1 + assert "not found" in result.output + assert "other-bundle" in result.output + + +class TestBundleInfo: + """Tests for `deadline bundle info` — validates local and queue info output.""" + + def test_info_local_bundle(self, tmp_path, fresh_deadline_config): + """Info on a local bundle directory prints template details.""" + bundle = tmp_path / "my-render" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Render Job", + "description": "Renders frames", + "steps": [{"name": "RenderStep"}], + "parameterDefinitions": [ + {"name": "Frames", "type": "STRING", "default": "1-10"} + ], + } + ) + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "info", str(bundle)]) + + assert result.exit_code == 0, result.output + assert "Render Job" in result.output + assert "RenderStep" in result.output + assert "Frames" in result.output + + def test_info_local_json_output(self, tmp_path, fresh_deadline_config): + """Info with --output json returns structured data.""" + bundle = tmp_path / "json-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "JSON Test", + "steps": [{"name": "S1"}], + } + ) + ) + + runner = CliRunner() + result = runner.invoke(main, ["bundle", "info", str(bundle), "--output", "json"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["name"] == "JSON Test" + assert data["steps"] == ["S1"] + + def test_info_not_found(self, tmp_path, fresh_deadline_config): + """Info on nonexistent path shows error.""" + runner = CliRunner() + result = runner.invoke(main, ["bundle", "info", str(tmp_path / "nope")]) + + assert result.exit_code == 1 + assert "not found" in result.output.lower() + + class TestDownloadProgressHeadObjectReuse: """Verify that get_bundle_size + download_full_bundle reuses the head_object call.""" def test_get_bundle_size_caches_head_for_download(self, fresh_deadline_config): - from deadline.client.job_bundle.repository import S3BundleRepository - mock_s3 = MagicMock() mock_s3.head_object.return_value = { "ETag": '"abc123"', diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 9ea181e98..43e1fb479 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -4,6 +4,7 @@ from __future__ import annotations +import io import json import os import sys @@ -15,6 +16,7 @@ from unittest.mock import MagicMock, patch from botocore.exceptions import ClientError +from deadline.client.exceptions import DeadlineOperationError from deadline.client.job_bundle.repository import ( LocalBundleRepository, S3BundleRepository, @@ -26,6 +28,8 @@ _read_template_from_archive_path, _safe_zip_extract, _strip_archive_ext, + archive_bundle_dir, + get_bundle_dir_size, sanitize_bundle_name, ) @@ -661,8 +665,6 @@ def test_set_bundle_visibility_raises_after_max_retries(self): {"Error": {"Code": "PreconditionFailed"}}, "PutObject" ) - from deadline.client.exceptions import DeadlineOperationError - with pytest.raises(DeadlineOperationError, match="Failed to update bundle visibility"): repo.set_bundle_visibility("bundle-a", hidden=True) @@ -714,3 +716,364 @@ def test_prune_hidden_set_noop_when_no_manifest(self): repo.prune_hidden_set(existing_names={"anything"}) repo._s3.put_object.assert_not_called() + + +class TestArchiveBundleDir: + """Tests for archive_bundle_dir — validates archiving produces correct zip content + and progress callbacks report accurate byte counts.""" + + def test_archives_all_files(self, tmp_path): + """All files in the bundle directory end up in the archive.""" + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Test\nsteps: []\n") + (bundle / "script.sh").write_text("#!/bin/bash\necho hello\n") + (bundle / "subdir").mkdir() + (bundle / "subdir" / "data.json").write_text('{"key": "value"}') + + buf = archive_bundle_dir(str(bundle)) + + with zipfile.ZipFile(buf, "r") as zf: + names = sorted(zf.namelist()) + assert "template.yaml" in names + assert "script.sh" in names + assert "subdir/data.json" in names + + def test_progress_reports_total_bytes(self, tmp_path): + """Progress callback receives total bytes equal to the source directory size.""" + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: T\nsteps: []\n") + (bundle / "big.bin").write_bytes(b"x" * 1000) + + reported = [] + archive_bundle_dir(str(bundle), progress_callback=lambda n: reported.append(n)) + + assert sum(reported) == get_bundle_dir_size(str(bundle)) + + def test_skips_symlinks(self, tmp_path): + """Symlinked files are not included in the archive.""" + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: T\nsteps: []\n") + target = tmp_path / "outside.txt" + target.write_text("secret") + (bundle / "link.txt").symlink_to(target) + + buf = archive_bundle_dir(str(bundle)) + + with zipfile.ZipFile(buf, "r") as zf: + assert "link.txt" not in zf.namelist() + + +class TestGetBundleDirSize: + def test_returns_total_file_size(self, tmp_path): + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "a.txt").write_bytes(b"x" * 100) + (bundle / "b.txt").write_bytes(b"y" * 200) + + assert get_bundle_dir_size(str(bundle)) == 300 + + +class TestSafeZipExtractWithProgress: + """Tests that _safe_zip_extract progress callback reports correct sizes.""" + + def test_progress_reports_uncompressed_sizes(self, tmp_path): + """Each callback receives the uncompressed file size.""" + archive = tmp_path / "test.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("small.txt", "hello") # 5 bytes + zf.writestr("bigger.txt", "x" * 100) # 100 bytes + + reported = [] + with zipfile.ZipFile(archive, "r") as zf: + _safe_zip_extract( + zf, str(tmp_path / "out"), progress_callback=lambda n: reported.append(n) + ) + + assert sorted(reported) == [5, 100] + + def test_size_callback_reports_total(self, tmp_path): + """size_callback is called once with total uncompressed size before extraction.""" + archive = tmp_path / "test.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("a.txt", "aaa") + zf.writestr("b.txt", "bbbbb") + + size_reports = [] + with zipfile.ZipFile(archive, "r") as zf: + _safe_zip_extract( + zf, + str(tmp_path / "out"), + progress_callback=lambda n: None, + size_callback=lambda t: size_reports.append(t), + ) + + assert size_reports == [8] # 3 + 5 + + +class TestFromConfig: + """Tests for S3BundleRepository.from_config — validates the parallel initialization path.""" + + @patch("deadline.client.api.get_queue_user_boto3_session") + @patch("deadline.client.api.get_boto3_client") + @patch("deadline.client.api.get_boto3_session") + @patch("deadline.client.job_bundle.repository.config_file") + def test_creates_repo_with_correct_bucket_and_prefix( + self, mock_config_file, mock_get_session, mock_get_client, mock_get_queue_session + ): + """from_config returns a repo with bucket/prefix from GetQueue response.""" + mock_config_file.get_setting.side_effect = lambda key, config=None: { + "defaults.farm_id": "farm-123", + "defaults.queue_id": "queue-456", + }.get(key, "") + + mock_deadline_client = MagicMock() + mock_deadline_client.get_queue.return_value = { + "jobAttachmentSettings": { + "s3BucketName": "my-bucket", + "rootPrefix": "DeadlineCloud", + }, + } + mock_get_client.return_value = mock_deadline_client + + mock_s3_session = MagicMock() + mock_s3_client = MagicMock() + mock_s3_session.client.return_value = mock_s3_client + mock_get_queue_session.return_value = mock_s3_session + + repo = S3BundleRepository.from_config() + + assert repo._bucket == "my-bucket" + assert "job-bundles" in repo._prefix + assert repo._s3 is mock_s3_client + mock_deadline_client.get_queue.assert_called_once_with( + farmId="farm-123", queueId="queue-456" + ) + + @patch("deadline.client.job_bundle.repository.config_file") + def test_raises_without_farm_or_queue(self, mock_config_file): + """from_config raises when farm/queue IDs are not configured.""" + mock_config_file.get_setting.return_value = "" + with pytest.raises(DeadlineOperationError, match="farm and queue"): + S3BundleRepository.from_config() + + @patch("deadline.client.api.get_queue_user_boto3_session") + @patch("deadline.client.api.get_boto3_client") + @patch("deadline.client.api.get_boto3_session") + @patch("deadline.client.job_bundle.repository.config_file") + def test_raises_without_attachment_settings( + self, mock_config_file, mock_get_session, mock_get_client, mock_get_queue_session + ): + """from_config raises when queue has no job attachment settings.""" + mock_config_file.get_setting.side_effect = lambda key, config=None: { + "defaults.farm_id": "farm-123", + "defaults.queue_id": "queue-456", + }.get(key, "") + + mock_deadline_client = MagicMock() + mock_deadline_client.get_queue.return_value = {} + mock_get_client.return_value = mock_deadline_client + mock_get_queue_session.return_value = MagicMock() + + with pytest.raises(DeadlineOperationError, match="attachment settings"): + S3BundleRepository.from_config() + + +class TestS3ListEntries: + """Tests for S3BundleRepository.list_entries — validates S3 listing logic.""" + + def _make_repo(self): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def test_lists_ojd_files_as_bundles(self): + repo = self._make_repo() + paginator = MagicMock() + paginator.paginate.return_value = [ + { + "CommonPrefixes": [], + "Contents": [ + {"Key": "DC/job-bundles/render.ojd"}, + {"Key": "DC/job-bundles/sim.ojd"}, + ], + } + ] + repo._s3.get_paginator.return_value = paginator + + entries = repo.list_entries(repo.root_path()) + + bundle_names = [e.name for e in entries if e.is_bundle] + assert sorted(bundle_names) == ["render", "sim"] + assert all(e.is_archive for e in entries if e.is_bundle) + + def test_lists_subfolders_as_non_bundles(self): + repo = self._make_repo() + paginator = MagicMock() + paginator.paginate.return_value = [ + { + "CommonPrefixes": [{"Prefix": "DC/job-bundles/rendering/"}], + "Contents": [], + } + ] + repo._s3.get_paginator.return_value = paginator + + entries = repo.list_entries(repo.root_path()) + + assert len(entries) == 1 + assert entries[0].name == "rendering" + assert entries[0].is_bundle is False + + def test_ignores_non_ojd_files(self): + repo = self._make_repo() + paginator = MagicMock() + paginator.paginate.return_value = [ + { + "CommonPrefixes": [], + "Contents": [ + {"Key": "DC/job-bundles/readme.txt"}, + {"Key": "DC/job-bundles/valid.ojd"}, + ], + } + ] + repo._s3.get_paginator.return_value = paginator + + entries = repo.list_entries(repo.root_path()) + + assert len(entries) == 1 + assert entries[0].name == "valid" + + +class TestResolveArchiveBundle: + """Tests for _resolve_archive_bundle — validates download, cache, and extract flow.""" + + def _make_repo(self, fresh_deadline_config): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def _make_ojd_bytes(self): + """Create a minimal .ojd archive in memory.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("template.yaml", "name: TestBundle\nsteps:\n- name: Step1\n") + return buf.getvalue() + + def test_downloads_and_extracts_to_cache(self, fresh_deadline_config, tmp_path): + repo = self._make_repo(fresh_deadline_config) + ojd_data = self._make_ojd_bytes() + + repo._s3.head_object.return_value = {"ETag": '"abc"', "ContentLength": len(ojd_data)} + repo._s3.download_fileobj.side_effect = lambda Fileobj, **kw: Fileobj.write(ojd_data) + + path = "s3://test-bucket/DC/job-bundles/test.ojd" + result = repo._resolve_archive_bundle(path) + + assert os.path.isfile(os.path.join(result, "template.yaml")) + + def test_uses_cache_on_matching_etag(self, fresh_deadline_config, tmp_path): + repo = self._make_repo(fresh_deadline_config) + ojd_data = self._make_ojd_bytes() + + repo._s3.head_object.return_value = {"ETag": '"abc"', "ContentLength": len(ojd_data)} + repo._s3.download_fileobj.side_effect = lambda Fileobj, **kw: Fileobj.write(ojd_data) + + path = "s3://test-bucket/DC/job-bundles/cached.ojd" + + # First call downloads + result1 = repo._resolve_archive_bundle(path) + assert repo._s3.download_fileobj.call_count == 1 + + # Second call uses cache (same ETag) + result2 = repo._resolve_archive_bundle(path) + assert repo._s3.download_fileobj.call_count == 1 # No additional download + assert result1 == result2 + + def test_calls_progress_callbacks(self, fresh_deadline_config): + repo = self._make_repo(fresh_deadline_config) + ojd_data = self._make_ojd_bytes() + + repo._s3.head_object.return_value = {"ETag": '"new"', "ContentLength": len(ojd_data)} + repo._s3.download_fileobj.side_effect = lambda Fileobj, **kw: Fileobj.write(ojd_data) + + dl_progress = [] + ex_progress = [] + size_reports = [] + + path = "s3://test-bucket/DC/job-bundles/progress.ojd" + repo._resolve_archive_bundle( + path, + progress_callback=lambda n: dl_progress.append(n), + extract_callback=lambda n: ex_progress.append(n), + extract_size_callback=lambda t: size_reports.append(t), + ) + + # Download callback is called (by download_fileobj via Callback kwarg) + # Extract callback receives file sizes + assert len(ex_progress) > 0 + assert len(size_reports) == 1 + assert size_reports[0] > 0 + + +class TestS3GetBundleInfo: + """Tests for S3BundleRepository.get_bundle_info — validates metadata preview path.""" + + def _make_repo(self): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def test_returns_info_from_s3_metadata(self): + """When head_object returns bundle metadata, uses it without downloading.""" + repo = self._make_repo() + repo._s3.head_object.return_value = { + "ETag": '"abc"', + "Metadata": { + "ojd-name": "Preview Bundle", + "ojd-desc": "A description", + "ojd-steps": "Step1", + "ojd-params": "Frames:STRING", + }, + } + + info = repo.get_bundle_info("s3://test-bucket/DC/job-bundles/preview.ojd") + + assert info is not None + assert info.name == "Preview Bundle" + assert info.description == "A description" + assert info.step_names == ["Step1"] + # No download needed + repo._s3.get_object.assert_not_called() + + def test_falls_back_to_download_when_no_metadata(self, fresh_deadline_config): + """When head_object has no bundle metadata, downloads and parses the archive.""" + repo = self._make_repo() + repo._s3.head_object.return_value = {"ETag": '"xyz"', "Metadata": {}} + + # Provide a real archive via get_object + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("template.yaml", "name: Downloaded\nsteps:\n- name: S1\n") + + repo._s3.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=buf.getvalue())), + "ETag": '"xyz"', + "LastModified": "2026-01-01", + } + + info = repo.get_bundle_info("s3://test-bucket/DC/job-bundles/fallback.ojd") + + assert info is not None + assert info.name == "Downloaded" + assert info.step_names == ["S1"] From fd7971a168a8cf8b01e52c172674bf972c140a2e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:36:42 -0500 Subject: [PATCH 57/89] fix: use existing commands for mcp Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/_mcp/tools/bundles.py | 90 ++++++++-------------- test/unit/deadline_mcp/test_mcp_bundles.py | 81 +++++++++++++++++++ 2 files changed, 111 insertions(+), 60 deletions(-) create mode 100644 test/unit/deadline_mcp/test_mcp_bundles.py diff --git a/src/deadline/_mcp/tools/bundles.py b/src/deadline/_mcp/tools/bundles.py index 66a035687..da629a280 100644 --- a/src/deadline/_mcp/tools/bundles.py +++ b/src/deadline/_mcp/tools/bundles.py @@ -4,15 +4,12 @@ Deadline Cloud Bundle sharing tools for MCP. """ -import os -import shutil +import json from typing import Any, Dict, Optional from click.testing import CliRunner from ...client.cli import main -from ...client.config import config_file -from ...client.job_bundle.repository import S3BundleRepository, sanitize_bundle_name def list_shared_bundles( @@ -25,35 +22,20 @@ def list_shared_bundles( Returns a list of bundles with their name and format. Hidden bundles are excluded by default unless show_hidden is True. """ - config = None + args = ["bundle", "list", "--queue", "--output", "json"] if farm_id: - config = config_file.read_config() - config_file.set_setting("defaults.farm_id", farm_id, config) + args.extend(["--farm-id", farm_id]) if queue_id: - if config is None: - config = config_file.read_config() - config_file.set_setting("defaults.queue_id", queue_id, config) - - repo = S3BundleRepository.from_config(config) - entries = repo.list_entries(repo.root_path()) - bundles = [e for e in entries if e.is_bundle] - - hidden_set: set[str] = set() - if not show_hidden: - hidden_set = repo.get_hidden_set() - bundles = [e for e in bundles if e.name not in hidden_set] - - return { - "bundles": [ - { - "name": e.name, - "path": e.path, - "format": "archive", - **({"hidden": True} if e.name in hidden_set else {}), - } - for e in bundles - ] - } + args.extend(["--queue-id", queue_id]) + if show_hidden: + args.append("--show-hidden") + + runner = CliRunner() + result = runner.invoke(main, args) + + if result.exit_code != 0: + return {"success": False, "error": result.output.strip()} + return {"bundles": json.loads(result.output)} def upload_bundle( @@ -96,37 +78,25 @@ def download_bundle( Args: bundle_name: Name of the bundle to download. - output_dir: Local directory to download to (defaults to current directory). + output_dir: Local directory to download to (uses cache if not specified). farm_id: The farm ID (uses default if not specified). queue_id: The queue ID (uses default if not specified). """ - config = None + args = ["bundle", "download", bundle_name] + if output_dir: + args.extend(["-o", output_dir]) if farm_id: - config = config_file.read_config() - config_file.set_setting("defaults.farm_id", farm_id, config) + args.extend(["--farm-id", farm_id]) if queue_id: - if config is None: - config = config_file.read_config() - config_file.set_setting("defaults.queue_id", queue_id, config) - - repo = S3BundleRepository.from_config(config) - output_dir = output_dir or os.getcwd() - os.makedirs(output_dir, exist_ok=True) - - entries = repo.list_entries(repo.root_path()) - match = next((e for e in entries if e.name == bundle_name and e.is_bundle), None) - if not match: - available = [e.name for e in entries if e.is_bundle] - return { - "success": False, - "error": f"Bundle '{bundle_name}' not found on queue.", - "available": available, - } - - local_path = repo.download_full_bundle(match.path, output_dir) - dest_path = os.path.join(output_dir, sanitize_bundle_name(bundle_name)) - if os.path.exists(dest_path): - shutil.rmtree(dest_path) - shutil.copytree(local_path, dest_path) - - return {"success": True, "path": dest_path} + args.extend(["--queue-id", queue_id]) + + runner = CliRunner() + result = runner.invoke(main, args) + + if result.exit_code != 0: + return {"success": False, "error": result.output.strip()} + + # Extract the path from output like "Downloaded bundle to: /path/to/bundle" + output = result.output.strip() + path = output.split(":", 1)[-1].strip() if ":" in output else output + return {"success": True, "path": path} diff --git a/test/unit/deadline_mcp/test_mcp_bundles.py b/test/unit/deadline_mcp/test_mcp_bundles.py new file mode 100644 index 000000000..f2d52d626 --- /dev/null +++ b/test/unit/deadline_mcp/test_mcp_bundles.py @@ -0,0 +1,81 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Tests for MCP bundle sharing tools.""" + +from unittest.mock import MagicMock, patch + +from deadline._mcp.tools.bundles import download_bundle, list_shared_bundles + +BUNDLE_GROUP = "deadline.client.cli._groups.bundle_group" + + +class TestListSharedBundles: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_returns_bundles_as_json(self, mock_from_config, mock_config): + from deadline.client.job_bundle.repository import BrowseEntry + + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry(name="render", path="s3://b/render.ojd", is_bundle=True, is_archive=True), + ] + mock_repo.get_hidden_set.return_value = set() + mock_from_config.return_value = mock_repo + + result = list_shared_bundles() + + assert "bundles" in result + assert result["bundles"][0]["name"] == "render" + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_returns_error_on_failure(self, mock_from_config, mock_config): + mock_from_config.side_effect = Exception("No credentials") + + result = list_shared_bundles() + + assert "error" in result + + +class TestDownloadBundle: + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_returns_path_on_success(self, mock_from_config, mock_config, tmp_path): + from deadline.client.job_bundle.repository import BrowseEntry + + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry( + name="my-bundle", path="s3://b/my-bundle.ojd", is_bundle=True, is_archive=True + ), + ] + mock_repo.get_bundle_size.return_value = 100 + cache_dir = tmp_path / "cache" / "my-bundle" + cache_dir.mkdir(parents=True) + (cache_dir / "template.yaml").write_text("name: Test\n") + mock_repo.download_full_bundle.return_value = str(cache_dir) + mock_from_config.return_value = mock_repo + + result = download_bundle("my-bundle", output_dir=str(tmp_path / "out")) + + assert result["success"] is True + assert "my-bundle" in result["path"] + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}.S3BundleRepository.from_config") + def test_returns_error_when_not_found(self, mock_from_config, mock_config): + from deadline.client.job_bundle.repository import BrowseEntry + + mock_repo = MagicMock() + mock_repo.root_path.return_value = "s3://bucket/DC/job-bundles/" + mock_repo.list_entries.return_value = [ + BrowseEntry(name="other", path="s3://b/other.ojd", is_bundle=True, is_archive=True), + ] + mock_from_config.return_value = mock_repo + + result = download_bundle("nonexistent") + + assert result["success"] is False + assert "not found" in result["error"] From 6d5cc519baea4aa7cfefca7a36ca9e4473b8bdef Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:19:33 -0500 Subject: [PATCH 58/89] fix: UTF-8 for metadata calculations and misc. bugs Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 153 +++++++++--------- .../dialogs/submit_job_to_deadline_dialog.py | 3 +- 2 files changed, 83 insertions(+), 73 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index fd1185c9b..179b63d79 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -563,22 +563,26 @@ def _print_response( def _truncate_metadata(value: str, limit: int, field: str) -> str: - """Truncate a metadata value, warning if truncation occurs. + """Truncate a metadata value to fit within a byte limit (UTF-8 encoded). S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values). We apply conservative per-field limits to stay well within that budget. See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata """ - if len(value) > limit: - click.echo( - click.style( - f"Warning: Bundle metadata '{field}' truncated from {len(value)} to {limit} characters", - fg="yellow", - ), - err=True, - ) - return value[: limit - 3] + "..." - return value + if len(value.encode("utf-8")) <= limit: + return value + # Truncate character by character until it fits with "..." suffix + truncated = value + while len(truncated.encode("utf-8")) > limit - 3: + truncated = truncated[:-1] + click.echo( + click.style( + f"Warning: Bundle metadata '{field}' truncated from {len(value.encode('utf-8'))} to {limit} bytes", + fg="yellow", + ), + err=True, + ) + return truncated + "..." def _get_queue_s3_settings(config): @@ -900,70 +904,75 @@ def bundle_upload(job_bundle_dir, name, **args): param_strs, remaining, METADATA_KEY_PARAMS ) else: + template = None for tname in ("template.yaml", "template.json"): tpath = os.path.join(job_bundle_dir, tname) if os.path.isfile(tpath): with open(tpath, encoding="utf-8") as f: template = _parse_template(f.read(), tname) - if template: - info = _extract_bundle_info( - template, - job_bundle_dir, - LocalBundleRepository._read_parameter_values(job_bundle_dir), - ) - bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME - ) - if info.description: - # S3 metadata values must be valid HTTP header values (no newlines) - desc = " ".join(info.description.split()) - bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( - desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC - ) - if info.step_names: - bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) - if info.parameters: - bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) - - # Dynamically allocate remaining budget to steps and params - steps_str = ",".join(info.step_names) if info.step_names else "" - param_strs = ( - ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) - if info.parameters - else "" + if template: + break + if template: + info = _extract_bundle_info( + template, + job_bundle_dir, + LocalBundleRepository._read_parameter_values(job_bundle_dir), + ) + bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( + info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) + if info.description: + # S3 metadata values must be valid HTTP header values (no newlines) + desc = " ".join(info.description.split()) + bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( + desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC ) + if info.step_names: + bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) + if info.parameters: + bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) + + # Dynamically allocate remaining budget to steps and params + steps_str = ",".join(info.step_names) if info.step_names else "" + param_strs = ( + ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) + if info.parameters + else "" + ) - # Calculate bytes used so far (key overhead = "x-amz-meta-" prefix + key name) - used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) - remaining = S3_METADATA_TOTAL_BUDGET - used - # Reserve key overhead for steps + params if they have content - keys_needed = 0 - if steps_str: - keys_needed += 12 + len(METADATA_KEY_STEPS) - if param_strs: - keys_needed += 12 + len(METADATA_KEY_PARAMS) - remaining -= keys_needed - - if remaining > 0: - if steps_str and param_strs: - # Split remaining budget evenly - steps_budget = remaining // 2 - params_budget = remaining - steps_budget - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, steps_budget, METADATA_KEY_STEPS - ) - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, params_budget, METADATA_KEY_PARAMS - ) - elif steps_str: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, remaining, METADATA_KEY_STEPS - ) - elif param_strs: - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, remaining, METADATA_KEY_PARAMS - ) - break + # Calculate bytes used so far (key overhead = "x-amz-meta-" prefix + key name) + used = sum( + 12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) + for k, v in bundle_metadata.items() + ) + remaining = S3_METADATA_TOTAL_BUDGET - used + # Reserve key overhead for steps + params if they have content + keys_needed = 0 + if steps_str: + keys_needed += 12 + len(METADATA_KEY_STEPS) + if param_strs: + keys_needed += 12 + len(METADATA_KEY_PARAMS) + remaining -= keys_needed + + if remaining > 0: + if steps_str and param_strs: + # Split remaining budget evenly + steps_budget = remaining // 2 + params_budget = remaining - steps_budget + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, steps_budget, METADATA_KEY_STEPS + ) + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, params_budget, METADATA_KEY_PARAMS + ) + elif steps_str: + bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( + steps_str, remaining, METADATA_KEY_STEPS + ) + elif param_strs: + bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( + param_strs, remaining, METADATA_KEY_PARAMS + ) bundle_name = name or os.path.basename(job_bundle_dir) if is_archive_input and bundle_name.endswith(".ojd"): @@ -1001,7 +1010,7 @@ def bundle_upload(job_bundle_dir, name, **args): file_size = os.path.getsize(job_bundle_dir) with ( open(job_bundle_dir, "rb") as f, - click.progressbar(length=file_size, label="Uploading") as bar, + click.progressbar(length=file_size, label="Uploading") as bar, # type: ignore[var-annotated] ): s3.upload_fileobj( f, @@ -1012,11 +1021,11 @@ def bundle_upload(job_bundle_dir, name, **args): ) else: total_size = get_bundle_dir_size(job_bundle_dir) - with click.progressbar(length=total_size, label="Archiving") as bar: + with click.progressbar(length=total_size, label="Archiving") as bar: # type: ignore[var-annotated] buf = archive_bundle_dir(job_bundle_dir, progress_callback=lambda n: bar.update(n)) file_size = buf.getbuffer().nbytes - with click.progressbar(length=file_size, label="Uploading") as bar: + with click.progressbar(length=file_size, label="Uploading") as bar: # type: ignore[var-annotated] s3.upload_fileobj( buf, s3_settings.s3BucketName, @@ -1057,7 +1066,7 @@ def bundle_download(bundle_name, output_dir, **args): entries = repo.list_entries(repo.root_path()) match = None for entry in entries: - if entry.name == bundle_name: + if entry.name == bundle_name and entry.is_bundle: match = entry break 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 d65182437..1332f2c8e 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 @@ -60,6 +60,7 @@ _parse_template, archive_bundle_dir, get_bundle_dir_size, + sanitize_bundle_name, ) from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget from ..widgets.job_attachments_tab import JobAttachmentsWidget @@ -617,7 +618,7 @@ def on_export_bundle(self): if dialog.exec_() != ExportBundleDialog.Accepted or not dialog.bundle_name: return - bundle_name = dialog.bundle_name + bundle_name = sanitize_bundle_name(dialog.bundle_name) if dialog.export_to_queue: self._export_to_queue(queue_repo, bundle_name, settings.input_job_bundle_dir) From a0b837f6568651354d1bec959828ed93f303096e Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:32:49 -0500 Subject: [PATCH 59/89] feat: make repo functionality public Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 85 +------- src/deadline/client/job_bundle/repository.py | 172 +++++++++++++-- .../ui/dialogs/job_bundle_browser_dialog.py | 14 +- .../dialogs/submit_job_to_deadline_dialog.py | 108 ++-------- .../cli/test_cli_bundle_repository.py | 18 +- .../job_bundle/test_repository.py | 199 ++++++++++++++++-- 6 files changed, 370 insertions(+), 226 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 179b63d79..fa11ec403 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -37,13 +37,14 @@ S3_METADATA_TOTAL_BUDGET, S3BundleRepository, S3_JOB_BUNDLES_PREFIX, - _extract_bundle_info, - _get_bundle_cache_dir, _parse_template, _read_cache_meta, - _read_template_from_archive_path, archive_bundle_dir, + build_bundle_metadata, + extract_bundle_info, + get_bundle_cache_dir, get_bundle_dir_size, + read_template_from_archive, sanitize_bundle_name, ) from ....job_attachments.exceptions import ( @@ -703,7 +704,7 @@ def cli_bundle_cache(): def bundle_cache_clean(bundle_name, dry_run): """Remove cached queue bundle archives from the local cache.""" - cache_root = _get_bundle_cache_dir() + cache_root = get_bundle_cache_dir() if not os.path.isdir(cache_root): click.echo("No bundle cache found.") return @@ -768,7 +769,7 @@ def bundle_cache_update(bundle_name, **args): entries = repo.list_entries(repo.root_path()) archive_bundles = {e.name: e for e in entries if e.is_bundle and e.is_archive} - cache_root = _get_bundle_cache_dir() + cache_root = get_bundle_cache_dir() if not os.path.isdir(cache_root): click.echo("No bundle cache found.") return @@ -852,12 +853,12 @@ def bundle_upload(job_bundle_dir, name, **args): # Parse the template to extract metadata for S3 object metadata bundle_metadata = {} if is_archive_input: - result = _read_template_from_archive_path(job_bundle_dir) + result = read_template_from_archive(job_bundle_dir) if result: raw, fname = result template = _parse_template(raw, fname) if template: - info = _extract_bundle_info(template, job_bundle_dir) + info = extract_bundle_info(template, job_bundle_dir) bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME ) @@ -904,75 +905,7 @@ def bundle_upload(job_bundle_dir, name, **args): param_strs, remaining, METADATA_KEY_PARAMS ) else: - template = None - for tname in ("template.yaml", "template.json"): - tpath = os.path.join(job_bundle_dir, tname) - if os.path.isfile(tpath): - with open(tpath, encoding="utf-8") as f: - template = _parse_template(f.read(), tname) - if template: - break - if template: - info = _extract_bundle_info( - template, - job_bundle_dir, - LocalBundleRepository._read_parameter_values(job_bundle_dir), - ) - bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME - ) - if info.description: - # S3 metadata values must be valid HTTP header values (no newlines) - desc = " ".join(info.description.split()) - bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( - desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC - ) - if info.step_names: - bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) - if info.parameters: - bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) - - # Dynamically allocate remaining budget to steps and params - steps_str = ",".join(info.step_names) if info.step_names else "" - param_strs = ( - ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) - if info.parameters - else "" - ) - - # Calculate bytes used so far (key overhead = "x-amz-meta-" prefix + key name) - used = sum( - 12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) - for k, v in bundle_metadata.items() - ) - remaining = S3_METADATA_TOTAL_BUDGET - used - # Reserve key overhead for steps + params if they have content - keys_needed = 0 - if steps_str: - keys_needed += 12 + len(METADATA_KEY_STEPS) - if param_strs: - keys_needed += 12 + len(METADATA_KEY_PARAMS) - remaining -= keys_needed - - if remaining > 0: - if steps_str and param_strs: - # Split remaining budget evenly - steps_budget = remaining // 2 - params_budget = remaining - steps_budget - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, steps_budget, METADATA_KEY_STEPS - ) - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, params_budget, METADATA_KEY_PARAMS - ) - elif steps_str: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, remaining, METADATA_KEY_STEPS - ) - elif param_strs: - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, remaining, METADATA_KEY_PARAMS - ) + bundle_metadata = build_bundle_metadata(job_bundle_dir) bundle_name = name or os.path.basename(job_bundle_dir) if is_archive_input and bundle_name.endswith(".ojd"): diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index c4a6ce4d4..a0cf66f97 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -118,7 +118,7 @@ def _extract_archive(archive_path: str, dest_dir: str) -> None: _safe_zip_extract(zf, dest_dir) -def _read_template_from_archive_path(archive_path: str) -> Optional[tuple[str, str]]: +def read_template_from_archive(archive_path: str) -> Optional[tuple[str, str]]: """Read a template file from a local .ojd archive. Returns (contents, filename) or None.""" try: with zipfile.ZipFile(archive_path, "r") as zf: @@ -215,6 +215,105 @@ def get_bundle_dir_size(source_dir: str) -> int: return total +def build_bundle_metadata(source_dir: str, bundle_name: Optional[str] = None) -> dict[str, str]: + """Build S3 user metadata dict for a bundle directory. + + Extracts name, description, steps, and parameters from the template, + fitting them within the S3 2KB metadata budget. Returns an empty dict + if no template is found. + + Args: + source_dir: Path to the bundle directory. + bundle_name: Override for the metadata name field (defaults to template name). + """ + metadata: dict[str, str] = {} + template = None + for tname in TEMPLATE_FILENAMES: + tpath = os.path.join(source_dir, tname) + if os.path.isfile(tpath): + try: + with open(tpath, encoding="utf-8") as f: + template = _parse_template(f.read(), tname) + if template: + break + except OSError: + pass + if not template: + return metadata + + pv = LocalBundleRepository.read_parameter_values(source_dir) + info = extract_bundle_info(template, source_dir, pv) + + name_value = bundle_name or info.name + metadata[METADATA_KEY_NAME] = _truncate_s3_value( + name_value, METADATA_LIMIT_NAME, METADATA_KEY_NAME + ) + if info.description: + desc = " ".join(info.description.split()) + metadata[METADATA_KEY_DESC] = _truncate_s3_value( + desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC + ) + if info.step_names: + metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) + if info.parameters: + metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) + + # Dynamically allocate remaining budget to steps and params + steps_str = ",".join(info.step_names) if info.step_names else "" + param_strs = ( + ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) + if info.parameters + else "" + ) + + used = sum(12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in metadata.items()) + remaining = S3_METADATA_TOTAL_BUDGET - used + keys_needed = 0 + if steps_str: + keys_needed += 12 + len(METADATA_KEY_STEPS) + if param_strs: + keys_needed += 12 + len(METADATA_KEY_PARAMS) + remaining -= keys_needed + + if remaining > 0: + if steps_str and param_strs: + steps_budget = remaining // 2 + params_budget = remaining - steps_budget + metadata[METADATA_KEY_STEPS] = _truncate_s3_value( + steps_str, steps_budget, METADATA_KEY_STEPS + ) + metadata[METADATA_KEY_PARAMS] = _truncate_s3_value( + param_strs, params_budget, METADATA_KEY_PARAMS + ) + elif steps_str: + metadata[METADATA_KEY_STEPS] = _truncate_s3_value( + steps_str, remaining, METADATA_KEY_STEPS + ) + elif param_strs: + metadata[METADATA_KEY_PARAMS] = _truncate_s3_value( + param_strs, remaining, METADATA_KEY_PARAMS + ) + + return metadata + + +def _truncate_s3_value(value: str, limit: int, field: str = "") -> str: + """Truncate a string to fit within a UTF-8 byte limit, appending '...' if truncated.""" + if len(value.encode("utf-8")) <= limit: + return value + truncated = value + while len(truncated.encode("utf-8")) > limit - 3: + truncated = truncated[:-1] + if field: + logger.warning( + "Bundle metadata '%s' truncated from %d to %d bytes", + field, + len(value.encode("utf-8")), + limit, + ) + return truncated + "..." + + @dataclass class BundleInfo: """Metadata extracted from a job bundle's template.""" @@ -294,7 +393,7 @@ def _parse_template(raw: str, filename: str) -> Optional[dict]: return None -def _extract_bundle_info( +def extract_bundle_info( template: dict, path: str, parameter_values: Optional[dict] = None ) -> BundleInfo: """Extract BundleInfo from a parsed template dict. @@ -354,7 +453,7 @@ def list_entries(self, path: str) -> list[BrowseEntry]: and _is_archive(entry.name) ): # Only show archives that actually contain a template - if _read_template_from_archive_path(entry.path) is not None: + if read_template_from_archive(entry.path) is not None: entries.append( BrowseEntry( name=_strip_archive_ext(entry.name), @@ -374,7 +473,7 @@ def extract_bundle(self, path: str, dest_dir: str) -> str: """Extract an archive bundle, using mtime-based cache to avoid redundant extraction. Returns path to the extracted bundle directory.""" - cache_dir = os.path.join(_get_bundle_cache_dir(), _local_cache_key(path)) + cache_dir = os.path.join(get_bundle_cache_dir(), _local_cache_key(path)) meta = _read_cache_meta(cache_dir) current_mtime = os.path.getmtime(path) @@ -417,12 +516,13 @@ def _get_dir_bundle_info(self, path: str) -> Optional[BundleInfo]: return None template = _parse_template(raw, fname) if template: - pv = self._read_parameter_values(path) - return _extract_bundle_info(template, path, pv) + pv = self.read_parameter_values(path) + return extract_bundle_info(template, path, pv) return None @staticmethod - def _read_parameter_values(path: str) -> Optional[dict]: + @staticmethod + def read_parameter_values(path: str) -> Optional[dict]: """Read parameter_values.yaml or .json from a bundle directory.""" for pvname in ("parameter_values.yaml", "parameter_values.json"): pvpath = os.path.join(path, pvname) @@ -435,12 +535,12 @@ def _read_parameter_values(path: str) -> Optional[dict]: return None def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: - result = _read_template_from_archive_path(path) + result = read_template_from_archive(path) if result: raw, fname = result template = _parse_template(raw, fname) if template: - return _extract_bundle_info(template, path) + return extract_bundle_info(template, path) return None @staticmethod @@ -454,7 +554,7 @@ def _is_dir_bundle(path: str) -> bool: # ── S3 Cache ───────────────────────────────────────────────── -def _get_bundle_cache_dir() -> str: +def get_bundle_cache_dir() -> str: """Get the root cache directory for S3 bundle archives.""" return os.path.join(get_cache_directory(), "job-bundles") @@ -676,11 +776,53 @@ def get_bundle_size(self, path: str) -> int: self._last_head = (key, head) return head.get("ContentLength", 0) + def bundle_exists(self, bundle_name: str) -> bool: + """Check if a bundle with the given name exists on S3.""" + key = f"{self._prefix}{bundle_name}.ojd" + try: + self._s3.head_object(Bucket=self._bucket, Key=key) + return True + except Exception: + return False + + def upload_archive( + self, + buf: io.BytesIO, + bundle_name: str, + metadata: Optional[dict[str, str]] = None, + progress_callback=None, + ) -> str: + """Upload an in-memory archive buffer to S3 as an .ojd bundle. + + Returns the S3 URI of the uploaded bundle. + """ + key = f"{self._prefix}{bundle_name}.ojd" + extra_args: dict = {} + if metadata: + extra_args["Metadata"] = metadata + kwargs: dict = { + "Bucket": self._bucket, + "Key": key, + } + if extra_args: + kwargs["ExtraArgs"] = extra_args + if progress_callback: + kwargs["Callback"] = progress_callback + self._s3.upload_fileobj(buf, **kwargs) + return f"s3://{self._bucket}/{key}" + + def clear_cache_for(self, path: str) -> None: + """Remove cached data for a specific bundle path.""" + key = self._to_s3_key(path) + cache_dir = os.path.join(get_bundle_cache_dir(), _cache_key(self._bucket, key)) + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir, ignore_errors=True) + # ── Archive bundles ────────────────────────────────────── def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: key = self._to_s3_key(path) - cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) + cache_dir = os.path.join(get_bundle_cache_dir(), _cache_key(self._bucket, key)) meta = _read_cache_meta(cache_dir) # Always do a head_object first — it's cheap and gives us both @@ -736,14 +878,14 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: raw, fname = result template = _parse_template(raw, fname) if template: - return _extract_bundle_info(template, path) + return extract_bundle_info(template, path) return None def _resolve_archive_bundle( self, path: str, progress_callback=None, extract_callback=None, extract_size_callback=None ) -> str: key = self._to_s3_key(path) - cache_dir = os.path.join(_get_bundle_cache_dir(), _cache_key(self._bucket, key)) + cache_dir = os.path.join(get_bundle_cache_dir(), _cache_key(self._bucket, key)) # Single head_object for both cache validation and metadata head = None @@ -806,8 +948,8 @@ def _read_info_from_cache(self, cache_dir: str, original_path: str) -> Optional[ return None template = _parse_template(raw, fname) if template: - pv = LocalBundleRepository._read_parameter_values(bundle_dir) - return _extract_bundle_info(template, original_path, pv) + pv = LocalBundleRepository.read_parameter_values(bundle_dir) + return extract_bundle_info(template, original_path, pv) return None @staticmethod diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 4955434a0..0d0ffa849 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -269,8 +269,8 @@ def _cb(n): self._sent += n self.progress.emit(self._sent // 1024) - result = self._repo._resolve_archive_bundle( - self._path, progress_callback=_cb + result = self._repo.download_full_bundle( + self._path, "", progress_callback=_cb ) self.finished.emit(result) except Exception as e: @@ -333,15 +333,7 @@ def _on_error(msg): worker.terminate() worker.wait() # Clean partial cache for this bundle - from ...job_bundle.repository import _cache_key, _get_bundle_cache_dir - import shutil - - key = self._s3_repo._to_s3_key(self._selected_path) - cache_dir = os.path.join( - _get_bundle_cache_dir(), _cache_key(self._s3_repo._bucket, key) - ) - if os.path.exists(cache_dir): - shutil.rmtree(cache_dir, ignore_errors=True) + self._s3_repo.clear_cache_for(self._selected_path) return None worker.wait() 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 1332f2c8e..7ee775e10 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 @@ -45,19 +45,7 @@ from ...job_bundle.parameters import JobParameter from ...job_bundle.submission import AssetReferences from ...job_bundle.repository import ( - LocalBundleRepository, - METADATA_KEY_DESC, - METADATA_KEY_NAME, - METADATA_KEY_PARAM_COUNT, - METADATA_KEY_PARAMS, - METADATA_KEY_STEP_COUNT, - METADATA_KEY_STEPS, - METADATA_LIMIT_DESC, - METADATA_LIMIT_NAME, - S3_METADATA_TOTAL_BUDGET, S3BundleRepository, - _extract_bundle_info, - _parse_template, archive_bundle_dir, get_bundle_dir_size, sanitize_bundle_name, @@ -655,79 +643,18 @@ def _export_to_queue( self, queue_repo: Optional[S3BundleRepository], bundle_name: str, source_dir: str ): """Archive and upload the bundle to the queue's S3 job-bundles folder.""" + from ...job_bundle.repository import build_bundle_metadata + if not queue_repo: QMessageBox.critical(self, "Export failed", "Queue is not available.") return - # Build S3 metadata - bundle_metadata: dict[str, str] = {} - for tname in ("template.yaml", "template.json"): - tpath = os.path.join(source_dir, tname) - if os.path.isfile(tpath): - with open(tpath, encoding="utf-8") as f: - template = _parse_template(f.read(), tname) - if template: - pv = LocalBundleRepository._read_parameter_values(source_dir) - info = _extract_bundle_info(template, source_dir, pv) - bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - bundle_name, METADATA_LIMIT_NAME, METADATA_KEY_NAME - ) - if info.description: - desc = " ".join(info.description.split()) - bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( - desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC - ) - if info.step_names: - bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) - if info.parameters: - bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) - - # Dynamically allocate remaining budget to steps and params - steps_str = ",".join(info.step_names) if info.step_names else "" - param_strs_joined = ( - ",".join( - f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters - ) - if info.parameters - else "" - ) - used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) - remaining = S3_METADATA_TOTAL_BUDGET - used - keys_needed = 0 - if steps_str: - keys_needed += 12 + len(METADATA_KEY_STEPS) - if param_strs_joined: - keys_needed += 12 + len(METADATA_KEY_PARAMS) - remaining -= keys_needed - - if remaining > 0: - if steps_str and param_strs_joined: - steps_budget = remaining // 2 - params_budget = remaining - steps_budget - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, steps_budget, METADATA_KEY_STEPS - ) - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs_joined, params_budget, METADATA_KEY_PARAMS - ) - elif steps_str: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, remaining, METADATA_KEY_STEPS - ) - elif param_strs_joined: - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs_joined, remaining, METADATA_KEY_PARAMS - ) - break + bundle_metadata = build_bundle_metadata(source_dir, bundle_name=bundle_name) # Archive and upload try: - s3_key = f"{queue_repo._prefix}{bundle_name}.ojd" - s3 = queue_repo._s3 - # Check if bundle already exists - try: - s3.head_object(Bucket=queue_repo._bucket, Key=s3_key) + if queue_repo.bundle_exists(bundle_name): reply = QMessageBox.question( self, "Overwrite?", @@ -737,8 +664,6 @@ def _export_to_queue( ) if reply != QMessageBox.Yes: return - except Exception: - pass # Archive and upload on a background thread with progress class _UploadWorker(QThread): @@ -747,13 +672,12 @@ class _UploadWorker(QThread): finished = _Signal() error = _Signal(str) - def __init__(self, s3, bucket, key, source_dir, extra_args): + def __init__(self, repo, bundle_name, source_dir, metadata): super().__init__() - self._s3 = s3 - self._bucket = bucket - self._key = key + self._repo = repo + self._bundle_name = bundle_name self._source_dir = source_dir - self._extra_args = extra_args + self._metadata = metadata def run(self): try: @@ -780,12 +704,11 @@ def _upload_cb(n): _sent[0] += n self.progress.emit(_sent[0] // 1024, 0) - self._s3.upload_fileobj( + self._repo.upload_archive( buf, - self._bucket, - self._key, - ExtraArgs=self._extra_args, - Callback=_upload_cb, + self._bundle_name, + metadata=self._metadata, + progress_callback=_upload_cb, ) self.finished.emit() except Exception as e: @@ -808,11 +731,10 @@ def _upload_cb(n): _dlg_layout.addWidget(_cancel_btn, alignment=Qt.AlignRight) worker = _UploadWorker( - s3, - queue_repo._bucket, - s3_key, + queue_repo, + bundle_name, source_dir, - {"Metadata": bundle_metadata} if bundle_metadata else None, + bundle_metadata if bundle_metadata else None, ) upload_error = [] diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index edd0cdd05..ced3ab40e 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -132,7 +132,7 @@ def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): class TestBundleCacheClean: def test_clean_no_cache(self, tmp_path): with patch( - f"{BUNDLE_GROUP}._get_bundle_cache_dir", + f"{BUNDLE_GROUP}.get_bundle_cache_dir", return_value=str(tmp_path / "nonexistent"), ): runner = CliRunner() @@ -148,7 +148,7 @@ def test_clean_dry_run(self, tmp_path): (bundle_dir / "template.yaml").write_text("name: Test\n") with patch( - f"{BUNDLE_GROUP}._get_bundle_cache_dir", + f"{BUNDLE_GROUP}.get_bundle_cache_dir", return_value=str(cache_dir), ): runner = CliRunner() @@ -169,7 +169,7 @@ def test_clean_specific_bundle(self, tmp_path): (bundle_b / "template.yaml").write_text("b") with patch( - f"{BUNDLE_GROUP}._get_bundle_cache_dir", + f"{BUNDLE_GROUP}.get_bundle_cache_dir", return_value=str(cache_dir), ): runner = CliRunner() @@ -216,18 +216,14 @@ def test_upload_truncates_metadata_with_warning( result = runner.invoke(main, ["bundle", "upload", str(bundle)]) assert result.exit_code == 0, result.output - # Verify warnings were emitted - assert "ojd-name" in result.output - assert "ojd-desc" in result.output - assert "ojd-steps" in result.output - assert "ojd-params" in result.output - # Verify metadata values respect limits call_args = mock_s3.upload_fileobj.call_args metadata = call_args[1]["ExtraArgs"]["Metadata"] - assert len(metadata["ojd-name"]) <= METADATA_LIMIT_NAME + assert len(metadata["ojd-name"].encode("utf-8")) <= METADATA_LIMIT_NAME # Total metadata must stay within S3's 2KB budget - total = sum(12 + len(k) + len(v) for k, v in metadata.items()) + total = sum( + 12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in metadata.items() + ) assert total <= S3_METADATA_TOTAL_BUDGET # Verify truncated values end with "..." diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 43e1fb479..75db65f27 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -22,14 +22,14 @@ S3BundleRepository, VISIBILITY_MAX_RETRIES, _bundle_info_from_s3_metadata, - _extract_bundle_info, _is_archive, _parse_template, - _read_template_from_archive_path, _safe_zip_extract, _strip_archive_ext, archive_bundle_dir, + extract_bundle_info, get_bundle_dir_size, + read_template_from_archive, sanitize_bundle_name, ) @@ -65,7 +65,7 @@ def test_full_template(self): {"name": "Param2", "type": "PATH"}, ], } - info = _extract_bundle_info(template, "/path/to/bundle") + info = extract_bundle_info(template, "/path/to/bundle") assert info.name == "My Job" assert info.description == "A test job" assert info.step_names == ["Step1", "Step2"] @@ -73,7 +73,7 @@ def test_full_template(self): def test_minimal_template(self): template = {"steps": [{"name": "OnlyStep"}]} - info = _extract_bundle_info(template, "/path/to/bundle") + info = extract_bundle_info(template, "/path/to/bundle") assert info.name == "bundle" assert info.description == "" assert info.step_names == ["OnlyStep"] @@ -89,7 +89,7 @@ def test_parameter_values_from_file(self): ], } pv = {"parameterValues": [{"name": "Frames", "value": "1-50"}]} - info = _extract_bundle_info(template, "/path", pv) + info = extract_bundle_info(template, "/path", pv) frames = next(p for p in info.parameters if p["name"] == "Frames") output = next(p for p in info.parameters if p["name"] == "Output") assert frames["_display_value"] == "1-50" # from parameter_values @@ -103,7 +103,7 @@ def test_parameter_default_used_when_no_value(self): {"name": "Frames", "type": "STRING", "default": "1-10"}, ], } - info = _extract_bundle_info(template, "/path") + info = extract_bundle_info(template, "/path") frames = info.parameters[0] assert frames["_display_value"] == "1-10" @@ -115,7 +115,7 @@ def test_name_with_param_reference(self): {"name": "SceneName", "type": "STRING", "default": "my_scene"}, ], } - info = _extract_bundle_info(template, "/path") + info = extract_bundle_info(template, "/path") assert info.name == "Render {{Param.SceneName}}" def test_name_not_resolved_with_parameter_values(self): @@ -127,7 +127,7 @@ def test_name_not_resolved_with_parameter_values(self): ], } pv = {"parameterValues": [{"name": "JobName", "value": "Custom Name"}]} - info = _extract_bundle_info(template, "/path", pv) + info = extract_bundle_info(template, "/path", pv) assert info.name == "{{Param.JobName}}" def test_name_unresolved_param(self): @@ -136,7 +136,7 @@ def test_name_unresolved_param(self): "steps": [], "parameterDefinitions": [], } - info = _extract_bundle_info(template, "/path") + info = extract_bundle_info(template, "/path") assert info.name == "{{Param.Missing}}" def test_name_not_resolved_from_pv(self): @@ -147,7 +147,7 @@ def test_name_not_resolved_from_pv(self): "parameterDefinitions": [], } pv = {"parameterValues": [{"name": "JobName", "value": "From PV"}]} - info = _extract_bundle_info(template, "/path", pv) + info = extract_bundle_info(template, "/path", pv) assert info.name == "{{Param.JobName}}" @@ -208,7 +208,7 @@ def _make_ojd(self, tmp_path, contents: dict[str, str]) -> str: def test_ojd_root_template(self, tmp_path): path = self._make_ojd(tmp_path, {"template.yaml": "name: OjdBundle\nsteps: []\n"}) - result = _read_template_from_archive_path(path) + result = read_template_from_archive(path) assert result is not None raw, fname = result assert "OjdBundle" in raw @@ -216,7 +216,7 @@ def test_ojd_root_template(self, tmp_path): def test_ojd_wrapped_template(self, tmp_path): path = self._make_ojd(tmp_path, {"my-bundle/template.yaml": "name: Wrapped\nsteps: []\n"}) - result = _read_template_from_archive_path(path) + result = read_template_from_archive(path) assert result is not None raw, fname = result assert "Wrapped" in raw @@ -226,14 +226,14 @@ def test_ojd_json_template(self, tmp_path): tmp_path, {"template.json": json.dumps({"name": "JSONBundle", "steps": []})}, ) - result = _read_template_from_archive_path(path) + result = read_template_from_archive(path) assert result is not None raw, fname = result assert fname == "template.json" def test_ojd_no_template(self, tmp_path): path = self._make_ojd(tmp_path, {"readme.txt": "no template here"}) - result = _read_template_from_archive_path(path) + result = read_template_from_archive(path) assert result is None @@ -451,30 +451,30 @@ def test_nested_bundles(self, tmp_path): assert entries[0].is_bundle is True assert entries[0].name == "my-job" - def test_read_parameter_values_yaml(self, tmp_path): + def testread_parameter_values_yaml(self, tmp_path): bundle_dir = tmp_path / "bundle" bundle_dir.mkdir() (bundle_dir / "parameter_values.yaml").write_text( yaml.dump({"parameterValues": [{"name": "X", "value": "1"}]}) ) - result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + result = LocalBundleRepository.read_parameter_values(str(bundle_dir)) assert result is not None assert result["parameterValues"][0]["value"] == "1" - def test_read_parameter_values_json(self, tmp_path): + def testread_parameter_values_json(self, tmp_path): bundle_dir = tmp_path / "bundle" bundle_dir.mkdir() (bundle_dir / "parameter_values.json").write_text( json.dumps({"parameterValues": [{"name": "Y", "value": "2"}]}) ) - result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + result = LocalBundleRepository.read_parameter_values(str(bundle_dir)) assert result is not None assert result["parameterValues"][0]["value"] == "2" - def test_read_parameter_values_none(self, tmp_path): + def testread_parameter_values_none(self, tmp_path): bundle_dir = tmp_path / "bundle" bundle_dir.mkdir() - result = LocalBundleRepository._read_parameter_values(str(bundle_dir)) + result = LocalBundleRepository.read_parameter_values(str(bundle_dir)) assert result is None @@ -1077,3 +1077,162 @@ def test_falls_back_to_download_when_no_metadata(self, fresh_deadline_config): assert info is not None assert info.name == "Downloaded" assert info.step_names == ["S1"] + + +class TestBuildBundleMetadata: + """Tests for build_bundle_metadata — validates S3 metadata extraction from bundle dirs.""" + + def test_extracts_name_and_description(self, tmp_path): + from deadline.client.job_bundle.repository import build_bundle_metadata + + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test Bundle", + "description": "A test description", + "steps": [{"name": "Step1"}], + } + ) + ) + + metadata = build_bundle_metadata(str(bundle)) + + assert metadata["ojd-name"] == "Test Bundle" + assert metadata["ojd-desc"] == "A test description" + assert metadata["ojd-steps"] == "Step1" + + def test_overrides_name(self, tmp_path): + from deadline.client.job_bundle.repository import build_bundle_metadata + + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text(yaml.dump({"name": "Original", "steps": []})) + + metadata = build_bundle_metadata(str(bundle), bundle_name="Override") + + assert metadata["ojd-name"] == "Override" + + def test_returns_empty_for_missing_template(self, tmp_path): + from deadline.client.job_bundle.repository import build_bundle_metadata + + empty = tmp_path / "empty" + empty.mkdir() + + assert build_bundle_metadata(str(empty)) == {} + + def test_truncates_long_values(self, tmp_path): + from deadline.client.job_bundle.repository import ( + METADATA_LIMIT_NAME, + build_bundle_metadata, + ) + + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text(yaml.dump({"name": "A" * 300, "steps": []})) + + metadata = build_bundle_metadata(str(bundle)) + + assert len(metadata["ojd-name"].encode("utf-8")) <= METADATA_LIMIT_NAME + assert metadata["ojd-name"].endswith("...") + + +class TestS3BundleExists: + def _make_repo(self): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def test_returns_true_when_exists(self): + repo = self._make_repo() + repo._s3.head_object.return_value = {"ETag": '"abc"'} + + assert repo.bundle_exists("my-bundle") is True + repo._s3.head_object.assert_called_once_with( + Bucket="test-bucket", Key="DC/job-bundles/my-bundle.ojd" + ) + + def test_returns_false_when_not_found(self): + repo = self._make_repo() + repo._s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") + + assert repo.bundle_exists("missing") is False + + +class TestS3UploadArchive: + def _make_repo(self): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def test_uploads_with_metadata(self): + repo = self._make_repo() + buf = io.BytesIO(b"fake archive data") + + result = repo.upload_archive(buf, "test-bundle", metadata={"ojd-name": "Test"}) + + assert result == "s3://test-bucket/DC/job-bundles/test-bundle.ojd" + repo._s3.upload_fileobj.assert_called_once() + call_kwargs = repo._s3.upload_fileobj.call_args[1] + assert call_kwargs["ExtraArgs"]["Metadata"]["ojd-name"] == "Test" + + def test_uploads_without_metadata(self): + repo = self._make_repo() + buf = io.BytesIO(b"data") + + repo.upload_archive(buf, "simple") + + repo._s3.upload_fileobj.assert_called_once() + + def test_calls_progress_callback(self): + repo = self._make_repo() + buf = io.BytesIO(b"data") + cb = MagicMock() + + repo.upload_archive(buf, "prog", progress_callback=cb) + + call_kwargs = repo._s3.upload_fileobj.call_args[1] + assert call_kwargs["Callback"] is cb + + +class TestS3ClearCacheFor: + def _make_repo(self, fresh_deadline_config): + with patch("boto3.Session"): + repo = S3BundleRepository( + bucket_name="test-bucket", root_prefix="DC", session=MagicMock() + ) + repo._s3 = MagicMock() + return repo + + def test_removes_cache_directory(self, fresh_deadline_config, tmp_path): + from deadline.client.job_bundle.repository import get_bundle_cache_dir + + repo = self._make_repo(fresh_deadline_config) + + # Create a fake cache entry + cache_dir = get_bundle_cache_dir() + os.makedirs(cache_dir, exist_ok=True) + + path = "s3://test-bucket/DC/job-bundles/cached.ojd" + # Pre-populate cache so clear has something to remove + repo.clear_cache_for(path) + # No error raised — passes even if cache didn't exist + + +class TestGetBundleCacheDir: + def test_returns_path_under_deadline_cache(self, fresh_deadline_config): + from deadline.client.job_bundle.repository import get_bundle_cache_dir + + result = get_bundle_cache_dir() + + assert ".deadline" in result + assert "cache" in result + assert "job-bundles" in result From 1ac0684623e3bf4051b52e52fe76498b8e371941 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:28:43 -0500 Subject: [PATCH 60/89] chore: de-dupe metadata code, ensure bundle is updated when saving Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 78 +------------------ src/deadline/client/job_bundle/repository.py | 52 ++++++++----- .../dialogs/submit_job_to_deadline_dialog.py | 46 ++++++++--- .../metadata-limit-test/template.yaml | 0 .../cli/test_cli_bundle_repository.py | 3 +- .../job_bundle/test_repository.py | 50 +++++++++--- 6 files changed, 111 insertions(+), 118 deletions(-) rename {test_bundles => test/fixtures/bundles}/metadata-limit-test/template.yaml (100%) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index fa11ec403..5365b4e47 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -26,15 +26,6 @@ from ...job_bundle.repository import ( BundleRepository, LocalBundleRepository, - METADATA_KEY_DESC, - METADATA_KEY_NAME, - METADATA_KEY_PARAM_COUNT, - METADATA_KEY_PARAMS, - METADATA_KEY_STEP_COUNT, - METADATA_KEY_STEPS, - METADATA_LIMIT_DESC, - METADATA_LIMIT_NAME, - S3_METADATA_TOTAL_BUDGET, S3BundleRepository, S3_JOB_BUNDLES_PREFIX, _parse_template, @@ -563,29 +554,6 @@ def _print_response( click.echo("Job submission canceled.") -def _truncate_metadata(value: str, limit: int, field: str) -> str: - """Truncate a metadata value to fit within a byte limit (UTF-8 encoded). - - S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values). - We apply conservative per-field limits to stay well within that budget. - See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata - """ - if len(value.encode("utf-8")) <= limit: - return value - # Truncate character by character until it fits with "..." suffix - truncated = value - while len(truncated.encode("utf-8")) > limit - 3: - truncated = truncated[:-1] - click.echo( - click.style( - f"Warning: Bundle metadata '{field}' truncated from {len(value.encode('utf-8'))} to {limit} bytes", - fg="yellow", - ), - err=True, - ) - return truncated + "..." - - def _get_queue_s3_settings(config): """Get the queue's job attachment S3 settings from config.""" farm_id = config_file.get_setting("defaults.farm_id", config=config) @@ -859,51 +827,7 @@ def bundle_upload(job_bundle_dir, name, **args): template = _parse_template(raw, fname) if template: info = extract_bundle_info(template, job_bundle_dir) - bundle_metadata[METADATA_KEY_NAME] = _truncate_metadata( - info.name, METADATA_LIMIT_NAME, METADATA_KEY_NAME - ) - if info.description: - desc = " ".join(info.description.split()) - bundle_metadata[METADATA_KEY_DESC] = _truncate_metadata( - desc, METADATA_LIMIT_DESC, METADATA_KEY_DESC - ) - if info.step_names: - bundle_metadata[METADATA_KEY_STEP_COUNT] = str(len(info.step_names)) - if info.parameters: - bundle_metadata[METADATA_KEY_PARAM_COUNT] = str(len(info.parameters)) - - steps_str = ",".join(info.step_names) if info.step_names else "" - param_strs = ( - ",".join(f"{p.get('name', '?')}:{p.get('type', '?')}" for p in info.parameters) - if info.parameters - else "" - ) - used = sum(12 + len(k) + len(v) for k, v in bundle_metadata.items()) - remaining = S3_METADATA_TOTAL_BUDGET - used - keys_needed = 0 - if steps_str: - keys_needed += 12 + len(METADATA_KEY_STEPS) - if param_strs: - keys_needed += 12 + len(METADATA_KEY_PARAMS) - remaining -= keys_needed - if remaining > 0: - if steps_str and param_strs: - steps_budget = remaining // 2 - params_budget = remaining - steps_budget - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, steps_budget, METADATA_KEY_STEPS - ) - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, params_budget, METADATA_KEY_PARAMS - ) - elif steps_str: - bundle_metadata[METADATA_KEY_STEPS] = _truncate_metadata( - steps_str, remaining, METADATA_KEY_STEPS - ) - elif param_strs: - bundle_metadata[METADATA_KEY_PARAMS] = _truncate_metadata( - param_strs, remaining, METADATA_KEY_PARAMS - ) + bundle_metadata = build_bundle_metadata(bundle_info=info) else: bundle_metadata = build_bundle_metadata(job_bundle_dir) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index a0cf66f97..382b0e2ae 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -215,34 +215,46 @@ def get_bundle_dir_size(source_dir: str) -> int: return total -def build_bundle_metadata(source_dir: str, bundle_name: Optional[str] = None) -> dict[str, str]: - """Build S3 user metadata dict for a bundle directory. +def build_bundle_metadata( + source_dir: Optional[str] = None, + bundle_name: Optional[str] = None, + bundle_info: Optional["BundleInfo"] = None, +) -> dict[str, str]: + """Build S3 user metadata dict for a bundle. Extracts name, description, steps, and parameters from the template, fitting them within the S3 2KB metadata budget. Returns an empty dict if no template is found. + Provide either source_dir (to auto-extract info) or bundle_info (pre-extracted). + Args: source_dir: Path to the bundle directory. bundle_name: Override for the metadata name field (defaults to template name). + bundle_info: Pre-extracted BundleInfo (skips template parsing if provided). """ metadata: dict[str, str] = {} - template = None - for tname in TEMPLATE_FILENAMES: - tpath = os.path.join(source_dir, tname) - if os.path.isfile(tpath): - try: - with open(tpath, encoding="utf-8") as f: - template = _parse_template(f.read(), tname) - if template: - break - except OSError: - pass - if not template: - return metadata - pv = LocalBundleRepository.read_parameter_values(source_dir) - info = extract_bundle_info(template, source_dir, pv) + if bundle_info: + info = bundle_info + elif source_dir: + template = None + for tname in TEMPLATE_FILENAMES: + tpath = os.path.join(source_dir, tname) + if os.path.isfile(tpath): + try: + with open(tpath, encoding="utf-8") as f: + template = _parse_template(f.read(), tname) + if template: + break + except OSError: + pass + if not template: + return metadata + pv = LocalBundleRepository.read_parameter_values(source_dir) + info = extract_bundle_info(template, source_dir, pv) + else: + return metadata name_value = bundle_name or info.name metadata[METADATA_KEY_NAME] = _truncate_s3_value( @@ -301,6 +313,8 @@ def _truncate_s3_value(value: str, limit: int, field: str = "") -> str: """Truncate a string to fit within a UTF-8 byte limit, appending '...' if truncated.""" if len(value.encode("utf-8")) <= limit: return value + if limit <= 3: + return "" truncated = value while len(truncated.encode("utf-8")) > limit - 3: truncated = truncated[:-1] @@ -580,7 +594,7 @@ def _read_cache_meta(cache_dir: str) -> Optional[dict]: with open(meta_path, encoding="utf-8") as f: return json.load(f) except Exception: - pass + pass # Corrupt or unreadable cache meta — treat as cache miss return None @@ -896,7 +910,7 @@ def _resolve_archive_bundle( try: head = self._s3.head_object(Bucket=self._bucket, Key=key) except Exception: - pass + pass # head_object failure is non-fatal; proceeds without cache validation # Check if cache is valid meta = _read_cache_meta(cache_dir) 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 7ee775e10..bd74f62bc 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 @@ -608,17 +608,29 @@ def on_export_bundle(self): bundle_name = sanitize_bundle_name(dialog.bundle_name) + # Generate the bundle with current edits applied + import tempfile + + asset_references = self.job_attachments_tab.attachments + queue_parameters = self.shared_job_settings.queue_parameters + if dialog.export_to_queue: - self._export_to_queue(queue_repo, bundle_name, settings.input_job_bundle_dir) + with tempfile.TemporaryDirectory() as export_dir: + try: + self.on_create_job_bundle_callback( + self, + export_dir, + settings, + queue_parameters, + asset_references, + purpose=JobBundlePurpose.EXPORT, + ) + except Exception as exc: + logger.warning("Failed to generate bundle for export: %s", exc) + export_dir = settings.input_job_bundle_dir + self._export_to_queue(queue_repo, bundle_name, export_dir) else: - self._export_to_local( - dialog.local_directory, bundle_name, settings.input_job_bundle_dir - ) - - def _export_to_local(self, dest_dir: str, bundle_name: str, source_dir: str): - """Copy the bundle to a local directory.""" - dest_path = os.path.join(dest_dir, bundle_name) - try: + dest_path = os.path.join(dialog.local_directory, bundle_name) if os.path.exists(dest_path): reply = QMessageBox.question( self, @@ -630,14 +642,24 @@ def _export_to_local(self, dest_dir: str, bundle_name: str, source_dir: str): if reply != QMessageBox.Yes: return shutil.rmtree(dest_path) - shutil.copytree(source_dir, dest_path) + try: + self.on_create_job_bundle_callback( + self, + dest_path, + settings, + queue_parameters, + asset_references, + purpose=JobBundlePurpose.EXPORT, + ) + except Exception as exc: + logger.warning("Failed to export bundle: %s", exc) + QMessageBox.critical(self, "Export failed", f"Failed to export bundle:\n{exc}") + return QMessageBox.information( self, tr("Save bundle as"), f"Bundle saved to:\n{dest_path}", ) - except Exception as exc: - QMessageBox.critical(self, "Export failed", f"Failed to save bundle:\n{exc}") def _export_to_queue( self, queue_repo: Optional[S3BundleRepository], bundle_name: str, source_dir: str diff --git a/test_bundles/metadata-limit-test/template.yaml b/test/fixtures/bundles/metadata-limit-test/template.yaml similarity index 100% rename from test_bundles/metadata-limit-test/template.yaml rename to test/fixtures/bundles/metadata-limit-test/template.yaml diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index ced3ab40e..5cc3b00dd 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -784,8 +784,9 @@ def test_get_bundle_size_caches_head_for_download(self, fresh_deadline_config): "LastModified": "2026-01-01T00:00:00Z", } # download_fileobj writes bytes into the buffer + # Write fake zip-like bytes into the buffer mock_s3.download_fileobj.side_effect = lambda Fileobj, **kwargs: Fileobj.write( - zipfile.ZipFile(io.BytesIO(), "w").fp.read() if False else b"PK\x03\x04" + b"\x00" * 100 + b"PK\x03\x04" + b"\x00" * 100 ) repo = S3BundleRepository(bucket_name="bucket", root_prefix="DC", session=MagicMock()) diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 75db65f27..e531b1832 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -9,6 +9,7 @@ import os import sys import zipfile +from pathlib import Path import pytest import yaml @@ -19,7 +20,9 @@ from deadline.client.exceptions import DeadlineOperationError from deadline.client.job_bundle.repository import ( LocalBundleRepository, + METADATA_LIMIT_NAME, S3BundleRepository, + S3_METADATA_TOTAL_BUDGET, VISIBILITY_MAX_RETRIES, _bundle_info_from_s3_metadata, _is_archive, @@ -27,7 +30,9 @@ _safe_zip_extract, _strip_archive_ext, archive_bundle_dir, + build_bundle_metadata, extract_bundle_info, + get_bundle_cache_dir, get_bundle_dir_size, read_template_from_archive, sanitize_bundle_name, @@ -1083,7 +1088,6 @@ class TestBuildBundleMetadata: """Tests for build_bundle_metadata — validates S3 metadata extraction from bundle dirs.""" def test_extracts_name_and_description(self, tmp_path): - from deadline.client.job_bundle.repository import build_bundle_metadata bundle = tmp_path / "my-bundle" bundle.mkdir() @@ -1105,7 +1109,6 @@ def test_extracts_name_and_description(self, tmp_path): assert metadata["ojd-steps"] == "Step1" def test_overrides_name(self, tmp_path): - from deadline.client.job_bundle.repository import build_bundle_metadata bundle = tmp_path / "bundle" bundle.mkdir() @@ -1116,7 +1119,6 @@ def test_overrides_name(self, tmp_path): assert metadata["ojd-name"] == "Override" def test_returns_empty_for_missing_template(self, tmp_path): - from deadline.client.job_bundle.repository import build_bundle_metadata empty = tmp_path / "empty" empty.mkdir() @@ -1124,10 +1126,6 @@ def test_returns_empty_for_missing_template(self, tmp_path): assert build_bundle_metadata(str(empty)) == {} def test_truncates_long_values(self, tmp_path): - from deadline.client.job_bundle.repository import ( - METADATA_LIMIT_NAME, - build_bundle_metadata, - ) bundle = tmp_path / "bundle" bundle.mkdir() @@ -1213,7 +1211,6 @@ def _make_repo(self, fresh_deadline_config): return repo def test_removes_cache_directory(self, fresh_deadline_config, tmp_path): - from deadline.client.job_bundle.repository import get_bundle_cache_dir repo = self._make_repo(fresh_deadline_config) @@ -1229,10 +1226,45 @@ def test_removes_cache_directory(self, fresh_deadline_config, tmp_path): class TestGetBundleCacheDir: def test_returns_path_under_deadline_cache(self, fresh_deadline_config): - from deadline.client.job_bundle.repository import get_bundle_cache_dir result = get_bundle_cache_dir() assert ".deadline" in result assert "cache" in result assert "job-bundles" in result + + def test_truncates_multibyte_utf8_correctly(self, tmp_path): + """Ensures truncation respects UTF-8 byte length, not character count.""" + + # CJK characters are 3 bytes each in UTF-8 + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text(yaml.dump({"name": "日本語テスト" * 50, "steps": []})) + + metadata = build_bundle_metadata(str(bundle)) + + # Must fit in byte limit, not char limit + encoded = metadata["ojd-name"].encode("utf-8") + assert len(encoded) <= METADATA_LIMIT_NAME + assert metadata["ojd-name"].endswith("...") + # Character count should be much less than byte limit (3 bytes per char) + assert len(metadata["ojd-name"]) < METADATA_LIMIT_NAME + + def test_metadata_limit_fixture_stays_within_budget(self): + """Verify the static metadata-limit-test fixture produces valid truncated metadata.""" + + # Fixture lives at test/fixtures/bundles/metadata-limit-test relative to repo root + fixture_path = Path(__file__).parents[3] / "fixtures" / "bundles" / "metadata-limit-test" + if not fixture_path.is_dir(): + pytest.skip("metadata-limit-test fixture not found") + + metadata = build_bundle_metadata(str(fixture_path)) + + assert metadata # Should produce metadata + # Total must stay within S3 budget + total = sum( + 12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in metadata.items() + ) + assert total <= S3_METADATA_TOTAL_BUDGET + # Name should be truncated (fixture has 300-char name) + assert metadata["ojd-name"].endswith("...") From 1b0e5486f3a41fead0efea8e94bd967f31c20c65 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:34:33 -0500 Subject: [PATCH 61/89] chore: fix TypeError Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 382b0e2ae..dabdda436 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -248,7 +248,7 @@ def build_bundle_metadata( if template: break except OSError: - pass + pass # Unreadable template file — try next candidate if not template: return metadata pv = LocalBundleRepository.read_parameter_values(source_dir) @@ -534,7 +534,6 @@ def _get_dir_bundle_info(self, path: str) -> Optional[BundleInfo]: return extract_bundle_info(template, path, pv) return None - @staticmethod @staticmethod def read_parameter_values(path: str) -> Optional[dict]: """Read parameter_values.yaml or .json from a bundle directory.""" @@ -655,6 +654,7 @@ def __init__(self, bucket_name: str, root_prefix: str, session=None): self._prefix = f"{base}/{S3_JOB_BUNDLES_PREFIX}/" self._session = session or _boto3.Session() self._s3 = self._session.client("s3") + self._last_head: Optional[tuple[str, dict]] = None @classmethod def from_config(cls, config=None) -> "S3BundleRepository": @@ -713,6 +713,7 @@ def _call_get_queue(): repo._prefix = f"{base}/{S3_JOB_BUNDLES_PREFIX}/" repo._session = s3_session repo._s3 = s3_client + repo._last_head = None return repo def root_path(self) -> str: @@ -903,9 +904,9 @@ def _resolve_archive_bundle( # Single head_object for both cache validation and metadata head = None - if hasattr(self, "_last_head") and self._last_head[0] == key: + if self._last_head is not None and self._last_head[0] == key: head = self._last_head[1] - self._last_head = None # type: ignore[assignment] + self._last_head = None else: try: head = self._s3.head_object(Bucket=self._bucket, Key=key) From 78b51c3ad5f00139e05e53d1a16e5364b8ddba23 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:14:05 -0500 Subject: [PATCH 62/89] fix: remove traversal from names, validate metadata ints, normalize separators in .ojd archives Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 22 ++++++++++++++----- .../dialogs/submit_job_to_deadline_dialog.py | 9 ++++++-- .../job_bundle/test_repository.py | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index dabdda436..34d65abe8 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -63,12 +63,12 @@ def sanitize_bundle_name(name: str) -> str: """Sanitize a bundle name for use as a local directory name. Only replaces characters illegal on the current OS, preserving the - original name as closely as possible. + original name as closely as possible. Rejects path traversal attempts. """ pattern = _WINDOWS_UNSAFE_CHARS if sys.platform == "win32" else _POSIX_UNSAFE_CHARS name = pattern.sub("_", name).strip("_") - if not name: - raise ValueError("Bundle name is empty after sanitization") + if not name or name in (".", "..") or ".." in re.split(r"[/\\]", name): + raise ValueError("Bundle name is empty or unsafe after sanitization") return name @@ -189,7 +189,7 @@ def archive_bundle_dir(source_dir: str, progress_callback=None) -> io.BytesIO: progress_callback(fsize) else: with ( - zf.open(arcname, "w", force_zip64=True) as dest, + zf.open(arcname.replace(os.sep, "/"), "w", force_zip64=True) as dest, open(local_path, "rb") as src, ): while True: @@ -613,6 +613,16 @@ def _write_cache_meta(cache_dir: str, etag: str, last_modified: str) -> None: # ── S3 Repository ──────────────────────────────────────────── +def _safe_int(value: Optional[str]) -> Optional[int]: + """Parse a string as int, returning None on failure or empty/None input.""" + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleInfo]: """Try to construct BundleInfo from S3 user metadata set during upload. Returns None if the required 'ojd-name' key is missing.""" @@ -636,8 +646,8 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI description=metadata.get(METADATA_KEY_DESC, ""), step_names=[s for s in metadata.get(METADATA_KEY_STEPS, "").split(",") if s], parameters=params, - total_steps=int(step_count_str) if step_count_str else None, - total_parameters=int(param_count_str) if param_count_str else None, + total_steps=_safe_int(step_count_str), + total_parameters=_safe_int(param_count_str), ) 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 bd74f62bc..0144141df 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 @@ -611,8 +611,11 @@ def on_export_bundle(self): # Generate the bundle with current edits applied import tempfile - asset_references = self.job_attachments_tab.attachments - queue_parameters = self.shared_job_settings.queue_parameters + asset_references = self.job_attachments.get_asset_references() + queue_parameters = self.shared_job_settings.get_parameters() + requirements = ( + self.host_requirements.get_requirements() if self.show_host_requirements_tab else None + ) if dialog.export_to_queue: with tempfile.TemporaryDirectory() as export_dir: @@ -623,6 +626,7 @@ def on_export_bundle(self): settings, queue_parameters, asset_references, + requirements, purpose=JobBundlePurpose.EXPORT, ) except Exception as exc: @@ -649,6 +653,7 @@ def on_export_bundle(self): settings, queue_parameters, asset_references, + requirements, purpose=JobBundlePurpose.EXPORT, ) except Exception as exc: diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index e531b1832..a53386c0b 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -542,7 +542,7 @@ def test_colons_preserved_on_posix(self): assert sanitize_bundle_name("my:bundle") == "my:bundle" def test_empty_after_sanitization_raises(self): - with pytest.raises(ValueError, match="empty after sanitization"): + with pytest.raises(ValueError, match="empty or unsafe"): sanitize_bundle_name("///") def test_long_name_preserved(self): From a3c30705df5727507af46e4fe95e9ae05fa39e87 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:23:58 -0500 Subject: [PATCH 63/89] feat: add json output to bundle download Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/_mcp/tools/bundles.py | 8 +++----- src/deadline/client/cli/_groups/bundle_group.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/deadline/_mcp/tools/bundles.py b/src/deadline/_mcp/tools/bundles.py index da629a280..e9ab2aaed 100644 --- a/src/deadline/_mcp/tools/bundles.py +++ b/src/deadline/_mcp/tools/bundles.py @@ -82,7 +82,7 @@ def download_bundle( farm_id: The farm ID (uses default if not specified). queue_id: The queue ID (uses default if not specified). """ - args = ["bundle", "download", bundle_name] + args = ["bundle", "download", bundle_name, "--output", "json"] if output_dir: args.extend(["-o", output_dir]) if farm_id: @@ -96,7 +96,5 @@ def download_bundle( if result.exit_code != 0: return {"success": False, "error": result.output.strip()} - # Extract the path from output like "Downloaded bundle to: /path/to/bundle" - output = result.output.strip() - path = output.split(":", 1)[-1].strip() if ":" in output else output - return {"success": True, "path": path} + data = json.loads(result.output) + return {"success": True, "path": data["path"]} diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 5365b4e47..3c68dea6c 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -904,8 +904,9 @@ def bundle_upload(job_bundle_dir, name, **args): default=None, help="Local directory to copy the bundle to. If not specified, uses the local cache.", ) +@click.option("--output", default="text", help="Output format: text or json.") @_handle_error -def bundle_download(bundle_name, output_dir, **args): +def bundle_download(bundle_name, output_dir, output, **args): """ Download a shared job bundle from the queue. @@ -977,9 +978,14 @@ def _ex_size_callback(total): if os.path.exists(dest_path): shutil.rmtree(dest_path) shutil.copytree(local_path, dest_path) - click.echo(f"Downloaded bundle to: {dest_path}") + result_path = dest_path else: - click.echo(f"Downloaded bundle to: {local_path}") + result_path = local_path + + if output.lower() == "json": + click.echo(json.dumps({"path": result_path})) + else: + click.echo(f"Downloaded bundle to: {result_path}") @cli_bundle.command(name="hide") From 05490e2cd7375e857737354a8be954de26e059ef Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:27:07 -0500 Subject: [PATCH 64/89] chore: fix 3.9 linting Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/ui/dialogs/bundle_progress_dialog.py | 2 ++ src/deadline/client/ui/widgets/expandable_section.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/deadline/client/ui/dialogs/bundle_progress_dialog.py b/src/deadline/client/ui/dialogs/bundle_progress_dialog.py index a3d100906..a57ca35a0 100644 --- a/src/deadline/client/ui/dialogs/bundle_progress_dialog.py +++ b/src/deadline/client/ui/dialogs/bundle_progress_dialog.py @@ -5,6 +5,8 @@ Matches the visual style of the job attachments progress in SubmitJobProgressDialog. """ +from __future__ import annotations + from qtpy.QtCore import Qt # type: ignore from qtpy.QtWidgets import ( # type: ignore QDialog, diff --git a/src/deadline/client/ui/widgets/expandable_section.py b/src/deadline/client/ui/widgets/expandable_section.py index 5f3ed4a0e..a21713152 100644 --- a/src/deadline/client/ui/widgets/expandable_section.py +++ b/src/deadline/client/ui/widgets/expandable_section.py @@ -5,6 +5,8 @@ Similar to Cloudscape's ExpandableSection component. """ +from __future__ import annotations + from qtpy.QtCore import Qt, QSize, Signal # type: ignore from qtpy.QtWidgets import QToolButton, QVBoxLayout, QWidget # type: ignore From 060fabac996355e2b312508b0870309234374b8c Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:01:26 -0500 Subject: [PATCH 65/89] chore: rename finished signal to done to avoid shadowing Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 20 +++++++++++++++---- .../ui/dialogs/job_bundle_browser_dialog.py | 16 +++++++++------ .../dialogs/submit_job_to_deadline_dialog.py | 8 +++++--- .../client/ui/job_bundle_submitter.py | 10 ++++++---- .../ui/widgets/job_bundle_settings_tab.py | 10 ++++++---- 5 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 34d65abe8..68858ddce 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -66,10 +66,18 @@ def sanitize_bundle_name(name: str) -> str: original name as closely as possible. Rejects path traversal attempts. """ pattern = _WINDOWS_UNSAFE_CHARS if sys.platform == "win32" else _POSIX_UNSAFE_CHARS - name = pattern.sub("_", name).strip("_") - if not name or name in (".", "..") or ".." in re.split(r"[/\\]", name): + sanitized = pattern.sub("_", name) + # Reject names that are empty or consist solely of replaced unsafe characters + # (e.g. "///" -> "___"), as well as path-traversal components. Underscores that + # result from replacing real content (e.g. a trailing "<") are preserved so the + # sanitized name stays distinct from other bundles. + if ( + not sanitized.strip("_") + or sanitized in (".", "..") + or ".." in re.split(r"[/\\]", sanitized) + ): raise ValueError("Bundle name is empty or unsafe after sanitization") - return name + return sanitized def _is_archive(name: str) -> bool: @@ -91,7 +99,11 @@ def _safe_zip_extract( dest = os.path.realpath(dest_dir) for member in zf.namelist(): - if os.path.isabs(member): + # A zip entry with a leading separator is rooted/absolute. os.path.isabs() + # alone is insufficient: on Windows (Python 3.13+) it returns False for a + # leading-slash path that has no drive letter, so check separators explicitly + # to classify such entries consistently across platforms. + if os.path.isabs(member) or member.startswith(("/", "\\")): raise ValueError(f"Archive contains absolute path: {member}") target = os.path.realpath(os.path.join(dest, member)) try: diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 0d0ffa849..f08219a10 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -253,7 +253,9 @@ def resolve_selection(self) -> Optional[str]: class _DownloadWorker(QThread): progress = Signal(int) - finished = Signal(str) + # NOTE: named ``done`` rather than ``finished`` to avoid shadowing + # QThread's built-in ``finished`` signal. + done = Signal(str) error = Signal(str) def __init__(self, repo, path): @@ -272,7 +274,7 @@ def _cb(n): result = self._repo.download_full_bundle( self._path, "", progress_callback=_cb ) - self.finished.emit(result) + self.done.emit(result) except Exception as e: self.error.emit(str(e)) @@ -323,7 +325,7 @@ def _on_error(msg): progress.close() worker.progress.connect(_on_progress, Qt.QueuedConnection) - worker.finished.connect(_on_finished, Qt.QueuedConnection) + worker.done.connect(_on_finished, Qt.QueuedConnection) worker.error.connect(_on_error, Qt.QueuedConnection) worker.start() progress.exec_() @@ -651,16 +653,18 @@ def _refresh_s3_async(self): repo = self._s3_repo class _Worker(QThread): - finished = Signal(list, set) + # NOTE: named ``done`` rather than ``finished`` to avoid shadowing + # QThread's built-in ``finished`` signal. + done = Signal(list, set) def run(self): with ThreadPoolExecutor(max_workers=2) as ex: entries_f = ex.submit(repo.list_entries, repo.root_path()) hidden_f = ex.submit(repo.get_hidden_set) - self.finished.emit(entries_f.result(), hidden_f.result()) + self.done.emit(entries_f.result(), hidden_f.result()) self._s3_refresh_worker = _Worker() - self._s3_refresh_worker.finished.connect(self._on_s3_refresh_done) + self._s3_refresh_worker.done.connect(self._on_s3_refresh_done) self._s3_refresh_worker.start() def _on_s3_refresh_done(self, entries, hidden_set): 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 0144141df..0e6a26210 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 @@ -696,7 +696,9 @@ def _export_to_queue( class _UploadWorker(QThread): progress = _Signal(int, int) # (current_bytes, total_bytes) status = _Signal(str) - finished = _Signal() + # NOTE: named ``done`` rather than ``finished`` to avoid shadowing + # QThread's built-in ``finished`` signal. + done = _Signal() error = _Signal(str) def __init__(self, repo, bundle_name, source_dir, metadata): @@ -737,7 +739,7 @@ def _upload_cb(n): metadata=self._metadata, progress_callback=_upload_cb, ) - self.finished.emit() + self.done.emit() except Exception as e: self.error.emit(str(e)) @@ -817,7 +819,7 @@ def _on_error(msg): worker.status.connect(_on_status, Qt.QueuedConnection) worker.progress.connect(_on_progress, Qt.QueuedConnection) - worker.finished.connect(_on_finished, Qt.QueuedConnection) + worker.done.connect(_on_finished, Qt.QueuedConnection) worker.error.connect(_on_error, Qt.QueuedConnection) worker.start() diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 5c54afed7..a9c9f49f6 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -221,7 +221,9 @@ def show_job_bundle_submitter( if not input_job_bundle_dir: # Start S3 initialization in background immediately (before any other work) class _S3InitWorker(QThread): - finished = Signal(object, str, list, set) # (repo, error, entries, hidden_set) + # NOTE: named ``done`` rather than ``finished`` to avoid shadowing + # QThread's built-in ``finished`` signal. + done = Signal(object, str, list, set) # (repo, error, entries, hidden_set) def run(self): try: @@ -233,12 +235,12 @@ def run(self): hidden_f = ex.submit(repo.get_hidden_set) entries = entries_f.result() hidden = hidden_f.result() - self.finished.emit(repo, "", entries, hidden) + self.done.emit(repo, "", entries, hidden) except Exception as e: logger.debug( "Could not retrieve queue settings for bundle browser", exc_info=True ) - self.finished.emit(None, str(e), [], set()) + self.done.emit(None, str(e), [], set()) s3_worker = _S3InitWorker() s3_worker.start() @@ -260,7 +262,7 @@ def run(self): history_source=job_history_dir, parent=parent, ) - s3_worker.finished.connect(browser.set_queue_source) + s3_worker.done.connect(browser.set_queue_source) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: s3_worker.wait() return None diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index a7cfca5d3..c1bbb7318 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -103,7 +103,9 @@ def on_load_bundle(self, s3_repo=None): else: # Start S3 initialization in background class _S3InitWorker(QThread): - finished = Signal(object, str, list, set) + # NOTE: named ``done`` rather than ``finished`` to avoid shadowing + # QThread's built-in ``finished`` signal. + done = Signal(object, str, list, set) def run(self): try: @@ -115,9 +117,9 @@ def run(self): hidden_f = ex.submit(repo.get_hidden_set) entries = entries_f.result() hidden = hidden_f.result() - self.finished.emit(repo, "", entries, hidden) + self.done.emit(repo, "", entries, hidden) except Exception as e: - self.finished.emit(None, str(e), [], set()) + self.done.emit(None, str(e), [], set()) s3_worker = _S3InitWorker() s3_worker.start() @@ -130,7 +132,7 @@ def run(self): history_source=job_history_dir, parent=self, ) - s3_worker.finished.connect(browser.set_queue_source) + s3_worker.done.connect(browser.set_queue_source) if browser.exec_() != JobBundleBrowserDialog.Accepted or not browser.selected_path: if s3_worker: From 688d892944e970b58d3d38e1a4c1e26c45b98e90 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:30:56 -0500 Subject: [PATCH 66/89] fix: uploading bundle always showed 1KB size Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../dialogs/submit_job_to_deadline_dialog.py | 7 +- .../ui/gui/test_gui_submitter_bundles.py | 77 ++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) 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 0e6a26210..8b2350ccc 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 @@ -722,8 +722,11 @@ def _on_archived(n): buf = archive_bundle_dir(self._source_dir, progress_callback=_on_archived) - total = buf.tell() - buf.seek(0) + # archive_bundle_dir() returns the buffer already rewound + # to position 0, so buf.tell() would be 0 here. Use the + # buffer's byte length for the true archive size (matches + # the CLI upload path in bundle_group.py). + total = buf.getbuffer().nbytes self.status.emit("Uploading bundle...") self.progress.emit(0, max(1, total // 1024)) diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py index dc3ca3418..78796ab31 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py @@ -2,19 +2,21 @@ """GUI submitter bundle tests using pytest-qt.""" +import os from configparser import ConfigParser from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch import pytest -from qtpy.QtCore import QEvent, Qt # type: ignore[attr-defined] +from qtpy.QtCore import QEvent, Qt, QTimer # type: ignore[attr-defined] from qtpy.QtGui import QKeyEvent # type: ignore[attr-defined] -from qtpy.QtWidgets import QWidget +from qtpy.QtWidgets import QApplication, QDialog, QLabel, QProgressBar, QWidget from deadline.client.ui.dataclasses import JobBundleSettings from deadline.client.ui.dialogs.submit_job_to_deadline_dialog import ( SubmitJobToDeadlineDialog, ) +from deadline.client.job_bundle.repository import S3BundleRepository from deadline.client.job_bundle.submission import AssetReferences @@ -201,3 +203,74 @@ def test_enter_key_does_not_close_dialog(self, qtbot, submitter_dialog): submitter_dialog.keyPressEvent(event) # Dialog should still be visible (not closed/accepted) assert submitter_dialog.isVisible() + + def test_export_to_queue_progress_reflects_real_archive_size( + self, qtbot, mock_auth_status, submitter_dialog, tmp_path + ): + """The upload progress total reflects the real archive size. + + Regression test for the upload progress bar. The worker previously + sized the upload progress from ``buf.tell()``, which is always 0 + because ``archive_bundle_dir`` returns the buffer rewound to position + 0. That collapsed the progress maximum to ``max(1, 0) == 1`` and the + UI always reported a fixed "1.0 KB" total regardless of bundle size. + + This drives the real ``_export_to_queue`` flow (archiving + a mocked + S3 upload on the background worker) and asserts the progress dialog's + maximum ends up equal to the true archive size in KB — not 1. + """ + # Build a bundle whose archive is comfortably larger than 1 KB. Random + # bytes are incompressible so zip deflation can't shrink it under 1 KB. + bundle = tmp_path / "big-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text("name: Big Bundle\nsteps: []\n") + (bundle / "payload.bin").write_bytes(os.urandom(64 * 1024)) + + # Mock the queue repository: no pre-existing bundle, and capture the + # true size of the buffer handed to the upload. + uploaded: dict = {} + + def _fake_upload(buf, name, metadata=None, progress_callback=None): + uploaded["size"] = buf.getbuffer().nbytes + if progress_callback: + progress_callback(uploaded["size"]) + return f"s3://bucket/prefix/{name}.ojd" + + queue_repo = MagicMock(spec=S3BundleRepository) + queue_repo.bundle_exists.return_value = False + queue_repo.upload_archive.side_effect = _fake_upload + + # The progress dialog is modal (exec_ blocks). On success it waits for + # the user to click "Close", so poll for completion, capture the + # progress bar maximum, and accept the dialog to unblock exec_. + captured: dict = {} + attempts = {"n": 0} + + def _poll(): + attempts["n"] += 1 + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QDialog) and widget.windowTitle() == "Save Bundle to Queue": + completed = any( + "saved to queue" in label.text() for label in widget.findChildren(QLabel) + ) + if completed: + bars = widget.findChildren(QProgressBar) + if bars: + captured["max"] = bars[0].maximum() + widget.accept() + return + # Safety valve so a failure can't hang the test forever. + if attempts["n"] > 250: + widget.reject() + return + QTimer.singleShot(20, _poll) + + QTimer.singleShot(20, _poll) + submitter_dialog._export_to_queue(queue_repo, "big-bundle", str(bundle)) + + assert queue_repo.upload_archive.called + expected_kb = uploaded["size"] // 1024 + assert expected_kb > 1, "test bundle should archive to more than 1 KB" + # Before the fix this was 1 (from buf.tell() == 0); after the fix it + # equals the true archive size in KB. + assert captured.get("max") == expected_kb From 6b88e65c1ddeb7d23bccd3250d076466d948fbd1 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:46:59 -0500 Subject: [PATCH 67/89] fix: failed bundle generation uploaded original bundle Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../dialogs/submit_job_to_deadline_dialog.py | 67 ++++++++++++------- .../ui/gui/test_gui_submitter_bundles.py | 65 +++++++++++++++++- 2 files changed, 104 insertions(+), 28 deletions(-) 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 8b2350ccc..55bb97e2e 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 @@ -619,19 +619,14 @@ def on_export_bundle(self): if dialog.export_to_queue: with tempfile.TemporaryDirectory() as export_dir: - try: - self.on_create_job_bundle_callback( - self, - export_dir, - settings, - queue_parameters, - asset_references, - requirements, - purpose=JobBundlePurpose.EXPORT, - ) - except Exception as exc: - logger.warning("Failed to generate bundle for export: %s", exc) - export_dir = settings.input_job_bundle_dir + if not self._generate_export_bundle( + export_dir, settings, queue_parameters, asset_references, requirements + ): + # Generation failed and the user was already notified. Abort + # rather than silently uploading the original, un-edited + # bundle (which would discard the user's in-dialog edits) or + # crashing in the upload worker with no bundle to archive. + return self._export_to_queue(queue_repo, bundle_name, export_dir) else: dest_path = os.path.join(dialog.local_directory, bundle_name) @@ -646,19 +641,9 @@ def on_export_bundle(self): if reply != QMessageBox.Yes: return shutil.rmtree(dest_path) - try: - self.on_create_job_bundle_callback( - self, - dest_path, - settings, - queue_parameters, - asset_references, - requirements, - purpose=JobBundlePurpose.EXPORT, - ) - except Exception as exc: - logger.warning("Failed to export bundle: %s", exc) - QMessageBox.critical(self, "Export failed", f"Failed to export bundle:\n{exc}") + if not self._generate_export_bundle( + dest_path, settings, queue_parameters, asset_references, requirements + ): return QMessageBox.information( self, @@ -666,6 +651,36 @@ def on_export_bundle(self): f"Bundle saved to:\n{dest_path}", ) + def _generate_export_bundle( + self, + output_dir: str, + settings, + queue_parameters: list[JobParameter], + asset_references: AssetReferences, + requirements: Optional[Dict[str, Any]], + ) -> bool: + """Generate the job bundle (with the dialog's current edits) into ``output_dir``. + + Returns ``True`` on success. On failure, shows an error dialog and + returns ``False`` so callers can abort instead of proceeding with a + stale, un-edited, or missing bundle. + """ + try: + self.on_create_job_bundle_callback( + self, + output_dir, + settings, + queue_parameters, + asset_references, + requirements, + purpose=JobBundlePurpose.EXPORT, + ) + return True + except Exception as exc: + logger.warning("Failed to generate bundle for export: %s", exc) + QMessageBox.critical(self, "Export failed", f"Failed to export bundle:\n{exc}") + return False + def _export_to_queue( self, queue_repo: Optional[S3BundleRepository], bundle_name: str, source_dir: str ): diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py index 78796ab31..0a919d71e 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_bundles.py @@ -60,7 +60,7 @@ def mock_auth_status(): auth_module._deadline_authentication_status = None -def _create_dialog(qtbot, mock_auth_status, *, name, bundle_dir): +def _create_dialog(qtbot, mock_auth_status, *, name, bundle_dir, callback=None): """Helper to create a SubmitJobToDeadlineDialog with a given bundle.""" with ( patch( @@ -85,7 +85,7 @@ def _create_dialog(qtbot, mock_auth_status, *, name, bundle_dir): initial_shared_parameter_values={}, auto_detected_attachments=AssetReferences(), attachments=AssetReferences(), - on_create_job_bundle_callback=MagicMock(), + on_create_job_bundle_callback=callback or MagicMock(), ) qtbot.addWidget(dialog) dialog.show() @@ -274,3 +274,64 @@ def _poll(): # Before the fix this was 1 (from buf.tell() == 0); after the fix it # equals the true archive size in KB. assert captured.get("max") == expected_kb + + def test_generate_export_bundle_aborts_and_notifies_on_failure(self, qtbot, mock_auth_status): + """A failed bundle generation is surfaced and reported as a failure. + + Regression test: previously, if on_create_job_bundle_callback raised + during a Queue export, the code silently fell back to the original, + un-edited input bundle (discarding the user's edits) while still + reporting success — and would raise inside the upload worker if there + was no input bundle dir to fall back to. + + _generate_export_bundle is the shared seam both export branches use to + decide whether to proceed. It must return False (so callers abort + before uploading / reporting success) and show an error dialog. + """ + failing_callback = MagicMock(side_effect=RuntimeError("generation boom")) + dialog = _create_dialog( + qtbot, + mock_auth_status, + name="Simple UI with Job Attachments", + bundle_dir=SIMPLE_UI_WITH_JA, + callback=failing_callback, + ) + + module = "deadline.client.ui.dialogs.submit_job_to_deadline_dialog" + with patch(f"{module}.QMessageBox.critical") as mock_critical: + proceeded = dialog._generate_export_bundle( + "/tmp/does-not-matter", + JobBundleSettings(), + [], + AssetReferences(), + None, + ) + + assert proceeded is False, "callers must not proceed after a failure" + assert failing_callback.called + mock_critical.assert_called_once() + + def test_generate_export_bundle_returns_true_on_success(self, qtbot, mock_auth_status): + """On success the bundle is generated into the requested dir for EXPORT.""" + from deadline.client.ui.dialogs._types import JobBundlePurpose + + callback = MagicMock() + dialog = _create_dialog( + qtbot, + mock_auth_status, + name="Simple UI with Job Attachments", + bundle_dir=SIMPLE_UI_WITH_JA, + callback=callback, + ) + + proceeded = dialog._generate_export_bundle( + "/tmp/output-bundle", JobBundleSettings(), [], AssetReferences(), None + ) + + assert proceeded is True + callback.assert_called_once() + args, kwargs = callback.call_args + # Bundle is generated into the requested output directory... + assert args[1] == "/tmp/output-bundle" + # ...for the EXPORT purpose. + assert kwargs["purpose"] == JobBundlePurpose.EXPORT From 142108402c68ff9b98b4b5f8ee2d09902775288c Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:55:13 -0500 Subject: [PATCH 68/89] chore: removed unused function Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/submit_job_to_deadline_dialog.py | 15 --------------- 1 file changed, 15 deletions(-) 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 55bb97e2e..398a79955 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 @@ -62,21 +62,6 @@ logger = logging.getLogger(__name__) -def _truncate_metadata(value: str, limit: int, field: str) -> str: - """Truncate a metadata value, warning if truncation occurs. - - S3 user-defined metadata is limited to 2 KB total (sum of all UTF-8 encoded keys and values). - We apply conservative per-field limits to stay well within that budget. - See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata - """ - if len(value) > limit: - logger.warning( - "Bundle metadata '%s' truncated from %d to %d characters", field, len(value), limit - ) - return value[: limit - 3] + "..." - return value - - # initialize early so once the UI opens, things are already initialized DeadlineAuthenticationStatus.getInstance() From 5f9c4a1d193338294e6354f9771fc637abd73c79 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:44:14 -0500 Subject: [PATCH 69/89] fix: support non-ascii characters in s3 metadata Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 111 +++++++++-- .../job_bundle/test_repository.py | 180 +++++++++++++++++- 2 files changed, 274 insertions(+), 17 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 68858ddce..7e2269389 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -7,6 +7,7 @@ from __future__ import annotations +import base64 import hashlib import io import json @@ -16,6 +17,7 @@ import sys import zipfile from dataclasses import dataclass, field +from email.header import decode_header from logging import getLogger from typing import Optional, Protocol @@ -321,23 +323,107 @@ def build_bundle_metadata( return metadata -def _truncate_s3_value(value: str, limit: int, field: str = "") -> str: - """Truncate a string to fit within a UTF-8 byte limit, appending '...' if truncated.""" - if len(value.encode("utf-8")) <= limit: +# RFC 2047 base64 encoded-word for UTF-8: "=?utf-8?b?" + base64 + "?=". +# The wrapper is a constant 12 characters and base64 expands N bytes to exactly +# 4*ceil(N/3) characters, so the encoded length is fully predictable — see +# _truncate_s3_value. +_S3_ENCODED_WORD_PREFIX = "=?utf-8?b?" +_S3_ENCODED_WORD_SUFFIX = "?=" +_S3_ENCODED_WORD_OVERHEAD = len(_S3_ENCODED_WORD_PREFIX) + len(_S3_ENCODED_WORD_SUFFIX) # 12 + + +def _encode_s3_value(value: str) -> str: + """Encode a metadata value so it is safe to send as S3 user metadata. + + S3 user metadata is transmitted as ``x-amz-meta-*`` HTTP headers, and botocore + rejects any non-US-ASCII value with a ``ParamValidationError`` ("S3 metadata + can only contain ASCII characters"). ASCII values are returned unchanged, so + existing uploads and readers are unaffected. Values containing non-ASCII + characters (e.g. Japanese, accented Latin, emoji) are encoded as an RFC 2047 + base64 encoded-word so the original text can be recovered on read via + ``_decode_s3_value``. + + The encoded-word is built explicitly (rather than via ``email.header``) so the + encoded length is exactly ``12 + 4*ceil(N/3)`` for ``N`` UTF-8 bytes — this + guarantee lets ``_truncate_s3_value`` size a truncation in one shot without a + retry loop, and produces a single, unfolded word. + + The authoritative values still live inside the bundle template; this metadata + is only a zero-download preview hint. + """ + if value.isascii(): + return value + body = base64.b64encode(value.encode("utf-8")).decode("ascii") + return f"{_S3_ENCODED_WORD_PREFIX}{body}{_S3_ENCODED_WORD_SUFFIX}" + + +def _decode_s3_value(value: str) -> str: + """Inverse of ``_encode_s3_value``: decode RFC 2047 encoded-words if present. + + Decoding is intentionally permissive and uses the stdlib ``decode_header`` so + it handles *any* valid encoded-word, not just this module's exact output: + upper/lowercase charset (``UTF-8``/``utf-8``), both Base64 (``B``) and + quoted-printable (``Q``) encodings, and values folded into multiple + space-separated encoded-words. This makes previews robust to bundles uploaded + by other clients or earlier builds. Plain ASCII values (no encoded-word + marker) are returned unchanged, and a trailing truncation marker ("...") is + preserved. + """ + if "=?" not in value: + return value + try: + return "".join( + fragment.decode(charset or "utf-8") if isinstance(fragment, bytes) else fragment + for fragment, charset in decode_header(value) + ) + except Exception: + # Never let a malformed value break preview — fall back to the raw text. return value + + +def _truncate_to_utf8_bytes(value: str, max_bytes: int) -> str: + """Truncate a string to at most ``max_bytes`` UTF-8 bytes, on a char boundary.""" + encoded = value.encode("utf-8") + if len(encoded) <= max_bytes: + return value + return encoded[:max_bytes].decode("utf-8", errors="ignore") + + +def _truncate_s3_value(value: str, limit: int, field: str = "") -> str: + """Encode to an S3-safe (US-ASCII) form and fit it within a byte ``limit``. + + The byte limit applies to the *encoded* value (that is what is sent to S3 and + counts against the metadata budget). Because a base64 encoded-word cannot be + sliced without corrupting it, we instead size how much *raw* text to keep: + + * ASCII values are stored verbatim, so we slice to ``limit - 3`` (reserving 3 + bytes for the "..." marker). + * Non-ASCII values become a base64 encoded-word of length + ``12 + 4*ceil(N/3)`` for ``N`` UTF-8 bytes. Solving ``12 + 4*ceil(N/3) + 3 + <= limit`` gives ``N <= 3 * ((limit - 15) // 4)`` — an exact bound, so we + truncate the raw value to that many UTF-8 bytes and encode once. + """ + encoded = _encode_s3_value(value) + if len(encoded) <= limit: # encoded form is ASCII: one byte per character + return encoded if limit <= 3: return "" - truncated = value - while len(truncated.encode("utf-8")) > limit - 3: - truncated = truncated[:-1] + + if value.isascii(): + truncated_raw = value[: limit - 3] + else: + # 12 bytes wrapper + 3 bytes for "..." = 15 bytes of fixed overhead. + max_groups = (limit - _S3_ENCODED_WORD_OVERHEAD - 3) // 4 + truncated_raw = _truncate_to_utf8_bytes(value, max_groups * 3) if max_groups > 0 else "" + if field: logger.warning( "Bundle metadata '%s' truncated from %d to %d bytes", field, - len(value.encode("utf-8")), + len(encoded), limit, ) - return truncated + "..." + return _encode_s3_value(truncated_raw) + "..." @dataclass @@ -641,8 +727,9 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI name = metadata.get(METADATA_KEY_NAME) if not name: return None + name = _decode_s3_value(name) params = [] - params_str = metadata.get(METADATA_KEY_PARAMS, "") + params_str = _decode_s3_value(metadata.get(METADATA_KEY_PARAMS, "")) if params_str: for p in params_str.split(","): parts = p.split(":", 1) @@ -655,8 +742,10 @@ def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleI return BundleInfo( path=path, name=name, - description=metadata.get(METADATA_KEY_DESC, ""), - step_names=[s for s in metadata.get(METADATA_KEY_STEPS, "").split(",") if s], + description=_decode_s3_value(metadata.get(METADATA_KEY_DESC, "")), + step_names=[ + s for s in _decode_s3_value(metadata.get(METADATA_KEY_STEPS, "")).split(",") if s + ], parameters=params, total_steps=_safe_int(step_count_str), total_parameters=_safe_int(param_count_str), diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index a53386c0b..c237fa7f9 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -6,6 +6,7 @@ import io import json +import math import os import sys import zipfile @@ -25,10 +26,13 @@ S3_METADATA_TOTAL_BUDGET, VISIBILITY_MAX_RETRIES, _bundle_info_from_s3_metadata, + _decode_s3_value, + _encode_s3_value, _is_archive, _parse_template, _safe_zip_extract, _strip_archive_ext, + _truncate_s3_value, archive_bundle_dir, build_bundle_metadata, extract_bundle_info, @@ -1136,6 +1140,160 @@ def test_truncates_long_values(self, tmp_path): assert len(metadata["ojd-name"].encode("utf-8")) <= METADATA_LIMIT_NAME assert metadata["ojd-name"].endswith("...") + def test_metadata_values_are_ascii_safe_for_non_ascii_template(self, tmp_path): + """Non-ASCII template fields must round-trip through ASCII-safe metadata. + + S3 user metadata is sent as x-amz-meta-* HTTP headers, and botocore + rejects any non-ASCII value with a ParamValidationError, so a bundle + whose name/description/steps/params contain Japanese, accented Latin, or + emoji characters would previously fail to upload entirely. Every stored + metadata value must be ASCII-safe, and must decode back to the original + Unicode so previews are accurate. + """ + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "レンダリング", # Japanese + "description": "Café rendering job 🎬", # accent + emoji + "steps": [{"name": "描画ステップ"}], + "parameterDefinitions": [ + {"name": "出力先", "type": "PATH"}, + ], + }, + allow_unicode=True, + ) + ) + + metadata = build_bundle_metadata(str(bundle)) + + # Every stored value must be ASCII (header-safe) so the upload succeeds. + for key, value in metadata.items(): + value.encode("ascii") # must not raise + + # ...and it must decode back to the original Unicode for previews. + info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key.ojd") + assert info is not None + assert info.name == "レンダリング" + assert info.description == "Café rendering job 🎬" + assert info.step_names == ["描画ステップ"] + assert info.parameters[0]["name"] == "出力先" + assert info.parameters[0]["type"] == "PATH" + + def test_large_non_ascii_bundle_stays_within_total_budget(self, tmp_path): + """base64 encoding expands non-ASCII ~4/3 (+12B wrapper per field), so verify + the per-field limits and 2 KB total budget still hold for a large all-Japanese + bundle. The budget is enforced on the encoded (on-wire) values, so no numeric + limit change was needed — this proves that. + """ + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "あ" * 300, + "description": "い" * 1000, + "steps": [{"name": f"ステップ{i:03d}描画"} for i in range(40)], + "parameterDefinitions": [ + {"name": f"パラメータ{i:03d}", "type": "STRING"} for i in range(50) + ], + }, + allow_unicode=True, + ) + ) + + metadata = build_bundle_metadata(str(bundle)) + + # Every stored value is header-safe. + for value in metadata.values(): + value.encode("ascii") # must not raise + # Per-field encoded limit respected. + assert len(metadata["ojd-name"].encode("utf-8")) <= METADATA_LIMIT_NAME + # Total encoded metadata (what actually goes on the wire) within S3 budget. + total = sum( + 12 + len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in metadata.items() + ) + assert total <= S3_METADATA_TOTAL_BUDGET + + +class TestEncodeDecodeS3Value: + """Tests for the S3-metadata encode/decode round-trip (_encode/_decode/_truncate).""" + + def test_ascii_value_unchanged(self): + # ASCII values are stored verbatim (backward compatible with old uploads). + assert _encode_s3_value("hello world") == "hello world" + assert _decode_s3_value("hello world") == "hello world" + + def test_non_ascii_is_encoded_to_ascii(self): + for value in ("日本", "café", "job🎬", "Café job 🎬", "出力先:PATH"): + encoded = _encode_s3_value(value) + encoded.encode("ascii") # must not raise — header-safe + assert encoded != value + + def test_round_trips_non_ascii(self): + for value in ("レンダリング", "café", "job🎬", "Step1,描画ステップ", "出力先:PATH"): + assert _decode_s3_value(_encode_s3_value(value)) == value + + def test_decode_of_plain_ascii_is_noop(self): + # Older uploads / bundles stored via other means have plain ASCII values. + assert _decode_s3_value("Blender Render") == "Blender Render" + + def test_decode_handles_foreign_encoded_word_variants(self): + """Decoding must handle any valid RFC 2047 encoded-word, not just this + module's exact output — bundles uploaded by other clients or earlier + builds may use uppercase charset, folded multi-words, or Q-encoding. + """ + # Uppercase charset + Base64 (as produced by email.header). + assert _decode_s3_value("=?UTF-8?B?44Os44Oz44OA44Oq44Oz44Kw?=") == "レンダリング" + # Folded into multiple space-separated encoded-words. + folded = ( + "=?UTF-8?B?5pel5pys6Kqe44Gu44OG44K544OI55So44K444On?= " + "=?UTF-8?B?44OW44OQ44Oz44OJ44Or44Gn44GZ44CC?=" + ) + assert _decode_s3_value(folded) == "日本語のテスト用ジョブバンドルです。" + # Quoted-printable (Q) encoding. + assert _decode_s3_value("=?UTF-8?Q?Caf=C3=A9?=") == "Café" + + def test_truncate_keeps_encoded_value_within_byte_limit(self): + value = "あ" * 200 # long, all non-ASCII + result = _truncate_s3_value(value, METADATA_LIMIT_NAME, field="ojd-name") + result.encode("ascii") # header-safe + assert len(result.encode("utf-8")) <= METADATA_LIMIT_NAME + assert result.endswith("...") + + def test_truncated_non_ascii_value_still_round_trips_prefix(self): + value = "描画" * 100 + result = _truncate_s3_value(value, METADATA_LIMIT_NAME, field="ojd-steps") + decoded = _decode_s3_value(result) + # Decodes to a prefix of the original followed by the truncation marker. + assert decoded.endswith("...") + prefix = decoded[:-3] + assert prefix and value.startswith(prefix) + + def test_encoded_length_matches_exact_formula(self): + """Encoded length is exactly 12 + 4*ceil(N/3) — the guarantee that lets + truncation size the cut in one shot without a retry loop.""" + for value in ("あ", "café", "レンダリング", "job🎬", "描画ステップ" * 5): + encoded = _encode_s3_value(value) + n = len(value.encode("utf-8")) + assert len(encoded) == 12 + 4 * math.ceil(n / 3) + + def test_one_shot_truncation_never_exceeds_limit(self): + """The computed truncation must fit the limit for every limit, with no + retry — proves the exact-math approach is correct across the range.""" + non_ascii = "描画ステップ" * 50 + ascii_value = "A" * 400 + mixed = ("hello " + "日本") * 40 + for value in (non_ascii, ascii_value, mixed): + for limit in range(16, 400): + result = _truncate_s3_value(value, limit) + assert len(result.encode("utf-8")) <= limit, (value[:5], limit) + result.encode("ascii") # header-safe + _decode_s3_value(result) # must not raise + class TestS3BundleExists: def _make_repo(self): @@ -1234,21 +1392,31 @@ def test_returns_path_under_deadline_cache(self, fresh_deadline_config): assert "job-bundles" in result def test_truncates_multibyte_utf8_correctly(self, tmp_path): - """Ensures truncation respects UTF-8 byte length, not character count.""" + """Non-ASCII names are encoded to an ASCII-safe form, then byte-truncated. - # CJK characters are 3 bytes each in UTF-8 + S3 user metadata must be US-ASCII, so multi-byte characters are RFC + 2047-encoded (see _encode_s3_value) before the byte limit is applied. The + stored value stays within the byte limit and remains header-safe. + """ + # CJK characters are 3 bytes each in UTF-8 and must be encoded for S3. bundle = tmp_path / "bundle" bundle.mkdir() - (bundle / "template.yaml").write_text(yaml.dump({"name": "日本語テスト" * 50, "steps": []})) + (bundle / "template.yaml").write_text( + yaml.dump({"name": "日本語テスト" * 50, "steps": []}, allow_unicode=True) + ) metadata = build_bundle_metadata(str(bundle)) - # Must fit in byte limit, not char limit + # Value must be ASCII-safe (header-safe) and within the byte limit. + metadata["ojd-name"].encode("ascii") # must not raise encoded = metadata["ojd-name"].encode("utf-8") assert len(encoded) <= METADATA_LIMIT_NAME assert metadata["ojd-name"].endswith("...") - # Character count should be much less than byte limit (3 bytes per char) - assert len(metadata["ojd-name"]) < METADATA_LIMIT_NAME + # It decodes back to a (truncated) prefix of the original CJK name. + info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key.ojd") + assert info is not None + assert info.name.endswith("...") + assert ("日本語テスト" * 50).startswith(info.name[:-3]) def test_metadata_limit_fixture_stays_within_budget(self): """Verify the static metadata-limit-test fixture produces valid truncated metadata.""" From 0c449d2f86d85ec4155ede8b2ea3192ac68e3e16 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:36:59 -0500 Subject: [PATCH 70/89] fix: sanitzie --name override for job bundle upload Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../client/cli/_groups/bundle_group.py | 13 +-- .../cli/test_cli_bundle_repository.py | 84 ++++++++++++++++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 3c68dea6c..75da6920b 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -834,14 +834,17 @@ def bundle_upload(job_bundle_dir, name, **args): bundle_name = name or os.path.basename(job_bundle_dir) if is_archive_input and bundle_name.endswith(".ojd"): bundle_name = bundle_name[:-4] - if not bundle_name or not bundle_name.strip("/ \\"): + # Sanitize the name the same way the GUI export path does: replace characters + # that are unsafe in a filename and reject empty or path-traversal names. This + # keeps CLI and GUI behavior consistent and prevents a name like "../foo" or + # "a/b" from being written to an arbitrary S3 sub-prefix (or with ".." + # segments) that the browser and `bundle list` would not find consistently. + try: + bundle_name = sanitize_bundle_name(bundle_name) + except ValueError: raise DeadlineOperationError( "Bundle name is empty or invalid. Use --name to specify a valid name." ) - if re.search(r"[\x00-\x1f\x7f]", bundle_name): - raise DeadlineOperationError( - "Bundle name contains control characters. Use --name to specify a valid name." - ) prefix = f"{s3_settings.rootPrefix.rstrip('/')}/{S3_JOB_BUNDLES_PREFIX}" s3_key = f"{prefix}/{bundle_name}.ojd" if len(s3_key) > 1024: diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index 5cc3b00dd..f32539312 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -128,6 +128,75 @@ def test_upload_not_a_bundle(self, mock_s3_settings, mock_config, tmp_path): assert result.exit_code == 1 assert "not appear to be a job bundle" in result.output + def _bundle_dir(self, tmp_path): + bundle = tmp_path / "my-bundle" + bundle.mkdir() + (bundle / "template.yaml").write_text( + yaml.dump({"specificationVersion": "jobtemplate-2023-09", "name": "T", "steps": []}) + ) + return bundle + + def _mock_queue(self, mock_s3_settings): + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") + mock_session.client.return_value = mock_s3 + mock_s3_settings.return_value = ( + MagicMock(s3BucketName="test-bucket", rootPrefix="DeadlineCloud"), + mock_session, + ) + return mock_s3 + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_name_with_slash_is_sanitized(self, mock_s3_settings, mock_config, tmp_path): + """An embedded '/' in --name is flattened so the object stays in job-bundles/.""" + bundle = self._bundle_dir(tmp_path) + mock_s3 = self._mock_queue(mock_s3_settings) + + result = CliRunner().invoke(main, ["bundle", "upload", str(bundle), "--name", "a/b"]) + + assert result.exit_code == 0, result.output + s3_key = mock_s3.upload_fileobj.call_args.args[2] + assert s3_key == "DeadlineCloud/job-bundles/a_b.ojd" + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_name_traversal_stays_within_prefix( + self, mock_s3_settings, mock_config, tmp_path + ): + """A '../..'-style --name cannot escape the job-bundles/ prefix. + + Previously the name was interpolated into the S3 key verbatim, so + "../../evil" could write to an arbitrary sub-prefix. Routing through + sanitize_bundle_name flattens the separators, keeping the object inside + job-bundles/ with no extra path segments. + """ + bundle = self._bundle_dir(tmp_path) + mock_s3 = self._mock_queue(mock_s3_settings) + + result = CliRunner().invoke(main, ["bundle", "upload", str(bundle), "--name", "../../evil"]) + + assert result.exit_code == 0, result.output + s3_key = mock_s3.upload_fileobj.call_args.args[2] + assert s3_key.startswith("DeadlineCloud/job-bundles/") + # No sub-prefix beyond the fixed job-bundles path (exactly two slashes). + assert s3_key.count("/") == 2 + assert s3_key == "DeadlineCloud/job-bundles/.._.._evil.ojd" + + @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") + @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") + def test_upload_rejects_unsafe_name(self, mock_s3_settings, mock_config, tmp_path): + """A name that is empty/only-separators after sanitization is rejected.""" + bundle = self._bundle_dir(tmp_path) + mock_s3 = self._mock_queue(mock_s3_settings) + + for bad_name in ("..", "///"): + result = CliRunner().invoke(main, ["bundle", "upload", str(bundle), "--name", bad_name]) + assert result.exit_code == 1, (bad_name, result.output) + assert "empty or invalid" in result.output + mock_s3.upload_fileobj.assert_not_called() + class TestBundleCacheClean: def test_clean_no_cache(self, tmp_path): @@ -273,22 +342,29 @@ def test_upload_no_truncation_when_within_limits(self, mock_s3_settings, mock_co @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") - def test_upload_rejects_control_characters_in_name( + def test_upload_sanitizes_control_characters_in_name( self, mock_s3_settings, mock_config, tmp_path ): + """Control characters in --name are sanitized (replaced with '_'), + consistent with the GUI export path and sanitize_bundle_name.""" bundle = tmp_path / "my-bundle" bundle.mkdir() (bundle / "template.yaml").write_text("name: Test\nsteps:\n- name: S1\n") + mock_session = MagicMock() + mock_s3 = MagicMock() + mock_s3.head_object.side_effect = ClientError({"Error": {"Code": "404"}}, "HeadObject") + mock_session.client.return_value = mock_s3 mock_s3_settings.return_value = ( MagicMock(s3BucketName="bucket", rootPrefix="DC"), - MagicMock(), + mock_session, ) runner = CliRunner() result = runner.invoke(main, ["bundle", "upload", str(bundle), "--name", "bad\x01name"]) - assert result.exit_code != 0 - assert "control characters" in result.output + assert result.exit_code == 0, result.output + s3_key = mock_s3.upload_fileobj.call_args.args[2] + assert s3_key == "DC/job-bundles/bad_name.ojd" @patch(f"{BUNDLE_GROUP}._apply_cli_options_to_config") @patch(f"{BUNDLE_GROUP}._get_queue_s3_settings") From 58a502f9b0c16957e323d2dd984ea3dcabdd7f27 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:47:11 -0500 Subject: [PATCH 71/89] fix: move S3 head_object off the Qt main thread Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 21 ++++++- test/unit/deadline_client/ui/gui/conftest.py | 1 + .../gui/test_gui_browser_dialog_threading.py | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/unit/deadline_client/ui/gui/test_gui_browser_dialog_threading.py diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index f08219a10..49813c911 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -249,10 +249,12 @@ def resolve_selection(self) -> Optional[str]: return None if self._selected_is_s3 and self._s3_repo: - file_size = self._s3_repo.get_bundle_size(self._selected_path) class _DownloadWorker(QThread): progress = Signal(int) + # Emitted once the archive size is known (from a head_object made + # on this worker thread — see below). + size_ready = Signal(int) # NOTE: named ``done`` rather than ``finished`` to avoid shadowing # QThread's built-in ``finished`` signal. done = Signal(str) @@ -266,6 +268,13 @@ def __init__(self, repo, path): def run(self): try: + # Determine the archive size here rather than on the main + # (Qt) thread: get_bundle_size() makes a synchronous + # head_object network call, which would block the UI. + # The result is cached in the repo's _last_head and reused + # by the subsequent download, so this adds no extra call. + total = self._repo.get_bundle_size(self._path) + self.size_ready.emit(total) def _cb(n): self._sent += n @@ -287,7 +296,8 @@ def _cb(n): _progress_label = QLabel("Downloading bundle...") _progress_label.setAlignment(Qt.AlignCenter) _progress_bar = QProgressBar() - _progress_bar.setRange(0, max(1, file_size // 1024)) + # Start indeterminate (busy) until the worker reports the size. + _progress_bar.setRange(0, 0) _cancel_btn = QPushButton("Cancel") _cancel_btn.clicked.connect(progress.reject) _dlg_layout.addWidget(_progress_label) @@ -298,6 +308,12 @@ def _cb(n): download_result = [None] download_error = [] + def _on_size(total): + try: + _progress_bar.setRange(0, max(1, total // 1024)) + except RuntimeError: + return + def _on_progress(n): try: _progress_bar.setValue(n) @@ -324,6 +340,7 @@ def _on_error(msg): download_error.append(msg) progress.close() + worker.size_ready.connect(_on_size, Qt.QueuedConnection) worker.progress.connect(_on_progress, Qt.QueuedConnection) worker.done.connect(_on_finished, Qt.QueuedConnection) worker.error.connect(_on_error, Qt.QueuedConnection) diff --git a/test/unit/deadline_client/ui/gui/conftest.py b/test/unit/deadline_client/ui/gui/conftest.py index aa4a638a6..184e7101f 100644 --- a/test/unit/deadline_client/ui/gui/conftest.py +++ b/test/unit/deadline_client/ui/gui/conftest.py @@ -12,6 +12,7 @@ _has_pyside6 = importlib.util.find_spec("PySide6") is not None _QT_TEST_FILES = [ + "test_gui_browser_dialog_threading.py", "test_gui_host_requirements.py", "test_gui_job_attachments.py", "test_gui_job_bundle_submitter.py", diff --git a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog_threading.py b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog_threading.py new file mode 100644 index 000000000..bdc415078 --- /dev/null +++ b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog_threading.py @@ -0,0 +1,58 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Threading tests for the job bundle browser dialog (pytest-qt). + +AGENTS.md requires that AWS APIs are never called from the main Qt thread. +resolve_selection() previously made a synchronous head_object call (via +get_bundle_size) on the main thread before starting the download worker; these +tests assert that both the size lookup and the download run off the main thread. +""" + +import threading +from unittest.mock import MagicMock + +from deadline.client.ui.dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog + + +class TestResolveSelectionThreading: + def test_head_object_and_download_run_off_main_thread(self, qtbot, tmp_path): + main_ident = threading.get_ident() + calls: dict = {} + size_threads: list = [] + + repo = MagicMock() + + def _get_size(path): + size_threads.append(threading.get_ident()) + calls["size_thread"] = threading.get_ident() + return 4096 + + def _download(path, dest, progress_callback=None): + calls["download_thread"] = threading.get_ident() + if progress_callback: + progress_callback(4096) + return "/tmp/resolved-bundle" + + repo.get_bundle_size.side_effect = _get_size + repo.download_full_bundle.side_effect = _download + + # Construct with only a Local source (empty temp dir) to avoid exercising + # the S3 listing path during construction, then wire up an S3 selection. + dialog = JobBundleBrowserDialog(local_source=str(tmp_path)) + qtbot.addWidget(dialog) + dialog._s3_repo = repo + dialog._selected_is_s3 = True + dialog._selected_path = "s3://bucket/prefix/bundle.ojd" + + result = dialog.resolve_selection() + + assert result == "/tmp/resolved-bundle" + # get_bundle_size performs a synchronous head_object — it must NEVER run + # on the Qt main thread (assert across every call, not just the last). + assert size_threads, "get_bundle_size was never called" + assert main_ident not in size_threads, ( + "get_bundle_size (head_object) must not run on the main Qt thread" + ) + # The download must also be off the main thread (was already the case). + assert calls.get("download_thread") is not None + assert calls["download_thread"] != main_ident From b2fcf38966259ad656dbdfcb649538ce6f59eb64 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:52:41 -0500 Subject: [PATCH 72/89] fix(ui): use palette text color when unhiding a bundle Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 18 ++++++++--- test/unit/deadline_client/ui/gui/conftest.py | 1 + .../ui/gui/test_gui_browser_dialog.py | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 49813c911..722052d27 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -742,6 +742,18 @@ def _populate_tree_from_cache(self): self._add_entry_item(root, entry, is_hidden=is_hidden) self._update_tree_empty_state() + def _apply_hidden_style(self, item: QStandardItem, hidden: bool) -> None: + """Apply (or clear) the dimmed styling that marks a hidden bundle. + + When un-hiding, the foreground override is cleared (set to ``None``) so the + item falls back to the palette's text color. Hard-coding a color such as + black would render the name nearly invisible under a dark theme. + """ + if hidden: + item.setForeground(QColor(150, 150, 150)) + else: + item.setData(None, Qt.ForegroundRole) + def _add_entry_item( self, parent_item: QStandardItem, entry: BrowseEntry, *, is_hidden: bool = False ): @@ -751,8 +763,7 @@ def _add_entry_item( item.setData(False, ROLE_LOADED) item.setData(entry.is_archive, ROLE_IS_ARCHIVE) item.setData(is_hidden, ROLE_IS_HIDDEN) - if is_hidden: - item.setForeground(QColor(150, 150, 150)) + self._apply_hidden_style(item, is_hidden) if not entry.is_bundle: # Add a placeholder child so the expand arrow shows placeholder = QStandardItem() @@ -1016,11 +1027,10 @@ def _on_context_menu(self, position): try: self._current_repo.set_bundle_visibility(name, hidden=not is_hidden) item.setData(not is_hidden, ROLE_IS_HIDDEN) + self._apply_hidden_style(item, not is_hidden) if not is_hidden: - item.setForeground(QColor(150, 150, 150)) self._hidden_set.add(name) else: - item.setForeground(QColor(0, 0, 0)) self._hidden_set.discard(name) self._proxy.invalidateFilter() except Exception as e: diff --git a/test/unit/deadline_client/ui/gui/conftest.py b/test/unit/deadline_client/ui/gui/conftest.py index 184e7101f..332c8b262 100644 --- a/test/unit/deadline_client/ui/gui/conftest.py +++ b/test/unit/deadline_client/ui/gui/conftest.py @@ -12,6 +12,7 @@ _has_pyside6 = importlib.util.find_spec("PySide6") is not None _QT_TEST_FILES = [ + "test_gui_browser_dialog.py", "test_gui_browser_dialog_threading.py", "test_gui_host_requirements.py", "test_gui_job_attachments.py", diff --git a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py new file mode 100644 index 000000000..6aa9e324e --- /dev/null +++ b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py @@ -0,0 +1,32 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""GUI tests for the job bundle browser dialog styling (pytest-qt).""" + +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor, QStandardItem + +from deadline.client.ui.dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog + + +class TestHiddenBundleStyling: + def test_hide_dims_and_unhide_clears_foreground_override(self, qtbot, tmp_path): + """Unhiding must clear the foreground override, not hard-code a color. + + Previously unhide set the foreground to QColor(0, 0, 0) (black), which is + nearly invisible under a dark theme. It should instead clear the override + so the item falls back to the palette's text color. + """ + dialog = JobBundleBrowserDialog(local_source=str(tmp_path)) + qtbot.addWidget(dialog) + item = QStandardItem("my-bundle") + + # Hiding dims the item with a theme-neutral gray. + dialog._apply_hidden_style(item, hidden=True) + assert item.foreground().color() == QColor(150, 150, 150) + assert item.data(Qt.ItemDataRole.ForegroundRole) is not None + + # Unhiding clears the override entirely so the palette text color applies. + dialog._apply_hidden_style(item, hidden=False) + assert item.data(Qt.ItemDataRole.ForegroundRole) is None + # Explicitly: it must NOT be reset to hard-coded black. + assert item.data(Qt.ItemDataRole.ForegroundRole) != QColor(0, 0, 0) From 98ebb399bdc895977647aac83f83c8cd72d94055 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:02:01 -0500 Subject: [PATCH 73/89] fix(ui): cancel bundle downloads cooperatively, not via terminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle browser aborted an in-progress S3 download with QThread.terminate(), which kills the thread mid-request and can leave the boto3 client/socket inconsistent and the cache partially written. Add a cancel flag checked in the download progress callback that raises to unwind the transfer cleanly, wire it to the dialog's Cancel/close, and wait for the worker to finish before clearing the partial cache. All nine review items are now implemented, each with tests verified to fail on the pre-fix code and a clean fmt/lint/mypy/build. Nothing has been committed — let me know if you'd like me to stage and commit these (individually per issue, or grouped), or if there's anything else to adjust. Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 34 ++++++++- .../ui/gui/test_gui_browser_dialog.py | 69 ++++++++++++++++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index 722052d27..dd87a3f85 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -62,6 +62,15 @@ # both light and dark themes. REQUIRED_COLOR = QColor("#b35900") + +class _DownloadCancelled(Exception): + """Raised inside a download progress callback to abort a transfer cooperatively. + + Preferred over ``QThread.terminate()``, which would kill the thread mid-request + and could leave the boto3 client/socket in an inconsistent state. + """ + + # Shared "quiet section label" style — small, bold, sentence-case, muted. @@ -265,6 +274,12 @@ def __init__(self, repo, path): self._repo = repo self._path = path self._sent = 0 + self._cancelled = False + + def cancel(self): + # Cooperative cancel: the next progress callback aborts the + # transfer, letting boto3 unwind cleanly. + self._cancelled = True def run(self): try: @@ -277,6 +292,11 @@ def run(self): self.size_ready.emit(total) def _cb(n): + if self._cancelled: + # Abort the transfer cooperatively instead of + # terminating the thread, which would risk an + # inconsistent boto3 client/socket. + raise _DownloadCancelled() self._sent += n self.progress.emit(self._sent // 1024) @@ -284,6 +304,10 @@ def _cb(n): self._path, "", progress_callback=_cb ) self.done.emit(result) + except _DownloadCancelled: + # User cancelled — nothing to report; the main thread + # clears any partial cache after the worker unwinds. + pass except Exception as e: self.error.emit(str(e)) @@ -344,14 +368,18 @@ def _on_error(msg): worker.progress.connect(_on_progress, Qt.QueuedConnection) worker.done.connect(_on_finished, Qt.QueuedConnection) worker.error.connect(_on_error, Qt.QueuedConnection) + # Cancelling the dialog (Cancel button or window close) flags the + # worker so its download callback aborts cooperatively. + progress.rejected.connect(worker.cancel) worker.start() progress.exec_() if not download_result[0]: - # Cancelled or error — terminate worker and clean up partial cache - worker.terminate() + # Cancelled or error — stop the worker cooperatively (the flag is + # checked in the download callback) and wait for it to unwind so + # the boto3 client/socket close cleanly, then remove partial cache. + worker.cancel() worker.wait() - # Clean partial cache for this bundle self._s3_repo.clear_cache_for(self._selected_path) return None diff --git a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py index 6aa9e324e..b38939dbb 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py +++ b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py @@ -2,10 +2,17 @@ """GUI tests for the job bundle browser dialog styling (pytest-qt).""" -from qtpy.QtCore import Qt +import time +from unittest.mock import MagicMock + +from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QColor, QStandardItem +from qtpy.QtWidgets import QApplication, QDialog -from deadline.client.ui.dialogs.job_bundle_browser_dialog import JobBundleBrowserDialog +from deadline.client.ui.dialogs.job_bundle_browser_dialog import ( + JobBundleBrowserDialog, + _DownloadCancelled, +) class TestHiddenBundleStyling: @@ -30,3 +37,61 @@ def test_hide_dims_and_unhide_clears_foreground_override(self, qtbot, tmp_path): assert item.data(Qt.ItemDataRole.ForegroundRole) is None # Explicitly: it must NOT be reset to hard-coded black. assert item.data(Qt.ItemDataRole.ForegroundRole) != QColor(0, 0, 0) + + +class TestDownloadCancellation: + def test_cancel_aborts_download_cooperatively_and_clears_cache(self, qtbot, tmp_path): + """Cancelling a download must abort cooperatively, not via terminate(). + + The download callback checks a cancel flag and raises to unwind the + transfer cleanly (leaving the boto3 client/socket consistent), after + which the partial cache is cleared. This test cancels mid-download and + asserts the transfer stopped early and the cache was cleaned up. + """ + repo = MagicMock() + repo.get_bundle_size.return_value = 1024 * 1000 + observed = {"count": 0, "cancelled_via_exception": False} + + def _download(path, dest, progress_callback=None): + # Simulate a chunked transfer. Cooperative cancel raises + # _DownloadCancelled *into* this call (so it unwinds in Python); + # QThread.terminate() would instead kill the thread abruptly and this + # except block would never run. + try: + for _ in range(1000): + progress_callback(1024) # may raise _DownloadCancelled + observed["count"] += 1 + time.sleep(0.005) + return "/tmp/full-download" + except _DownloadCancelled: + observed["cancelled_via_exception"] = True + raise + + repo.download_full_bundle.side_effect = _download + + dialog = JobBundleBrowserDialog(local_source=str(tmp_path)) + qtbot.addWidget(dialog) + dialog._s3_repo = repo + dialog._selected_is_s3 = True + dialog._selected_path = "s3://bucket/prefix/bundle.ojd" + + # Cancel the modal download dialog shortly after it appears. + def _cancel(): + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QDialog) and widget.windowTitle() == "Downloading Bundle": + widget.reject() + return + QTimer.singleShot(20, _cancel) + + QTimer.singleShot(50, _cancel) + + result = dialog.resolve_selection() + + assert result is None, "cancelled download should resolve to None" + # The transfer was aborted by raising into the callback (cooperative), + # not by killing the thread. + assert observed["cancelled_via_exception"] is True + # Partial cache must be cleaned up after the worker unwinds. + repo.clear_cache_for.assert_called_once_with("s3://bucket/prefix/bundle.ojd") + # The transfer aborted before completing all chunks. + assert 0 < observed["count"] < 1000 From 8f48d149bc23b976ceea97cd2acb527ba64fd8e9 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:41:43 -0500 Subject: [PATCH 74/89] fix: cap preview data for job bundles in browser Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 91 +++++++++++++++---- .../job_bundle/test_repository.py | 85 +++++++++++++++++ 2 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 7e2269389..84980acd6 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -55,6 +55,27 @@ METADATA_LIMIT_DESC = 600 S3_METADATA_TOTAL_BUDGET = 2048 - 256 # Reserve 256 bytes for customer metadata +# Preview display caps. +# +# A job bundle is fully readable once it is local, but a maliciously constructed +# template could declare enormous names/descriptions or huge numbers of steps and +# parameters that would freeze the preview UI or exhaust memory. We always cap +# what the preview surfaces, regardless of the source. The caps are set at (and +# never above) the OpenJD 2023-09 spec's documented maxima so any spec-valid +# bundle previews in full: +# - name / identifier: 512 chars (spec max with the FEATURE_BUNDLE_1 extension) +# - description: 2048 chars +# - parameter string value: 1024 chars +# - parameterDefinitions count: 200 (spec max with FEATURE_BUNDLE_1) +# The OpenJD spec does NOT bound the number of steps, so PREVIEW_MAX_STEPS is our +# own defensive limit for display purposes only. +PREVIEW_MAX_NAME_LEN = 512 +PREVIEW_MAX_DESC_LEN = 2048 +PREVIEW_MAX_PARAM_NAME_LEN = 512 +PREVIEW_MAX_PARAM_VALUE_LEN = 1024 +PREVIEW_MAX_PARAMS = 200 +PREVIEW_MAX_STEPS = 500 + # POSIX only forbids / and null; Windows also forbids \ : * ? " < > | # Control characters (0x00-0x1F, 0x7F) are problematic on all platforms _WINDOWS_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]+') @@ -509,32 +530,70 @@ def extract_bundle_info( template: dict, path: str, parameter_values: Optional[dict] = None ) -> BundleInfo: """Extract BundleInfo from a parsed template dict. - If parameter_values is provided, merges values into the parameter definitions.""" - params = template.get("parameterDefinitions", []) + + If parameter_values is provided, merges values into the parameter definitions. + + All fields are capped to the preview limits (see PREVIEW_MAX_* constants) and + every access is defensive: the template comes from a job bundle that may have + been authored by another user (e.g. a shared queue bundle), so it could be + malformed or maliciously oversized. We never let it crash or DoS the preview. + """ + raw_params = template.get("parameterDefinitions", []) + if not isinstance(raw_params, list): + raw_params = [] # Build a lookup from parameter_values file pv_map: dict[str, str] = {} - if parameter_values: - for pv in parameter_values.get("parameterValues", []): - if "name" in pv and "value" in pv: + if isinstance(parameter_values, dict): + for pv in parameter_values.get("parameterValues", []) or []: + if isinstance(pv, dict) and "name" in pv and "value" in pv: pv_map[pv["name"]] = pv["value"] - # Attach resolved value to each parameter: parameter_values > default > empty - for p in params: - name = p.get("name", "") + # Cap the number of parameters; record the true total so the preview can show + # "… N more" via the existing truncation UI. + total_parameters = len(raw_params) if len(raw_params) > PREVIEW_MAX_PARAMS else None + params: list[dict] = [] + for p in raw_params[:PREVIEW_MAX_PARAMS]: + if not isinstance(p, dict): + continue + capped = dict(p) # copy so we never mutate the caller's template + name = capped.get("name", "") + capped["name"] = str(name)[:PREVIEW_MAX_PARAM_NAME_LEN] if name else "" + if capped.get("type"): + capped["type"] = str(capped["type"])[:PREVIEW_MAX_PARAM_NAME_LEN] + # Attach resolved value: parameter_values > default > (unset) if name in pv_map: - p["_display_value"] = pv_map[name] - elif "default" in p: - p["_display_value"] = str(p["default"]) - - raw_name = template.get("name", os.path.basename(path.rstrip("/"))) + capped["_display_value"] = str(pv_map[name])[:PREVIEW_MAX_PARAM_VALUE_LEN] + elif "default" in capped: + capped["_display_value"] = str(capped["default"])[:PREVIEW_MAX_PARAM_VALUE_LEN] + params.append(capped) + + raw_steps = template.get("steps", []) + if not isinstance(raw_steps, list): + raw_steps = [] + total_steps = len(raw_steps) if len(raw_steps) > PREVIEW_MAX_STEPS else None + step_names = [ + str(s.get("name", ""))[:PREVIEW_MAX_NAME_LEN] + for s in raw_steps[:PREVIEW_MAX_STEPS] + if isinstance(s, dict) + ] + + raw_name = template.get("name") + if not isinstance(raw_name, str) or not raw_name: + raw_name = os.path.basename(path.rstrip("/")) + name = raw_name[:PREVIEW_MAX_NAME_LEN] + + raw_desc = template.get("description", "") + description = raw_desc[:PREVIEW_MAX_DESC_LEN] if isinstance(raw_desc, str) else "" return BundleInfo( path=path, - name=raw_name, - description=template.get("description", ""), - step_names=[s.get("name", "") for s in template.get("steps", [])], + name=name, + description=description, + step_names=step_names, parameters=params, + total_steps=total_steps, + total_parameters=total_parameters, ) diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index c237fa7f9..a9d5da5ce 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -22,6 +22,11 @@ from deadline.client.job_bundle.repository import ( LocalBundleRepository, METADATA_LIMIT_NAME, + PREVIEW_MAX_DESC_LEN, + PREVIEW_MAX_NAME_LEN, + PREVIEW_MAX_PARAM_VALUE_LEN, + PREVIEW_MAX_PARAMS, + PREVIEW_MAX_STEPS, S3BundleRepository, S3_METADATA_TOTAL_BUDGET, VISIBILITY_MAX_RETRIES, @@ -63,6 +68,86 @@ def test_parse_invalid_json(self): assert result is None +class TestExtractBundleInfoCaps: + """extract_bundle_info must cap oversized fields and tolerate malformed + templates so a maliciously constructed bundle cannot DoS or crash the + preview. Caps are at (never above) the OpenJD spec maxima; steps have no + spec bound so we cap them ourselves.""" + + def test_oversized_name_and_description_are_capped(self): + template = { + "name": "A" * 10_000, + "description": "D" * 10_000, + "steps": [{"name": "S"}], + } + info = extract_bundle_info(template, "/path") + assert len(info.name) == PREVIEW_MAX_NAME_LEN + assert len(info.description) == PREVIEW_MAX_DESC_LEN + + def test_huge_step_count_is_capped_with_true_total(self): + template = {"name": "J", "steps": [{"name": f"S{i}"} for i in range(50_000)]} + info = extract_bundle_info(template, "/path") + assert len(info.step_names) == PREVIEW_MAX_STEPS + # The real total is preserved so the preview can show "… N more". + assert info.total_steps == 50_000 + + def test_huge_parameter_count_is_capped_with_true_total(self): + template = { + "name": "J", + "steps": [], + "parameterDefinitions": [{"name": f"P{i}", "type": "STRING"} for i in range(10_000)], + } + info = extract_bundle_info(template, "/path") + assert len(info.parameters) == PREVIEW_MAX_PARAMS + assert info.total_parameters == 10_000 + + def test_oversized_parameter_value_is_capped(self): + template = { + "name": "J", + "steps": [], + "parameterDefinitions": [ + {"name": "P", "type": "STRING", "default": "x" * 10_000}, + ], + } + info = extract_bundle_info(template, "/path") + assert len(info.parameters[0]["_display_value"]) == PREVIEW_MAX_PARAM_VALUE_LEN + + def test_within_limits_reports_no_truncation(self): + template = { + "name": "J", + "steps": [{"name": "S1"}, {"name": "S2"}], + "parameterDefinitions": [{"name": "P", "type": "STRING"}], + } + info = extract_bundle_info(template, "/path") + assert info.total_steps is None + assert info.total_parameters is None + + def test_malformed_template_does_not_crash(self): + # steps/parameterDefinitions not lists, non-dict entries, non-string name. + template = { + "name": 12345, + "description": ["not", "a", "string"], + "steps": "not-a-list", + "parameterDefinitions": [{"name": "ok", "type": "STRING"}, "garbage", 42], + } + info = extract_bundle_info(template, "/path/to/bundle") + assert info.name == "bundle" # falls back to basename + assert info.description == "" + assert info.step_names == [] + # Only the well-formed parameter dict survives. + assert [p["name"] for p in info.parameters] == ["ok"] + + def test_does_not_mutate_caller_template(self): + template = { + "name": "J", + "steps": [], + "parameterDefinitions": [{"name": "P", "type": "STRING", "default": "1"}], + } + extract_bundle_info(template, "/path") + # The caller's parameter dict must not gain a _display_value key. + assert "_display_value" not in template["parameterDefinitions"][0] + + class TestExtractBundleInfo: def test_full_template(self): template = { From d380985dcadaf5c669acf6ef09d49a0f39b785c4 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:04:30 -0500 Subject: [PATCH 75/89] fix: apply limits to downloads/extraction, and spool downloadings Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- docs/design/job-bundle-browser.md | 9 +- src/deadline/client/job_bundle/repository.py | 219 +++++++++++++----- .../client/ui/controllers/_async_task.py | 56 +++-- .../ui/dialogs/job_bundle_browser_dialog.py | 33 ++- .../job_bundle/test_repository.py | 169 +++++++++++++- .../ui/controllers/test_async_task.py | 74 ++++++ .../ui/gui/test_gui_browser_dialog.py | 67 +++++- 7 files changed, 543 insertions(+), 84 deletions(-) diff --git a/docs/design/job-bundle-browser.md b/docs/design/job-bundle-browser.md index 2dfa6a96c..367769391 100644 --- a/docs/design/job-bundle-browser.md +++ b/docs/design/job-bundle-browser.md @@ -585,7 +585,7 @@ Retries are transparent to the user — the operation either succeeds silently o Operations that involve network I/O show progress to the user: -- **Browser dialog**: When selecting an S3 bundle and clicking "Select", a progress dialog shows download progress (bytes transferred / total). The dialog uses `QProgressDialog` with the S3 `download_fileobj` callback delivering updates via Qt signals from the background transfer threads. +- **Browser dialog**: Selecting an S3 bundle and clicking "Select" opens a cancellable progress dialog (`QDialog` + `QProgressBar`) showing download progress via the S3 `download_fileobj` callback delivering updates over Qt signals from the background transfer thread. The progress bar is scaled to KiB and the size signal is typed as `qlonglong` to avoid 32-bit int overflow for large (multi-GiB) bundles, and the label is formatted with `human_readable_file_size` (e.g. "1.2 GB / 2.1 GB"). - **Save to Queue (GUI)**: Archiving and upload run on a background `QThread`. A `QProgressDialog` shows two phases: 1. "Archiving bundle... X MB / Y MB" — progress updates per file (small files use fast `zf.write()`, files >8MB use chunked 4MB writes for smoother progress). 2. "Uploading bundle... X MB / Y MB" — byte-level progress via `upload_fileobj` callback. @@ -609,6 +609,13 @@ Archives are validated before extraction to prevent path traversal attacks: - All entry paths are checked for absolute paths and `../` traversal using `os.path.commonpath()` with `os.path.realpath()` — this handles mixed path separators on Windows. The entire archive is rejected if any entry would extract outside the target directory. +Resource-exhaustion / zip-bomb protection is enforced before extraction. Checks read the zip central directory (`infolist()`), so nothing is decompressed to validate; `zipfile` also caps actual output at each entry's declared `file_size`, making these central-directory values a sound upper bound. The archive is rejected if: + +- It has more than `MAX_ARCHIVE_ENTRIES` (100,000) entries. +- Its declared uncompressed size exceeds `max(MAX_ARCHIVE_UNCOMPRESSED_FLOOR, compressed × MAX_ARCHIVE_COMPRESSION_RATIO)` — i.e. `max(256 MB, compressed × 200)`. A generous absolute floor is always permitted; above it the expansion ratio must be plausible for real data. There is no absolute size ceiling, so legitimately large (low-ratio) bundles still extract. +- The extracted payload wouldn't fit in the destination filesystem's free space. +- The template entry is larger than `MAX_TEMPLATE_BYTES` (16 MB), refused when reading the template (a template, even with embedded scripts, is small). + Symlink protection during upload: - `os.walk(followlinks=False)` is used when archiving bundles. Symlinked files and directories are skipped to prevent unintended inclusion of files outside the bundle directory. diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 84980acd6..60f1b9d22 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -15,6 +15,7 @@ import re import shutil import sys +import tempfile import zipfile from dataclasses import dataclass, field from email.header import decode_header @@ -76,6 +77,23 @@ PREVIEW_MAX_PARAMS = 200 PREVIEW_MAX_STEPS = 500 +# Archive extraction safety limits (always enforced, independent of any UI +# warning) to defend against maliciously constructed .ojd archives: +# - too many entries (resource exhaustion), +# - decompression bombs (tiny compressed size expanding to a huge payload). +# Uncompressed sizes come from the zip central directory via infolist(), which +# does not decompress anything; zipfile also caps actual extraction output at the +# declared file_size, so this is a sound upper bound. A generous absolute floor is +# always allowed; above it, the expansion ratio must be plausible for real data +# (a bomb has a tiny compressed size relative to its uncompressed size). There is +# no absolute size ceiling here — the physical limit is free disk space, checked +# separately — so legitimately large (low-ratio) bundles still extract. +MAX_ARCHIVE_ENTRIES = 100_000 +MAX_ARCHIVE_COMPRESSION_RATIO = 200 +MAX_ARCHIVE_UNCOMPRESSED_FLOOR = 256 * 1024 * 1024 # 256 MB always permitted +# A template (even with embedded scripts) is small; refuse to read a giant one. +MAX_TEMPLATE_BYTES = 16 * 1024 * 1024 # 16 MB + # POSIX only forbids / and null; Windows also forbids \ : * ? " < > | # Control characters (0x00-0x1F, 0x7F) are problematic on all platforms _WINDOWS_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]+') @@ -137,6 +155,8 @@ def _safe_zip_extract( if common != dest: raise ValueError(f"Archive entry would extract outside target directory: {member}") + _check_archive_extraction_safety(zf, dest) + if progress_callback: if size_callback: size_callback(sum(info.file_size for info in zf.infolist())) @@ -147,6 +167,46 @@ def _safe_zip_extract( zf.extractall(dest_dir) +def _check_archive_extraction_safety(zf: zipfile.ZipFile, dest: str) -> None: + """Reject archives that would exhaust resources on extraction. + + Uses the zip central directory (infolist) — no decompression — to check the + entry count and the declared uncompressed size against a bomb-detection ratio, + then verifies the payload fits on the destination filesystem. Raises ValueError + if the archive is unsafe to extract. + """ + infos = zf.infolist() + if len(infos) > MAX_ARCHIVE_ENTRIES: + raise ValueError( + f"Archive has too many entries ({len(infos)} > {MAX_ARCHIVE_ENTRIES}); " + "refusing to extract" + ) + + total_uncompressed = sum(i.file_size for i in infos) + total_compressed = sum(i.compress_size for i in infos) + max_uncompressed = max( + MAX_ARCHIVE_UNCOMPRESSED_FLOOR, + total_compressed * MAX_ARCHIVE_COMPRESSION_RATIO, + ) + if total_uncompressed > max_uncompressed: + raise ValueError( + f"Archive expands to {total_uncompressed} bytes from {total_compressed} " + f"compressed, exceeding the safe limit of {max_uncompressed} bytes " + "(possible zip bomb); refusing to extract" + ) + + # Refuse if the extracted payload wouldn't fit on the target filesystem. + try: + free = shutil.disk_usage(dest if os.path.exists(dest) else os.path.dirname(dest)).free + except OSError: + free = None + if free is not None and total_uncompressed > free: + raise ValueError( + f"Not enough free disk space to extract archive: needs {total_uncompressed} " + f"bytes, {free} available" + ) + + def _extract_archive(archive_path: str, dest_dir: str) -> None: """Extract an .ojd archive to dest_dir.""" with zipfile.ZipFile(archive_path, "r") as zf: @@ -170,29 +230,61 @@ def _read_template_from_zip(zf: zipfile.ZipFile) -> Optional[tuple[str, str]]: matches = [n for n in names if n == fname or n.endswith("/" + fname)] matches.sort(key=lambda n: n.count("/")) if matches: + # Refuse to read an implausibly large template (bomb defense). The + # declared size bounds the actual read: zipfile caps output at + # file_size, so this check on the central-directory value is sound. + info = zf.getinfo(matches[0]) + if info.file_size > MAX_TEMPLATE_BYTES: + raise ValueError( + f"Template '{matches[0]}' is too large to read " + f"({info.file_size} > {MAX_TEMPLATE_BYTES} bytes)" + ) return zf.read(matches[0]).decode("utf-8"), fname return None -def _read_template_from_bytes(data: bytes) -> Optional[tuple[str, str]]: - """Read a template from .ojd archive bytes in memory. Returns (contents, template_filename) or None.""" - try: - with zipfile.ZipFile(io.BytesIO(data), "r") as zf: - return _read_template_from_zip(zf) - except Exception: - return None - - -def _extract_archive_from_bytes( - data: bytes, dest_dir: str, progress_callback=None, size_callback=None +def _extract_archive_from_fileobj( + fileobj, dest_dir: str, progress_callback=None, size_callback=None ) -> None: - """Extract an .ojd archive from bytes in memory to dest_dir.""" - with zipfile.ZipFile(io.BytesIO(data), "r") as zf: + """Extract an .ojd archive from a seekable binary file object to dest_dir. + + Operating on a file object (rather than a bytes blob) lets callers stream a + large download into a temp file and extract from it without ever holding the + whole compressed archive in memory. + """ + fileobj.seek(0) + with zipfile.ZipFile(fileobj, "r") as zf: _safe_zip_extract( zf, dest_dir, progress_callback=progress_callback, size_callback=size_callback ) +# Downloads at or below this size are buffered in memory; larger archives spill +# to a temp file so we never hold a multi-GB archive (in several copies) in RAM. +_DOWNLOAD_SPOOL_THRESHOLD = 64 * 1024 * 1024 # 64 MB + + +def _open_download_sink(size_hint: int): + """Return a seekable binary sink for a download of approximately size_hint bytes. + + Small downloads stay in memory (fast); larger ones spill to a temporary file + created inside the bundle cache directory. Keeping the temp file in the cache + dir — rather than the shared system temp dir — co-locates the spill with the + extraction target's filesystem and keeps it out of world-writable /tmp. The + temp file is unlinked on close (immediately, on POSIX), so no other process + can reference or tamper with it. + + ``SpooledTemporaryFile`` is intentionally avoided: before Python 3.11 it does + not implement ``seekable()``, which ``zipfile`` requires. ``io.BytesIO`` and + ``tempfile.TemporaryFile`` are fully seekable on all supported versions. + """ + if size_hint and size_hint > _DOWNLOAD_SPOOL_THRESHOLD: + cache_root = get_bundle_cache_dir() + os.makedirs(cache_root, exist_ok=True) + return tempfile.TemporaryFile(dir=cache_root) + return io.BytesIO() + + _LARGE_FILE_THRESHOLD = 8 * 1024 * 1024 # 8MB _CHUNK_SIZE = 4 * 1024 * 1024 # 4MB @@ -1037,34 +1129,45 @@ def _get_archive_bundle_info(self, path: str) -> Optional[BundleInfo]: if info: return info - # Cache miss or stale — download, cache, and parse + # Cache miss or stale — download, cache, and parse. Stream into a + # memory/temp-file sink so a large archive is never held whole in RAM. + buf = _open_download_sink(head.get("ContentLength", 0) if head else 0) try: - resp = self._s3.get_object(Bucket=self._bucket, Key=key) - data = resp["Body"].read() - etag = resp.get("ETag", "") - last_modified = str(resp.get("LastModified", "")) - except Exception: - logger.debug("Failed to download S3 archive %s", key, exc_info=True) - return None + try: + resp = self._s3.get_object(Bucket=self._bucket, Key=key) + shutil.copyfileobj(resp["Body"], buf) + etag = resp.get("ETag", "") + last_modified = str(resp.get("LastModified", "")) + except Exception: + logger.debug("Failed to download S3 archive %s", key, exc_info=True) + return None - # Extract to cache so resolve_bundle can reuse it - if os.path.exists(cache_dir): - shutil.rmtree(cache_dir) - os.makedirs(cache_dir, exist_ok=True) - try: - _extract_archive_from_bytes(data, cache_dir) - _write_cache_meta(cache_dir, etag, last_modified) - except Exception: - logger.debug("Failed to cache S3 archive %s", key, exc_info=True) + # Extract to cache so resolve_bundle can reuse it + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + os.makedirs(cache_dir, exist_ok=True) + try: + _extract_archive_from_fileobj(buf, cache_dir) + _write_cache_meta(cache_dir, etag, last_modified) + return self._read_info_from_cache(cache_dir, path) + except Exception: + logger.debug("Failed to cache S3 archive %s", key, exc_info=True) - # Parse template from the downloaded bytes - result = _read_template_from_bytes(data) - if result: - raw, fname = result - template = _parse_template(raw, fname) - if template: - return extract_bundle_info(template, path) - return None + # Caching failed — still try to read the template for a preview. + try: + buf.seek(0) + with zipfile.ZipFile(buf, "r") as zf: + result = _read_template_from_zip(zf) + except Exception: + return None + if result: + raw, fname = result + template = _parse_template(raw, fname) + if template: + return extract_bundle_info(template, path) + return None + finally: + buf.close() def _resolve_archive_bundle( self, path: str, progress_callback=None, extract_callback=None, extract_size_callback=None @@ -1096,22 +1199,30 @@ def _resolve_archive_bundle( etag = head.get("ETag", "") if head else "" last_modified = str(head.get("LastModified", "")) if head else "" - buf = io.BytesIO() - download_kwargs: dict = {"Bucket": self._bucket, "Key": key} - if progress_callback: - download_kwargs["Callback"] = progress_callback - self._s3.download_fileobj(Fileobj=buf, **download_kwargs) - data = buf.getvalue() - - # Clear old cache and extract - if os.path.exists(cache_dir): - shutil.rmtree(cache_dir) - os.makedirs(cache_dir, exist_ok=True) - - _extract_archive_from_bytes( - data, cache_dir, progress_callback=extract_callback, size_callback=extract_size_callback - ) - _write_cache_meta(cache_dir, etag, last_modified) + # Stream small archives through memory and large ones through a temp file + # in the cache dir, so a large bundle is never held whole (in several + # copies) in RAM. + buf = _open_download_sink(head.get("ContentLength", 0) if head else 0) + try: + download_kwargs: dict = {"Bucket": self._bucket, "Key": key} + if progress_callback: + download_kwargs["Callback"] = progress_callback + self._s3.download_fileobj(Fileobj=buf, **download_kwargs) + + # Clear old cache and extract + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + os.makedirs(cache_dir, exist_ok=True) + + _extract_archive_from_fileobj( + buf, + cache_dir, + progress_callback=extract_callback, + size_callback=extract_size_callback, + ) + _write_cache_meta(cache_dir, etag, last_modified) + finally: + buf.close() bundle_path = self._find_bundle_in_cache(cache_dir) if bundle_path: diff --git a/src/deadline/client/ui/controllers/_async_task.py b/src/deadline/client/ui/controllers/_async_task.py index 4c21c074f..f61602530 100644 --- a/src/deadline/client/ui/controllers/_async_task.py +++ b/src/deadline/client/ui/controllers/_async_task.py @@ -7,11 +7,15 @@ with proper Qt signal integration and automatic cancellation handling. """ +from logging import getLogger from typing import Any, Callable, Iterator, Optional from qtpy.QtCore import QObject, QRunnable, Signal +logger = getLogger(__name__) + + class WorkerSignals(QObject): """ Signals for QRunnable workers. @@ -104,6 +108,28 @@ def is_canceled(self) -> bool: """Check if this task has been canceled.""" return self._is_canceled + def _safe_emit(self, signal_name: str, *args: Any) -> None: + """ + Emit ``self.signals.`` unless the task was canceled or the + signal source has already been deleted. + + ``run()`` executes in a background thread and may still be in-flight when + the owning runner (or the widget it is parented to) is destroyed. When that + happens the underlying ``WorkerSignals`` C++ object is deleted out from under + us, and touching/emitting it raises ``RuntimeError("Signal source has been + deleted")``. A deleted source has no live listeners, so there is nothing to + deliver - we log at debug level and move on instead of letting the exception + cascade through the result/error/finished emissions. + """ + if self._is_canceled: + return + try: + getattr(self.signals, signal_name).emit(*args) + except RuntimeError: + # WorkerSignals was deleted while the task was still running; the + # results are no longer needed by anyone. + logger.debug("Skipping '%s' emit; signal source has been deleted", signal_name) + def run(self) -> None: """ Execute the task in the thread pool. @@ -116,20 +142,21 @@ def run(self) -> None: 2. Execute the function 3. Check if canceled before emitting result/error 4. Emit finished signal (if not canceled) + + Emissions are routed through :meth:`_safe_emit`, which additionally + tolerates the signal source being deleted mid-flight (e.g. when the + owning runner/widget is torn down before a slow task returns). """ if self._is_canceled: return try: result = self.fn(*self.args, **self.kwargs) - if not self._is_canceled: - self.signals.result.emit(result) + self._safe_emit("result", result) except Exception as e: - if not self._is_canceled: - self.signals.error.emit(e) + self._safe_emit("error", e) finally: - if not self._is_canceled: - self.signals.finished.emit() + self._safe_emit("finished") class StreamingAsyncTask(AsyncTask): @@ -159,7 +186,9 @@ def run(self) -> None: Runs in a background thread. Emits ``progress`` per yielded item, a single terminal ``result`` once exhausted, ``error`` if the generator raises, and - ``finished`` at the end. All emissions are guarded by cancellation checks. + ``finished`` at the end. All emissions are guarded by cancellation checks + and tolerate the signal source being deleted mid-flight (see + :meth:`AsyncTask._safe_emit`). """ if self._is_canceled: return @@ -169,13 +198,10 @@ def run(self) -> None: for item in iterator: if self._is_canceled: return - self.signals.progress.emit(item) - if not self._is_canceled: - # Terminal result (no aggregate payload; progress already delivered each item). - self.signals.result.emit(None) + self._safe_emit("progress", item) + # Terminal result (no aggregate payload; progress already delivered each item). + self._safe_emit("result", None) except Exception as e: - if not self._is_canceled: - self.signals.error.emit(e) + self._safe_emit("error", e) finally: - if not self._is_canceled: - self.signals.finished.emit() + self._safe_emit("finished") diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index dd87a3f85..e46b045b6 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -12,6 +12,8 @@ from logging import getLogger from typing import Optional +from deadline.job_attachments.api import human_readable_file_size + from qtpy.QtCore import Qt, QModelIndex, QSize, QSortFilterProxyModel, QThread, QTimer, Signal # type: ignore from qtpy.QtGui import QColor, QPalette, QStandardItemModel, QStandardItem # type: ignore from qtpy.QtWidgets import ( # type: ignore @@ -262,8 +264,12 @@ def resolve_selection(self) -> Optional[str]: class _DownloadWorker(QThread): progress = Signal(int) # Emitted once the archive size is known (from a head_object made - # on this worker thread — see below). - size_ready = Signal(int) + # on this worker thread — see below). Declared as ``qlonglong`` + # (64-bit) because archive sizes routinely exceed INT_MAX (2 GiB); + # a plain ``Signal(int)`` maps to a 32-bit C++ int, so a >2 GiB + # size overflows (shiboken raises OverflowError / clamps the value, + # which is why the reported total came out as a garbage "1 KB"). + size_ready = Signal("qlonglong") # type: ignore[arg-type] # NOTE: named ``done`` rather than ``finished`` to avoid shadowing # QThread's built-in ``finished`` signal. done = Signal(str) @@ -331,9 +337,17 @@ def _cb(n): worker = _DownloadWorker(self._s3_repo, self._selected_path) download_result = [None] download_error = [] + # The true archive size in bytes. Tracked on the Python side rather + # than read back from the progress bar's maximum, because the bar + # works in KiB (a 32-bit int, so multi-GiB byte counts would overflow + # it) and reconstructing bytes from it loses precision. + total_bytes = [0] def _on_size(total): + total_bytes[0] = total try: + # Progress bar tracks KiB to stay within QProgressBar's + # 32-bit int range for large (multi-GiB) archives. _progress_bar.setRange(0, max(1, total // 1024)) except RuntimeError: return @@ -343,18 +357,13 @@ def _on_progress(n): _progress_bar.setValue(n) except RuntimeError: return - total = _progress_bar.maximum() * 1024 + total = total_bytes[0] current = n * 1024 if total > 0: - if total >= 1024 * 1024 * 1024: - label = f"Downloading bundle... {current / (1024**3):.1f} / {total / (1024**3):.1f} GB" - elif total >= 1024 * 1024: - label = f"Downloading bundle... {current / (1024**2):.1f} / {total / (1024**2):.1f} MB" - else: - label = ( - f"Downloading bundle... {current / 1024:.0f} / {total / 1024:.0f} KB" - ) - _progress_label.setText(label) + _progress_label.setText( + f"Downloading bundle... {human_readable_file_size(current)}" + f" / {human_readable_file_size(total)}" + ) def _on_finished(path): download_result[0] = path diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index a9d5da5ce..4c44a93ba 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -21,6 +21,7 @@ from deadline.client.exceptions import DeadlineOperationError from deadline.client.job_bundle.repository import ( LocalBundleRepository, + MAX_ARCHIVE_ENTRIES, METADATA_LIMIT_NAME, PREVIEW_MAX_DESC_LEN, PREVIEW_MAX_NAME_LEN, @@ -31,9 +32,12 @@ S3_METADATA_TOTAL_BUDGET, VISIBILITY_MAX_RETRIES, _bundle_info_from_s3_metadata, + _check_archive_extraction_safety, _decode_s3_value, + _DOWNLOAD_SPOOL_THRESHOLD, _encode_s3_value, _is_archive, + _open_download_sink, _parse_template, _safe_zip_extract, _strip_archive_ext, @@ -1117,6 +1121,139 @@ def test_calls_progress_callbacks(self, fresh_deadline_config): assert size_reports[0] > 0 +class TestArchiveExtractionSafety: + """_check_archive_extraction_safety guards against zip bombs, excessive entry + counts, and extractions that wouldn't fit on disk — always enforced.""" + + def _fake_zip(self, entries): + """entries: list of (file_size, compress_size) tuples.""" + zf = MagicMock() + infos = [] + for file_size, compress_size in entries: + zi = MagicMock() + zi.file_size = file_size + zi.compress_size = compress_size + infos.append(zi) + zf.infolist.return_value = infos + return zf + + def _ample_disk(self): + return patch( + "deadline.client.job_bundle.repository.shutil.disk_usage", + return_value=MagicMock(free=100 * 1024**3), + ) + + def test_rejects_zip_bomb_high_ratio(self, tmp_path): + # 1 GB uncompressed from 1 KB compressed — ratio far above the limit. + zf = self._fake_zip([(1024 * 1024 * 1024, 1024)]) + with pytest.raises(ValueError, match="zip bomb"): + _check_archive_extraction_safety(zf, str(tmp_path)) + + def test_rejects_too_many_entries(self, tmp_path): + zf = self._fake_zip([(10, 10)] * (MAX_ARCHIVE_ENTRIES + 1)) + with pytest.raises(ValueError, match="too many entries"): + _check_archive_extraction_safety(zf, str(tmp_path)) + + def test_allows_small_high_ratio_within_floor(self, tmp_path): + # 100 MB (< 256 MB floor) from tiny compressed — allowed despite high ratio. + zf = self._fake_zip([(100 * 1024 * 1024, 100)]) + with self._ample_disk(): + _check_archive_extraction_safety(zf, str(tmp_path)) # must not raise + + def test_allows_large_low_ratio_bundle(self, tmp_path): + # 1 GB uncompressed from 900 MB compressed (real data) — passes the ratio. + zf = self._fake_zip([(1024**3, 900 * 1024 * 1024)]) + with self._ample_disk(): + _check_archive_extraction_safety(zf, str(tmp_path)) # must not raise + + def test_blocks_when_insufficient_disk(self, tmp_path): + # Low ratio (passes bomb check) but larger than the free space available. + zf = self._fake_zip([(100 * 1024 * 1024, 60 * 1024 * 1024)]) + with patch( + "deadline.client.job_bundle.repository.shutil.disk_usage", + return_value=MagicMock(free=1024), # only 1 KB free + ): + with pytest.raises(ValueError, match="disk space"): + _check_archive_extraction_safety(zf, str(tmp_path)) + + def test_safe_zip_extract_rejects_bomb_end_to_end(self, tmp_path): + # A real, highly compressible archive; lower the floor so it trips the + # ratio check, proving _safe_zip_extract enforces the guard on real zips. + archive = tmp_path / "bomb.ojd" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("payload.bin", b"\x00" * (2 * 1024 * 1024)) # 2 MB of zeros + dest = tmp_path / "out" + dest.mkdir() + with patch("deadline.client.job_bundle.repository.MAX_ARCHIVE_UNCOMPRESSED_FLOOR", 1024): + with zipfile.ZipFile(archive, "r") as zf: + with pytest.raises(ValueError, match="zip bomb"): + _safe_zip_extract(zf, str(dest)) + + +class TestTemplateReadCap: + """_read_template_from_zip refuses to read an implausibly large template.""" + + def test_rejects_oversized_template(self, tmp_path): + archive = tmp_path / "b.ojd" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template.yaml", "name: T\nsteps: []\n") + with patch("deadline.client.job_bundle.repository.MAX_TEMPLATE_BYTES", 5): + # read_template_from_archive swallows the error and returns None. + assert read_template_from_archive(str(archive)) is None + + def test_reads_normal_template(self, tmp_path): + archive = tmp_path / "b.ojd" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template.yaml", "name: T\nsteps: []\n") + result = read_template_from_archive(str(archive)) + assert result is not None + assert result[1] == "template.yaml" + + +class TestDownloadSink: + """_open_download_sink chooses memory vs. a temp file based on size, and spills + large downloads to disk in the cache dir (not shared /tmp).""" + + def test_small_download_stays_in_memory(self, tmp_path): + with patch( + "deadline.client.job_bundle.repository.get_bundle_cache_dir", + return_value=str(tmp_path), + ): + sink = _open_download_sink(1024) + try: + assert isinstance(sink, io.BytesIO) + finally: + sink.close() + + def test_large_download_spills_to_temp_file_in_cache_dir(self, tmp_path): + cache = tmp_path / "cache" + with patch( + "deadline.client.job_bundle.repository.get_bundle_cache_dir", + return_value=str(cache), + ): + sink = _open_download_sink(_DOWNLOAD_SPOOL_THRESHOLD + 1) + try: + # A real on-disk temp file (not an in-memory buffer), created under the + # cache dir so it shares the extraction filesystem and stays off /tmp. + assert not isinstance(sink, io.BytesIO) + assert sink.fileno() >= 0 # backed by a real file descriptor + assert cache.is_dir() # cache dir was created for the spill + finally: + sink.close() + + def test_zero_size_hint_stays_in_memory(self, tmp_path): + # Unknown size (no ContentLength) must not force a temp file. + with patch( + "deadline.client.job_bundle.repository.get_bundle_cache_dir", + return_value=str(tmp_path), + ): + sink = _open_download_sink(0) + try: + assert isinstance(sink, io.BytesIO) + finally: + sink.close() + + class TestS3GetBundleInfo: """Tests for S3BundleRepository.get_bundle_info — validates metadata preview path.""" @@ -1161,7 +1298,11 @@ def test_falls_back_to_download_when_no_metadata(self, fresh_deadline_config): zf.writestr("template.yaml", "name: Downloaded\nsteps:\n- name: S1\n") repo._s3.get_object.return_value = { - "Body": MagicMock(read=MagicMock(return_value=buf.getvalue())), + # A BytesIO is a faithful stand-in for a botocore StreamingBody: + # read(n) returns chunks and b"" at EOF, so streaming (copyfileobj) + # terminates. A MagicMock with a fixed read() return value would loop + # forever. + "Body": io.BytesIO(buf.getvalue()), "ETag": '"xyz"', "LastModified": "2026-01-01", } @@ -1172,6 +1313,32 @@ def test_falls_back_to_download_when_no_metadata(self, fresh_deadline_config): assert info.name == "Downloaded" assert info.step_names == ["S1"] + def test_large_archive_download_streams_via_temp_file(self, fresh_deadline_config): + """A large ContentLength routes the download through a temp-file sink and + still extracts/parses correctly (exercises the spill-to-disk branch).""" + repo = self._make_repo() + repo._s3.head_object.return_value = { + "ETag": '"xyz"', + "Metadata": {}, + "ContentLength": _DOWNLOAD_SPOOL_THRESHOLD + 1, + } + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("template.yaml", "name: BigBundle\nsteps:\n- name: S1\n") + + repo._s3.get_object.return_value = { + "Body": io.BytesIO(buf.getvalue()), + "ETag": '"xyz"', + "LastModified": "2026-01-01", + } + + info = repo.get_bundle_info("s3://test-bucket/DC/job-bundles/big.ojd") + + assert info is not None + assert info.name == "BigBundle" + assert info.step_names == ["S1"] + class TestBuildBundleMetadata: """Tests for build_bundle_metadata — validates S3 metadata extraction from bundle dirs.""" diff --git a/test/unit/deadline_client/ui/controllers/test_async_task.py b/test/unit/deadline_client/ui/controllers/test_async_task.py index 6f7ad434c..9af72016f 100644 --- a/test/unit/deadline_client/ui/controllers/test_async_task.py +++ b/test/unit/deadline_client/ui/controllers/test_async_task.py @@ -214,6 +214,60 @@ def test_auto_delete_is_enabled(self): assert task.autoDelete() is True + def test_run_swallows_deleted_signal_source_on_result(self, qtbot): + """ + If the signals object is deleted while the task is running, emitting the + result must not raise - it should be swallowed (no listeners remain). + + This reproduces the "RuntimeError: Signal source has been deleted" + cascade that occurs when the owning runner/widget is torn down before a + slow task returns. + """ + fn = Mock(return_value="result") + task = AsyncTask(fn) + + # Simulate the WorkerSignals C++ object having been deleted: any access to + # a signal raises RuntimeError, mirroring PySide behavior. + deleted_signals = Mock() + deleted_signals.result.emit.side_effect = RuntimeError("Signal source has been deleted") + deleted_signals.error.emit.side_effect = RuntimeError("Signal source has been deleted") + deleted_signals.finished.emit.side_effect = RuntimeError("Signal source has been deleted") + task.signals = deleted_signals + + # Must not raise despite every emit failing. + task.run() + + deleted_signals.result.emit.assert_called_once_with("result") + # finished is always attempted in the finally block. + deleted_signals.finished.emit.assert_called_once_with() + + def test_run_swallows_deleted_signal_source_on_error(self, qtbot): + """A deleted signal source during error emission must not raise.""" + fn = Mock(side_effect=ValueError("boom")) + task = AsyncTask(fn) + + deleted_signals = Mock() + deleted_signals.error.emit.side_effect = RuntimeError("Signal source has been deleted") + deleted_signals.finished.emit.side_effect = RuntimeError("Signal source has been deleted") + task.signals = deleted_signals + + # Must not raise despite the emits failing. + task.run() + + deleted_signals.error.emit.assert_called_once() + deleted_signals.finished.emit.assert_called_once_with() + + def test_safe_emit_does_nothing_when_canceled(self): + """_safe_emit is a no-op for canceled tasks and never touches the signal.""" + task = AsyncTask(Mock()) + signals = Mock() + task.signals = signals + + task.cancel() + task._safe_emit("result", "value") + + signals.result.emit.assert_not_called() + class TestStreamingAsyncTask: """Tests for StreamingAsyncTask, the progressive-result variant of AsyncTask.""" @@ -321,3 +375,23 @@ def gen(): assert progress == ["a"] assert errors == [test_error] + + def test_run_swallows_deleted_signal_source(self, qtbot): + """ + If the signals object is deleted while the streaming task is running, + emitting progress/result/finished must not raise. + """ + task = StreamingAsyncTask(lambda: iter(["a", "b"])) + + deleted_signals = Mock() + deleted_signals.progress.emit.side_effect = RuntimeError("Signal source has been deleted") + deleted_signals.result.emit.side_effect = RuntimeError("Signal source has been deleted") + deleted_signals.finished.emit.side_effect = RuntimeError("Signal source has been deleted") + task.signals = deleted_signals + + # Must not raise despite every emit failing. + task.run() + + assert deleted_signals.progress.emit.call_count == 2 + deleted_signals.result.emit.assert_called_once_with(None) + deleted_signals.finished.emit.assert_called_once_with() diff --git a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py index b38939dbb..ff3dd8458 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py +++ b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py @@ -7,7 +7,7 @@ from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QColor, QStandardItem -from qtpy.QtWidgets import QApplication, QDialog +from qtpy.QtWidgets import QApplication, QDialog, QLabel, QProgressBar from deadline.client.ui.dialogs.job_bundle_browser_dialog import ( JobBundleBrowserDialog, @@ -95,3 +95,68 @@ def _cancel(): repo.clear_cache_for.assert_called_once_with("s3://bucket/prefix/bundle.ojd") # The transfer aborted before completing all chunks. assert 0 < observed["count"] < 1000 + + +class TestDownloadSizeReporting: + def test_large_bundle_size_reports_human_readable_total(self, qtbot, tmp_path): + """A >2 GiB archive must report its true total, not overflow to a bogus "1 KB". + + ``size_ready`` carries the raw byte count. A plain ``Signal(int)`` maps to + a 32-bit C++ int, so a size above INT_MAX (2,147,483,647) overflowed and + the dialog showed "1 KB" as the total. The signal is now ``qlonglong`` and + the label is formatted with ``human_readable_file_size``. + """ + repo = MagicMock() + # ~2 GiB, larger than INT_MAX — this is the value that previously overflowed. + big_size = 2148139290 + repo.get_bundle_size.return_value = big_size + + def _download(path, dest, progress_callback=None): + # Emit a handful of 100 MiB chunks so the dialog stays open long + # enough for the inspector timer to read the label/range. + for _ in range(30): + progress_callback(100 * 1024 * 1024) + time.sleep(0.01) + return "/tmp/full-download" + + repo.download_full_bundle.side_effect = _download + + dialog = JobBundleBrowserDialog(local_source=str(tmp_path)) + qtbot.addWidget(dialog) + dialog._s3_repo = repo + dialog._selected_is_s3 = True + dialog._selected_path = "s3://bucket/prefix/big.ojd" + + captured_max = [0] + captured_label = [""] + + def _inspect(): + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QDialog) and widget.windowTitle() == "Downloading Bundle": + bar = widget.findChild(QProgressBar) + label = widget.findChild(QLabel) + # Wait until both the size (range) and at least one progress + # update (value + label) have been processed. + if ( + bar is not None + and bar.maximum() > 1 + and bar.value() > 0 + and label is not None + and "GB" in label.text() + ): + captured_max[0] = bar.maximum() + captured_label[0] = label.text() + widget.reject() + return + QTimer.singleShot(10, _inspect) + + QTimer.singleShot(20, _inspect) + + dialog.resolve_selection() + + # The full 2 GiB size survived the signal round-trip without overflowing: + # the bar's maximum is the size in KiB (~2 million, safely within int32). + assert captured_max[0] == big_size // 1024 + # The label reports a human-readable GB total, never a bogus "1 KB". + assert "GB" in captured_label[0] + assert "1 KB" not in captured_label[0] From 5c68c1a71bf5dafdeca8e2f8485719fa93bfd029 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:05 -0500 Subject: [PATCH 76/89] fix: reject non-zip archive .ojd files Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/job_bundle/repository.py | 66 ++++++++++++++----- .../job_bundle/test_repository.py | 64 ++++++++++++++++++ 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index 60f1b9d22..b64b686ad 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -208,9 +208,19 @@ def _check_archive_extraction_safety(zf: zipfile.ZipFile, dest: str) -> None: def _extract_archive(archive_path: str, dest_dir: str) -> None: - """Extract an .ojd archive to dest_dir.""" - with zipfile.ZipFile(archive_path, "r") as zf: - _safe_zip_extract(zf, dest_dir) + """Extract an .ojd archive to dest_dir. + + Raises ValueError with a clear message if the file is not a valid zip archive + (an ``.ojd`` is a zip under the hood), so callers surface a friendly error + instead of a raw ``zipfile.BadZipFile`` for a corrupt/renamed/non-zip file. + """ + try: + with zipfile.ZipFile(archive_path, "r") as zf: + _safe_zip_extract(zf, dest_dir) + except zipfile.BadZipFile as e: + raise ValueError( + f"{os.path.basename(archive_path)!r} is not a valid .ojd archive (expected a zip file)" + ) from e def read_template_from_archive(archive_path: str) -> Optional[tuple[str, str]]: @@ -251,12 +261,21 @@ def _extract_archive_from_fileobj( Operating on a file object (rather than a bytes blob) lets callers stream a large download into a temp file and extract from it without ever holding the whole compressed archive in memory. + + Raises ValueError with a clear message if the object is not a valid zip + archive, so a corrupt or non-`.ojd` download surfaces a friendly error rather + than a raw ``zipfile.BadZipFile``. """ fileobj.seek(0) - with zipfile.ZipFile(fileobj, "r") as zf: - _safe_zip_extract( - zf, dest_dir, progress_callback=progress_callback, size_callback=size_callback - ) + try: + with zipfile.ZipFile(fileobj, "r") as zf: + _safe_zip_extract( + zf, dest_dir, progress_callback=progress_callback, size_callback=size_callback + ) + except zipfile.BadZipFile as e: + raise ValueError( + "Downloaded object is not a valid .ojd archive (expected a zip file)" + ) from e # Downloads at or below this size are buffered in memory; larger archives spill @@ -874,29 +893,46 @@ def _safe_int(value: Optional[str]) -> Optional[int]: def _bundle_info_from_s3_metadata(metadata: dict, path: str) -> Optional[BundleInfo]: """Try to construct BundleInfo from S3 user metadata set during upload. - Returns None if the required 'ojd-name' key is missing.""" + Returns None if the required 'ojd-name' key is missing. + + For bundles uploaded outside this tool the metadata is attacker-influenced, + so every value is capped to the same PREVIEW_MAX_* limits used for templates. + S3 already bounds total user metadata to ~2 KB, but we defend in depth and + stay consistent with the template preview path so a crafted object can't + bloat the preview. + """ name = metadata.get(METADATA_KEY_NAME) if not name: return None - name = _decode_s3_value(name) + name = _decode_s3_value(name)[:PREVIEW_MAX_NAME_LEN] params = [] params_str = _decode_s3_value(metadata.get(METADATA_KEY_PARAMS, "")) if params_str: - for p in params_str.split(","): + for p in params_str.split(",")[:PREVIEW_MAX_PARAMS]: parts = p.split(":", 1) if len(parts) == 2: - params.append({"name": parts[0], "type": parts[1], "_from_metadata": True}) + params.append( + { + "name": parts[0][:PREVIEW_MAX_PARAM_NAME_LEN], + "type": parts[1][:PREVIEW_MAX_PARAM_NAME_LEN], + "_from_metadata": True, + } + ) step_count_str = metadata.get(METADATA_KEY_STEP_COUNT) param_count_str = metadata.get(METADATA_KEY_PARAM_COUNT) + step_names = [ + s[:PREVIEW_MAX_NAME_LEN] + for s in _decode_s3_value(metadata.get(METADATA_KEY_STEPS, "")).split(",") + if s + ][:PREVIEW_MAX_STEPS] + return BundleInfo( path=path, name=name, - description=_decode_s3_value(metadata.get(METADATA_KEY_DESC, "")), - step_names=[ - s for s in _decode_s3_value(metadata.get(METADATA_KEY_STEPS, "")).split(",") if s - ], + description=_decode_s3_value(metadata.get(METADATA_KEY_DESC, ""))[:PREVIEW_MAX_DESC_LEN], + step_names=step_names, parameters=params, total_steps=_safe_int(step_count_str), total_parameters=_safe_int(param_count_str), diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 4c44a93ba..40c5feb67 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -36,6 +36,8 @@ _decode_s3_value, _DOWNLOAD_SPOOL_THRESHOLD, _encode_s3_value, + _extract_archive, + _extract_archive_from_fileobj, _is_archive, _open_download_sink, _parse_template, @@ -277,6 +279,33 @@ def test_name_only(self): assert info.step_names == [] assert info.parameters == [] + def test_oversized_metadata_values_are_capped(self): + """Attacker-influenced S3 metadata must be capped to the PREVIEW_MAX_* limits, + matching the template preview path, so a crafted object can't bloat the preview.""" + metadata = { + "ojd-name": "N" * (PREVIEW_MAX_NAME_LEN + 500), + "ojd-desc": "D" * (PREVIEW_MAX_DESC_LEN + 500), + "ojd-steps": ",".join(f"step{i}" for i in range(PREVIEW_MAX_STEPS + 50)), + "ojd-params": ",".join(f"p{i}:STRING" for i in range(PREVIEW_MAX_PARAMS + 50)), + } + info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key") + assert info is not None + assert len(info.name) == PREVIEW_MAX_NAME_LEN + assert len(info.description) == PREVIEW_MAX_DESC_LEN + assert len(info.step_names) == PREVIEW_MAX_STEPS + assert len(info.parameters) == PREVIEW_MAX_PARAMS + + def test_oversized_param_name_and_type_are_capped(self): + metadata = { + "ojd-name": "Bundle", + "ojd-params": f"{'x' * 5000}:{'y' * 5000}", + } + info = _bundle_info_from_s3_metadata(metadata, "s3://bucket/key") + assert info is not None + assert len(info.parameters) == 1 + assert len(info.parameters[0]["name"]) <= PREVIEW_MAX_NAME_LEN + assert len(info.parameters[0]["type"]) <= PREVIEW_MAX_NAME_LEN + class TestArchiveHelpers: def test_is_archive(self): @@ -334,6 +363,41 @@ def test_ojd_no_template(self, tmp_path): result = read_template_from_archive(path) assert result is None + def test_non_zip_ojd_returns_none(self, tmp_path): + """A .ojd that isn't actually a zip (corrupt/renamed/malicious) must be + tolerated on the read/preview path, not raise.""" + path = str(tmp_path / "bogus.ojd") + with open(path, "wb") as f: + f.write(b"this is definitely not a zip archive") + assert read_template_from_archive(path) is None + + def test_truncated_zip_ojd_returns_none(self, tmp_path): + """A file that starts with the zip magic but is truncated/garbage must also + be handled gracefully.""" + path = str(tmp_path / "truncated.ojd") + with open(path, "wb") as f: + f.write(b"PK\x03\x04" + b"\x00" * 8) # zip local-file magic, then garbage + assert read_template_from_archive(path) is None + + +class TestExtractNonZipArchive: + """Extraction must convert a raw zipfile.BadZipFile into a clear ValueError so + a corrupt/renamed/non-zip .ojd surfaces a friendly, catchable error.""" + + def test_extract_archive_rejects_non_zip(self, tmp_path): + path = str(tmp_path / "bogus.ojd") + with open(path, "wb") as f: + f.write(b"not a zip file at all") + dest = str(tmp_path / "out") + with pytest.raises(ValueError, match="not a valid .ojd archive"): + _extract_archive(path, dest) + + def test_extract_archive_from_fileobj_rejects_non_zip(self, tmp_path): + buf = io.BytesIO(b"still not a zip") + dest = str(tmp_path / "out") + with pytest.raises(ValueError, match="not a valid .ojd archive"): + _extract_archive_from_fileobj(buf, dest) + class TestLocalBundleRepository: def test_root_path_default(self): From 5e9248f82114c6d78dcb1c53043f624216625037 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:30:40 -0500 Subject: [PATCH 77/89] fix: .ojd are uploaded with 'application/zip' ContentType Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- src/deadline/client/cli/_groups/bundle_group.py | 11 +++++++++-- src/deadline/client/job_bundle/repository.py | 9 ++++++--- .../deadline_client/cli/test_cli_bundle_repository.py | 2 ++ .../deadline_client/job_bundle/test_repository.py | 6 ++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 75da6920b..b593b0c58 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -865,6 +865,13 @@ def bundle_upload(job_bundle_dir, name, **args): raise # Archive and upload + # Advertise the archive's true type as a courtesy hint for other consumers. + # The download/browse paths do NOT trust ContentType (it's set by the + # uploader) and validate by parsing the zip; this just keeps the object + # correctly typed. Kept consistent with S3BundleRepository.upload_archive. + extra_args: dict = {"ContentType": "application/zip"} + if bundle_metadata: + extra_args["Metadata"] = bundle_metadata if is_archive_input: # Already an .ojd — upload directly file_size = os.path.getsize(job_bundle_dir) @@ -876,7 +883,7 @@ def bundle_upload(job_bundle_dir, name, **args): f, s3_settings.s3BucketName, s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ExtraArgs=extra_args, Callback=lambda bytes_sent: bar.update(bytes_sent), ) else: @@ -890,7 +897,7 @@ def bundle_upload(job_bundle_dir, name, **args): buf, s3_settings.s3BucketName, s3_key, - ExtraArgs={"Metadata": bundle_metadata} if bundle_metadata else None, + ExtraArgs=extra_args, Callback=lambda bytes_sent: bar.update(bytes_sent), ) click.echo(f"Uploaded bundle to s3://{s3_settings.s3BucketName}/{s3_key}") diff --git a/src/deadline/client/job_bundle/repository.py b/src/deadline/client/job_bundle/repository.py index b64b686ad..46269c191 100644 --- a/src/deadline/client/job_bundle/repository.py +++ b/src/deadline/client/job_bundle/repository.py @@ -1110,15 +1110,18 @@ def upload_archive( Returns the S3 URI of the uploaded bundle. """ key = f"{self._prefix}{bundle_name}.ojd" - extra_args: dict = {} + # Advertise the archive's true type. This is a courtesy hint for other + # consumers/tools; the download path deliberately does NOT trust it (an + # object's ContentType is set by whoever uploaded it), and validates by + # actually parsing the zip. See _extract_archive_from_fileobj. + extra_args: dict = {"ContentType": "application/zip"} if metadata: extra_args["Metadata"] = metadata kwargs: dict = { "Bucket": self._bucket, "Key": key, + "ExtraArgs": extra_args, } - if extra_args: - kwargs["ExtraArgs"] = extra_args if progress_callback: kwargs["Callback"] = progress_callback self._s3.upload_fileobj(buf, **kwargs) diff --git a/test/unit/deadline_client/cli/test_cli_bundle_repository.py b/test/unit/deadline_client/cli/test_cli_bundle_repository.py index f32539312..ace8ee721 100644 --- a/test/unit/deadline_client/cli/test_cli_bundle_repository.py +++ b/test/unit/deadline_client/cli/test_cli_bundle_repository.py @@ -288,6 +288,8 @@ def test_upload_truncates_metadata_with_warning( # Verify metadata values respect limits call_args = mock_s3.upload_fileobj.call_args metadata = call_args[1]["ExtraArgs"]["Metadata"] + # The object is typed as a zip (courtesy hint; not trusted on download). + assert call_args[1]["ExtraArgs"]["ContentType"] == "application/zip" assert len(metadata["ojd-name"].encode("utf-8")) <= METADATA_LIMIT_NAME # Total metadata must stay within S3's 2KB budget total = sum( diff --git a/test/unit/deadline_client/job_bundle/test_repository.py b/test/unit/deadline_client/job_bundle/test_repository.py index 40c5feb67..9da6fc04c 100644 --- a/test/unit/deadline_client/job_bundle/test_repository.py +++ b/test/unit/deadline_client/job_bundle/test_repository.py @@ -1655,6 +1655,8 @@ def test_uploads_with_metadata(self): repo._s3.upload_fileobj.assert_called_once() call_kwargs = repo._s3.upload_fileobj.call_args[1] assert call_kwargs["ExtraArgs"]["Metadata"]["ojd-name"] == "Test" + # ContentType is advertised as a courtesy hint (not trusted on download). + assert call_kwargs["ExtraArgs"]["ContentType"] == "application/zip" def test_uploads_without_metadata(self): repo = self._make_repo() @@ -1663,6 +1665,10 @@ def test_uploads_without_metadata(self): repo.upload_archive(buf, "simple") repo._s3.upload_fileobj.assert_called_once() + # ContentType is set even when no user metadata is provided. + call_kwargs = repo._s3.upload_fileobj.call_args[1] + assert call_kwargs["ExtraArgs"]["ContentType"] == "application/zip" + assert "Metadata" not in call_kwargs["ExtraArgs"] def test_calls_progress_callback(self): repo = self._make_repo() From e5ca94876ec80f2bb7d5c50320b2fb570a269930 Mon Sep 17 00:00:00 2001 From: Morgan Epp <60796713+epmog@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:55:12 -0500 Subject: [PATCH 78/89] fix: all preview is Qt.PlainText Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com> --- .../ui/dialogs/job_bundle_browser_dialog.py | 15 +++++- .../ui/gui/test_gui_browser_dialog.py | 54 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py index e46b045b6..48b4c9cba 100644 --- a/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py +++ b/src/deadline/client/ui/dialogs/job_bundle_browser_dialog.py @@ -563,6 +563,12 @@ def _build_ui(self): # Title — top of a 3-step scale (title / body / section-label). self._preview_name = _WrappingLabel() self._preview_name.setWordWrap(True) + # Plain text: the bundle-derived name must never be interpreted as markup. + # Wrapping and all visual styling come from setWordWrap + the stylesheet + # below (widget-level styling is independent of the text format); we only + # forgo CSS break-word for a pathological unbroken name, which is bounded + # by PREVIEW_MAX_NAME_LEN and shrinks via _WrappingLabel. + self._preview_name.setTextFormat(Qt.PlainText) self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") preview_layout.addWidget(self._preview_name) @@ -579,6 +585,9 @@ def _build_ui(self): self._desc_section.set_header_style(f"{_SECTION_LABEL_QSS} {muted_qss}") self._preview_desc = _WrappingLabel() self._preview_desc.setWordWrap(True) + # Plain text: bundle-derived descriptions/errors must never be interpreted + # as rich text (defense against HTML/CSS injection from crafted metadata). + self._preview_desc.setTextFormat(Qt.PlainText) self._desc_section.set_content(self._preview_desc) preview_layout.addWidget(self._desc_section) @@ -587,6 +596,8 @@ def _build_ui(self): self._steps_section.set_header_style(f"{_SECTION_LABEL_QSS} {muted_qss}") self._preview_steps = QLabel() self._preview_steps.setWordWrap(True) + # Plain text: step names come from bundle metadata and must be literal. + self._preview_steps.setTextFormat(Qt.PlainText) self._steps_section.set_content(self._preview_steps) preview_layout.addWidget(self._steps_section) @@ -1099,7 +1110,9 @@ def _load_preview(self, path: str, item: Optional[QStandardItem] = None): return self._preview_stack.setCurrentIndex(1) # show detail page - self._preview_name.setText(f'

{info.name}

') + # Plain text (see label setup): the name is shown literally, so crafted + # metadata markup is inert without needing to escape it. + self._preview_name.setText(info.name) self._preview_name.setStyleSheet("font-weight: bold; font-size: 15px;") self._preview_name.setVisible(True) diff --git a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py index ff3dd8458..ca1fb99ae 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py +++ b/test/unit/deadline_client/ui/gui/test_gui_browser_dialog.py @@ -9,6 +9,7 @@ from qtpy.QtGui import QColor, QStandardItem from qtpy.QtWidgets import QApplication, QDialog, QLabel, QProgressBar +from deadline.client.job_bundle.repository import BundleInfo from deadline.client.ui.dialogs.job_bundle_browser_dialog import ( JobBundleBrowserDialog, _DownloadCancelled, @@ -160,3 +161,56 @@ def _inspect(): # The label reports a human-readable GB total, never a bogus "1 KB". assert "GB" in captured_label[0] assert "1 KB" not in captured_label[0] + + +class TestPreviewInjectionHardening: + """Bundle-derived preview values (name/description/steps) come from a template + or S3 metadata that a queue-writer can control. The preview renders them + inertly: the name, description, and steps labels are all forced to plain text + so no value is ever interpreted as markup. (Qt QLabels have no script engine, + so this is about HTML/CSS injection, not code execution.)""" + + def _dialog_with_info(self, qtbot, tmp_path, info): + dialog = JobBundleBrowserDialog(local_source=str(tmp_path)) + qtbot.addWidget(dialog) + dialog._selected_is_archive = False + repo = MagicMock() + repo.get_bundle_info.return_value = info + dialog._current_repo = repo + dialog._load_preview("some/path") + return dialog + + def test_malicious_name_is_plain_text(self, qtbot, tmp_path): + info = BundleInfo( + path="s3://bucket/evil.ojd", + name='pwn', + ) + dialog = self._dialog_with_info(qtbot, tmp_path, info) + + # Plain text: the name is shown literally and never parsed as markup, + # so the payload is inert (no rich-text/HTML rendering path at all). + assert dialog._preview_name.textFormat() == Qt.PlainText # type: ignore[attr-defined] + assert dialog._preview_name.text() == info.name + + def test_description_label_is_plain_text(self, qtbot, tmp_path): + info = BundleInfo( + path="s3://bucket/evil.ojd", + name="Bundle", + description="", + ) + dialog = self._dialog_with_info(qtbot, tmp_path, info) + + # Forced plain text so markup is shown literally and never interpreted. + assert dialog._preview_desc.textFormat() == Qt.PlainText # type: ignore[attr-defined] + assert "