python-jira-plus 3.2.0__py3-none-any.whl → 4.1.0__py3-none-any.whl

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.
@@ -5,14 +5,11 @@ An enhanced Python client for JIRA with better error handling,
5
5
  pagination, metadata validation, and more.
6
6
  """
7
7
 
8
- from dotenv import load_dotenv
9
-
10
8
  from python_jira_plus.describe_allowed_value import describe_allowed_value
11
9
  from python_jira_plus.jira_base import JiraBase, UrlScheme
12
10
  from python_jira_plus.jira_cloud import JiraCloud
13
11
  from python_jira_plus.jira_on_premise import JiraOnPremise
14
-
15
- load_dotenv()
12
+ from python_jira_plus.types import CreateField
16
13
 
17
14
  BASIC_FIELDS = [
18
15
  "created",
@@ -29,4 +26,12 @@ BASIC_FIELDS = [
29
26
  "resolution",
30
27
  "parent",
31
28
  ]
32
- __all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
29
+ __all__ = [
30
+ "JiraBase",
31
+ "JiraCloud",
32
+ "JiraOnPremise",
33
+ "UrlScheme",
34
+ "BASIC_FIELDS",
35
+ "describe_allowed_value",
36
+ "CreateField",
37
+ ]
@@ -1 +1,22 @@
1
+ import re
2
+ from typing import NamedTuple
3
+
1
4
  LOGGER_NAME = "python_jira_plus"
5
+
6
+
7
+ class FieldTypeCheck(NamedTuple):
8
+ python_types: type | tuple[type, ...]
9
+ label: str
10
+
11
+
12
+ ALLOWED_VALUE_KEYS = ("name", "key", "value", "id")
13
+ SCHEME_PATTERN = re.compile(r"^(https?)://", re.IGNORECASE)
14
+ FIELD_TYPE_CHECKS = {
15
+ "string": FieldTypeCheck(str, "a string"),
16
+ "number": FieldTypeCheck((int, float), "a number"),
17
+ "boolean": FieldTypeCheck(bool, "a boolean"),
18
+ "array": FieldTypeCheck(list, "an array"),
19
+ "object": FieldTypeCheck(dict, "an object"),
20
+ "issuetype": FieldTypeCheck(dict, "an issue type"),
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")
@@ -8,11 +8,19 @@ from enum import Enum
8
8
  from typing import Any
9
9
 
10
10
  from custom_python_logger import get_logger
11
+ from dotenv import load_dotenv
11
12
  from jira import JIRA, Issue, JIRAError
12
13
  from jira.client import ResultList
13
14
 
14
- from python_jira_plus.consts import LOGGER_NAME
15
+ from python_jira_plus.consts import (
16
+ ALLOWED_VALUE_KEYS,
17
+ FIELD_TYPE_CHECKS,
18
+ LOGGER_NAME,
19
+ SCHEME_PATTERN,
20
+ )
15
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
16
24
 
17
25
 
18
26
  class UrlScheme(Enum):
@@ -20,7 +28,7 @@ class UrlScheme(Enum):
20
28
  HTTPS = "https"
21
29
 
22
30
 
23
- class JiraBase(ABC):
31
+ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
24
32
  use_createmeta: bool
25
33
 
26
34
  def __init__(
@@ -31,21 +39,45 @@ class JiraBase(ABC):
31
39
  jira_token: str | None = None,
32
40
  verify_ssl: bool = True,
33
41
  url_scheme: UrlScheme = UrlScheme.HTTPS,
42
+ raise_on_error: bool = False,
34
43
  ) -> None:
35
44
  self.logger = get_logger(LOGGER_NAME)
36
45
  get_logger("urllib3").setLevel(urllib3_log_level)
37
46
 
47
+ if not (jira_username and jira_token and base_url):
48
+ load_dotenv() # fallback for missing arguments only — no import-time side effect
38
49
  self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
39
50
  self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
40
- self.base_url = base_url or os.getenv("JIRA_BASE_URL")
41
- self.url_scheme = url_scheme.value
51
+ self.base_url, self.url_scheme = self._parse_base_url(base_url or os.getenv("JIRA_BASE_URL"), url_scheme)
42
52
  self.server = f"{self.url_scheme}://{self.base_url}"
43
53
  self.verify_ssl = verify_ssl
54
+ self.raise_on_error = raise_on_error
55
+ self._validate_credentials()
44
56
 
45
57
  self.jira_client = self.create_connection()
46
- self.check_client_connection()
47
58
  self.server_version = self.get_server_version()
48
59
 
60
+ @staticmethod
61
+ def _parse_base_url(base_url: str | None, url_scheme: UrlScheme) -> tuple[str, str]:
62
+ """Split an optional scheme off `base_url`; a scheme embedded in the URL wins over `url_scheme`."""
63
+ if not base_url or not base_url.strip():
64
+ raise ValueError("Jira base URL is missing — pass `base_url` or set JIRA_BASE_URL")
65
+ base_url = base_url.strip()
66
+ scheme = url_scheme.value
67
+ if match := SCHEME_PATTERN.match(base_url):
68
+ scheme = match.group(1).lower()
69
+ base_url = base_url[match.end() :]
70
+ return base_url.rstrip("/"), scheme
71
+
72
+ def _requires_username(self) -> bool:
73
+ return True
74
+
75
+ def _validate_credentials(self) -> None:
76
+ if not self.jira_token:
77
+ raise ValueError("Jira token is missing — pass `jira_token` or set JIRA_TOKEN")
78
+ if self._requires_username() and not self.jira_username:
79
+ raise ValueError("Jira username is missing — pass `jira_username` or set JIRA_USER_NAME")
80
+
49
81
  @abstractmethod
50
82
  def create_connection(self, timeout: int = 580) -> JIRA: ...
51
83
 
@@ -55,13 +87,19 @@ class JiraBase(ABC):
55
87
  query: str,
56
88
  max_results: int,
57
89
  specific_fields: str | list[str],
58
- jira_err_limit: int,
59
90
  json_result: bool,
91
+ limit: int | None = None,
60
92
  ) -> ResultList[Issue] | list: ...
61
93
 
62
94
  @abstractmethod
63
95
  def _format_assignee(self, assignee: str) -> dict: ...
64
96
 
97
+ def _handle_error(self, message: str, err: Exception) -> None:
98
+ """Log `err`; re-raise it when `raise_on_error` is set. Callers return their failure value after."""
99
+ self.logger.exception(f"{message}\n{err}")
100
+ if self.raise_on_error:
101
+ raise err
102
+
65
103
  def close_connection(self) -> None:
66
104
  try:
67
105
  self.jira_client.close()
@@ -69,7 +107,6 @@ class JiraBase(ABC):
69
107
  self.logger.exception(f"JIRAError \n{err}")
70
108
  except Exception as err:
71
109
  self.logger.exception(f"Exception \n{err}")
72
- self.check_client_connection()
73
110
 
74
111
  def check_client_connection(self) -> bool:
75
112
  try:
@@ -90,16 +127,14 @@ class JiraBase(ABC):
90
127
  def get_issue_by_key(
91
128
  self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
92
129
  ) -> None | Issue | dict[str, Any]:
130
+ check_issue_ref(key)
93
131
  try:
94
132
  jira_issue = self.jira_client.issue(id=key, fields=specific_fields)
95
133
  if json_result:
96
134
  return jira_issue.raw
97
135
  return jira_issue
98
- except JIRAError as err:
99
- self.logger.exception(f"JIRAError \n{err}")
100
- return None
101
136
  except Exception as err:
102
- self.logger.exception(f"Exception \n{err}")
137
+ self._handle_error(f"Failed to get issue {key}", err)
103
138
  return None
104
139
 
105
140
  def get_objects_by_query(
@@ -107,31 +142,58 @@ class JiraBase(ABC):
107
142
  query: str,
108
143
  max_results: int = 5000,
109
144
  specific_fields: str | list[str] = "*all",
110
- jira_err_count: int = 3,
111
145
  json_result: bool = True,
146
+ limit: int | None = None,
112
147
  ) -> ResultList[Issue] | list:
148
+ """Run a JQL query and return all matches.
149
+
150
+ Args:
151
+ query: JQL string.
152
+ max_results: Page size (on-premise: also the total cap unless `limit` is given).
153
+ specific_fields: Fields to fetch.
154
+ json_result: Return raw dicts instead of `Issue` objects.
155
+ limit: Optional cap on the total number of issues returned.
156
+
157
+ Raises:
158
+ JIRAError: If Jira rejects the query or a request fails.
159
+ """
113
160
  return self._paginate(
114
161
  query=query,
115
162
  max_results=max_results,
116
163
  specific_fields=specific_fields,
117
- jira_err_limit=jira_err_count,
118
164
  json_result=json_result,
165
+ limit=limit,
119
166
  )
120
167
 
168
+ @staticmethod
169
+ def _find_issue_meta(meta: dict, project_key: str, issue_type: str) -> tuple[dict | None, dict | None]:
170
+ if (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)) is None:
171
+ return None, None
172
+ issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
173
+ return project_meta, issue_meta
174
+
121
175
  def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
122
- meta = self.jira_client.createmeta(
123
- projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
124
- )
125
- if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
176
+ expand = "projects.issuetypes.fields"
177
+ meta = self.jira_client.createmeta(projectKeys=project_key, issuetypeNames=issue_type, expand=expand)
178
+ project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
179
+ if issue_meta is None:
180
+ # `issuetypeNames` is matched case-sensitively server-side, so a wrong-cased name comes back empty.
181
+ meta = self.jira_client.createmeta(projectKeys=project_key, expand=expand)
182
+ project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
183
+ if project_meta is None:
126
184
  raise ValueError(f"Project {project_key} not found in metadata")
127
- issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
128
- if not issue_meta:
185
+ if issue_meta is None:
129
186
  raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
130
187
  return issue_meta["fields"]
131
188
 
132
189
  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)
190
+ issue_types = {t.name.lower(): t for t in self.jira_client.project_issue_types(project_key)}
191
+ if (matched := issue_types.get(issue_type.lower())) is None:
192
+ available = sorted(t.name for t in issue_types.values())
193
+ raise ValueError(
194
+ f"Issue type '{issue_type}' not found in project '{project_key}'. Available issue types: {available}"
195
+ )
196
+ field_list = self.jira_client.project_issue_fields(project_key, matched.id)
135
197
  return {f.fieldId: f.raw for f in field_list}
136
198
 
137
199
  def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
@@ -161,40 +223,70 @@ class JiraBase(ABC):
161
223
  return None
162
224
  return allowed
163
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
+
164
242
  @staticmethod
165
243
  def _is_value_allowed(value: Any, allowed_values: list[Any]) -> bool:
166
- allowed_names = {av.get("name") for av in allowed_values}
167
- allowed_keys = {av.get("key") for av in allowed_values}
168
- allowed_values = allowed_names | allowed_keys
169
- return describe_allowed_value(value, allowed_values=allowed_values)
244
+ allowed = {av.get(key) for av in allowed_values for key in ALLOWED_VALUE_KEYS}
245
+ allowed.discard(None)
246
+ return describe_allowed_value(value, allowed_values=allowed)
170
247
 
171
- def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
172
- fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
173
- for custom_field, value in fields.items():
174
- if custom_field not in fields_metadata:
248
+ @staticmethod
249
+ def _check_field_type(field_id: str, field_type: str | None, value: Any) -> None:
250
+ if (check := FIELD_TYPE_CHECKS.get(field_type or "")) and not isinstance(value, check.python_types):
251
+ raise ValueError(f"Field '{field_id}' must be {check.label}")
252
+
253
+ def _validate_against_metadata(self, fields_metadata: dict, fields: dict, context: str) -> None:
254
+ for field_id, value in fields.items():
255
+ if field_id not in fields_metadata:
256
+ raise ValueError(f'Invalid field(s) for {context}: "{field_id}"')
257
+ field_meta = fields_metadata[field_id]
258
+ if (allowed_value := field_meta.get("allowedValues")) and not self._is_value_allowed(
259
+ value=value, allowed_values=allowed_value
260
+ ):
175
261
  raise ValueError(
176
- f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
262
+ f"Invalid value '{value}' for field '{field_id}' in {context}. "
263
+ f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
177
264
  )
178
- if allowed_value := fields_metadata[custom_field].get("allowedValues"):
179
- if not self._is_value_allowed(value=value, allowed_values=allowed_value):
180
- raise ValueError(
181
- f"Invalid value '{value}' for field '{custom_field}' in project '{project_key}' "
182
- f"with issue type '{issue_type}'."
183
- f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
184
- )
185
- field_type = fields_metadata[custom_field]["schema"]["type"]
186
- if field_type == "string" and not isinstance(value, str):
187
- raise ValueError(f"Field '{custom_field}' must be a string")
188
- if field_type == "number" and not isinstance(value, int | float):
189
- raise ValueError(f"Field '{custom_field}' must be a number")
190
- if field_type == "boolean" and not isinstance(value, bool):
191
- raise ValueError(f"Field '{custom_field}' must be a boolean")
192
- if field_type == "array" and not isinstance(value, list):
193
- raise ValueError(f"Field '{custom_field}' must be an array")
194
- if field_type == "object" and not isinstance(value, dict):
195
- raise ValueError(f"Field '{custom_field}' must be an object")
196
- if field_type == "issuetype" and not isinstance(value, dict):
197
- raise ValueError(f"Field '{custom_field}' must be an issue type")
265
+ self._check_field_type(field_id, field_meta.get("schema", {}).get("type"), value)
266
+
267
+ def validate_fields(
268
+ self, project_key: str, issue_type: str, fields: dict, fields_metadata: dict | None = None
269
+ ) -> None:
270
+ """Validate `fields`; pass `fields_metadata` to reuse an already-fetched result."""
271
+ if fields_metadata is None:
272
+ fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
273
+ self._validate_against_metadata(
274
+ fields_metadata, fields, context=f'project "{project_key}", issue type "{issue_type}"'
275
+ )
276
+
277
+ @staticmethod
278
+ def _canonical_issue_type(fields_metadata: dict, issue_type: str) -> str:
279
+ """Return the issue type's real casing (e.g. 'task' -> 'Task'), or `issue_type` if metadata lacks it."""
280
+ allowed = fields_metadata.get("issuetype", {}).get("allowedValues", [])
281
+ return next((av["name"] for av in allowed if av.get("name", "").lower() == issue_type.lower()), issue_type)
282
+
283
+ @staticmethod
284
+ def _merge_custom_fields(issue_fields: dict[str, Any], custom_fields: dict | None) -> dict[str, Any]:
285
+ """Merge `custom_fields` in, refusing to let them override the dedicated arguments."""
286
+ for key, value in (custom_fields or {}).items():
287
+ if key in issue_fields and issue_fields[key] != value:
288
+ raise ValueError(f"custom_fields['{key}'] conflicts with the dedicated argument for '{key}'")
289
+ return {**(custom_fields or {}), **issue_fields}
198
290
 
