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

|
|
@@ -59,7 +61,7 @@ pip install python-jira-plus
|
|
|
59
61
|
---
|
|
60
62
|
|
|
61
63
|
## Requirements
|
|
62
|
-
- Python 3.
|
|
64
|
+
- Python 3.12+
|
|
63
65
|
- `jira` package
|
|
64
66
|
- `retrying` package
|
|
65
67
|
- `custom-python-logger` package
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-jira-plus"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.8"
|
|
8
8
|
description = "Enhanced Python client for JIRA with better error handling, pagination, and metadata validation."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -22,12 +22,17 @@ classifiers = [
|
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
24
|
"custom-python-logger>=2.0.10",
|
|
25
|
-
"dotenv>=0.
|
|
25
|
+
"python-dotenv>=1.0.0",
|
|
26
26
|
"jira>=3.10.5",
|
|
27
|
-
"
|
|
28
|
-
"pytest>=9.0.1",
|
|
27
|
+
"packaging>=26.2",
|
|
29
28
|
"requests>=2.32.5",
|
|
30
29
|
"retrying>=1.4.2",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pre-commit>=4.5.0",
|
|
35
|
+
"pytest>=9.0.1",
|
|
31
36
|
"setuptools>=80.9.0",
|
|
32
37
|
"wheel>=0.45.1",
|
|
33
38
|
]
|
|
@@ -8,7 +8,7 @@ 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.jira_plus import JiraPlus, ServerType
|
|
11
|
+
from python_jira_plus.jira_plus import JiraPlus, ServerType, UrlScheme
|
|
12
12
|
|
|
13
13
|
load_dotenv()
|
|
14
14
|
|
|
@@ -27,4 +27,4 @@ BASIC_FIELDS = [
|
|
|
27
27
|
"resolution",
|
|
28
28
|
"parent",
|
|
29
29
|
]
|
|
30
|
-
__all__ = ["JiraPlus", "ServerType", "BASIC_FIELDS"]
|
|
30
|
+
__all__ = ["JiraPlus", "ServerType", "UrlScheme", "BASIC_FIELDS"]
|
{python_jira_plus-2.0.7 → python_jira_plus-2.0.8}/python_jira_plus/describe_allowed_value.py
RENAMED
|
@@ -11,17 +11,17 @@ def describe_allowed_value(value: Any, allowed_values: set = None) -> bool:
|
|
|
11
11
|
|
|
12
12
|
@describe_allowed_value.register
|
|
13
13
|
def _(value: int, allowed_values: list = None) -> bool:
|
|
14
|
-
return value
|
|
14
|
+
return value in allowed_values
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
@describe_allowed_value.register
|
|
18
18
|
def _(value: float, allowed_values: list = None) -> bool:
|
|
19
|
-
return value
|
|
19
|
+
return value in allowed_values
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@describe_allowed_value.register
|
|
23
23
|
def _(value: str, allowed_values: list = None) -> bool:
|
|
24
|
-
return value
|
|
24
|
+
return value in allowed_values
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@describe_allowed_value.register
|
|
@@ -53,10 +53,12 @@ class JiraPlus:
|
|
|
53
53
|
|
|
54
54
|
self.jira_client = self.create_connection()
|
|
55
55
|
self.check_client_connection()
|
|
56
|
+
self.server_version = self.get_server_version()
|
|
57
|
+
self.use_legacy_api = self._resolve_use_legacy_api()
|
|
56
58
|
|
|
57
59
|
@retry(stop_max_attempt_number=3, wait_fixed=180000)
|
|
58
|
-
def create_connection(self, timeout: int = 580) -> JIRA
|
|
59
|
-
if self.server_type == ServerType.ON_PREMISE and not self.sso:
|
|
60
|
+
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
61
|
+
if self.server_type == ServerType.ON_PREMISE and not self.sso:
|
|
60
62
|
jira_client = JIRA(
|
|
61
63
|
token_auth=self.jira_token, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout
|
|
62
64
|
)
|
|
@@ -142,12 +144,16 @@ class JiraPlus:
|
|
|
142
144
|
all_issues = []
|
|
143
145
|
retries = 0
|
|
144
146
|
page = None
|
|
145
|
-
while True:
|
|
147
|
+
while True:
|
|
146
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
|
+
|
|
147
153
|
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
148
154
|
page = self.jira_client.enhanced_search_issues(
|
|
149
155
|
jql_str=query,
|
|
150
|
-
nextPageToken=
|
|
156
|
+
nextPageToken=next_token,
|
|
151
157
|
maxResults=max_results,
|
|
152
158
|
fields=specific_fields,
|
|
153
159
|
json_result=json_result,
|
|
@@ -156,7 +162,8 @@ class JiraPlus:
|
|
|
156
162
|
break
|
|
157
163
|
|
|
158
164
|
all_issues += page["issues"] if json_result else page
|
|
159
|
-
|
|
165
|
+
is_last = page['isLast'] if json_result else page.isLast
|
|
166
|
+
if is_last:
|
|
160
167
|
break
|
|
161
168
|
retries = 0 # reset on success
|
|
162
169
|
except JIRAError as err:
|
|
@@ -268,21 +275,16 @@ class JiraPlus:
|
|
|
268
275
|
def get_server_version(self) -> tuple[int, ...]:
|
|
269
276
|
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
270
277
|
|
|
271
|
-
def
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
)
|
|
277
|
-
self.logger.warning(
|
|
278
|
-
f"JIRA server version {server_version} is not fully compatible with createmeta-based field validation. "
|
|
279
|
-
"Some features may not work as expected."
|
|
280
|
-
)
|
|
281
|
-
return False
|
|
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)")
|
|
282
284
|
return True
|
|
283
285
|
|
|
284
286
|
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
285
|
-
if self.
|
|
287
|
+
if self.use_legacy_api:
|
|
286
288
|
_, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
|
|
287
289
|
return issue_meta["fields"]
|
|
288
290
|
_project_issue_types = self.jira_client.project_issue_types(project_key)
|
|
@@ -291,7 +293,7 @@ class JiraPlus:
|
|
|
291
293
|
return {f.fieldId: f.raw for f in field_list}
|
|
292
294
|
|
|
293
295
|
def validate_fields( # pylint: disable=R1260
|
|
294
|
-
self, project_key: str, issue_type: str, fields: dict
|
|
296
|
+
self, project_key: str, issue_type: str, fields: dict
|
|
295
297
|
) -> None:
|
|
296
298
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
297
299
|
for custom_field, value in fields.items():
|
|
@@ -370,7 +372,7 @@ class JiraPlus:
|
|
|
370
372
|
|
|
371
373
|
def delete_issue(self, issue_key: str) -> bool:
|
|
372
374
|
try:
|
|
373
|
-
issue = self.get_issue_by_key(key=issue_key)
|
|
375
|
+
issue = self.get_issue_by_key(key=issue_key, json_result=False)
|
|
374
376
|
issue.delete()
|
|
375
377
|
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
376
378
|
return True
|
|
@@ -455,10 +457,13 @@ class JiraPlus:
|
|
|
455
457
|
return False
|
|
456
458
|
|
|
457
459
|
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
458
|
-
|
|
459
|
-
f"
|
|
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,
|
|
460
465
|
)
|
|
461
|
-
response
|
|
466
|
+
response.raise_for_status()
|
|
462
467
|
return response.json()
|
|
463
468
|
|
|
464
469
|
def get_account_id(self, user_mail: str) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.8
|
|
4
4
|
Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: custom-python-logger>=2.0.10
|
|
20
|
-
Requires-Dist: dotenv>=0.
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: pytest>=9.0.1
|
|
22
|
+
Requires-Dist: packaging>=26.2
|
|
24
23
|
Requires-Dist: requests>=2.32.5
|
|
25
24
|
Requires-Dist: retrying>=1.4.2
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit>=4.5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
28
|
+
Requires-Dist: setuptools>=80.9.0; extra == "dev"
|
|
29
|
+
Requires-Dist: wheel>=0.45.1; extra == "dev"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|

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