python-jira-plus 4.0.0__tar.gz → 4.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-4.0.0/python_jira_plus.egg-info → python_jira_plus-4.1.0}/PKG-INFO +1 -1
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/pyproject.toml +1 -1
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/__init__.py +10 -1
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/consts.py +1 -0
- python_jira_plus-4.1.0/python_jira_plus/helpers.py +16 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_base.py +27 -0
- python_jira_plus-4.1.0/python_jira_plus/types.py +9 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0/python_jira_plus.egg-info}/PKG-INFO +1 -1
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/SOURCES.txt +4 -1
- python_jira_plus-4.1.0/tests/test_jira_plus_v41.py +67 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/LICENSE +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/MANIFEST.in +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/README.md +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/describe_allowed_value.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_cloud.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_on_premise.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/requires.txt +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/top_level.txt +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/setup.cfg +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_describe_allowed_value.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_cloud_integration.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_cloud_write_integration.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_plus.py +0 -0
- {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_plus_hardening.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-jira-plus"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.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"
|
|
@@ -9,6 +9,7 @@ from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
|
9
9
|
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
10
10
|
from python_jira_plus.jira_cloud import JiraCloud
|
|
11
11
|
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
12
|
+
from python_jira_plus.types import CreateField
|
|
12
13
|
|
|
13
14
|
BASIC_FIELDS = [
|
|
14
15
|
"created",
|
|
@@ -25,4 +26,12 @@ BASIC_FIELDS = [
|
|
|
25
26
|
"resolution",
|
|
26
27
|
"parent",
|
|
27
28
|
]
|
|
28
|
-
__all__ = [
|
|
29
|
+
__all__ = [
|
|
30
|
+
"JiraBase",
|
|
31
|
+
"JiraCloud",
|
|
32
|
+
"JiraOnPremise",
|
|
33
|
+
"UrlScheme",
|
|
34
|
+
"BASIC_FIELDS",
|
|
35
|
+
"describe_allowed_value",
|
|
36
|
+
"CreateField",
|
|
37
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from python_jira_plus.consts import ISSUE_REF_PATTERN
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def allowed_value_label(value: Any) -> str:
|
|
7
|
+
"""A readable label for one entry of a field's `allowedValues`."""
|
|
8
|
+
if isinstance(value, dict):
|
|
9
|
+
return str(value.get("name") or value.get("value") or value.get("key") or value.get("id"))
|
|
10
|
+
return str(value)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_issue_ref(issue_ref: Any) -> None:
|
|
14
|
+
"""Reject an issue key/id that could change the request path (jira-python does not escape it)."""
|
|
15
|
+
if isinstance(issue_ref, str) and not ISSUE_REF_PATTERN.fullmatch(issue_ref):
|
|
16
|
+
raise ValueError(f"Invalid issue key '{issue_ref}'. Expected a key like PROJ-123 or a numeric id")
|
|
@@ -19,6 +19,8 @@ from python_jira_plus.consts import (
|
|
|
19
19
|
SCHEME_PATTERN,
|
|
20
20
|
)
|
|
21
21
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
22
|
+
from python_jira_plus.helpers import allowed_value_label, check_issue_ref
|
|
23
|
+
from python_jira_plus.types import CreateField
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
class UrlScheme(Enum):
|
|
@@ -125,6 +127,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
125
127
|
def get_issue_by_key(
|
|
126
128
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
127
129
|
) -> None | Issue | dict[str, Any]:
|
|
130
|
+
check_issue_ref(key)
|
|
128
131
|
try:
|
|
129
132
|
jira_issue = self.jira_client.issue(id=key, fields=specific_fields)
|
|
130
133
|
if json_result:
|
|
@@ -220,6 +223,22 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
220
223
|
return None
|
|
221
224
|
return allowed
|
|
222
225
|
|
|
226
|
+
def get_create_fields(self, project_key: str, issue_type: str) -> list[CreateField]:
|
|
227
|
+
"""Fields for creating an issue: required first, each with its allowed values as readable labels."""
|
|
228
|
+
result = [
|
|
229
|
+
CreateField(
|
|
230
|
+
id=field_id,
|
|
231
|
+
name=meta.get("name", field_id),
|
|
232
|
+
required=bool(meta.get("required")),
|
|
233
|
+
type=(meta.get("schema") or {}).get("type"),
|
|
234
|
+
allowed_values=(
|
|
235
|
+
[allowed_value_label(v) for v in allowed] if (allowed := meta.get("allowedValues")) else None
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
for field_id, meta in self.get_project_fields_metadata(project_key, issue_type).items()
|
|
239
|
+
]
|
|
240
|
+
return sorted(result, key=lambda f: (not f["required"], f["name"]))
|
|
241
|
+
|
|
223
242
|
@staticmethod
|
|
224
243
|
def _is_value_allowed(value: Any, allowed_values: list[Any]) -> bool:
|
|
225
244
|
allowed = {av.get(key) for av in allowed_values for key in ALLOWED_VALUE_KEYS}
|
|
@@ -300,6 +319,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
300
319
|
Raises:
|
|
301
320
|
ValueError: If a field is not editable or a value is invalid (always raised).
|
|
302
321
|
"""
|
|
322
|
+
check_issue_ref(issue_key)
|
|
303
323
|
try:
|
|
304
324
|
issue = self.jira_client.issue(issue_key)
|
|
305
325
|
edit_fields = self.jira_client.editmeta(issue_key)["fields"]
|
|
@@ -314,6 +334,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
314
334
|
return None
|
|
315
335
|
|
|
316
336
|
def delete_issue(self, issue_key: str) -> bool:
|
|
337
|
+
check_issue_ref(issue_key)
|
|
317
338
|
try:
|
|
318
339
|
issue = self.jira_client.issue(issue_key)
|
|
319
340
|
issue.delete()
|
|
@@ -324,6 +345,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
324
345
|
return False
|
|
325
346
|
|
|
326
347
|
def add_comment(self, issue: str, comment: str) -> None:
|
|
348
|
+
check_issue_ref(issue)
|
|
327
349
|
self.jira_client.add_comment(issue=issue, body=comment)
|
|
328
350
|
self.logger.debug(f"Comment added to issue {issue} successfully.")
|
|
329
351
|
|
|
@@ -338,6 +360,8 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
338
360
|
return {link_type["name"] for link_type in self.get_link_types()}
|
|
339
361
|
|
|
340
362
|
def create_link(self, issue_key: str, link_type: str, in_issue_key: str) -> bool:
|
|
363
|
+
check_issue_ref(issue_key)
|
|
364
|
+
check_issue_ref(in_issue_key)
|
|
341
365
|
try:
|
|
342
366
|
available_link_types = self._get_available_link_types()
|
|
343
367
|
except Exception as err:
|
|
@@ -358,6 +382,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
358
382
|
return False
|
|
359
383
|
|
|
360
384
|
def upload_attachment(self, issue_key: str, file_path: str) -> bool:
|
|
385
|
+
check_issue_ref(issue_key)
|
|
361
386
|
try:
|
|
362
387
|
self.jira_client.add_attachment(issue=issue_key, attachment=file_path)
|
|
363
388
|
return True
|
|
@@ -371,6 +396,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
371
396
|
|
|
372
397
|
def get_transitions(self, issue_key: str) -> list[dict[str, str]]:
|
|
373
398
|
"""Return the transitions currently available for an issue as `{id, name}` dicts."""
|
|
399
|
+
check_issue_ref(issue_key)
|
|
374
400
|
return [{"id": t["id"], "name": t["name"]} for t in self._fetch_transitions(issue_key)]
|
|
375
401
|
|
|
376
402
|
def _resolve_transition(self, issue_key: str, transition_name: str) -> dict:
|
|
@@ -394,6 +420,7 @@ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
|
394
420
|
Jira silently drops a transition comment when the transition screen has no comment field,
|
|
395
421
|
so in that case the comment is added separately after the transition succeeds.
|
|
396
422
|
"""
|
|
423
|
+
check_issue_ref(issue_key)
|
|
397
424
|
needs_separate_comment = False
|
|
398
425
|
try:
|
|
399
426
|
transition = self._resolve_transition(issue_key, transition_name)
|
|
@@ -5,9 +5,11 @@ pyproject.toml
|
|
|
5
5
|
python_jira_plus/__init__.py
|
|
6
6
|
python_jira_plus/consts.py
|
|
7
7
|
python_jira_plus/describe_allowed_value.py
|
|
8
|
+
python_jira_plus/helpers.py
|
|
8
9
|
python_jira_plus/jira_base.py
|
|
9
10
|
python_jira_plus/jira_cloud.py
|
|
10
11
|
python_jira_plus/jira_on_premise.py
|
|
12
|
+
python_jira_plus/types.py
|
|
11
13
|
python_jira_plus.egg-info/PKG-INFO
|
|
12
14
|
python_jira_plus.egg-info/SOURCES.txt
|
|
13
15
|
python_jira_plus.egg-info/dependency_links.txt
|
|
@@ -17,4 +19,5 @@ tests/test_describe_allowed_value.py
|
|
|
17
19
|
tests/test_jira_cloud_integration.py
|
|
18
20
|
tests/test_jira_cloud_write_integration.py
|
|
19
21
|
tests/test_jira_plus.py
|
|
20
|
-
tests/test_jira_plus_hardening.py
|
|
22
|
+
tests/test_jira_plus_hardening.py
|
|
23
|
+
tests/test_jira_plus_v41.py
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# pylint: disable=no-self-use
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from python_jira_plus.helpers import check_issue_ref
|
|
8
|
+
from python_jira_plus.jira_base import JiraBase
|
|
9
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
10
|
+
|
|
11
|
+
BAD_KEY = "PROJ-1/../myself"
|
|
12
|
+
ISSUE_METHODS: list[tuple[str, Callable[[JiraBase], object]]] = [
|
|
13
|
+
("get_issue_by_key", lambda jp: jp.get_issue_by_key(BAD_KEY)),
|
|
14
|
+
("update_issue", lambda jp: jp.update_issue(BAD_KEY, {"summary": "x"})),
|
|
15
|
+
("delete_issue", lambda jp: jp.delete_issue(BAD_KEY)),
|
|
16
|
+
("add_comment", lambda jp: jp.add_comment(BAD_KEY, "hi")),
|
|
17
|
+
("upload_attachment", lambda jp: jp.upload_attachment(BAD_KEY, "f.txt")),
|
|
18
|
+
("get_transitions", lambda jp: jp.get_transitions(BAD_KEY)),
|
|
19
|
+
("transition_issue", lambda jp: jp.transition_issue(BAD_KEY, "Done")),
|
|
20
|
+
("create_link (inward)", lambda jp: jp.create_link(BAD_KEY, "Blocks", "PROJ-2")),
|
|
21
|
+
("create_link (outward)", lambda jp: jp.create_link("PROJ-2", "Blocks", BAD_KEY)),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestGetCreateFields:
|
|
26
|
+
def test_required_first_and_labels(self, jira_plus_cloud: JiraCloud) -> None:
|
|
27
|
+
jira_plus_cloud.get_project_fields_metadata = MagicMock(
|
|
28
|
+
return_value={
|
|
29
|
+
"customfield_1": {
|
|
30
|
+
"name": "Severity",
|
|
31
|
+
"required": False,
|
|
32
|
+
"schema": {"type": "option"},
|
|
33
|
+
"allowedValues": [{"value": "High", "id": "1"}, {"id": "2"}],
|
|
34
|
+
},
|
|
35
|
+
"summary": {"name": "Summary", "required": True, "schema": {"type": "string"}},
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
fields = jira_plus_cloud.get_create_fields("OK", "Bug")
|
|
39
|
+
assert [f["id"] for f in fields] == ["summary", "customfield_1"]
|
|
40
|
+
assert fields[0]["allowed_values"] is None
|
|
41
|
+
assert fields[1]["allowed_values"] == ["High", "2"]
|
|
42
|
+
assert fields[1]["type"] == "option"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestIssueRefValidation:
|
|
46
|
+
@pytest.mark.parametrize("ref", ["PROJ-1", "proj-12", "10001", "AB_C-3"])
|
|
47
|
+
def test_valid(self, ref: str) -> None:
|
|
48
|
+
check_issue_ref(ref)
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize("ref", [BAD_KEY, "PROJ-1?x=1", "", "a b", "PROJ-1#x"])
|
|
51
|
+
def test_invalid(self, ref: str) -> None:
|
|
52
|
+
with pytest.raises(ValueError, match="Invalid issue key"):
|
|
53
|
+
check_issue_ref(ref)
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("call", [c for _, c in ISSUE_METHODS], ids=[n for n, _ in ISSUE_METHODS])
|
|
56
|
+
def test_every_issue_method_rejects_before_any_request(
|
|
57
|
+
self, jira_plus_cloud: JiraCloud, call: Callable[[JiraBase], object]
|
|
58
|
+
) -> None:
|
|
59
|
+
jira = jira_plus_cloud.jira_client
|
|
60
|
+
jira.reset_mock()
|
|
61
|
+
with pytest.raises(ValueError, match="Invalid issue key"):
|
|
62
|
+
call(jira_plus_cloud) # raised even with raise_on_error=False
|
|
63
|
+
assert not jira.method_calls, jira.method_calls
|
|
64
|
+
|
|
65
|
+
def test_valid_key_still_reaches_jira(self, jira_plus_cloud: JiraCloud) -> None:
|
|
66
|
+
jira_plus_cloud.add_comment("PROJ-1", "hi")
|
|
67
|
+
jira_plus_cloud.jira_client.add_comment.assert_called_once_with(issue="PROJ-1", body="hi")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/describe_allowed_value.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_cloud_write_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|