199
291
  def create_issue(
200
292
  self,
@@ -213,79 +305,108 @@ class JiraBase(ABC):
213
305
  }
214
306
  if assignee:
215
307
  issue_fields["assignee"] = self._format_assignee(assignee)
216
- if custom_fields:
217
- issue_fields = issue_fields | custom_fields
218
- self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
308
+ issue_fields = self._merge_custom_fields(issue_fields, custom_fields)
309
+ fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
310
+ issue_fields["issuetype"] = {"name": self._canonical_issue_type(fields_metadata, issue_type)}
311
+ self.validate_fields(project_key, issue_type, issue_fields, fields_metadata=fields_metadata)
219
312
  issue = self.jira_client.create_issue(fields=issue_fields)
220
313
  self.logger.info(f"Issue {issue.key} created successfully.")
221
314
  return issue
222
315
 
223
316
  def update_issue(self, issue_key: str, fields_to_update: dict) -> Issue | None:
317
+ """Update an issue after validating `fields_to_update` against its edit metadata.
318
+
319
+ Raises:
320
+ ValueError: If a field is not editable or a value is invalid (always raised).
321
+ """
322
+ check_issue_ref(issue_key)
224
323
  try:
225
324
  issue = self.jira_client.issue(issue_key)
226
- self.validate_fields(
227
- project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
228
- )
325
+ edit_fields = self.jira_client.editmeta(issue_key)["fields"]
326
+ self._validate_against_metadata(edit_fields, fields_to_update, context=f'issue "{issue_key}"')
229
327
  issue.update(fields=fields_to_update)
