python-jira-plus 2.0.8__tar.gz → 3.1.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.1.0}/PKG-INFO +2 -2
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/pyproject.toml +2 -2
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus/__init__.py +4 -2
- python_jira_plus-3.1.0/python_jira_plus/const.py +1 -0
- python_jira_plus-2.0.8/python_jira_plus/jira_plus.py → python_jira_plus-3.1.0/python_jira_plus/jira_base.py +43 -198
- python_jira_plus-3.1.0/python_jira_plus/jira_cloud.py +111 -0
- python_jira_plus-3.1.0/python_jira_plus/jira_on_premise.py +98 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0/python_jira_plus.egg-info}/PKG-INFO +2 -2
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus.egg-info/SOURCES.txt +4 -1
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus.egg-info/requires.txt +1 -1
- python_jira_plus-3.1.0/tests/test_describe_allowed_value.py +84 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/tests/test_jira_cloud_integration.py +23 -27
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.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.1.0}/LICENSE +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/MANIFEST.in +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/README.md +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus/describe_allowed_value.py +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus.egg-info/top_level.txt +0 -0
- {python_jira_plus-2.0.8 → python_jira_plus-3.1.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.1.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
|
|
@@ -16,7 +16,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.0
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
22
|
Requires-Dist: packaging>=26.2
|
|
@@ -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.1.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"
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
|
-
"custom-python-logger>=
|
|
24
|
+
"custom-python-logger>=4.0.0",
|
|
25
25
|
"python-dotenv>=1.0.0",
|
|
26
26
|
"jira>=3.10.5",
|
|
27
27
|
"packaging>=26.2",
|
|
@@ -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"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
LOGGER_NAME = "pytest_depends_on"
|
|
@@ -1,18 +1,17 @@
|
|
|
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
|
|
13
|
+
|
|
14
|
+
from python_jira_plus.const import LOGGER_NAME
|
|
16
15
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
17
16
|
|
|
18
17
|
|
|
@@ -21,59 +20,47 @@ class UrlScheme(Enum):
|
|
|
21
20
|
HTTPS = "https"
|
|
22
21
|
|
|
23
22
|
|
|
24
|
-
class
|
|
25
|
-
|
|
26
|
-
ON_PREMISE = "on_premise"
|
|
27
|
-
|
|
23
|
+
class JiraBase(ABC):
|
|
24
|
+
use_createmeta: bool
|
|
28
25
|
|
|
29
|
-
class JiraPlus:
|
|
30
26
|
def __init__(
|
|
31
27
|
self,
|
|
32
28
|
base_url: str | None = None,
|
|
33
|
-
server_type: ServerType = ServerType.CLOUD,
|
|
34
29
|
urllib3_log_level: int = logging.WARNING,
|
|
35
30
|
jira_username: str | None = None,
|
|
36
|
-
jira_token: str | None = None,
|
|
31
|
+
jira_token: str | None = None,
|
|
37
32
|
verify_ssl: bool = True,
|
|
38
|
-
sso: bool = False,
|
|
39
33
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
34
|
) -> None:
|
|
41
|
-
self.logger = get_logger(
|
|
35
|
+
self.logger = get_logger(LOGGER_NAME)
|
|
42
36
|
get_logger("urllib3").setLevel(urllib3_log_level)
|
|
43
37
|
|
|
44
38
|
self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
|
|
45
39
|
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
46
40
|
self.base_url = base_url or os.getenv("JIRA_BASE_URL")
|
|
47
41
|
self.url_scheme = url_scheme.value
|
|
48
|
-
|
|
49
|
-
self.server_type = server_type
|
|
50
42
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
51
43
|
self.verify_ssl = verify_ssl
|
|
52
|
-
self.sso = sso
|
|
53
44
|
|
|
54
45
|
self.jira_client = self.create_connection()
|
|
55
46
|
self.check_client_connection()
|
|
56
47
|
self.server_version = self.get_server_version()
|
|
57
|
-
self.use_legacy_api = self._resolve_use_legacy_api()
|
|
58
48
|
|
|
59
|
-
@
|
|
60
|
-
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def _paginate(
|
|
54
|
+
self,
|
|
55
|
+
query: str,
|
|
56
|
+
max_results: int,
|
|
57
|
+
specific_fields: str | list[str],
|
|
58
|
+
jira_err_limit: int,
|
|
59
|
+
json_result: bool,
|
|
60
|
+
) -> ResultList[Issue] | list: ...
|
|
71
61
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return jira_client
|
|
75
|
-
self.logger.error("Jira Connection Failed")
|
|
76
|
-
raise JIRAError("Jira Connection Failed")
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
77
64
|
|
|
78
65
|
def close_connection(self) -> None:
|
|
79
66
|
try:
|
|
@@ -82,7 +69,6 @@ class JiraPlus:
|
|
|
82
69
|
self.logger.exception(f"JIRAError \n{err}")
|
|
83
70
|
except Exception as err:
|
|
84
71
|
self.logger.exception(f"Exception \n{err}")
|
|
85
|
-
|
|
86
72
|
self.check_client_connection()
|
|
87
73
|
|
|
88
74
|
def check_client_connection(self) -> bool:
|
|
@@ -98,6 +84,9 @@ class JiraPlus:
|
|
|
98
84
|
self.logger.exception(f"Exception \n{err}")
|
|
99
85
|
return False
|
|
100
86
|
|
|
87
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
88
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
89
|
+
|
|
101
90
|
def get_issue_by_key(
|
|
102
91
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
103
92
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -120,12 +109,8 @@ class JiraPlus:
|
|
|
120
109
|
specific_fields: str | list[str] = "*all",
|
|
121
110
|
jira_err_count: int = 3,
|
|
122
111
|
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(
|
|
112
|
+
) -> ResultList[Issue] | list:
|
|
113
|
+
return self._paginate(
|
|
129
114
|
query=query,
|
|
130
115
|
max_results=max_results,
|
|
131
116
|
specific_fields=specific_fields,
|
|
@@ -133,107 +118,26 @@ class JiraPlus:
|
|
|
133
118
|
json_result=json_result,
|
|
134
119
|
)
|
|
135
120
|
|
|
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]:
|
|
121
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
226
122
|
meta = self.jira_client.createmeta(
|
|
227
123
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
228
124
|
)
|
|
229
|
-
|
|
230
125
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
231
126
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
232
|
-
|
|
233
127
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
234
128
|
if not issue_meta:
|
|
235
129
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
236
|
-
return
|
|
130
|
+
return issue_meta["fields"]
|
|
131
|
+
|
|
132
|
+
def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
|
|
133
|
+
issue_types = {t.name: t for t in self.jira_client.project_issue_types(project_key)}
|
|
134
|
+
field_list = self.jira_client.project_issue_fields(project_key, issue_types[issue_type].id)
|
|
135
|
+
return {f.fieldId: f.raw for f in field_list}
|
|
136
|
+
|
|
137
|
+
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
138
|
+
if self.use_createmeta:
|
|
139
|
+
return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
|
|
140
|
+
return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
|
|
237
141
|
|
|
238
142
|
def get_allowed_values(
|
|
239
143
|
self,
|
|
@@ -241,28 +145,20 @@ class JiraPlus:
|
|
|
241
145
|
issue_type: str,
|
|
242
146
|
field_id_or_name: str,
|
|
243
147
|
) -> list | None:
|
|
244
|
-
"""
|
|
245
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
246
|
-
"""
|
|
148
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
247
149
|
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
150
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
251
|
-
# Try matching by name (case-insensitive)
|
|
252
151
|
for _, fmeta in fields_metadata.items():
|
|
253
152
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
254
153
|
field = fmeta
|
|
255
154
|
break
|
|
256
|
-
|
|
257
155
|
if not field:
|
|
258
156
|
raise ValueError(
|
|
259
157
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
260
158
|
)
|
|
261
|
-
|
|
262
159
|
if not (allowed := field.get("allowedValues")):
|
|
263
160
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
264
161
|
return None
|
|
265
|
-
|
|
266
162
|
return allowed
|
|
267
163
|
|
|
268
164
|
@staticmethod
|
|
@@ -272,36 +168,13 @@ class JiraPlus:
|
|
|
272
168
|
allowed_values = allowed_names | allowed_keys
|
|
273
169
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
274
170
|
|
|
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:
|
|
171
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
298
172
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
299
173
|
for custom_field, value in fields.items():
|
|
300
174
|
if custom_field not in fields_metadata:
|
|
301
175
|
raise ValueError(
|
|
302
176
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
303
177
|
)
|
|
304
|
-
|
|
305
178
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
306
179
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
307
180
|
raise ValueError(
|
|
@@ -309,7 +182,6 @@ class JiraPlus:
|
|
|
309
182
|
f"with issue type '{issue_type}'."
|
|
310
183
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
311
184
|
)
|
|
312
|
-
|
|
313
185
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
314
186
|
if field_type == "string" and not isinstance(value, str):
|
|
315
187
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -323,7 +195,6 @@ class JiraPlus:
|
|
|
323
195
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
324
196
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
325
197
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
326
|
-
# Add more field type checks as needed
|
|
327
198
|
|
|
328
199
|
def create_issue(
|
|
329
200
|
self,
|
|
@@ -334,21 +205,17 @@ class JiraPlus:
|
|
|
334
205
|
assignee: str | None = None,
|
|
335
206
|
custom_fields: dict | None = None,
|
|
336
207
|
) -> Issue | None:
|
|
337
|
-
issue_fields = {
|
|
208
|
+
issue_fields: dict[str, Any] = {
|
|
338
209
|
"project": {"key": project_key},
|
|
339
210
|
"issuetype": {"name": issue_type},
|
|
340
211
|
"summary": summary,
|
|
341
212
|
"description": description,
|
|
342
213
|
}
|
|
343
214
|
if assignee:
|
|
344
|
-
issue_fields["assignee"] = (
|
|
345
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
346
|
-
)
|
|
347
|
-
|
|
215
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
348
216
|
if custom_fields:
|
|
349
217
|
issue_fields = issue_fields | custom_fields
|
|
350
218
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
351
|
-
|
|
352
219
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
353
220
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
354
221
|
return issue
|
|
@@ -359,7 +226,6 @@ class JiraPlus:
|
|
|
359
226
|
self.validate_fields(
|
|
360
227
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
361
228
|
)
|
|
362
|
-
|
|
363
229
|
issue.update(fields=fields_to_update)
|
|
364
230
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
365
231
|
return issue
|
|
@@ -398,7 +264,6 @@ class JiraPlus:
|
|
|
398
264
|
available_link_types = self._get_available_link_types()
|
|
399
265
|
if link_type not in available_link_types:
|
|
400
266
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
401
|
-
|
|
402
267
|
try:
|
|
403
268
|
self.jira_client.create_issue_link(
|
|
404
269
|
type=link_type,
|
|
@@ -438,12 +303,10 @@ class JiraPlus:
|
|
|
438
303
|
f"\nAvailable transitions: {transition_names}"
|
|
439
304
|
)
|
|
440
305
|
return False
|
|
441
|
-
|
|
442
306
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
443
307
|
if not transition_id:
|
|
444
308
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
445
309
|
return False
|
|
446
|
-
|
|
447
310
|
self.jira_client.transition_issue(
|
|
448
311
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
449
312
|
)
|
|
@@ -455,21 +318,3 @@ class JiraPlus:
|
|
|
455
318
|
except Exception as err:
|
|
456
319
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
457
320
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.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
|
|
@@ -16,7 +16,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.0
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
22
|
Requires-Dist: packaging>=26.2
|
|
@@ -3,8 +3,11 @@ MANIFEST.in
|
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
python_jira_plus/__init__.py
|
|
6
|
+
python_jira_plus/const.py
|
|
6
7
|
python_jira_plus/describe_allowed_value.py
|
|
7
|
-
python_jira_plus/
|
|
8
|
+
python_jira_plus/jira_base.py
|
|
9
|
+
python_jira_plus/jira_cloud.py
|
|
10
|
+
python_jira_plus/jira_on_premise.py
|
|
8
11
|
python_jira_plus.egg-info/PKG-INFO
|
|
9
12
|
python_jira_plus.egg-info/SOURCES.txt
|
|
10
13
|
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.1.0}/python_jira_plus/describe_allowed_value.py
RENAMED
|
File without changes
|
{python_jira_plus-2.0.8 → python_jira_plus-3.1.0}/python_jira_plus.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|