|
36 | 36 | Type,
|
37 | 37 | TypeVar,
|
38 | 38 | Union,
|
39 |
| - cast, |
40 | 39 | no_type_check,
|
41 | 40 | overload,
|
42 | 41 | )
|
|
48 | 47 | from requests.auth import AuthBase
|
49 | 48 | from requests.structures import CaseInsensitiveDict
|
50 | 49 | from requests.utils import get_netrc_auth
|
| 50 | +from requests_toolbelt import MultipartEncoder |
51 | 51 |
|
52 | 52 | from jira import __version__
|
53 | 53 | from jira.exceptions import JIRAError
|
54 |
| -from jira.resilientsession import ResilientSession, raise_on_error |
| 54 | +from jira.resilientsession import PrepareRequestForRetry, ResilientSession |
55 | 55 | from jira.resources import (
|
56 | 56 | AgileResource,
|
57 | 57 | Attachment,
|
|
93 | 93 | )
|
94 | 94 | from jira.utils import json_loads, threaded_requests
|
95 | 95 |
|
96 |
| -try: |
97 |
| - # noinspection PyUnresolvedReferences |
98 |
| - from requests_toolbelt import MultipartEncoder |
99 |
| -except ImportError: |
100 |
| - pass |
101 |
| - |
102 | 96 | try:
|
103 | 97 | from requests_jwt import JWTAuth
|
104 | 98 | except ImportError:
|
@@ -1000,70 +994,68 @@ def add_attachment(
|
1000 | 994 | """
|
1001 | 995 | close_attachment = False
|
1002 | 996 | if isinstance(attachment, str):
|
1003 |
| - attachment: BufferedReader = open(attachment, "rb") # type: ignore |
1004 |
| - attachment = cast(BufferedReader, attachment) |
| 997 | + attachment_io = open(attachment, "rb") # type: ignore |
1005 | 998 | close_attachment = True
|
1006 |
| - elif isinstance(attachment, BufferedReader) and attachment.mode != "rb": |
1007 |
| - self.log.warning( |
1008 |
| - "%s was not opened in 'rb' mode, attaching file may fail." |
1009 |
| - % attachment.name |
1010 |
| - ) |
1011 |
| - |
1012 |
| - url = self._get_url("issue/" + str(issue) + "/attachments") |
| 999 | + else: |
| 1000 | + attachment_io = attachment |
| 1001 | + if isinstance(attachment, BufferedReader) and attachment.mode != "rb": |
| 1002 | + self.log.warning( |
| 1003 | + "%s was not opened in 'rb' mode, attaching file may fail." |
| 1004 | + % attachment.name |
| 1005 | + ) |
1013 | 1006 |
|
1014 | 1007 | fname = filename
|
1015 | 1008 | if not fname and isinstance(attachment, BufferedReader):
|
1016 | 1009 | fname = os.path.basename(attachment.name)
|
1017 | 1010 |
|
1018 |
| - if "MultipartEncoder" not in globals(): |
1019 |
| - method = "old" |
1020 |
| - try: |
1021 |
| - r = self._session.post( |
1022 |
| - url, |
1023 |
| - files={"file": (fname, attachment, "application/octet-stream")}, |
1024 |
| - headers=CaseInsensitiveDict( |
1025 |
| - {"content-type": None, "X-Atlassian-Token": "no-check"} |
1026 |
| - ), |
1027 |
| - ) |
1028 |
| - finally: |
1029 |
| - if close_attachment: |
1030 |
| - attachment.close() |
1031 |
| - else: |
1032 |
| - method = "MultipartEncoder" |
1033 |
| - |
1034 |
| - def file_stream() -> MultipartEncoder: |
1035 |
| - """Returns files stream of attachment.""" |
1036 |
| - return MultipartEncoder( |
1037 |
| - fields={"file": (fname, attachment, "application/octet-stream")} |
1038 |
| - ) |
1039 |
| - |
1040 |
| - m = file_stream() |
1041 |
| - try: |
1042 |
| - r = self._session.post( |
1043 |
| - url, |
1044 |
| - data=m, |
1045 |
| - headers=CaseInsensitiveDict( |
1046 |
| - { |
1047 |
| - "content-type": m.content_type, |
1048 |
| - "X-Atlassian-Token": "no-check", |
1049 |
| - } |
1050 |
| - ), |
1051 |
| - retry_data=file_stream, |
1052 |
| - ) |
1053 |
| - finally: |
1054 |
| - if close_attachment: |
1055 |
| - attachment.close() |
| 1011 | + def generate_multipartencoded_request_args() -> Tuple[ |
| 1012 | + MultipartEncoder, CaseInsensitiveDict |
| 1013 | + ]: |
| 1014 | + """Returns MultipartEncoder stream of attachment, and the header.""" |
| 1015 | + attachment_io.seek(0) |
| 1016 | + encoded_data = MultipartEncoder( |
| 1017 | + fields={"file": (fname, attachment_io, "application/octet-stream")} |
| 1018 | + ) |
| 1019 | + request_headers = CaseInsensitiveDict( |
| 1020 | + { |
| 1021 | + "content-type": encoded_data.content_type, |
| 1022 | + "X-Atlassian-Token": "no-check", |
| 1023 | + } |
| 1024 | + ) |
| 1025 | + return encoded_data, request_headers |
| 1026 | + |
| 1027 | + class RetryableMultipartEncoder(PrepareRequestForRetry): |
| 1028 | + def prepare( |
| 1029 | + self, original_request_kwargs: CaseInsensitiveDict |
| 1030 | + ) -> CaseInsensitiveDict: |
| 1031 | + encoded_data, request_headers = generate_multipartencoded_request_args() |
| 1032 | + original_request_kwargs["data"] = encoded_data |
| 1033 | + original_request_kwargs["headers"] = request_headers |
| 1034 | + return super().prepare(original_request_kwargs) |
| 1035 | + |
| 1036 | + url = self._get_url(f"issue/{issue}/attachments") |
| 1037 | + try: |
| 1038 | + encoded_data, request_headers = generate_multipartencoded_request_args() |
| 1039 | + r = self._session.post( |
| 1040 | + url, |
| 1041 | + data=encoded_data, |
| 1042 | + headers=request_headers, |
| 1043 | + _prepare_retry_class=RetryableMultipartEncoder(), # type: ignore[call-arg] # ResilientSession handles |
| 1044 | + ) |
| 1045 | + finally: |
| 1046 | + if close_attachment: |
| 1047 | + attachment_io.close() |
1056 | 1048 |
|
1057 | 1049 | js: Union[Dict[str, Any], List[Dict[str, Any]]] = json_loads(r)
|
1058 | 1050 | if not js or not isinstance(js, Iterable):
|
1059 |
| - raise JIRAError(f"Unable to parse JSON: {js}") |
| 1051 | + raise JIRAError(f"Unable to parse JSON: {js}. Failed to add attachment?") |
1060 | 1052 | jira_attachment = Attachment(
|
1061 | 1053 | self._options, self._session, js[0] if isinstance(js, List) else js
|
1062 | 1054 | )
|
1063 | 1055 | if jira_attachment.size == 0:
|
1064 | 1056 | raise JIRAError(
|
1065 |
| - "Added empty attachment via %s method?!: r: %s\nattachment: %s" |
1066 |
| - % (method, r, jira_attachment) |
| 1057 | + "Added empty attachment?!: " |
| 1058 | + + f"Response: {r}\nAttachment: {jira_attachment}" |
1067 | 1059 | )
|
1068 | 1060 | return jira_attachment
|
1069 | 1061 |
|
@@ -1836,8 +1828,7 @@ def assign_issue(self, issue: Union[int, str], assignee: Optional[str]) -> bool:
|
1836 | 1828 | url = self._get_latest_url(f"issue/{issue}/assignee")
|
1837 | 1829 | user_id = self._get_user_id(assignee)
|
1838 | 1830 | payload = {"accountId": user_id} if self._is_cloud else {"name": user_id}
|
1839 |
| - r = self._session.put(url, data=json.dumps(payload)) |
1840 |
| - raise_on_error(r) |
| 1831 | + self._session.put(url, data=json.dumps(payload)) |
1841 | 1832 | return True
|
1842 | 1833 |
|
1843 | 1834 | @translate_resource_args
|
@@ -2717,7 +2708,7 @@ def create_temp_project_avatar(
|
2717 | 2708 | if size != size_from_file:
|
2718 | 2709 | size = size_from_file
|
2719 | 2710 |
|
2720 |
| - params = {"filename": filename, "size": size} |
| 2711 | + params: Dict[str, Union[int, str]] = {"filename": filename, "size": size} |
2721 | 2712 |
|
2722 | 2713 | headers: Dict[str, Any] = {"X-Atlassian-Token": "no-check"}
|
2723 | 2714 | if contentType is not None:
|
@@ -3227,7 +3218,11 @@ def create_temp_user_avatar(
|
3227 | 3218 | # remove path from filename
|
3228 | 3219 | filename = os.path.split(filename)[1]
|
3229 | 3220 |
|
3230 |
| - params = {"username": user, "filename": filename, "size": size} |
| 3221 | + params: Dict[str, Union[str, int]] = { |
| 3222 | + "username": user, |
| 3223 | + "filename": filename, |
| 3224 | + "size": size, |
| 3225 | + } |
3231 | 3226 |
|
3232 | 3227 | headers: Dict[str, Any]
|
3233 | 3228 | headers = {"X-Atlassian-Token": "no-check"}
|
@@ -3791,8 +3786,7 @@ def rename_user(self, old_user: str, new_user: str):
|
3791 | 3786 | # raw displayName
|
3792 | 3787 | self.log.debug(f"renaming {self.user(old_user).emailAddress}")
|
3793 | 3788 |
|
3794 |
| - r = self._session.put(url, params=params, data=json.dumps(payload)) |
3795 |
| - raise_on_error(r) |
| 3789 | + self._session.put(url, params=params, data=json.dumps(payload)) |
3796 | 3790 | else:
|
3797 | 3791 | raise NotImplementedError(
|
3798 | 3792 | "Support for renaming users in Jira " "< 6.0.0 has been removed."
|
@@ -4612,13 +4606,29 @@ def sprints(
|
4612 | 4606 | self.AGILE_BASE_URL,
|
4613 | 4607 | )
|
4614 | 4608 |
|
4615 |
| - def sprints_by_name(self, id, extended=False): |
| 4609 | + def sprints_by_name( |
| 4610 | + self, id: Union[str, int], extended: bool = False, state: str = None |
| 4611 | + ) -> Dict[str, Dict[str, Any]]: |
| 4612 | + """Get a dictionary of sprint Resources where the name of the sprint is the key. |
| 4613 | +
|
| 4614 | + Args: |
| 4615 | + board_id (int): the board to get sprints from |
| 4616 | + extended (bool): Deprecated. |
| 4617 | + state (str): Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. |
| 4618 | + You can define multiple states separated by commas |
| 4619 | +
|
| 4620 | + Returns: |
| 4621 | + Dict[str, Dict[str, Any]]: dictionary of sprints with the sprint name as key |
| 4622 | + """ |
4616 | 4623 | sprints = {}
|
4617 |
| - for s in self.sprints(id, extended=extended): |
| 4624 | + for s in self.sprints(id, extended=extended, state=state): |
4618 | 4625 | if s.name not in sprints:
|
4619 | 4626 | sprints[s.name] = s.raw
|
4620 | 4627 | else:
|
4621 |
| - raise Exception |
| 4628 | + raise JIRAError( |
| 4629 | + f"There are multiple sprints defined with the name {s.name} on board id {id},\n" |
| 4630 | + f"returning a dict with sprint names as a key, assumes unique names for each sprint" |
| 4631 | + ) |
4622 | 4632 | return sprints
|
4623 | 4633 |
|
4624 | 4634 | def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None):
|
|
0 commit comments