230
328
  self.logger.info(f"Issue {issue.key} updated successfully.")
231
329
  return issue
232
- except JIRAError as err:
233
- self.logger.error(f"JIRAError while updating issue: {err.text}")
234
- return None
330
+ except ValueError:
331
+ raise
235
332
  except Exception as err:
236
- self.logger.exception(f"Unexpected error: {err}")
333
+ self._handle_error(f"Failed to update issue {issue_key}", err)
237
334
  return None
238
335
 
239
336
  def delete_issue(self, issue_key: str) -> bool:
337
+ check_issue_ref(issue_key)
240
338
  try:
241
- issue = self.get_issue_by_key(key=issue_key, json_result=False)
339
+ issue = self.jira_client.issue(issue_key)
242
340
  issue.delete()
243
341
  self.logger.info(f"Issue {issue_key} deleted successfully.")
244
342
  return True
245
- except JIRAError as err:
246
- self.logger.error(f"JIRAError while deleting issue: {err.text}")
247
- return False
248
343
  except Exception as err:
249
- self.logger.exception(f"Unexpected error: {err}")
344
+ self._handle_error(f"Failed to delete issue {issue_key}", err)
250
345
  return False
251
346
 
252
347
  def add_comment(self, issue: str, comment: str) -> None:
348
+ check_issue_ref(issue)
253
349
  self.jira_client.add_comment(issue=issue, body=comment)
