python-jira-plus 2.0.8__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.8/python_jira_plus.egg-info → python_jira_plus-3.0.0}/PKG-INFO +1 -1
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/pyproject.toml +1 -1
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus/__init__.py +4 -2
- python_jira_plus-2.0.8/python_jira_plus/jira_plus.py → python_jira_plus-3.0.0/python_jira_plus/jira_base.py +41 -197
- 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.8 → python_jira_plus-3.0.0/python_jira_plus.egg-info}/PKG-INFO +1 -1
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/SOURCES.txt +3 -1
- python_jira_plus-3.0.0/tests/test_describe_allowed_value.py +84 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/tests/test_jira_cloud_integration.py +23 -27
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/tests/test_jira_plus.py +79 -62
- python_jira_plus-2.0.8/tests/test_describe_allowed_value.py +0 -77
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/LICENSE +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/MANIFEST.in +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/README.md +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/requires.txt +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/setup.cfg +0 -0
|
@@ -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"
|
|
@@ -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"]
|
|
@@ -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,35 +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()
|
|
56
46
|
self.server_version = self.get_server_version()
|
|
57
|
-
self.use_legacy_api = self._resolve_use_legacy_api()
|
|
58
47
|
|
|
59
|
-
@
|
|
60
|
-
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: ...
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return jira_client
|
|
75
|
-
self.logger.error("Jira Connection Failed")
|
|
76
|
-
raise JIRAError("Jira Connection Failed")
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
77
63
|
|
|
78
64
|
def close_connection(self) -> None:
|
|
79
65
|
try:
|
|
@@ -82,7 +68,6 @@ class JiraPlus:
|
|
|
82
68
|
self.logger.exception(f"JIRAError \n{err}")
|
|
83
69
|
except Exception as err:
|
|
84
70
|
self.logger.exception(f"Exception \n{err}")
|
|
85
|
-
|
|
86
71
|
self.check_client_connection()
|
|
87
72
|
|
|
88
73
|
def check_client_connection(self) -> bool:
|
|
@@ -98,6 +83,9 @@ class JiraPlus:
|
|
|
98
83
|
self.logger.exception(f"Exception \n{err}")
|
|
99
84
|
return False
|
|
100
85
|
|
|
86
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
87
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
88
|
+
|
|
101
89
|
def get_issue_by_key(
|
|
102
90
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
103
91
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -120,12 +108,8 @@ class JiraPlus:
|
|
|
120
108
|
specific_fields: str | list[str] = "*all",
|
|
121
109
|
jira_err_count: int = 3,
|
|
122
110
|
json_result: bool = True,
|
|
123
|
-
) -> ResultList[Issue] |
|
|
124
|
-
|
|
125
|
-
_paginate_query_func = self._paginate_query
|
|
126
|
-
else:
|
|
127
|
-
_paginate_query_func = self._paginate_query_new
|
|
128
|
-
return _paginate_query_func(
|
|
111
|
+
) -> ResultList[Issue] | list:
|
|
112
|
+
return self._paginate(
|
|
129
113
|
query=query,
|
|
130
114
|
max_results=max_results,
|
|
131
115
|
specific_fields=specific_fields,
|
|
@@ -133,107 +117,26 @@ class JiraPlus:
|
|
|
133
117
|
json_result=json_result,
|
|
134
118
|
)
|
|
135
119
|
|
|
136
|
-
def
|
|
137
|
-
self,
|
|
138
|
-
query: str,
|
|
139
|
-
max_results: int,
|
|
140
|
-
specific_fields: str | list[str],
|
|
141
|
-
jira_err_limit: int,
|
|
142
|
-
json_result: bool,
|
|
143
|
-
) -> ResultList[Issue] | list:
|
|
144
|
-
all_issues = []
|
|
145
|
-
retries = 0
|
|
146
|
-
page = None
|
|
147
|
-
while True:
|
|
148
|
-
try:
|
|
149
|
-
next_token = None
|
|
150
|
-
if page is not None:
|
|
151
|
-
next_token = page.get('nextPageToken') if json_result else page.nextPageToken
|
|
152
|
-
|
|
153
|
-
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
154
|
-
page = self.jira_client.enhanced_search_issues(
|
|
155
|
-
jql_str=query,
|
|
156
|
-
nextPageToken=next_token,
|
|
157
|
-
maxResults=max_results,
|
|
158
|
-
fields=specific_fields,
|
|
159
|
-
json_result=json_result,
|
|
160
|
-
)
|
|
161
|
-
if not page:
|
|
162
|
-
break
|
|
163
|
-
|
|
164
|
-
all_issues += page["issues"] if json_result else page
|
|
165
|
-
is_last = page['isLast'] if json_result else page.isLast
|
|
166
|
-
if is_last:
|
|
167
|
-
break
|
|
168
|
-
retries = 0 # reset on success
|
|
169
|
-
except JIRAError as err:
|
|
170
|
-
if err.status_code == 400:
|
|
171
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
172
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
173
|
-
retries += 1
|
|
174
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
175
|
-
if retries >= jira_err_limit:
|
|
176
|
-
self.logger.exception("Max retries exceeded.")
|
|
177
|
-
return []
|
|
178
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
179
|
-
return all_issues
|
|
180
|
-
|
|
181
|
-
def _paginate_query(
|
|
182
|
-
self,
|
|
183
|
-
query: str,
|
|
184
|
-
max_results: int,
|
|
185
|
-
specific_fields: str | list[str],
|
|
186
|
-
jira_err_limit: int,
|
|
187
|
-
json_result: bool,
|
|
188
|
-
) -> ResultList[Issue] | list:
|
|
189
|
-
start_at = 0
|
|
190
|
-
all_issues = []
|
|
191
|
-
retries = 0
|
|
192
|
-
while True: # pylint: disable=W0149
|
|
193
|
-
try:
|
|
194
|
-
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
195
|
-
page = self.jira_client.search_issues(
|
|
196
|
-
jql_str=query,
|
|
197
|
-
startAt=start_at,
|
|
198
|
-
maxResults=max_results,
|
|
199
|
-
fields=specific_fields,
|
|
200
|
-
json_result=json_result,
|
|
201
|
-
)
|
|
202
|
-
if not page:
|
|
203
|
-
break
|
|
204
|
-
|
|
205
|
-
all_issues += page["issues"] if json_result else page
|
|
206
|
-
if json_result:
|
|
207
|
-
start_at += page["maxResults"]
|
|
208
|
-
else:
|
|
209
|
-
start_at += page.maxResults
|
|
210
|
-
if start_at >= max_results:
|
|
211
|
-
break
|
|
212
|
-
retries = 0 # reset on success
|
|
213
|
-
except JIRAError as err:
|
|
214
|
-
if err.status_code == 400:
|
|
215
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
216
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
217
|
-
retries += 1
|
|
218
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
219
|
-
if retries >= jira_err_limit:
|
|
220
|
-
self.logger.exception("Max retries exceeded.")
|
|
221
|
-
return []
|
|
222
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
223
|
-
return all_issues
|
|
224
|
-
|
|
225
|
-
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:
|
|
226
121
|
meta = self.jira_client.createmeta(
|
|
227
122
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
228
123
|
)
|
|
229
|
-
|
|
230
124
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
231
125
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
232
|
-
|
|
233
126
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
234
127
|
if not issue_meta:
|
|
235
128
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
236
|
-
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)
|
|
237
140
|
|
|
238
141
|
def get_allowed_values(
|
|
239
142
|
self,
|
|
@@ -241,28 +144,20 @@ class JiraPlus:
|
|
|
241
144
|
issue_type: str,
|
|
242
145
|
field_id_or_name: str,
|
|
243
146
|
) -> list | None:
|
|
244
|
-
"""
|
|
245
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
246
|
-
"""
|
|
147
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
247
148
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
248
|
-
|
|
249
|
-
# Try to find by ID or fallback to case-insensitive name
|
|
250
149
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
251
|
-
# Try matching by name (case-insensitive)
|
|
252
150
|
for _, fmeta in fields_metadata.items():
|
|
253
151
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
254
152
|
field = fmeta
|
|
255
153
|
break
|
|
256
|
-
|
|
257
154
|
if not field:
|
|
258
155
|
raise ValueError(
|
|
259
156
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
260
157
|
)
|
|
261
|
-
|
|
262
158
|
if not (allowed := field.get("allowedValues")):
|
|
263
159
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
264
160
|
return None
|
|
265
|
-
|
|
266
161
|
return allowed
|
|
267
162
|
|
|
268
163
|
@staticmethod
|
|
@@ -272,36 +167,13 @@ class JiraPlus:
|
|
|
272
167
|
allowed_values = allowed_names | allowed_keys
|
|
273
168
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
274
169
|
|
|
275
|
-
def
|
|
276
|
-
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
277
|
-
|
|
278
|
-
def _resolve_use_legacy_api(self) -> bool:
|
|
279
|
-
if self.server_type == ServerType.ON_PREMISE:
|
|
280
|
-
use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
|
|
281
|
-
self.logger.debug(f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API (ON_PREMISE {self.server_version})")
|
|
282
|
-
return use_legacy
|
|
283
|
-
self.logger.debug("Using legacy createmeta API (Cloud)")
|
|
284
|
-
return True
|
|
285
|
-
|
|
286
|
-
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
287
|
-
if self.use_legacy_api:
|
|
288
|
-
_, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
|
|
289
|
-
return issue_meta["fields"]
|
|
290
|
-
_project_issue_types = self.jira_client.project_issue_types(project_key)
|
|
291
|
-
_issue_types = {t.name: t for t in _project_issue_types}
|
|
292
|
-
field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
|
|
293
|
-
return {f.fieldId: f.raw for f in field_list}
|
|
294
|
-
|
|
295
|
-
def validate_fields( # pylint: disable=R1260
|
|
296
|
-
self, project_key: str, issue_type: str, fields: dict
|
|
297
|
-
) -> None:
|
|
170
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
298
171
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
299
172
|
for custom_field, value in fields.items():
|
|
300
173
|
if custom_field not in fields_metadata:
|
|
301
174
|
raise ValueError(
|
|
302
175
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
303
176
|
)
|
|
304
|
-
|
|
305
177
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
306
178
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
307
179
|
raise ValueError(
|
|
@@ -309,7 +181,6 @@ class JiraPlus:
|
|
|
309
181
|
f"with issue type '{issue_type}'."
|
|
310
182
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
311
183
|
)
|
|
312
|
-
|
|
313
184
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
314
185
|
if field_type == "string" and not isinstance(value, str):
|
|
315
186
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -323,7 +194,6 @@ class JiraPlus:
|
|
|
323
194
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
324
195
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
325
196
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
326
|
-
# Add more field type checks as needed
|
|
327
197
|
|
|
328
198
|
def create_issue(
|
|
329
199
|
self,
|
|
@@ -334,21 +204,17 @@ class JiraPlus:
|
|
|
334
204
|
assignee: str | None = None,
|
|
335
205
|
custom_fields: dict | None = None,
|
|
336
206
|
) -> Issue | None:
|
|
337
|
-
issue_fields = {
|
|
207
|
+
issue_fields: dict[str, Any] = {
|
|
338
208
|
"project": {"key": project_key},
|
|
339
209
|
"issuetype": {"name": issue_type},
|
|
340
210
|
"summary": summary,
|
|
341
211
|
"description": description,
|
|
342
212
|
}
|
|
343
213
|
if assignee:
|
|
344
|
-
issue_fields["assignee"] = (
|
|
345
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
346
|
-
)
|
|
347
|
-
|
|
214
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
348
215
|
if custom_fields:
|
|
349
216
|
issue_fields = issue_fields | custom_fields
|
|
350
217
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
351
|
-
|
|
352
218
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
353
219
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
354
220
|
return issue
|
|
@@ -359,7 +225,6 @@ class JiraPlus:
|
|
|
359
225
|
self.validate_fields(
|
|
360
226
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
361
227
|
)
|
|
362
|
-
|
|
363
228
|
issue.update(fields=fields_to_update)
|
|
364
229
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
365
230
|
return issue
|
|
@@ -398,7 +263,6 @@ class JiraPlus:
|
|
|
398
263
|
available_link_types = self._get_available_link_types()
|
|
399
264
|
if link_type not in available_link_types:
|
|
400
265
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
401
|
-
|
|
402
266
|
try:
|
|
403
267
|
self.jira_client.create_issue_link(
|
|
404
268
|
type=link_type,
|
|
@@ -438,12 +302,10 @@ class JiraPlus:
|
|
|
438
302
|
f"\nAvailable transitions: {transition_names}"
|
|
439
303
|
)
|
|
440
304
|
return False
|
|
441
|
-
|
|
442
305
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
443
306
|
if not transition_id:
|
|
444
307
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
445
308
|
return False
|
|
446
|
-
|
|
447
309
|
self.jira_client.transition_issue(
|
|
448
310
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
449
311
|
)
|
|
@@ -455,21 +317,3 @@ class JiraPlus:
|
|
|
455
317
|
except Exception as err:
|
|
456
318
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
457
319
|
return False
|
|
458
|
-
|
|
459
|
-
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
460
|
-
response = requests.get(
|
|
461
|
-
f"{self.server}/rest/api/3/user/search",
|
|
462
|
-
params={"query": user_mail},
|
|
463
|
-
auth=(self.jira_username, self.jira_token),
|
|
464
|
-
timeout=30,
|
|
465
|
-
)
|
|
466
|
-
response.raise_for_status()
|
|
467
|
-
return response.json()
|
|
468
|
-
|
|
469
|
-
def get_account_id(self, user_mail: str) -> str:
|
|
470
|
-
data = self.get_account_data(user_mail=user_mail)
|
|
471
|
-
return data[0]["accountId"]
|
|
472
|
-
|
|
473
|
-
def get_account_display_name(self, user_display_name: str) -> str:
|
|
474
|
-
data = self.get_account_data(user_mail=user_display_name)
|
|
475
|
-
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
|
|
@@ -4,7 +4,9 @@ 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
|
|
@@ -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"})
|
|
@@ -16,12 +16,14 @@ Run only integration tests:
|
|
|
16
16
|
uv run pytest -m integration -v
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
# pylint: disable=no-self-use,redefined-outer-name,consider-using-assignment-expr
|
|
19
20
|
import os
|
|
20
21
|
|
|
21
22
|
import pytest
|
|
23
|
+
from jira import Issue, JIRAError
|
|
22
24
|
|
|
23
25
|
from python_jira_plus import BASIC_FIELDS
|
|
24
|
-
from python_jira_plus.
|
|
26
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
25
27
|
|
|
26
28
|
pytestmark = pytest.mark.integration
|
|
27
29
|
|
|
@@ -33,11 +35,10 @@ def _missing_env() -> list[str]:
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@pytest.fixture(scope="module")
|
|
36
|
-
def jira() ->
|
|
37
|
-
missing
|
|
38
|
-
if missing:
|
|
38
|
+
def jira() -> JiraCloud:
|
|
39
|
+
if missing := _missing_env():
|
|
39
40
|
pytest.skip(f"Missing required env vars: {', '.join(missing)}")
|
|
40
|
-
return
|
|
41
|
+
return JiraCloud()
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
@pytest.fixture(scope="module")
|
|
@@ -61,22 +62,22 @@ def test_field() -> str:
|
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
class TestConnection:
|
|
64
|
-
def test_client_is_connected(self, jira:
|
|
65
|
+
def test_client_is_connected(self, jira: JiraCloud) -> None:
|
|
65
66
|
result = jira.check_client_connection()
|
|
66
67
|
assert result is True, "Expected active connection to Jira Cloud"
|
|
67
68
|
|
|
68
|
-
def test_server_version_is_tuple(self, jira:
|
|
69
|
-
assert isinstance(jira.server_version, tuple),
|
|
70
|
-
f"Expected tuple, got {type(jira.server_version)}"
|
|
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
71
|
assert len(jira.server_version) > 0, "Expected non-empty version tuple"
|
|
72
72
|
|
|
73
|
-
def
|
|
74
|
-
assert
|
|
75
|
-
|
|
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"
|
|
76
77
|
|
|
77
78
|
|
|
78
79
|
class TestGetObjectsByQuery:
|
|
79
|
-
def test_returns_list_of_issues(self, jira:
|
|
80
|
+
def test_returns_list_of_issues(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
80
81
|
query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
|
|
81
82
|
result = jira.get_objects_by_query(
|
|
82
83
|
query=query,
|
|
@@ -86,7 +87,7 @@ class TestGetObjectsByQuery:
|
|
|
86
87
|
)
|
|
87
88
|
assert isinstance(result, list), f"Expected list, got {type(result)}"
|
|
88
89
|
|
|
89
|
-
def test_returned_issues_have_basic_fields(self, jira:
|
|
90
|
+
def test_returned_issues_have_basic_fields(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
90
91
|
query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
|
|
91
92
|
result = jira.get_objects_by_query(
|
|
92
93
|
query=query,
|
|
@@ -101,47 +102,42 @@ class TestGetObjectsByQuery:
|
|
|
101
102
|
assert "key" in issue, f"Expected 'key' in issue, got keys: {list(issue.keys())}"
|
|
102
103
|
assert "fields" in issue, f"Expected 'fields' in issue, got keys: {list(issue.keys())}"
|
|
103
104
|
|
|
104
|
-
def test_invalid_jql_raises(self, jira:
|
|
105
|
-
from jira import JIRAError
|
|
105
|
+
def test_invalid_jql_raises(self, jira: JiraCloud) -> None:
|
|
106
106
|
with pytest.raises(JIRAError):
|
|
107
107
|
jira.get_objects_by_query(query="INVALID JQL !!!", max_results=1)
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
class TestGetIssueByKey:
|
|
111
|
-
def test_json_result_true_returns_dict(self, jira:
|
|
111
|
+
def test_json_result_true_returns_dict(self, jira: JiraCloud, issue_key: str) -> None:
|
|
112
112
|
result = jira.get_issue_by_key(key=issue_key, json_result=True)
|
|
113
113
|
if result is None:
|
|
114
114
|
pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
|
|
115
115
|
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
|
116
|
-
assert "key" in result,
|
|
116
|
+
assert "key" in result, "Expected 'key' in result"
|
|
117
117
|
|
|
118
|
-
def test_json_result_false_returns_issue_object(self, jira:
|
|
119
|
-
from jira import Issue
|
|
118
|
+
def test_json_result_false_returns_issue_object(self, jira: JiraCloud, issue_key: str) -> None:
|
|
120
119
|
result = jira.get_issue_by_key(key=issue_key, json_result=False)
|
|
121
120
|
if result is None:
|
|
122
121
|
pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
|
|
123
122
|
assert isinstance(result, Issue), f"Expected Issue object, got {type(result)}"
|
|
124
123
|
|
|
125
|
-
def test_nonexistent_issue_returns_none(self, jira:
|
|
124
|
+
def test_nonexistent_issue_returns_none(self, jira: JiraCloud) -> None:
|
|
126
125
|
result = jira.get_issue_by_key(key="NONEXISTENT-99999999")
|
|
127
126
|
assert result is None, "Expected None for a nonexistent issue key"
|
|
128
127
|
|
|
129
128
|
|
|
130
129
|
class TestGetAllowedValues:
|
|
131
130
|
def test_returns_list_for_valid_field(
|
|
132
|
-
self, jira:
|
|
131
|
+
self, jira: JiraCloud, project_key: str, issue_type: str, test_field: str
|
|
133
132
|
) -> None:
|
|
134
133
|
result = jira.get_allowed_values(
|
|
135
134
|
project_key=project_key,
|
|
136
135
|
issue_type=issue_type,
|
|
137
136
|
field_id_or_name=test_field,
|
|
138
137
|
)
|
|
139
|
-
assert result is None or isinstance(result, list),
|
|
140
|
-
f"Expected list or None, got {type(result)}"
|
|
138
|
+
assert result is None or isinstance(result, list), f"Expected list or None, got {type(result)}"
|
|
141
139
|
|
|
142
|
-
def test_invalid_field_raises_value_error(
|
|
143
|
-
self, jira: JiraPlus, project_key: str, issue_type: str
|
|
144
|
-
) -> None:
|
|
140
|
+
def test_invalid_field_raises_value_error(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
|
|
145
141
|
with pytest.raises(ValueError, match="not found"):
|
|
146
142
|
jira.get_allowed_values(
|
|
147
143
|
project_key=project_key,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
# pylint: disable=no-self-use,protected-access
|
|
1
2
|
from unittest.mock import MagicMock, patch
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
4
5
|
from jira import JIRAError
|
|
5
6
|
|
|
6
|
-
from python_jira_plus.
|
|
7
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
8
|
+
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def _make_issue_type_mock(name: str, issue_id: str) -> MagicMock:
|
|
@@ -21,21 +23,22 @@ def _make_field_mock(field_id: str, raw: dict) -> MagicMock:
|
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class TestResolveLegacyApi:
|
|
24
|
-
def test_cloud_uses_legacy_api(self, jira_plus_cloud:
|
|
25
|
-
assert
|
|
26
|
-
|
|
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"
|
|
27
30
|
|
|
28
|
-
def test_on_premise_below_9_uses_legacy_api(self, jira_plus_on_premise_legacy:
|
|
29
|
-
assert jira_plus_on_premise_legacy.
|
|
30
|
-
"ON_PREMISE < 9.0 should use legacy createmeta API"
|
|
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"
|
|
31
33
|
|
|
32
|
-
def test_on_premise_9_and_above_uses_new_api(self, jira_plus_on_premise_new:
|
|
33
|
-
assert
|
|
34
|
-
|
|
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"
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class TestGetIssueByKey:
|
|
38
|
-
def test_json_result_true_returns_raw_dict(self, jira_plus_cloud:
|
|
41
|
+
def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraCloud) -> None:
|
|
39
42
|
mock_issue = MagicMock()
|
|
40
43
|
mock_issue.raw = {"key": "PROJ-1", "fields": {}}
|
|
41
44
|
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
@@ -44,7 +47,7 @@ class TestGetIssueByKey:
|
|
|
44
47
|
|
|
45
48
|
assert result == {"key": "PROJ-1", "fields": {}}, f"Expected raw dict, got {result}"
|
|
46
49
|
|
|
47
|
-
def test_json_result_false_returns_issue_object(self, jira_plus_cloud:
|
|
50
|
+
def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraCloud) -> None:
|
|
48
51
|
mock_issue = MagicMock()
|
|
49
52
|
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
50
53
|
|
|
@@ -52,14 +55,14 @@ class TestGetIssueByKey:
|
|
|
52
55
|
|
|
53
56
|
assert result is mock_issue, "Expected Issue object, not raw dict"
|
|
54
57
|
|
|
55
|
-
def test_jira_error_returns_none(self, jira_plus_cloud:
|
|
58
|
+
def test_jira_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
|
|
56
59
|
jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Not found", status_code=404)
|
|
57
60
|
|
|
58
61
|
result = jira_plus_cloud.get_issue_by_key(key="PROJ-999")
|
|
59
62
|
|
|
60
63
|
assert result is None, "Expected None on JIRAError"
|
|
61
64
|
|
|
62
|
-
def test_unexpected_error_returns_none(self, jira_plus_cloud:
|
|
65
|
+
def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
|
|
63
66
|
jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
|
|
64
67
|
|
|
65
68
|
result = jira_plus_cloud.get_issue_by_key(key="PROJ-1")
|
|
@@ -68,7 +71,7 @@ class TestGetIssueByKey:
|
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
class TestDeleteIssue:
|
|
71
|
-
def test_success_returns_true_and_calls_delete(self, jira_plus_cloud:
|
|
74
|
+
def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraCloud) -> None:
|
|
72
75
|
mock_issue = MagicMock()
|
|
73
76
|
jira_plus_cloud.jira_client.issue.return_value = mock_issue
|
|
74
77
|
|
|
@@ -77,14 +80,14 @@ class TestDeleteIssue:
|
|
|
77
80
|
assert result is True, "Expected True on successful delete"
|
|
78
81
|
mock_issue.delete.assert_called_once()
|
|
79
82
|
|
|
80
|
-
def test_jira_error_returns_false(self, jira_plus_cloud:
|
|
83
|
+
def test_jira_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
|
|
81
84
|
jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Forbidden", status_code=403)
|
|
82
85
|
|
|
83
86
|
result = jira_plus_cloud.delete_issue("PROJ-1")
|
|
84
87
|
|
|
85
88
|
assert result is False, "Expected False on JIRAError"
|
|
86
89
|
|
|
87
|
-
def test_unexpected_error_returns_false(self, jira_plus_cloud:
|
|
90
|
+
def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
|
|
88
91
|
jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
|
|
89
92
|
|
|
90
93
|
result = jira_plus_cloud.delete_issue("PROJ-1")
|
|
@@ -93,67 +96,84 @@ class TestDeleteIssue:
|
|
|
93
96
|
|
|
94
97
|
|
|
95
98
|
class TestValidateFields:
|
|
96
|
-
def _setup_createmeta(self, jira_plus:
|
|
99
|
+
def _setup_createmeta(self, jira_plus: JiraCloud, fields: dict) -> None:
|
|
97
100
|
jira_plus.jira_client.createmeta.return_value = {
|
|
98
|
-
"projects": [
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
"projects": [
|
|
102
|
+
{
|
|
103
|
+
"key": "PROJ",
|
|
104
|
+
"issuetypes": [{"name": "Story", "fields": fields}],
|
|
105
|
+
}
|
|
106
|
+
]
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
def test_valid_string_field_raises_no_error(self, jira_plus_cloud:
|
|
105
|
-
self._setup_createmeta(
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
)
|
|
108
116
|
|
|
109
117
|
jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": "Hello"})
|
|
110
118
|
|
|
111
|
-
def test_field_not_in_metadata_raises_value_error(self, jira_plus_cloud:
|
|
112
|
-
self._setup_createmeta(
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
)
|
|
115
126
|
|
|
116
127
|
with pytest.raises(ValueError, match="Invalid field"):
|
|
117
128
|
jira_plus_cloud.validate_fields("PROJ", "Story", {"nonexistent_field": "value"})
|
|
118
129
|
|
|
119
|
-
def test_wrong_type_for_string_field_raises_value_error(self, jira_plus_cloud:
|
|
120
|
-
self._setup_createmeta(
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
)
|
|
123
137
|
|
|
124
138
|
with pytest.raises(ValueError, match="must be a string"):
|
|
125
139
|
jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": 123})
|
|
126
140
|
|
|
127
|
-
def test_wrong_type_for_number_field_raises_value_error(self, jira_plus_cloud:
|
|
128
|
-
self._setup_createmeta(
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
)
|
|
131
148
|
|
|
132
149
|
with pytest.raises(ValueError, match="must be a number"):
|
|
133
150
|
jira_plus_cloud.validate_fields("PROJ", "Story", {"story_points": "five"})
|
|
134
151
|
|
|
135
|
-
def test_invalid_allowed_value_raises_value_error(self, jira_plus_cloud:
|
|
136
|
-
self._setup_createmeta(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"
|
|
140
|
-
|
|
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
|
+
},
|
|
141
161
|
},
|
|
142
|
-
|
|
162
|
+
)
|
|
143
163
|
|
|
144
164
|
with pytest.raises(ValueError, match="Invalid value"):
|
|
145
165
|
jira_plus_cloud.validate_fields("PROJ", "Story", {"priority": "Critical"})
|
|
146
166
|
|
|
147
167
|
|
|
148
168
|
class TestPaginateQueryNew:
|
|
149
|
-
def test_single_page_returns_issues(self, jira_plus_cloud:
|
|
169
|
+
def test_single_page_returns_issues(self, jira_plus_cloud: JiraCloud) -> None:
|
|
150
170
|
issues = [{"key": "PROJ-1"}, {"key": "PROJ-2"}]
|
|
151
171
|
jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {
|
|
152
172
|
"issues": issues,
|
|
153
173
|
"isLast": True,
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
result = jira_plus_cloud.
|
|
176
|
+
result = jira_plus_cloud._paginate(
|
|
157
177
|
query="project = PROJ",
|
|
158
178
|
max_results=50,
|
|
159
179
|
specific_fields="*all",
|
|
@@ -163,12 +183,12 @@ class TestPaginateQueryNew:
|
|
|
163
183
|
|
|
164
184
|
assert result == issues, f"Expected {issues}, got {result}"
|
|
165
185
|
|
|
166
|
-
def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud:
|
|
186
|
+
def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraCloud) -> None:
|
|
167
187
|
page1 = {"issues": [{"key": "PROJ-1"}], "isLast": False, "nextPageToken": "token-2"}
|
|
168
188
|
page2 = {"issues": [{"key": "PROJ-2"}], "isLast": True}
|
|
169
189
|
jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = [page1, page2]
|
|
170
190
|
|
|
171
|
-
result = jira_plus_cloud.
|
|
191
|
+
result = jira_plus_cloud._paginate(
|
|
172
192
|
query="project = PROJ",
|
|
173
193
|
max_results=50,
|
|
174
194
|
specific_fields="*all",
|
|
@@ -176,13 +196,12 @@ class TestPaginateQueryNew:
|
|
|
176
196
|
json_result=True,
|
|
177
197
|
)
|
|
178
198
|
|
|
179
|
-
assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}],
|
|
180
|
-
f"Expected both pages merged, got {result}"
|
|
199
|
+
assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], f"Expected both pages merged, got {result}"
|
|
181
200
|
|
|
182
|
-
def test_empty_page_returns_empty_list(self, jira_plus_cloud:
|
|
201
|
+
def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraCloud) -> None:
|
|
183
202
|
jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {}
|
|
184
203
|
|
|
185
|
-
result = jira_plus_cloud.
|
|
204
|
+
result = jira_plus_cloud._paginate(
|
|
186
205
|
query="project = PROJ",
|
|
187
206
|
max_results=50,
|
|
188
207
|
specific_fields="*all",
|
|
@@ -192,13 +211,11 @@ class TestPaginateQueryNew:
|
|
|
192
211
|
|
|
193
212
|
assert result == [], f"Expected empty list, got {result}"
|
|
194
213
|
|
|
195
|
-
def test_bad_request_raises_jira_error(self, jira_plus_cloud:
|
|
196
|
-
jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = JIRAError(
|
|
197
|
-
"Bad JQL", status_code=400
|
|
198
|
-
)
|
|
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)
|
|
199
216
|
|
|
200
217
|
with pytest.raises(JIRAError):
|
|
201
|
-
jira_plus_cloud.
|
|
218
|
+
jira_plus_cloud._paginate(
|
|
202
219
|
query="invalid JQL !!!",
|
|
203
220
|
max_results=50,
|
|
204
221
|
specific_fields="*all",
|
|
@@ -208,20 +225,20 @@ class TestPaginateQueryNew:
|
|
|
208
225
|
|
|
209
226
|
|
|
210
227
|
class TestGetAccountData:
|
|
211
|
-
def test_success_returns_user_list(self, jira_plus_cloud:
|
|
228
|
+
def test_success_returns_user_list(self, jira_plus_cloud: JiraCloud) -> None:
|
|
212
229
|
mock_response = MagicMock()
|
|
213
230
|
mock_response.json.return_value = [{"accountId": "abc123", "displayName": "Test User"}]
|
|
214
231
|
|
|
215
|
-
with patch("python_jira_plus.
|
|
232
|
+
with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
|
|
216
233
|
result = jira_plus_cloud.get_account_data("test@test.com")
|
|
217
234
|
|
|
218
235
|
assert result[0]["accountId"] == "abc123", f"Expected 'abc123', got {result[0]['accountId']}"
|
|
219
236
|
mock_response.raise_for_status.assert_called_once()
|
|
220
237
|
|
|
221
|
-
def test_http_error_raises(self, jira_plus_cloud:
|
|
238
|
+
def test_http_error_raises(self, jira_plus_cloud: JiraCloud) -> None:
|
|
222
239
|
mock_response = MagicMock()
|
|
223
240
|
mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
|
|
224
241
|
|
|
225
|
-
with patch("python_jira_plus.
|
|
242
|
+
with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
|
|
226
243
|
with pytest.raises(Exception, match="401"):
|
|
227
244
|
jira_plus_cloud.get_account_data("bad@test.com")
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class TestDescribeAllowedValueStr:
|
|
7
|
-
def test_str_in_allowed_set_returns_true(self) -> None:
|
|
8
|
-
assert describe_allowed_value("High", allowed_values={"High", "Medium", "Low"}), \
|
|
9
|
-
"Expected True for 'High' in allowed set"
|
|
10
|
-
|
|
11
|
-
def test_str_not_in_allowed_set_returns_false(self) -> None:
|
|
12
|
-
assert not describe_allowed_value("Critical", allowed_values={"High", "Medium", "Low"}), \
|
|
13
|
-
"Expected False for 'Critical' not in allowed set"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class TestDescribeAllowedValueInt:
|
|
17
|
-
def test_int_in_allowed_set_returns_true(self) -> None:
|
|
18
|
-
assert describe_allowed_value(1, allowed_values={1, 2, 3}), \
|
|
19
|
-
"Expected True for 1 in allowed set"
|
|
20
|
-
|
|
21
|
-
def test_int_not_in_allowed_set_returns_false(self) -> None:
|
|
22
|
-
assert not describe_allowed_value(9, allowed_values={1, 2, 3}), \
|
|
23
|
-
"Expected False for 9 not in allowed set"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class TestDescribeAllowedValueFloat:
|
|
27
|
-
def test_float_in_allowed_set_returns_true(self) -> None:
|
|
28
|
-
assert describe_allowed_value(1.5, allowed_values={1.5, 2.5}), \
|
|
29
|
-
"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}), \
|
|
33
|
-
"Expected False for 9.9 not in allowed set"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class TestDescribeAllowedValueList:
|
|
37
|
-
def test_list_of_strings_with_match_returns_true(self) -> None:
|
|
38
|
-
assert describe_allowed_value(["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(["Critical", "Unknown"], allowed_values={"High", "Medium"}), \
|
|
43
|
-
"Expected False when no items match"
|
|
44
|
-
|
|
45
|
-
def test_list_of_dicts_with_match_returns_true(self) -> None:
|
|
46
|
-
assert describe_allowed_value([{"name": "High"}], allowed_values={"High", "Medium"}), \
|
|
47
|
-
"Expected True when dict value matches"
|
|
48
|
-
|
|
49
|
-
def test_list_of_dicts_no_match_returns_false(self) -> None:
|
|
50
|
-
assert not describe_allowed_value([{"name": "Critical"}], allowed_values={"High", "Medium"}), \
|
|
51
|
-
"Expected False when dict value doesn't match"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestDescribeAllowedValueSet:
|
|
55
|
-
def test_set_with_match_returns_true(self) -> None:
|
|
56
|
-
assert describe_allowed_value({"High", "Unknown"}, allowed_values={"High", "Medium"}), \
|
|
57
|
-
"Expected True when set contains a match"
|
|
58
|
-
|
|
59
|
-
def test_set_no_match_returns_false(self) -> None:
|
|
60
|
-
assert not describe_allowed_value({"Critical"}, allowed_values={"High", "Medium"}), \
|
|
61
|
-
"Expected False when set has no match"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class TestDescribeAllowedValueDict:
|
|
65
|
-
def test_dict_value_in_allowed_set_returns_true(self) -> None:
|
|
66
|
-
assert describe_allowed_value({"name": "High"}, allowed_values={"High", "Medium"}), \
|
|
67
|
-
"Expected True when dict value matches"
|
|
68
|
-
|
|
69
|
-
def test_dict_value_not_in_allowed_set_returns_false(self) -> None:
|
|
70
|
-
assert not describe_allowed_value({"name": "Critical"}, allowed_values={"High", "Medium"}), \
|
|
71
|
-
"Expected False when dict value doesn't match"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class TestDescribeAllowedValueUnknownType:
|
|
75
|
-
def test_unknown_type_raises_not_implemented_error(self) -> None:
|
|
76
|
-
with pytest.raises(NotImplementedError):
|
|
77
|
-
describe_allowed_value(object(), allowed_values={"High"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py
RENAMED
|
File without changes
|
{python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|