From ea179f7c06a76b4f92bc90ec1775b6d60ba2bfdf Mon Sep 17 00:00:00 2001 From: jerubball Date: Fri, 13 Mar 2026 14:36:48 -0400 Subject: [PATCH 1/2] add get_all_attachment_contents method and update docs --- atlassian/jira.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++- docs/jira.rst | 18 ++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/atlassian/jira.py b/atlassian/jira.py index 454c57da7..6574f248a 100644 --- a/atlassian/jira.py +++ b/atlassian/jira.py @@ -2,6 +2,7 @@ import logging import os import re +import zipfile from typing import Any, BinaryIO, Dict, List, Optional, Union, cast from warnings import warn @@ -257,7 +258,7 @@ def get_attachment(self, attachment_id: T_id) -> T_resp_json: def download_issue_attachments( self, - issue: str, + issue: T_id, path: Optional[str] = None, overwrite: bool = False, stream: bool = False, @@ -266,6 +267,8 @@ def download_issue_attachments( ) -> Optional[str]: """ Downloads all attachments from a Jira issue. + This method downloads zip file compressed from Jira server side, and may fail if total attachment size is too large. + Use `get_all_attachment_contents()` to download individual attachments and zip from client side. :param issue: The issue-key of the Jira issue :param path: Path to directory where attachments will be saved. If None, current working directory will be used. :param overwrite: If True, always download and create new zip file. @@ -378,6 +381,53 @@ def get_attachment_content(self, attachment_id: T_id) -> bytes: headers={"Accept": "*/*"}, ) + def get_all_attachment_contents( + self, + issue: T_id, + path: Optional[str] = None, + overwrite: bool = False, + compression: int = zipfile.ZIP_STORED, + ) -> Optional[str]: + """ + Downloads all attachments from a Jira issue by downloading individual files and creating zip file. + This method is useful when total attachment size is too large for Jira server to compress as single file. + If total attachment size is small enough, using `download_issue_attachments()` may be more efficient. + :param issue: The issue-key of the Jira issue + :param path: Path to directory where attachments will be saved. If None, current working directory will be used. + :param overwrite: If True, always download and create new zip file. + If False (default), download will be skipped when zip file already exists in path. + :param compression: Compression method for zipfile. Should be one of the constants listed in documentation page. + https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile + :return: File path of the zip file if file is existing or download is successful. None if attachment does not exist. + """ + try: + if path is None: + path = os.getcwd() + issue_id = self.issue(issue, fields="id")["id"] + attachment_name = f"{issue_id}_attachments.zip" + file_path = os.path.join(path, attachment_name) + if not overwrite and os.path.isfile(file_path): + return file_path + + attachments_metadata = self.get_attachments_ids_from_issue(issue) + if not attachments_metadata: + return None + + with zipfile.ZipFile(file_path, "w", compression=compression) as file: + for meta in attachments_metadata: + file.writestr(meta["filename"], self.get_attachment_content(meta["attachment_id"])) + + return file_path + + except FileNotFoundError: + raise FileNotFoundError("Verify if directory path is correct and/or if directory exists") + except PermissionError: + raise PermissionError( + "Directory found, but there is a problem with saving file to this directory. Check directory permissions" + ) + except Exception as e: + raise e + def remove_attachment(self, attachment_id: T_id) -> T_resp_json: """ Remove an attachment from an issue diff --git a/docs/jira.rst b/docs/jira.rst index fe2923d2c..61549e494 100644 --- a/docs/jira.rst +++ b/docs/jira.rst @@ -577,10 +577,24 @@ Attachments actions # Add attachment (IO Object) to issue jira.add_attachment_object(issue_key, attachment) + # Gets the binary raw data of single attachment in bytes. + jira.get_attachment_content(attachment_id) + # Download attachments from the issue - jira.download_attachments_from_issue(issue, path=None, cloud=True): + # For both methods, if path is None, current working directory is used. + # zip file name is in following format: "_attachments.zip" + + # This method downloads zip file compressed from Jira server side. + # Best when total attachment size is less than 1 GB. + # Returns a message indicating the result of the download operation. + jira.download_issue_attachments(issue_key, path=None, overwrite=False) + + # This method downloads individual files and compresses zip file locally. + # Best when total attachment size is greater than 1 GB. + # Returns the file path of created zip file. + jira.get_all_attachment_contents(issue_key, path=None, overwrite=False) - # Get list of attachments ids from issue + # Get list of attachment names and ids from issue jira.get_attachments_ids_from_issue(issue_key) Manage components From 6dc1850862984eadcb1b8ccca0ce7ef112209389 Mon Sep 17 00:00:00 2001 From: jerubball Date: Mon, 16 Mar 2026 16:33:39 -0400 Subject: [PATCH 2/2] Optimize get_all_attachment_contents to use less API calls --- atlassian/jira.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/atlassian/jira.py b/atlassian/jira.py index 6574f248a..d6d293e44 100644 --- a/atlassian/jira.py +++ b/atlassian/jira.py @@ -403,19 +403,27 @@ def get_all_attachment_contents( try: if path is None: path = os.getcwd() - issue_id = self.issue(issue, fields="id")["id"] + issue_data = self.issue(issue, fields="id,attachment") + issue_id = issue_data["id"] attachment_name = f"{issue_id}_attachments.zip" file_path = os.path.join(path, attachment_name) if not overwrite and os.path.isfile(file_path): return file_path - attachments_metadata = self.get_attachments_ids_from_issue(issue) + attachments_metadata = issue_data["fields"]["attachment"] if not attachments_metadata: return None with zipfile.ZipFile(file_path, "w", compression=compression) as file: for meta in attachments_metadata: - file.writestr(meta["filename"], self.get_attachment_content(meta["attachment_id"])) + # stream download should not be used, as writestr expects full content with filename. + content = self.get( + meta["content"], + not_json_response=True, + absolute=True, + headers={"Accept": "*/*"}, + ) + file.writestr(meta["filename"], content) return file_path