254
350
  self.logger.debug(f"Comment added to issue {issue} successfully.")
255
351
 
352
+ def get_link_types(self) -> list[dict[str, str]]:
353
+ """Return the instance's issue link types as `{name, inward, outward}` dicts."""
354
+ return [
355
+ {"name": link_type.name, "inward": link_type.inward, "outward": link_type.outward}
356
+ for link_type in self.jira_client.issue_link_types()
357
+ ]
358
+
256
359
  def _get_available_link_types(self) -> set[str]:
257
- try:
258
- return {link_type.name for link_type in self.jira_client.issue_link_types()}
259
- except Exception as err:
260
- self.logger.exception(f"Failed to retrieve link types from JIRA. \n{err}")
261
- return set()
360
+ return {link_type["name"] for link_type in self.get_link_types()}
262
361
 
263
362
  def create_link(self, issue_key: str, link_type: str, in_issue_key: str) -> bool:
264
- available_link_types = self._get_available_link_types()
265
- if link_type not in available_link_types:
266
- raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
363
+ check_issue_ref(issue_key)
364
+ check_issue_ref(in_issue_key)
365
+ try:
366
+ available_link_types = self._get_available_link_types()
367
+ except Exception as err:
368
+ self._handle_error("Failed to retrieve link types from JIRA", err)
369
+ return False
370
+ if (canonical := {name.lower(): name for name in available_link_types}.get(link_type.lower())) is None:
371
+ raise ValueError(f"Invalid link type '{link_type}'. Available link types: {sorted(available_link_types)}")
267
372
  try:
268
373
  self.jira_client.create_issue_link(
269
- type=link_type,
374
+ type=canonical,
270
375
  inwardIssue=issue_key,
271
376
  outwardIssue=in_issue_key,
272
377
  )
273
378
  self.logger.info(f"Link between {issue_key} and {in_issue_key} was created")
274
379
  return True
275
- except JIRAError as err:
276
- self.logger.exception(f"JIRAError - Link between {issue_key} and {in_issue_key} was not created\n{err}")
277
- return False
278
380
  except Exception as err:
279
- self.logger.exception(f"Exception - Link between {issue_key} and {in_issue_key} was not created\n{err}")
381
+ self._handle_error(f"Link between {issue_key} and {in_issue_key} was not created", err)
280
382
  return False
