python-jira-plus 2.0.7__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_jira_plus-2.0.7/python_jira_plus.egg-info → python_jira_plus-3.0.0}/PKG-INFO +9 -7
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/README.md +1 -1
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/pyproject.toml +9 -4
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus/__init__.py +4 -2
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py +3 -3
- python_jira_plus-2.0.7/python_jira_plus/jira_plus.py → python_jira_plus-3.0.0/python_jira_plus/jira_base.py +43 -194
- python_jira_plus-3.0.0/python_jira_plus/jira_cloud.py +111 -0
- python_jira_plus-3.0.0/python_jira_plus/jira_on_premise.py +98 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0/python_jira_plus.egg-info}/PKG-INFO +9 -7
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/SOURCES.txt +7 -2
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/requires.txt +6 -3
- python_jira_plus-3.0.0/tests/test_describe_allowed_value.py +84 -0
- python_jira_plus-3.0.0/tests/test_jira_cloud_integration.py +146 -0
- python_jira_plus-3.0.0/tests/test_jira_plus.py +244 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/LICENSE +0 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/MANIFEST.in +0 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
- {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: custom-python-logger>=2.0.10
|
|
20
|
-
Requires-Dist: dotenv>=0.
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: pytest>=9.0.1
|
|
22
|
+
Requires-Dist: packaging>=26.2
|
|
24
23
|
Requires-Dist: requests>=2.32.5
|
|
25
24
|
Requires-Dist: retrying>=1.4.2
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit>=4.5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
28
|
+
Requires-Dist: setuptools>=80.9.0; extra == "dev"
|
|
29
|
+
Requires-Dist: wheel>=0.45.1; extra == "dev"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|

|
|
@@ -59,7 +61,7 @@ pip install python-jira-plus
|
|
|
59
61
|
---
|
|
60
62
|
|
|
61
63
|
## Requirements
|
|
62
|
-
- Python 3.
|
|
64
|
+
- Python 3.12+
|
|
63
65
|
- `jira` package
|
|
64
66
|
- `retrying` package
|
|
65
67
|
- `custom-python-logger` package
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-jira-plus"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0.0"
|
|
8
8
|
description = "Enhanced Python client for JIRA with better error handling, pagination, and metadata validation."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -22,12 +22,17 @@ classifiers = [
|
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
24
|
"custom-python-logger>=2.0.10",
|
|
25
|
-
"dotenv>=0.
|
|
25
|
+
"python-dotenv>=1.0.0",
|
|
26
26
|
"jira>=3.10.5",
|
|
27
|
-
"
|
|
28
|
-
"pytest>=9.0.1",
|
|
27
|
+
"packaging>=26.2",
|
|
29
28
|
"requests>=2.32.5",
|
|
30
29
|
"retrying>=1.4.2",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pre-commit>=4.5.0",
|
|
35
|
+
"pytest>=9.0.1",
|
|
31
36
|
"setuptools>=80.9.0",
|
|
32
37
|
"wheel>=0.45.1",
|
|
33
38
|
]
|
|
@@ -8,7 +8,9 @@ pagination, metadata validation, and more.
|
|
|
8
8
|
from dotenv import load_dotenv
|
|
9
9
|
|
|
10
10
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
11
|
-
from python_jira_plus.
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
13
|
+
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
12
14
|
|
|
13
15
|
load_dotenv()
|
|
14
16
|
|
|
@@ -27,4 +29,4 @@ BASIC_FIELDS = [
|
|
|
27
29
|
"resolution",
|
|
28
30
|
"parent",
|
|
29
31
|
]
|
|
30
|
-
__all__ = ["
|
|
32
|
+
__all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
|
{python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py
RENAMED
|
@@ -11,17 +11,17 @@ def describe_allowed_value(value: Any, allowed_values: set = None) -> bool:
|
|
|
11
11
|
|
|
12
12
|
@describe_allowed_value.register
|
|
13
13
|
def _(value: int, allowed_values: list = None) -> bool:
|
|
14
|
-
return value
|
|
14
|
+
return value in allowed_values
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
@describe_allowed_value.register
|
|
18
18
|
def _(value: float, allowed_values: list = None) -> bool:
|
|
19
|
-
return value
|
|
19
|
+
return value in allowed_values
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@describe_allowed_value.register
|
|
23
23
|
def _(value: str, allowed_values: list = None) -> bool:
|
|
24
|
-
return value
|
|
24
|
+
return value in allowed_values
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@describe_allowed_value.register
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jira_base.py
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
import
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
7
|
from enum import Enum
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
import requests
|
|
11
10
|
from custom_python_logger import get_logger
|
|
12
11
|
from jira import JIRA, Issue, JIRAError
|
|
13
12
|
from jira.client import ResultList
|
|
14
|
-
|
|
15
|
-
from packaging.version import Version
|
|
13
|
+
|
|
16
14
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
17
15
|
|
|
18
16
|
|
|
@@ -21,21 +19,16 @@ class UrlScheme(Enum):
|
|
|
21
19
|
HTTPS = "https"
|
|
22
20
|
|
|
23
21
|
|
|
24
|
-
class
|
|
25
|
-
|
|
26
|
-
ON_PREMISE = "on_premise"
|
|
27
|
-
|
|
22
|
+
class JiraBase(ABC):
|
|
23
|
+
use_createmeta: bool
|
|
28
24
|
|
|
29
|
-
class JiraPlus:
|
|
30
25
|
def __init__(
|
|
31
26
|
self,
|
|
32
27
|
base_url: str | None = None,
|
|
33
|
-
server_type: ServerType = ServerType.CLOUD,
|
|
34
28
|
urllib3_log_level: int = logging.WARNING,
|
|
35
29
|
jira_username: str | None = None,
|
|
36
|
-
jira_token: str | None = None,
|
|
30
|
+
jira_token: str | None = None,
|
|
37
31
|
verify_ssl: bool = True,
|
|
38
|
-
sso: bool = False,
|
|
39
32
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
33
|
) -> None:
|
|
41
34
|
self.logger = get_logger(self.__class__.__name__)
|
|
@@ -45,33 +38,28 @@ class JiraPlus:
|
|
|
45
38
|
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
46
39
|
self.base_url = base_url or os.getenv("JIRA_BASE_URL")
|
|
47
40
|
self.url_scheme = url_scheme.value
|
|
48
|
-
|
|
49
|
-
self.server_type = server_type
|
|
50
41
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
51
42
|
self.verify_ssl = verify_ssl
|
|
52
|
-
self.sso = sso
|
|
53
43
|
|
|
54
44
|
self.jira_client = self.create_connection()
|
|
55
45
|
self.check_client_connection()
|
|
46
|
+
self.server_version = self.get_server_version()
|
|
56
47
|
|
|
57
|
-
@
|
|
58
|
-
def create_connection(self, timeout: int = 580) -> JIRA
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def _paginate(
|
|
53
|
+
self,
|
|
54
|
+
query: str,
|
|
55
|
+
max_results: int,
|
|
56
|
+
specific_fields: str | list[str],
|
|
57
|
+
jira_err_limit: int,
|
|
58
|
+
json_result: bool,
|
|
59
|
+
) -> ResultList[Issue] | list: ...
|
|
69
60
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return jira_client
|
|
73
|
-
self.logger.error("Jira Connection Failed")
|
|
74
|
-
raise JIRAError("Jira Connection Failed")
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
75
63
|
|
|
76
64
|
def close_connection(self) -> None:
|
|
77
65
|
try:
|
|
@@ -80,7 +68,6 @@ class JiraPlus:
|
|
|
80
68
|
self.logger.exception(f"JIRAError \n{err}")
|
|
81
69
|
except Exception as err:
|
|
82
70
|
self.logger.exception(f"Exception \n{err}")
|
|
83
|
-
|
|
84
71
|
self.check_client_connection()
|
|
85
72
|
|
|
86
73
|
def check_client_connection(self) -> bool:
|
|
@@ -96,6 +83,9 @@ class JiraPlus:
|
|
|
96
83
|
self.logger.exception(f"Exception \n{err}")
|
|
97
84
|
return False
|
|
98
85
|
|
|
86
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
87
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
88
|
+
|
|
99
89
|
def get_issue_by_key(
|
|
100
90
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
101
91
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -118,12 +108,8 @@ class JiraPlus:
|
|
|
118
108
|
specific_fields: str | list[str] = "*all",
|
|
119
109
|
jira_err_count: int = 3,
|
|
120
110
|
json_result: bool = True,
|
|
121
|
-
) -> ResultList[Issue] |
|
|
122
|
-
|
|
123
|
-
_paginate_query_func = self._paginate_query
|
|
124
|
-
else:
|
|
125
|
-
_paginate_query_func = self._paginate_query_new
|
|
126
|
-
return _paginate_query_func(
|
|
111
|
+
) -> ResultList[Issue] | list:
|
|
112
|
+
return self._paginate(
|
|
127
113
|
query=query,
|
|
128
114
|
max_results=max_results,
|
|
129
115
|
specific_fields=specific_fields,
|
|
@@ -131,102 +117,26 @@ class JiraPlus:
|
|
|
131
117
|
json_result=json_result,
|
|
132
118
|
)
|
|
133
119
|
|
|
134
|
-
def
|
|
135
|
-
self,
|
|
136
|
-
query: str,
|
|
137
|
-
max_results: int,
|
|
138
|
-
specific_fields: str | list[str],
|
|
139
|
-
jira_err_limit: int,
|
|
140
|
-
json_result: bool,
|
|
141
|
-
) -> ResultList[Issue] | list:
|
|
142
|
-
all_issues = []
|
|
143
|
-
retries = 0
|
|
144
|
-
page = None
|
|
145
|
-
while True: # pylint: disable=W0149
|
|
146
|
-
try:
|
|
147
|
-
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
148
|
-
page = self.jira_client.enhanced_search_issues(
|
|
149
|
-
jql_str=query,
|
|
150
|
-
nextPageToken=page['nextPageToken'] if page else None,
|
|
151
|
-
maxResults=max_results,
|
|
152
|
-
fields=specific_fields,
|
|
153
|
-
json_result=json_result,
|
|
154
|
-
)
|
|
155
|
-
if not page:
|
|
156
|
-
break
|
|
157
|
-
|
|
158
|
-
all_issues += page["issues"] if json_result else page
|
|
159
|
-
if page['isLast']:
|
|
160
|
-
break
|
|
161
|
-
retries = 0 # reset on success
|
|
162
|
-
except JIRAError as err:
|
|
163
|
-
if err.status_code == 400:
|
|
164
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
165
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
166
|
-
retries += 1
|
|
167
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
168
|
-
if retries >= jira_err_limit:
|
|
169
|
-
self.logger.exception("Max retries exceeded.")
|
|
170
|
-
return []
|
|
171
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
172
|
-
return all_issues
|
|
173
|
-
|
|
174
|
-
def _paginate_query(
|
|
175
|
-
self,
|
|
176
|
-
query: str,
|
|
177
|
-
max_results: int,
|
|
178
|
-
specific_fields: str | list[str],
|
|
179
|
-
jira_err_limit: int,
|
|
180
|
-
json_result: bool,
|
|
181
|
-
) -> ResultList[Issue] | list:
|
|
182
|
-
start_at = 0
|
|
183
|
-
all_issues = []
|
|
184
|
-
retries = 0
|
|
185
|
-
while True: # pylint: disable=W0149
|
|
186
|
-
try:
|
|
187
|
-
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
188
|
-
page = self.jira_client.search_issues(
|
|
189
|
-
jql_str=query,
|
|
190
|
-
startAt=start_at,
|
|
191
|
-
maxResults=max_results,
|
|
192
|
-
fields=specific_fields,
|
|
193
|
-
json_result=json_result,
|
|
194
|
-
)
|
|
195
|
-
if not page:
|
|
196
|
-
break
|
|
197
|
-
|
|
198
|
-
all_issues += page["issues"] if json_result else page
|
|
199
|
-
if json_result:
|
|
200
|
-
start_at += page["maxResults"]
|
|
201
|
-
else:
|
|
202
|
-
start_at += page.maxResults
|
|
203
|
-
if start_at >= max_results:
|
|
204
|
-
break
|
|
205
|
-
retries = 0 # reset on success
|
|
206
|
-
except JIRAError as err:
|
|
207
|
-
if err.status_code == 400:
|
|
208
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
209
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
210
|
-
retries += 1
|
|
211
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
212
|
-
if retries >= jira_err_limit:
|
|
213
|
-
self.logger.exception("Max retries exceeded.")
|
|
214
|
-
return []
|
|
215
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
216
|
-
return all_issues
|
|
217
|
-
|
|
218
|
-
def _fetch_metadata(self, project_key: str, issue_type: str) -> tuple[dict, dict]:
|
|
120
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
219
121
|
meta = self.jira_client.createmeta(
|
|
220
122
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
221
123
|
)
|
|
222
|
-
|
|
223
124
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
224
125
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
225
|
-
|
|
226
126
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
227
127
|
if not issue_meta:
|
|
228
128
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
229
|
-
return
|
|
129
|
+
return issue_meta["fields"]
|
|
130
|
+
|
|
131
|
+
def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
|
|
132
|
+
issue_types = {t.name: t for t in self.jira_client.project_issue_types(project_key)}
|
|
133
|
+
field_list = self.jira_client.project_issue_fields(project_key, issue_types[issue_type].id)
|
|
134
|
+
return {f.fieldId: f.raw for f in field_list}
|
|
135
|
+
|
|
136
|
+
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
137
|
+
if self.use_createmeta:
|
|
138
|
+
return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
|
|
139
|
+
return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
|
|
230
140
|
|
|
231
141
|
def get_allowed_values(
|
|
232
142
|
self,
|
|
@@ -234,28 +144,20 @@ class JiraPlus:
|
|
|
234
144
|
issue_type: str,
|
|
235
145
|
field_id_or_name: str,
|
|
236
146
|
) -> list | None:
|
|
237
|
-
"""
|
|
238
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
239
|
-
"""
|
|
147
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
240
148
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
241
|
-
|
|
242
|
-
# Try to find by ID or fallback to case-insensitive name
|
|
243
149
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
244
|
-
# Try matching by name (case-insensitive)
|
|
245
150
|
for _, fmeta in fields_metadata.items():
|
|
246
151
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
247
152
|
field = fmeta
|
|
248
153
|
break
|
|
249
|
-
|
|
250
154
|
if not field:
|
|
251
155
|
raise ValueError(
|
|
252
156
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
253
157
|
)
|
|
254
|
-
|
|
255
158
|
if not (allowed := field.get("allowedValues")):
|
|
256
159
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
257
160
|
return None
|
|
258
|
-
|
|
259
161
|
return allowed
|
|
260
162
|
|
|
261
163
|
@staticmethod
|
|
@@ -265,41 +167,13 @@ class JiraPlus:
|
|
|
265
167
|
allowed_values = allowed_names | allowed_keys
|
|
266
168
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
267
169
|
|
|
268
|
-
def
|
|
269
|
-
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
270
|
-
|
|
271
|
-
def check_server_createmeta_compatibility(self) -> bool:
|
|
272
|
-
server_version = self.get_server_version()
|
|
273
|
-
if (
|
|
274
|
-
self.server_type == ServerType.ON_PREMISE and
|
|
275
|
-
Version(".".join(str(x) for x in server_version)) > Version("9.0.0")
|
|
276
|
-
):
|
|
277
|
-
self.logger.warning(
|
|
278
|
-
f"JIRA server version {server_version} is not fully compatible with createmeta-based field validation. "
|
|
279
|
-
"Some features may not work as expected."
|
|
280
|
-
)
|
|
281
|
-
return False
|
|
282
|
-
return True
|
|
283
|
-
|
|
284
|
-
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
285
|
-
if self.check_server_createmeta_compatibility():
|
|
286
|
-
_, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
|
|
287
|
-
return issue_meta["fields"]
|
|
288
|
-
_project_issue_types = self.jira_client.project_issue_types(project_key)
|
|
289
|
-
_issue_types = {t.name: t for t in _project_issue_types}
|
|
290
|
-
field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
|
|
291
|
-
return {f.fieldId: f.raw for f in field_list}
|
|
292
|
-
|
|
293
|
-
def validate_fields( # pylint: disable=R1260
|
|
294
|
-
self, project_key: str, issue_type: str, fields: dict | None = None
|
|
295
|
-
) -> None:
|
|
170
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
296
171
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
297
172
|
for custom_field, value in fields.items():
|
|
298
173
|
if custom_field not in fields_metadata:
|
|
299
174
|
raise ValueError(
|
|
300
175
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
301
176
|
)
|
|
302
|
-
|
|
303
177
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
304
178
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
305
179
|
raise ValueError(
|
|
@@ -307,7 +181,6 @@ class JiraPlus:
|
|
|
307
181
|
f"with issue type '{issue_type}'."
|
|
308
182
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
309
183
|
)
|
|
310
|
-
|
|
311
184
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
312
185
|
if field_type == "string" and not isinstance(value, str):
|
|
313
186
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -321,7 +194,6 @@ class JiraPlus:
|
|
|
321
194
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
322
195
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
323
196
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
324
|
-
# Add more field type checks as needed
|
|
325
197
|
|
|
326
198
|
def create_issue(
|
|
327
199
|
self,
|
|
@@ -332,21 +204,17 @@ class JiraPlus:
|
|
|
332
204
|
assignee: str | None = None,
|
|
333
205
|
custom_fields: dict | None = None,
|
|
334
206
|
) -> Issue | None:
|
|
335
|
-
issue_fields = {
|
|
207
|
+
issue_fields: dict[str, Any] = {
|
|
336
208
|
"project": {"key": project_key},
|
|
337
209
|
"issuetype": {"name": issue_type},
|
|
338
210
|
"summary": summary,
|
|
339
211
|
"description": description,
|
|
340
212
|
}
|
|
341
213
|
if assignee:
|
|
342
|
-
issue_fields["assignee"] = (
|
|
343
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
344
|
-
)
|
|
345
|
-
|
|
214
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
346
215
|
if custom_fields:
|
|
347
216
|
issue_fields = issue_fields | custom_fields
|
|
348
217
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
349
|
-
|
|
350
218
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
351
219
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
352
220
|
return issue
|
|
@@ -357,7 +225,6 @@ class JiraPlus:
|
|
|
357
225
|
self.validate_fields(
|
|
358
226
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
359
227
|
)
|
|
360
|
-
|
|
361
228
|
issue.update(fields=fields_to_update)
|
|
362
229
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
363
230
|
return issue
|
|
@@ -370,7 +237,7 @@ class JiraPlus:
|
|
|
370
237
|
|
|
371
238
|
def delete_issue(self, issue_key: str) -> bool:
|
|
372
239
|
try:
|
|
373
|
-
issue = self.get_issue_by_key(key=issue_key)
|
|
240
|
+
issue = self.get_issue_by_key(key=issue_key, json_result=False)
|
|
374
241
|
issue.delete()
|
|
375
242
|
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
376
243
|
return True
|
|
@@ -396,7 +263,6 @@ class JiraPlus:
|
|
|
396
263
|
available_link_types = self._get_available_link_types()
|
|
397
264
|
if link_type not in available_link_types:
|
|
398
265
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
399
|
-
|
|
400
266
|
try:
|
|
401
267
|
self.jira_client.create_issue_link(
|
|
402
268
|
type=link_type,
|
|
@@ -436,12 +302,10 @@ class JiraPlus:
|
|
|
436
302
|
f"\nAvailable transitions: {transition_names}"
|
|
437
303
|
)
|
|
438
304
|
return False
|
|
439
|
-
|
|
440
305
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
441
306
|
if not transition_id:
|
|
442
307
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
443
308
|
return False
|
|
444
|
-
|
|
445
309
|
self.jira_client.transition_issue(
|
|
446
310
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
447
311
|
)
|
|
@@ -453,18 +317,3 @@ class JiraPlus:
|
|
|
453
317
|
except Exception as err:
|
|
454
318
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
455
319
|
return False
|
|
456
|
-
|
|
457
|
-
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
458
|
-
jira_rst_url = (
|
|
459
|
-
f"https://{self.jira_username}:{self.jira_token}@{self.base_url}/rest/api/{3}/user/search?query={user_mail}"
|
|
460
|
-
)
|
|
461
|
-
response = requests.get(jira_rst_url)
|
|
462
|
-
return response.json()
|
|
463
|
-
|
|
464
|
-
def get_account_id(self, user_mail: str) -> str:
|
|
465
|
-
data = self.get_account_data(user_mail=user_mail)
|
|
466
|
-
return data[0]["accountId"]
|
|
467
|
-
|
|
468
|
-
def get_account_display_name(self, user_display_name: str) -> str:
|
|
469
|
-
data = self.get_account_data(user_mail=user_display_name)
|
|
470
|
-
return data[0]["displayName"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# jira_cloud.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from jira import JIRA, Issue, JIRAError
|
|
8
|
+
from jira.client import ResultList
|
|
9
|
+
from retrying import retry
|
|
10
|
+
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JiraCloud(JiraBase):
|
|
15
|
+
use_createmeta = True
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
urllib3_log_level: int = logging.WARNING,
|
|
21
|
+
jira_username: str | None = None,
|
|
22
|
+
jira_token: str | None = None,
|
|
23
|
+
verify_ssl: bool = True,
|
|
24
|
+
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
urllib3_log_level=urllib3_log_level,
|
|
29
|
+
jira_username=jira_username,
|
|
30
|
+
jira_token=jira_token,
|
|
31
|
+
verify_ssl=verify_ssl,
|
|
32
|
+
url_scheme=url_scheme,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@retry(stop_max_attempt_number=3, wait_fixed=180000)
|
|
36
|
+
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
37
|
+
jira_client = JIRA(
|
|
38
|
+
basic_auth=(self.jira_username, self.jira_token),
|
|
39
|
+
options={"server": self.server, "verify": self.verify_ssl},
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
)
|
|
42
|
+
if jira_client.projects():
|
|
43
|
+
self.logger.info("Jira Connection Successful")
|
|
44
|
+
return jira_client
|
|
45
|
+
self.logger.error("Jira Connection Failed")
|
|
46
|
+
raise JIRAError("Jira Connection Failed")
|
|
47
|
+
|
|
48
|
+
def _format_assignee(self, assignee: str) -> dict:
|
|
49
|
+
return {"accountId": assignee}
|
|
50
|
+
|
|
51
|
+
def _paginate(
|
|
52
|
+
self,
|
|
53
|
+
query: str,
|
|
54
|
+
max_results: int,
|
|
55
|
+
specific_fields: str | list[str],
|
|
56
|
+
jira_err_limit: int,
|
|
57
|
+
json_result: bool,
|
|
58
|
+
) -> ResultList[Issue] | list:
|
|
59
|
+
all_issues = []
|
|
60
|
+
retries = 0
|
|
61
|
+
page = None
|
|
62
|
+
while True: # pylint: disable=while-used
|
|
63
|
+
try:
|
|
64
|
+
next_token = None
|
|
65
|
+
if page is not None:
|
|
66
|
+
next_token = page.get("nextPageToken") if json_result else page.nextPageToken
|
|
67
|
+
|
|
68
|
+
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
69
|
+
page = self.jira_client.enhanced_search_issues(
|
|
70
|
+
jql_str=query,
|
|
71
|
+
nextPageToken=next_token,
|
|
72
|
+
maxResults=max_results,
|
|
73
|
+
fields=specific_fields,
|
|
74
|
+
json_result=json_result,
|
|
75
|
+
)
|
|
76
|
+
if not page:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
all_issues += page["issues"] if json_result else page
|
|
80
|
+
if page["isLast"] if json_result else page.isLast:
|
|
81
|
+
break
|
|
82
|
+
retries = 0
|
|
83
|
+
except JIRAError as err:
|
|
84
|
+
if err.status_code == 400:
|
|
85
|
+
self.logger.error(f"Bad Request: {err.text}")
|
|
86
|
+
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
87
|
+
retries += 1
|
|
88
|
+
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
89
|
+
if retries >= jira_err_limit:
|
|
90
|
+
self.logger.exception("Max retries exceeded.")
|
|
91
|
+
return []
|
|
92
|
+
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
93
|
+
return all_issues
|
|
94
|
+
|
|
95
|
+
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
96
|
+
response = requests.get(
|
|
97
|
+
f"{self.server}/rest/api/3/user/search",
|
|
98
|
+
params={"query": user_mail},
|
|
99
|
+
auth=(self.jira_username, self.jira_token),
|
|
100
|
+
timeout=30,
|
|
101
|
+
)
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
return response.json()
|
|
104
|
+
|
|
105
|
+
def get_account_id(self, user_mail: str) -> str:
|
|
106
|
+
data = self.get_account_data(user_mail=user_mail)
|
|
107
|
+
return data[0]["accountId"]
|
|
108
|
+
|
|
109
|
+
def get_account_display_name(self, user_display_name: str) -> str:
|
|
110
|
+
data = self.get_account_data(user_mail=user_display_name)
|
|
111
|
+
return data[0]["displayName"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# jira_on_premise.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from jira import JIRA, Issue, JIRAError
|
|
7
|
+
from jira.client import ResultList
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
from retrying import retry
|
|
10
|
+
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JiraOnPremise(JiraBase):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
base_url: str | None = None,
|
|
18
|
+
urllib3_log_level: int = logging.WARNING,
|
|
19
|
+
jira_username: str | None = None,
|
|
20
|
+
jira_token: str | None = None,
|
|
21
|
+
verify_ssl: bool = True,
|
|
22
|
+
sso: bool = False,
|
|
23
|
+
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.sso = sso # must be set before super().__init__ — create_connection reads it
|
|
26
|
+
super().__init__(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
urllib3_log_level=urllib3_log_level,
|
|
29
|
+
jira_username=jira_username,
|
|
30
|
+
jira_token=jira_token,
|
|
31
|
+
verify_ssl=verify_ssl,
|
|
32
|
+
url_scheme=url_scheme,
|
|
33
|
+
)
|
|
34
|
+
use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
|
|
35
|
+
self.logger.debug(
|
|
36
|
+
f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API "
|
|
37
|
+
f"(ON_PREMISE {self.server_version})"
|
|
38
|
+
)
|
|
39
|
+
self.use_createmeta = use_legacy
|
|
40
|
+
|
|
41
|
+
@retry(stop_max_attempt_number=3, wait_fixed=180000)
|
|
42
|
+
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
43
|
+
auth = (
|
|
44
|
+
{"token_auth": self.jira_token} if not self.sso else {"basic_auth": (self.jira_username, self.jira_token)}
|
|
45
|
+
)
|
|
46
|
+
jira_client = JIRA(**auth, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout)
|
|
47
|
+
if jira_client.projects():
|
|
48
|
+
self.logger.info("Jira Connection Successful")
|
|
49
|
+
return jira_client
|
|
50
|
+
self.logger.error("Jira Connection Failed")
|
|
51
|
+
raise JIRAError("Jira Connection Failed")
|
|
52
|
+
|
|
53
|
+
def _format_assignee(self, assignee: str) -> dict:
|
|
54
|
+
return {"name": assignee}
|
|
55
|
+
|
|
56
|
+
def _paginate(
|
|
57
|
+
self,
|
|
58
|
+
query: str,
|
|
59
|
+
max_results: int,
|
|
60
|
+
specific_fields: str | list[str],
|
|
61
|
+
jira_err_limit: int,
|
|
62
|
+
json_result: bool,
|
|
63
|
+
) -> ResultList[Issue] | list:
|
|
64
|
+
start_at = 0
|
|
65
|
+
all_issues = []
|
|
66
|
+
retries = 0
|
|
67
|
+
while True: # pylint: disable=while-used
|
|
68
|
+
try:
|
|
69
|
+
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
70
|
+
page = self.jira_client.search_issues(
|
|
71
|
+
jql_str=query,
|
|
72
|
+
startAt=start_at,
|
|
73
|
+
maxResults=max_results,
|
|
74
|
+
fields=specific_fields,
|
|
75
|
+
json_result=json_result,
|
|
76
|
+
)
|
|
77
|
+
if not page:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
all_issues += page["issues"] if json_result else page
|
|
81
|
+
if json_result:
|
|
82
|
+
start_at += page["maxResults"]
|
|
83
|
+
else:
|
|
84
|
+
start_at += page.maxResults
|
|
85
|
+
if start_at >= max_results:
|
|
86
|
+
break
|
|
87
|
+
retries = 0
|
|
88
|
+
except JIRAError as err:
|
|
89
|
+
if err.status_code == 400:
|
|
90
|
+
self.logger.error(f"Bad Request: {err.text}")
|
|
91
|
+
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
92
|
+
retries += 1
|
|
93
|
+
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
94
|
+
if retries >= jira_err_limit:
|
|
95
|
+
self.logger.exception("Max retries exceeded.")
|
|
96
|
+
return []
|
|
97
|
+
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
98
|
+
return all_issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: custom-python-logger>=2.0.10
|
|
20
|
-
Requires-Dist: dotenv>=0.
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: pytest>=9.0.1
|
|
22
|
+
Requires-Dist: packaging>=26.2
|
|
24
23
|
Requires-Dist: requests>=2.32.5
|
|
25
24
|
Requires-Dist: retrying>=1.4.2
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit>=4.5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
28
|
+
Requires-Dist: setuptools>=80.9.0; extra == "dev"
|
|
29
|
+
Requires-Dist: wheel>=0.45.1; extra == "dev"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|

|
|
@@ -59,7 +61,7 @@ pip install python-jira-plus
|
|
|
59
61
|
---
|
|
60
62
|
|
|
61
63
|
## Requirements
|
|
62
|
-
- Python 3.
|
|
64
|
+
- Python 3.12+
|
|
63
65
|
- `jira` package
|
|
64
66
|
- `retrying` package
|
|
65
67
|
- `custom-python-logger` package
|
|
@@ -4,9 +4,14 @@ README.md
|
|
|
4
4
|
pyproject.toml
|
|
5
5
|
python_jira_plus/__init__.py
|
|
6
6
|
python_jira_plus/describe_allowed_value.py
|
|
7
|
-
python_jira_plus/
|
|
7
|
+
python_jira_plus/jira_base.py
|
|
8
|
+
python_jira_plus/jira_cloud.py
|
|
9
|
+
python_jira_plus/jira_on_premise.py
|
|
8
10
|
python_jira_plus.egg-info/PKG-INFO
|
|
9
11
|
python_jira_plus.egg-info/SOURCES.txt
|
|
10
12
|
python_jira_plus.egg-info/dependency_links.txt
|
|
11
13
|
python_jira_plus.egg-info/requires.txt
|
|
12
|
-
python_jira_plus.egg-info/top_level.txt
|
|
14
|
+
python_jira_plus.egg-info/top_level.txt
|
|
15
|
+
tests/test_describe_allowed_value.py
|
|
16
|
+
tests/test_jira_cloud_integration.py
|
|
17
|
+
tests/test_jira_plus.py
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
custom-python-logger>=2.0.10
|
|
2
|
-
dotenv>=0.
|
|
2
|
+
python-dotenv>=1.0.0
|
|
3
3
|
jira>=3.10.5
|
|
4
|
-
|
|
5
|
-
pytest>=9.0.1
|
|
4
|
+
packaging>=26.2
|
|
6
5
|
requests>=2.32.5
|
|
7
6
|
retrying>=1.4.2
|
|
7
|
+
|
|
8
|
+
[dev]
|
|
9
|
+
pre-commit>=4.5.0
|
|
10
|
+
pytest>=9.0.1
|
|
8
11
|
setuptools>=80.9.0
|
|
9
12
|
wheel>=0.45.1
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pylint: disable=no-self-use
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestDescribeAllowedValueStr:
|
|
8
|
+
def test_str_in_allowed_set_returns_true(self) -> None:
|
|
9
|
+
assert describe_allowed_value(
|
|
10
|
+
"High", allowed_values={"High", "Medium", "Low"}
|
|
11
|
+
), "Expected True for 'High' in allowed set"
|
|
12
|
+
|
|
13
|
+
def test_str_not_in_allowed_set_returns_false(self) -> None:
|
|
14
|
+
assert not describe_allowed_value(
|
|
15
|
+
"Critical", allowed_values={"High", "Medium", "Low"}
|
|
16
|
+
), "Expected False for 'Critical' not in allowed set"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestDescribeAllowedValueInt:
|
|
20
|
+
def test_int_in_allowed_set_returns_true(self) -> None:
|
|
21
|
+
assert describe_allowed_value(1, allowed_values={1, 2, 3}), "Expected True for 1 in allowed set"
|
|
22
|
+
|
|
23
|
+
def test_int_not_in_allowed_set_returns_false(self) -> None:
|
|
24
|
+
assert not describe_allowed_value(9, allowed_values={1, 2, 3}), "Expected False for 9 not in allowed set"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestDescribeAllowedValueFloat:
|
|
28
|
+
def test_float_in_allowed_set_returns_true(self) -> None:
|
|
29
|
+
assert describe_allowed_value(1.5, allowed_values={1.5, 2.5}), "Expected True for 1.5 in allowed set"
|
|
30
|
+
|
|
31
|
+
def test_float_not_in_allowed_set_returns_false(self) -> None:
|
|
32
|
+
assert not describe_allowed_value(9.9, allowed_values={1.5, 2.5}), "Expected False for 9.9 not in allowed set"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestDescribeAllowedValueList:
|
|
36
|
+
def test_list_of_strings_with_match_returns_true(self) -> None:
|
|
37
|
+
assert describe_allowed_value(
|
|
38
|
+
["High", "Unknown"], allowed_values={"High", "Medium"}
|
|
39
|
+
), "Expected True when at least one item matches"
|
|
40
|
+
|
|
41
|
+
def test_list_of_strings_no_match_returns_false(self) -> None:
|
|
42
|
+
assert not describe_allowed_value(
|
|
43
|
+
["Critical", "Unknown"], allowed_values={"High", "Medium"}
|
|
44
|
+
), "Expected False when no items match"
|
|
45
|
+
|
|
46
|
+
def test_list_of_dicts_with_match_returns_true(self) -> None:
|
|
47
|
+
assert describe_allowed_value(
|
|
48
|
+
[{"name": "High"}], allowed_values={"High", "Medium"}
|
|
49
|
+
), "Expected True when dict value matches"
|
|
50
|
+
|
|
51
|
+
def test_list_of_dicts_no_match_returns_false(self) -> None:
|
|
52
|
+
assert not describe_allowed_value(
|
|
53
|
+
[{"name": "Critical"}], allowed_values={"High", "Medium"}
|
|
54
|
+
), "Expected False when dict value doesn't match"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestDescribeAllowedValueSet:
|
|
58
|
+
def test_set_with_match_returns_true(self) -> None:
|
|
59
|
+
assert describe_allowed_value(
|
|
60
|
+
{"High", "Unknown"}, allowed_values={"High", "Medium"}
|
|
61
|
+
), "Expected True when set contains a match"
|
|
62
|
+
|
|
63
|
+
def test_set_no_match_returns_false(self) -> None:
|
|
64
|
+
assert not describe_allowed_value(
|
|
65
|
+
{"Critical"}, allowed_values={"High", "Medium"}
|
|
66
|
+
), "Expected False when set has no match"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestDescribeAllowedValueDict:
|
|
70
|
+
def test_dict_value_in_allowed_set_returns_true(self) -> None:
|
|
71
|
+
assert describe_allowed_value(
|
|
72
|
+
{"name": "High"}, allowed_values={"High", "Medium"}
|
|
73
|
+
), "Expected True when dict value matches"
|
|
74
|
+
|
|
75
|
+
def test_dict_value_not_in_allowed_set_returns_false(self) -> None:
|
|
76
|
+
assert not describe_allowed_value(
|
|
77
|
+
{"name": "Critical"}, allowed_values={"High", "Medium"}
|
|
78
|
+
), "Expected False when dict value doesn't match"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestDescribeAllowedValueUnknownType:
|
|
82
|
+
def test_unknown_type_raises_not_implemented_error(self) -> None:
|
|
83
|
+
with pytest.raises(NotImplementedError):
|
|
84
|
+
describe_allowed_value(object(), allowed_values={"High"})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests against a real Jira Cloud instance.
|
|
3
|
+
|
|
4
|
+
Required env vars:
|
|
5
|
+
JIRA_USER_NAME your Jira email
|
|
6
|
+
JIRA_TOKEN your Jira API token
|
|
7
|
+
JIRA_BASE_URL e.g. your-instance.atlassian.net
|
|
8
|
+
|
|
9
|
+
Optional env vars (fall back to defaults if not set):
|
|
10
|
+
JIRA_TEST_PROJECT project key to query (default: SUP)
|
|
11
|
+
JIRA_TEST_ISSUE_TYPE issue type to query (default: [System] Service request)
|
|
12
|
+
JIRA_TEST_ISSUE_KEY a known issue key (default: SUP-1)
|
|
13
|
+
JIRA_TEST_FIELD field for allowed values check (default: Priority)
|
|
14
|
+
|
|
15
|
+
Run only integration tests:
|
|
16
|
+
uv run pytest -m integration -v
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# pylint: disable=no-self-use,redefined-outer-name,consider-using-assignment-expr
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
from jira import Issue, JIRAError
|
|
24
|
+
|
|
25
|
+
from python_jira_plus import BASIC_FIELDS
|
|
26
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
27
|
+
|
|
28
|
+
pytestmark = pytest.mark.integration
|
|
29
|
+
|
|
30
|
+
_REQUIRED_ENV = ("JIRA_USER_NAME", "JIRA_TOKEN", "JIRA_BASE_URL")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _missing_env() -> list[str]:
|
|
34
|
+
return [var for var in _REQUIRED_ENV if not os.getenv(var)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture(scope="module")
|
|
38
|
+
def jira() -> JiraCloud:
|
|
39
|
+
if missing := _missing_env():
|
|
40
|
+
pytest.skip(f"Missing required env vars: {', '.join(missing)}")
|
|
41
|
+
return JiraCloud()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(scope="module")
|
|
45
|
+
def project_key() -> str:
|
|
46
|
+
return os.getenv("JIRA_TEST_PROJECT", "SUP")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture(scope="module")
|
|
50
|
+
def issue_type() -> str:
|
|
51
|
+
return os.getenv("JIRA_TEST_ISSUE_TYPE", "[System] Service request")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture(scope="module")
|
|
55
|
+
def issue_key() -> str:
|
|
56
|
+
return os.getenv("JIRA_TEST_ISSUE_KEY", "SUP-1")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture(scope="module")
|
|
60
|
+
def test_field() -> str:
|
|
61
|
+
return os.getenv("JIRA_TEST_FIELD", "Priority")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestConnection:
|
|
65
|
+
def test_client_is_connected(self, jira: JiraCloud) -> None:
|
|
66
|
+
result = jira.check_client_connection()
|
|
67
|
+
assert result is True, "Expected active connection to Jira Cloud"
|
|
68
|
+
|
|
69
|
+
def test_server_version_is_tuple(self, jira: JiraCloud) -> None:
|
|
70
|
+
assert isinstance(jira.server_version, tuple), f"Expected tuple, got {type(jira.server_version)}"
|
|
71
|
+
assert len(jira.server_version) > 0, "Expected non-empty version tuple"
|
|
72
|
+
|
|
73
|
+
def test_cloud_uses_createmeta(self, jira: JiraCloud) -> None:
|
|
74
|
+
assert (
|
|
75
|
+
jira.use_createmeta is True
|
|
76
|
+
), "Cloud always uses createmeta — project_issue_types is not supported for Cloud by the jira library"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestGetObjectsByQuery:
|
|
80
|
+
def test_returns_list_of_issues(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
81
|
+
query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
|
|
82
|
+
result = jira.get_objects_by_query(
|
|
83
|
+
query=query,
|
|
84
|
+
specific_fields=BASIC_FIELDS,
|
|
85
|
+
max_results=10,
|
|
86
|
+
json_result=True,
|
|
87
|
+
)
|
|
88
|
+
assert isinstance(result, list), f"Expected list, got {type(result)}"
|
|
89
|
+
|
|
90
|
+
def test_returned_issues_have_basic_fields(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
91
|
+
query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
|
|
92
|
+
result = jira.get_objects_by_query(
|
|
93
|
+
query=query,
|
|
94
|
+
specific_fields=BASIC_FIELDS,
|
|
95
|
+
max_results=5,
|
|
96
|
+
json_result=True,
|
|
97
|
+
)
|
|
98
|
+
if not result:
|
|
99
|
+
pytest.skip(f"No issues found in project '{project_key}' with issue type '{issue_type}'")
|
|
100
|
+
|
|
101
|
+
issue = result[0]
|
|
102
|
+
assert "key" in issue, f"Expected 'key' in issue, got keys: {list(issue.keys())}"
|
|
103
|
+
assert "fields" in issue, f"Expected 'fields' in issue, got keys: {list(issue.keys())}"
|
|
104
|
+
|
|
105
|
+
def test_invalid_jql_raises(self, jira: JiraCloud) -> None:
|
|
106
|
+
with pytest.raises(JIRAError):
|
|
107
|
+
jira.get_objects_by_query(query="INVALID JQL !!!", max_results=1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestGetIssueByKey:
|
|
111
|
+
def test_json_result_true_returns_dict(self, jira: JiraCloud, issue_key: str) -> None:
|
|
112
|
+
result = jira.get_issue_by_key(key=issue_key, json_result=True)
|
|
113
|
+
if result is None:
|
|
114
|
+
pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
|
|
115
|
+
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
|
116
|
+
assert "key" in result, "Expected 'key' in result"
|
|
117
|
+
|
|
118
|
+
def test_json_result_false_returns_issue_object(self, jira: JiraCloud, issue_key: str) -> None:
|
|
119
|
+
result = jira.get_issue_by_key(key=issue_key, json_result=False)
|
|
120
|
+
if result is None:
|
|
121
|
+
pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
|
|
122
|
+
assert isinstance(result, Issue), f"Expected Issue object, got {type(result)}"
|
|
123
|
+
|
|
124
|
+
def test_nonexistent_issue_returns_none(self, jira: JiraCloud) -> None:
|
|
125
|
+
result = jira.get_issue_by_key(key="NONEXISTENT-99999999")
|
|
126
|
+
assert result is None, "Expected None for a nonexistent issue key"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestGetAllowedValues:
|
|
130
|
+
def test_returns_list_for_valid_field(
|
|
131
|
+
self, jira: JiraCloud, project_key: str, issue_type: str, test_field: str
|
|
132
|
+
) -> None:
|
|
133
|
+
result = jira.get_allowed_values(
|
|
134
|
+
project_key=project_key,
|
|
135
|
+
issue_type=issue_type,
|
|
136
|
+
field_id_or_name=test_field,
|
|
137
|
+
)
|
|
138
|
+
assert result is None or isinstance(result, list), f"Expected list or None, got {type(result)}"
|
|
139
|
+
|
|
140
|
+
def test_invalid_field_raises_value_error(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
141
|
+
with pytest.raises(ValueError, match="not found"):
|
|
142
|
+
jira.get_allowed_values(
|
|
143
|
+
project_key=project_key,
|
|
144
|
+
issue_type=issue_type,
|
|
145
|
+
field_id_or_name="nonexistent_field_xyz_999",
|
|
146
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# pylint: disable=no-self-use,protected-access
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from jira import JIRAError
|
|
6
|
+
|
|
7
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
8
|
+
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_issue_type_mock(name: str, issue_id: str) -> MagicMock:
|
|
12
|
+
mock = MagicMock()
|
|
13
|
+
mock.name = name
|
|
14
|
+
mock.id = issue_id
|
|
15
|
+
return mock
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_field_mock(field_id: str, raw: dict) -> MagicMock:
|
|
19
|
+
mock = MagicMock()
|
|
20
|
+
mock.fieldId = field_id
|
|
21
|
+
mock.raw = raw
|
|
22
|
+
return mock
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestResolveLegacyApi:
|
|
26
|
+
def test_cloud_uses_legacy_api(self, jira_plus_cloud: JiraCloud) -> None:
|
|
27
|
+
assert (
|
|
28
|
+
jira_plus_cloud.use_createmeta is True
|
|
29
|
+
), "Cloud always uses createmeta — project_issue_types is not supported for Cloud by the jira library"
|
|
30
|
+
|
|
31
|
+
def test_on_premise_below_9_uses_legacy_api(self, jira_plus_on_premise_legacy: JiraOnPremise) -> None:
|
|
32
|
+
assert jira_plus_on_premise_legacy.use_createmeta is True, "ON_PREMISE < 9.0 should use createmeta API"
|
|
33
|
+
|
|
34
|
+
def test_on_premise_9_and_above_uses_new_api(self, jira_plus_on_premise_new: JiraOnPremise) -> None:
|
|
35
|
+
assert (
|
|
36
|
+
jira_plus_on_premise_new.use_createmeta is False
|
|
37
|
+
), "ON_PREMISE >= 9.0 should use project_issue_types / project_issue_fields API"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestGetIssueByKey:
|
|
41
|
+
def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraCloud) -> None:
|
|
42
|
+
mock_issue = MagicMock()
|
|
43
|
+
mock_issue.raw = {"key": "PROJ-1", "fields": {}}
|
|
44
|
+
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
45
|
+
|
|
46
|
+
result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=True)
|
|
47
|
+
|
|
48
|
+
assert result == {"key": "PROJ-1", "fields": {}}, f"Expected raw dict, got {result}"
|
|
49
|
+
|
|
50
|
+
def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraCloud) -> None:
|
|
51
|
+
mock_issue = MagicMock()
|
|
52
|
+
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
53
|
+
|
|
54
|
+
result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=False)
|
|
55
|
+
|
|
56
|
+
assert result is mock_issue, "Expected Issue object, not raw dict"
|
|
57
|
+
|
|
58
|
+
def test_jira_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
|
|
59
|
+
jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Not found", status_code=404)
|
|
60
|
+
|
|
61
|
+
result = jira_plus_cloud.get_issue_by_key(key="PROJ-999")
|
|
62
|
+
|
|
63
|
+
assert result is None, "Expected None on JIRAError"
|
|
64
|
+
|
|
65
|
+
def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
|
|
66
|
+
jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
|
|
67
|
+
|
|
68
|
+
result = jira_plus_cloud.get_issue_by_key(key="PROJ-1")
|
|
69
|
+
|
|
70
|
+
assert result is None, "Expected None on unexpected error"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestDeleteIssue:
|
|
74
|
+
def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraCloud) -> None:
|
|
75
|
+
mock_issue = MagicMock()
|
|
76
|
+
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
77
|
+
|
|
78
|
+
result = jira_plus_cloud.delete_issue("PROJ-1")
|
|
79
|
+
|
|
80
|
+
assert result is True, "Expected True on successful delete"
|
|
81
|
+
mock_issue.delete.assert_called_once()
|
|
82
|
+
|
|
83
|
+
def test_jira_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
|
|
84
|
+
jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Forbidden", status_code=403)
|
|
85
|
+
|
|
86
|
+
result = jira_plus_cloud.delete_issue("PROJ-1")
|
|
87
|
+
|
|
88
|
+
assert result is False, "Expected False on JIRAError"
|
|
89
|
+
|
|
90
|
+
def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
|
|
91
|
+
jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
|
|
92
|
+
|
|
93
|
+
result = jira_plus_cloud.delete_issue("PROJ-1")
|
|
94
|
+
|
|
95
|
+
assert result is False, "Expected False on unexpected error"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestValidateFields:
|
|
99
|
+
def _setup_createmeta(self, jira_plus: JiraCloud, fields: dict) -> None:
|
|
100
|
+
jira_plus.jira_client.createmeta.return_value = {
|
|
101
|
+
"projects": [
|
|
102
|
+
{
|
|
103
|
+
"key": "PROJ",
|
|
104
|
+
"issuetypes": [{"name": "Story", "fields": fields}],
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def test_valid_string_field_raises_no_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
110
|
+
self._setup_createmeta(
|
|
111
|
+
jira_plus_cloud,
|
|
112
|
+
{
|
|
113
|
+
"summary": {"schema": {"type": "string"}, "name": "Summary"},
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": "Hello"})
|
|
118
|
+
|
|
119
|
+
def test_field_not_in_metadata_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
120
|
+
self._setup_createmeta(
|
|
121
|
+
jira_plus_cloud,
|
|
122
|
+
{
|
|
123
|
+
"summary": {"schema": {"type": "string"}, "name": "Summary"},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
with pytest.raises(ValueError, match="Invalid field"):
|
|
128
|
+
jira_plus_cloud.validate_fields("PROJ", "Story", {"nonexistent_field": "value"})
|
|
129
|
+
|
|
130
|
+
def test_wrong_type_for_string_field_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
131
|
+
self._setup_createmeta(
|
|
132
|
+
jira_plus_cloud,
|
|
133
|
+
{
|
|
134
|
+
"summary": {"schema": {"type": "string"}, "name": "Summary"},
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
with pytest.raises(ValueError, match="must be a string"):
|
|
139
|
+
jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": 123})
|
|
140
|
+
|
|
141
|
+
def test_wrong_type_for_number_field_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
142
|
+
self._setup_createmeta(
|
|
143
|
+
jira_plus_cloud,
|
|
144
|
+
{
|
|
145
|
+
"story_points": {"schema": {"type": "number"}, "name": "Story Points"},
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
with pytest.raises(ValueError, match="must be a number"):
|
|
150
|
+
jira_plus_cloud.validate_fields("PROJ", "Story", {"story_points": "five"})
|
|
151
|
+
|
|
152
|
+
def test_invalid_allowed_value_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
153
|
+
self._setup_createmeta(
|
|
154
|
+
jira_plus_cloud,
|
|
155
|
+
{
|
|
156
|
+
"priority": {
|
|
157
|
+
"schema": {"type": "string"},
|
|
158
|
+
"name": "Priority",
|
|
159
|
+
"allowedValues": [{"name": "High"}, {"name": "Low"}],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
with pytest.raises(ValueError, match="Invalid value"):
|
|
165
|
+
jira_plus_cloud.validate_fields("PROJ", "Story", {"priority": "Critical"})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TestPaginateQueryNew:
|
|
169
|
+
def test_single_page_returns_issues(self, jira_plus_cloud: JiraCloud) -> None:
|
|
170
|
+
issues = [{"key": "PROJ-1"}, {"key": "PROJ-2"}]
|
|
171
|
+
jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {
|
|
172
|
+
"issues": issues,
|
|
173
|
+
"isLast": True,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
result = jira_plus_cloud._paginate(
|
|
177
|
+
query="project = PROJ",
|
|
178
|
+
max_results=50,
|
|
179
|
+
specific_fields="*all",
|
|
180
|
+
jira_err_limit=3,
|
|
181
|
+
json_result=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
assert result == issues, f"Expected {issues}, got {result}"
|
|
185
|
+
|
|
186
|
+
def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraCloud) -> None:
|
|
187
|
+
page1 = {"issues": [{"key": "PROJ-1"}], "isLast": False, "nextPageToken": "token-2"}
|
|
188
|
+
page2 = {"issues": [{"key": "PROJ-2"}], "isLast": True}
|
|
189
|
+
jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = [page1, page2]
|
|
190
|
+
|
|
191
|
+
result = jira_plus_cloud._paginate(
|
|
192
|
+
query="project = PROJ",
|
|
193
|
+
max_results=50,
|
|
194
|
+
specific_fields="*all",
|
|
195
|
+
jira_err_limit=3,
|
|
196
|
+
json_result=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], f"Expected both pages merged, got {result}"
|
|
200
|
+
|
|
201
|
+
def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraCloud) -> None:
|
|
202
|
+
jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {}
|
|
203
|
+
|
|
204
|
+
result = jira_plus_cloud._paginate(
|
|
205
|
+
query="project = PROJ",
|
|
206
|
+
max_results=50,
|
|
207
|
+
specific_fields="*all",
|
|
208
|
+
jira_err_limit=3,
|
|
209
|
+
json_result=True,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert result == [], f"Expected empty list, got {result}"
|
|
213
|
+
|
|
214
|
+
def test_bad_request_raises_jira_error(self, jira_plus_cloud: JiraCloud) -> None:
|
|
215
|
+
jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = JIRAError("Bad JQL", status_code=400)
|
|
216
|
+
|
|
217
|
+
with pytest.raises(JIRAError):
|
|
218
|
+
jira_plus_cloud._paginate(
|
|
219
|
+
query="invalid JQL !!!",
|
|
220
|
+
max_results=50,
|
|
221
|
+
specific_fields="*all",
|
|
222
|
+
jira_err_limit=3,
|
|
223
|
+
json_result=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestGetAccountData:
|
|
228
|
+
def test_success_returns_user_list(self, jira_plus_cloud: JiraCloud) -> None:
|
|
229
|
+
mock_response = MagicMock()
|
|
230
|
+
mock_response.json.return_value = [{"accountId": "abc123", "displayName": "Test User"}]
|
|
231
|
+
|
|
232
|
+
with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
|
|
233
|
+
result = jira_plus_cloud.get_account_data("test@test.com")
|
|
234
|
+
|
|
235
|
+
assert result[0]["accountId"] == "abc123", f"Expected 'abc123', got {result[0]['accountId']}"
|
|
236
|
+
mock_response.raise_for_status.assert_called_once()
|
|
237
|
+
|
|
238
|
+
def test_http_error_raises(self, jira_plus_cloud: JiraCloud) -> None:
|
|
239
|
+
mock_response = MagicMock()
|
|
240
|
+
mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
|
|
241
|
+
|
|
242
|
+
with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
|
|
243
|
+
with pytest.raises(Exception, match="401"):
|
|
244
|
+
jira_plus_cloud.get_account_data("bad@test.com")
|
|
File without changes
|
|
File without changes
|
{python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|