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.
Files changed (25) hide show
  1. {python_jira_plus-4.0.0/python_jira_plus.egg-info → python_jira_plus-4.1.0}/PKG-INFO +1 -1
  2. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/pyproject.toml +1 -1
  3. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/__init__.py +10 -1
  4. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/consts.py +1 -0
  5. python_jira_plus-4.1.0/python_jira_plus/helpers.py +16 -0
  6. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_base.py +27 -0
  7. python_jira_plus-4.1.0/python_jira_plus/types.py +9 -0
  8. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0/python_jira_plus.egg-info}/PKG-INFO +1 -1
  9. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/SOURCES.txt +4 -1
  10. python_jira_plus-4.1.0/tests/test_jira_plus_v41.py +67 -0
  11. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/LICENSE +0 -0
  12. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/MANIFEST.in +0 -0
  13. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/README.md +0 -0
  14. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/describe_allowed_value.py +0 -0
  15. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_cloud.py +0 -0
  16. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus/jira_on_premise.py +0 -0
  17. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
  18. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/requires.txt +0 -0
  19. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/python_jira_plus.egg-info/top_level.txt +0 -0
  20. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/setup.cfg +0 -0
  21. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_describe_allowed_value.py +0 -0
  22. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_cloud_integration.py +0 -0
  23. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_cloud_write_integration.py +0 -0
  24. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_plus.py +0 -0
  25. {python_jira_plus-4.0.0 → python_jira_plus-4.1.0}/tests/test_jira_plus_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 4.0.0
3
+ Version: 4.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jira-plus"
7
- version = "4.0.0"
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__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS", "describe_allowed_value"]
29
+ __all__ = [
30
+ "JiraBase",
31
+ "JiraCloud",
32
+ "JiraOnPremise",
33
+ "UrlScheme",
34
+ "BASIC_FIELDS",
35
+ "describe_allowed_value",
36
+ "CreateField",
37
+ ]
@@ -19,3 +19,4 @@ FIELD_TYPE_CHECKS = {
19
19
  "object": FieldTypeCheck(dict, "an object"),
20
20
  "issuetype": FieldTypeCheck(dict, "an issue type"),
21
21
  }
22
+ ISSUE_REF_PATTERN = re.compile(r"[A-Za-z0-9_]+(-[0-9]+)?")
@@ -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)
@@ -0,0 +1,9 @@
1
+ from typing import TypedDict
2
+
3
+
4
+ class CreateField(TypedDict):
5
+ id: str
6
+ name: str
7
+ required: bool
8
+ type: str | None
9
+ allowed_values: list[str] | None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 4.0.0
3
+ Version: 4.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
@@ -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")