281
383
 
282
- def upload_attachment(self, issue_key: str, file_path: str) -> None:
384
+ def upload_attachment(self, issue_key: str, file_path: str) -> bool:
385
+ check_issue_ref(issue_key)
283
386
  try:
284
387
  self.jira_client.add_attachment(issue=issue_key, attachment=file_path)
285
- except JIRAError as err:
286
- self.logger.exception(f"JIRAError - Attachment {file_path} was not uploaded to issue {issue_key}\n{err}")
388
+ return True
287
389
  except Exception as err:
288
- self.logger.exception(f"Exception - Attachment {file_path} was not uploaded to issue {issue_key}\n{err}")
390
+ self._handle_error(f"Attachment {file_path} was not uploaded to issue {issue_key}", err)
391
+ return False
392
+
393
+ def _fetch_transitions(self, issue_key: str) -> list[dict]:
394
+ """Single place that fetches transitions; `fields` is expanded so callers can inspect the screen."""
395
+ return self.jira_client.transitions(issue_key, expand="transitions.fields")
396
+
397
+ def get_transitions(self, issue_key: str) -> list[dict[str, str]]:
398
+ """Return the transitions currently available for an issue as `{id, name}` dicts."""
399
+ check_issue_ref(issue_key)
400
+ return [{"id": t["id"], "name": t["name"]} for t in self._fetch_transitions(issue_key)]
401
+
402
+ def _resolve_transition(self, issue_key: str, transition_name: str) -> dict:
403
+ transitions = self._fetch_transitions(issue_key)
404
+ if transition := next((t for t in transitions if t["name"].lower() == transition_name.lower()), None):
405
+ return transition
406
+ raise ValueError(
407
+ f"Transition '{transition_name}' not found for issue {issue_key}. "
408
+ f"Available transitions: {sorted(t['name'] for t in transitions)}"
409
+ )
289
410
 
290
411
  def transition_issue(
291
412
  self,
@@ -294,27 +415,34 @@ class JiraBase(ABC):
294
415
  fields: dict | None = None,
295
416
  comment: str | None = None,
296
417
  ) -> bool:
418
+ """Transition an issue by name (case-insensitive), optionally with a comment.
419
+
420
+ Jira silently drops a transition comment when the transition screen has no comment field,
421
+ so in that case the comment is added separately after the transition succeeds.
422
+ """
423
+ check_issue_ref(issue_key)
424
+ needs_separate_comment = False
297
425
  try:
298
- transitions = self.jira_client.transitions(issue_key)
299
- transition_names = {t["name"] for t in transitions}
300
- if transition_name not in transition_names:
301
- self.logger.error(
302
- f"Transition '{transition_name}' not found for issue {issue_key}. "
303
- f"\nAvailable transitions: {transition_names}"
304
- )
305
- return False
306
- transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
307
- if not transition_id:
308
- self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
309
- return False
426
+ transition = self._resolve_transition(issue_key, transition_name)
427
+ needs_separate_comment = bool(comment) and "comment" not in transition.get("fields", {})
310
428
  self.jira_client.transition_issue(
311
- issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
429
+ issue=issue_key,
430
+ transition=transition["id"],
431
+ fields=fields or {},
432
+ comment=None if needs_separate_comment else comment or None,
312
433
  )
313
- self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
314
- return True
315
- except JIRAError as err:
316
- self.logger.exception(f"JIRAError during transition of {issue_key}: {err}")
317
- return False
318
434
  except Exception as err:
319
- self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
435
+ self._handle_error(f"Failed to transition {issue_key} to '{transition_name}'", err)
320
436
  return False
437
+ self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
438
+ if needs_separate_comment:
439
+ self._comment_after_transition(issue_key, comment)
440
+ return True
441
+
442
+ def _comment_after_transition(self, issue_key: str, comment: str | None) -> None:
443
+ """The transition already happened, so a comment failure is reported but does not undo its success."""
444
+ try:
445
+ if comment:
446
+ self.add_comment(issue=issue_key, comment=comment)
447
+ except Exception as err:
448
+ self._handle_error(f"Issue {issue_key} was transitioned but the comment was not added", err)
@@ -1,36 +1,18 @@
1
1
  # jira_cloud.py
2
2
 
3
- import logging
4
- import time
3
+ from functools import partial
4
+ from typing import Any
5
5
 
6
6
  import requests
7
7
  from jira import JIRA, Issue, JIRAError
8
8
  from jira.client import ResultList
9
9
 
10
- from python_jira_plus.jira_base import JiraBase, UrlScheme
10
+ from python_jira_plus.jira_base import JiraBase
11
11
 
12
12
 
13
13
  class JiraCloud(JiraBase):
14
14
  use_createmeta = True
15
15
 
16
- def __init__(
17
- self,
18
- base_url: str | None = None,
19
- urllib3_log_level: int = logging.WARNING,
20
- jira_username: str | None = None,
21
- jira_token: str | None = None,
22
- verify_ssl: bool = True,
23
- url_scheme: UrlScheme = UrlScheme.HTTPS,
24
- ) -> None:
25
- super().__init__(
26
- base_url=base_url,
27
- urllib3_log_level=urllib3_log_level,
28
- jira_username=jira_username,
29
- jira_token=jira_token,
30
- verify_ssl=verify_ssl,
31
- url_scheme=url_scheme,
32
- )
33
-
34
16
  def create_connection(self, timeout: int = 580) -> JIRA:
35
17
  jira_client = JIRA(
36
18
  basic_auth=(self.jira_username, self.jira_token),
@@ -46,64 +28,60 @@ class JiraCloud(JiraBase):
46
28
  def _format_assignee(self, assignee: str) -> dict:
47
29
  return {"accountId": assignee}
48
30
 
31
+ @staticmethod
32
+ def _split_page(page: Any, json_result: bool) -> tuple[list, bool, str | None]:
33
+ """Return (issues, is_last, next_page_token) from a search page; an empty page counts as the last."""
34
+ if not page:
35
+ return [], True, None
36
+ if json_result:
37
+ return page.get("issues", []), page["isLast"], page.get("nextPageToken")
38
+ return list(page), page.isLast, page.nextPageToken
39
+
49
40
  def _paginate(
50
41
  self,
51
42
  query: str,
52
43
  max_results: int,
53
44
  specific_fields: str | list[str],
54
- jira_err_limit: int,
55
45
  json_result: bool,
46
+ limit: int | None = None,
56
47
  ) -> ResultList[Issue] | list:
57
- all_issues = []
58
- retries = 0
59
- page = None
60
- while True: # pylint: disable=while-used
61
- try:
62
- next_token = None
63
- if page is not None:
64
- next_token = page.get("nextPageToken") if json_result else page.nextPageToken
65
-
66
- self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
67
- page = self.jira_client.enhanced_search_issues(
68
- jql_str=query,
69
- nextPageToken=next_token,
70
- maxResults=max_results,
71
- fields=specific_fields,
72
- json_result=json_result,
73
- )
74
- if not page:
75
- break
76
-
77
- all_issues += page["issues"] if json_result else page
78
- if page["isLast"] if json_result else page.isLast:
79
- break
80
- retries = 0
81
- except JIRAError as err:
82
- if err.status_code == 400:
83
- self.logger.error(f"Bad Request: {err.text}")
84
- raise JIRAError(f"Bad Request: {err.text}") from err
85
- retries += 1
86
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
87
- if retries >= jira_err_limit:
88
- self.logger.exception("Max retries exceeded.")
89
- return []
90
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
91
- return all_issues
48
+ all_issues: list = []
49
+ next_token = None
50
+ while limit is None or len(all_issues) < limit: # pylint: disable=while-used
51
+ self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
52
+ page_size = max_results if limit is None else min(max_results, limit - len(all_issues))
53
+ fetch = partial(
54
+ self.jira_client.enhanced_search_issues,
55
+ jql_str=query,
56
+ nextPageToken=next_token,
57
+ maxResults=page_size,
58
+ fields=specific_fields,
59
+ json_result=json_result,
60
+ )
61
+ issues, is_last, next_token = self._split_page(fetch(), json_result)
62
+ all_issues += issues
63
+ if is_last or not issues:
64
+ break
65
+ return all_issues if limit is None else all_issues[:limit]
92
66
 
93
67
  def get_account_data(self, user_mail: str) -> list[dict]:
94
68
  response = requests.get(
95
69
  f"{self.server}/rest/api/3/user/search",
96
70
  params={"query": user_mail},
97
71
  auth=(self.jira_username, self.jira_token),
72
+ verify=self.verify_ssl,
98
73
  timeout=30,
99
74
  )
100
75
  response.raise_for_status()
101
76
  return response.json()
102
77
 
78
+ def _first_account(self, query: str) -> dict:
79
+ if not (data := self.get_account_data(user_mail=query)):
80
+ raise ValueError(f"No Jira user found matching '{query}'")
81
+ return data[0]
82
+
103
83
  def get_account_id(self, user_mail: str) -> str:
104
- data = self.get_account_data(user_mail=user_mail)
105
- return data[0]["accountId"]
84
+ return self._first_account(user_mail)["accountId"]
106
85
 
107
86
  def get_account_display_name(self, user_display_name: str) -> str:
108
- data = self.get_account_data(user_mail=user_display_name)
109
- return data[0]["displayName"]
87
+ return self._first_account(user_display_name)["displayName"]
@@ -1,7 +1,7 @@
1
1
  # jira_on_premise.py
2
2
 
3
3
  import logging
4
- import time
4
+ from functools import partial
5
5
 
6
6
  from jira import JIRA, Issue, JIRAError
7
7
  from jira.client import ResultList
@@ -20,6 +20,7 @@ class JiraOnPremise(JiraBase):
20
20
  verify_ssl: bool = True,
21
21
  sso: bool = False,
22
22
  url_scheme: UrlScheme = UrlScheme.HTTPS,
23
+ raise_on_error: bool = False,
23
24
  ) -> None:
24
25
  self.sso = sso # must be set before super().__init__ — create_connection reads it
25
26
  super().__init__(
@@ -29,6 +30,7 @@ class JiraOnPremise(JiraBase):
29
30
  jira_token=jira_token,
30
31
  verify_ssl=verify_ssl,
31
32
  url_scheme=url_scheme,
33
+ raise_on_error=raise_on_error,
32
34
  )
33
35
  use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
34
36
  self.logger.debug(
@@ -37,6 +39,9 @@ class JiraOnPremise(JiraBase):
37
39
  )
38
40
  self.use_createmeta = use_legacy
39
41
 
42
+ def _requires_username(self) -> bool:
43
+ return self.sso # token auth needs no username
44
+
40
45
  def create_connection(self, timeout: int = 580) -> JIRA:
41
46
  auth = (
42
47
  {"token_auth": self.jira_token} if not self.sso else {"basic_auth": (self.jira_username, self.jira_token)}
@@ -56,41 +61,27 @@ class JiraOnPremise(JiraBase):
56
61
  query: str,
57
62
  max_results: int,
58
63
  specific_fields: str | list[str],
59
- jira_err_limit: int,
60
64
  json_result: bool,
65
+ limit: int | None = None,
61
66
  ) -> ResultList[Issue] | list:
67
+ cap = max_results if limit is None else limit # without `limit`, max_results is also the total cap
62
68
  start_at = 0
63
- all_issues = []
64
- retries = 0
65
- while True: # pylint: disable=while-used
66
- try:
67
- self.logger.debug(f"Fetching issues from startAt={start_at}")
68
- page = self.jira_client.search_issues(
69
- jql_str=query,
70
- startAt=start_at,
71
- maxResults=max_results,
72
- fields=specific_fields,
73
- json_result=json_result,
74
- )
75
- if not page:
76
- break
77
-
78
- all_issues += page["issues"] if json_result else page
79
- if json_result:
80
- start_at += page["maxResults"]
81
- else:
82
- start_at += page.maxResults
83
- if start_at >= max_results:
84
- break
85
- retries = 0
86
- except JIRAError as err:
87
- if err.status_code == 400:
88
- self.logger.error(f"Bad Request: {err.text}")
89
- raise JIRAError(f"Bad Request: {err.text}") from err
90
- retries += 1
91
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
92
- if retries >= jira_err_limit:
93
- self.logger.exception("Max retries exceeded.")
94
- return []
95
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
96
- return all_issues
69
+ all_issues: list = []
70
+ while start_at < cap: # pylint: disable=while-used
71
+ self.logger.debug(f"Fetching issues from startAt={start_at}")
72
+ fetch = partial(
73
+ self.jira_client.search_issues,
74
+ jql_str=query,
75
+ startAt=start_at,
76
+ maxResults=min(max_results, cap - start_at),
77
+ fields=specific_fields,
78
+ json_result=json_result,
79
+ )
80
+ page = fetch()
81
+ if not (issues := (page.get("issues") if json_result else list(page)) if page else None):
82
+ break
83
+ all_issues += issues
84
+ start_at += len(issues)
85
+ if start_at >= (page["total"] if json_result else page.total):
86
+ break
87
+ return all_issues[:cap]
@@ -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: 3.2.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
@@ -21,11 +21,6 @@ Requires-Dist: python-dotenv>=1.0.0
21
21
  Requires-Dist: jira>=3.10.5
22
22
  Requires-Dist: packaging>=26.2
23
23
  Requires-Dist: requests>=2.32.5
24
- Provides-Extra: dev
25
- Requires-Dist: pre-commit>=4.5.0; extra == "dev"
26
- Requires-Dist: pytest>=9.0.1; extra == "dev"
27
- Requires-Dist: setuptools>=80.9.0; extra == "dev"
28
- Requires-Dist: wheel>=0.45.1; extra == "dev"
29
24
  Dynamic: license-file
30
25
 
31
26
  ![PyPI version](https://img.shields.io/pypi/v/python-jira-plus)
@@ -44,7 +39,7 @@ An enhanced Python client for JIRA that extends the functionality of the officia
44
39
 
45
40
  ## Features
46
41
  - ✅ Simplified connection to JIRA Cloud and On-Premise instances
47
- - ✅ Robust error handling with automatic retries
42
+ - ✅ Clear error handling: failures raise (`raise_on_error=True`) or are logged and returned as `None`/`False`. Rate limits (429) and 503 are retried by `jira-python` itself
48
43
  - ✅ Built-in pagination for large result sets
49
44
  - ✅ Field validation against JIRA metadata
50
45
  - ✅ Enhanced issue creation, retrieval, and updating
@@ -62,7 +57,6 @@ pip install python-jira-plus
62
57
  ## Requirements
63
58
  - Python 3.12+
64
59
  - `jira` package
65
- - `retrying` package
66
60
  - `custom-python-logger` package
67
61
 
68
62
  ---
@@ -77,13 +71,26 @@ JIRA_TOKEN=your_jira_api_token
77
71
  JIRA_BASE_URL=your-instance.atlassian.net # Only used if base_url is not provided to constructor
78
72
  ```
79
73
 
74
+ ## Error handling
75
+ By default, write helpers (`delete_issue`, `create_link`, `transition_issue`, `upload_attachment`,
76
+ `update_issue`) and `get_issue_by_key` log the error and return `False`/`None`. Pass
77
+ `raise_on_error=True` to the constructor to get the original exception instead:
78
+
79
+ ```python
80
+ jira_client = JiraCloud(raise_on_error=True)
81
+ jira_client.get_issue_by_key("PROJ-404") # raises JIRAError instead of returning None
82
+ ```
83
+
84
+ Validation problems (`ValueError`) and failed searches (`JIRAError`) are always raised.
85
+ Rate limits (429) and 503 are retried by `jira-python`'s own session, not by this package.
86
+
80
87
  ## Examples
81
88
 
82
89
  ### Creating an Issue with Custom Fields
83
90
  ```python
84
- from python_jira_plus.jira_plus import JiraPlus
91
+ from python_jira_plus import JiraCloud # or JiraOnPremise
85
92
 
86
- jira_client = JiraPlus()
93
+ jira_client = JiraCloud()
87
94
  issue = jira_client.create_issue(
88
95
  project_key="PROJ",
89
96
  summary="Implement new feature",
@@ -98,12 +105,13 @@ issue = jira_client.create_issue(
98
105
 
99
106
  ### Searching for Issues
100
107
  ```python
101
- from python_jira_plus.jira_plus import JiraPlus
108
+ from python_jira_plus import JiraCloud
102
109
 
103
- jira_client = JiraPlus()
110
+ jira_client = JiraCloud()
104
111
  issues = jira_client.get_objects_by_query(
105
112
  query="project = PROJ AND status = 'In Progress' ORDER BY created DESC",
106
- max_results=50,
113
+ max_results=50, # page size
114
+ limit=200, # optional cap on the total number of issues returned
107
115
  specific_fields=["summary", "status", "assignee"],
108
116
  json_result=False
109
117
  )
@@ -114,9 +122,9 @@ for issue in issues:
114
122
 
115
123
  ### Updating an Issue
116
124
  ```python
117
- from python_jira_plus.jira_plus import JiraPlus
125
+ from python_jira_plus import JiraCloud
118
126
 
119
- jira_client = JiraPlus()
127
+ jira_client = JiraCloud()
120
128
  issue = jira_client.get_issue_by_key(key="PROJ-123", json_result=False)
121
129
 
122
130
  fields_to_update = {
@@ -0,0 +1,13 @@
1
+ python_jira_plus/__init__.py,sha256=IwvlvL8KpeiSyeFh_DKLAqlGcHfZk7JINVUAGFRdYTo,801
2
+ python_jira_plus/consts.py,sha256=YkuTHnXbP7JFC-u-7O3Kq_xf1I0x5djZuWgA5D8eDzw,669
3
+ python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
4
+ python_jira_plus/helpers.py,sha256=DZybIN1zoW2QtT9WXJ2T45Lphtmym-GfG6gHOrT-AHI,686
5
+ python_jira_plus/jira_base.py,sha256=SEObK7QW-Z6YNy4H7XXylagSNNLienZp0Nedopr1TS0,20143
6
+ python_jira_plus/jira_cloud.py,sha256=CfkJTgf6j3wu7AnUI6BnykHRIL0XhDMtk0PejMiAsjg,3208
7
+ python_jira_plus/jira_on_premise.py,sha256=8GDUDzJ6FYQQgSjbZs01cP9WBvplU5myIG-x6Ykw0hs,3240
8
+ python_jira_plus/types.py,sha256=KxMR3Iv1ztmZjr9nhkdTpiyB9x7Mu1hrwDBBUuWdOi8,164
9
+ python_jira_plus-4.1.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
10
+ python_jira_plus-4.1.0.dist-info/METADATA,sha256=OnG9XYFlZnIWSsYGX1oypuHWpqXQWlUlXIJKSCCPJA0,4836
11
+ python_jira_plus-4.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ python_jira_plus-4.1.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
13
+ python_jira_plus-4.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,11 +0,0 @@
1
- python_jira_plus/__init__.py,sha256=HMlPp5HOfdEO3VYE7Zc2qfhLsIPpPJ9wQaUojhCOp0k,729
2
- python_jira_plus/consts.py,sha256=pkMp6dZUDjEwqa9OucBIeSs3mxnQt4KXxIpyuyHmY8A,33
3
- python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
4
- python_jira_plus/jira_base.py,sha256=FEdw86I5kh8QUD2ulRNvz69R671Iqohts9DiAUTKQaQ,13875
5
- python_jira_plus/jira_cloud.py,sha256=5PsyaX0oPoTTq0HBTVlGeIbtt8Zc45qqeyCQlfoYVls,3795
6
- python_jira_plus/jira_on_premise.py,sha256=lueq9Glo8gluHfQcorlm7SSyT6eGzx4r94UTOrynNP0,3549
7
- python_jira_plus-3.2.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
8
- python_jira_plus-3.2.0.dist-info/METADATA,sha256=6crZgWe_uZEXKINRoAb5K6sp-ArFZmdYvFfyPfMSSXA,4253
9
- python_jira_plus-3.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
- python_jira_plus-3.2.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
11
- python_jira_plus-3.2.0.dist-info/RECORD,,