python-jira-plus 3.2.0__py3-none-any.whl → 4.0.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,15 +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
12
 
15
- load_dotenv()
16
-
17
13
  BASIC_FIELDS = [
18
14
  "created",
19
15
  "updated",
@@ -29,4 +25,4 @@ BASIC_FIELDS = [
29
25
  "resolution",
30
26
  "parent",
31
27
  ]
32
- __all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
28
+ __all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS", "describe_allowed_value"]
@@ -1 +1,21 @@
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
+ }
@@ -8,10 +8,16 @@ 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
16
22
 
17
23
 
@@ -20,7 +26,7 @@ class UrlScheme(Enum):
20
26
  HTTPS = "https"
21
27
 
22
28
 
23
- class JiraBase(ABC):
29
+ class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
24
30
  use_createmeta: bool
25
31
 
26
32
  def __init__(
@@ -31,21 +37,45 @@ class JiraBase(ABC):
31
37
  jira_token: str | None = None,
32
38
  verify_ssl: bool = True,
33
39
  url_scheme: UrlScheme = UrlScheme.HTTPS,
40
+ raise_on_error: bool = False,
34
41
  ) -> None:
35
42
  self.logger = get_logger(LOGGER_NAME)
36
43
  get_logger("urllib3").setLevel(urllib3_log_level)
37
44
 
45
+ if not (jira_username and jira_token and base_url):
46
+ load_dotenv() # fallback for missing arguments only — no import-time side effect
38
47
  self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
39
48
  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
49
+ self.base_url, self.url_scheme = self._parse_base_url(base_url or os.getenv("JIRA_BASE_URL"), url_scheme)
42
50
  self.server = f"{self.url_scheme}://{self.base_url}"
43
51
  self.verify_ssl = verify_ssl
52
+ self.raise_on_error = raise_on_error
53
+ self._validate_credentials()
44
54
 
45
55
  self.jira_client = self.create_connection()
46
- self.check_client_connection()
47
56
  self.server_version = self.get_server_version()
48
57
 
58
+ @staticmethod
59
+ def _parse_base_url(base_url: str | None, url_scheme: UrlScheme) -> tuple[str, str]:
60
+ """Split an optional scheme off `base_url`; a scheme embedded in the URL wins over `url_scheme`."""
61
+ if not base_url or not base_url.strip():
62
+ raise ValueError("Jira base URL is missing — pass `base_url` or set JIRA_BASE_URL")
63
+ base_url = base_url.strip()
64
+ scheme = url_scheme.value
65
+ if match := SCHEME_PATTERN.match(base_url):
66
+ scheme = match.group(1).lower()
67
+ base_url = base_url[match.end() :]
68
+ return base_url.rstrip("/"), scheme
69
+
70
+ def _requires_username(self) -> bool:
71
+ return True
72
+
73
+ def _validate_credentials(self) -> None:
74
+ if not self.jira_token:
75
+ raise ValueError("Jira token is missing — pass `jira_token` or set JIRA_TOKEN")
76
+ if self._requires_username() and not self.jira_username:
77
+ raise ValueError("Jira username is missing — pass `jira_username` or set JIRA_USER_NAME")
78
+
49
79
  @abstractmethod
50
80
  def create_connection(self, timeout: int = 580) -> JIRA: ...
51
81
 
@@ -55,13 +85,19 @@ class JiraBase(ABC):
55
85
  query: str,
56
86
  max_results: int,
57
87
  specific_fields: str | list[str],
58
- jira_err_limit: int,
59
88
  json_result: bool,
89
+ limit: int | None = None,
60
90
  ) -> ResultList[Issue] | list: ...
61
91
 
62
92
  @abstractmethod
63
93
  def _format_assignee(self, assignee: str) -> dict: ...
64
94
 
95
+ def _handle_error(self, message: str, err: Exception) -> None:
96
+ """Log `err`; re-raise it when `raise_on_error` is set. Callers return their failure value after."""
97
+ self.logger.exception(f"{message}\n{err}")
98
+ if self.raise_on_error:
99
+ raise err
100
+
65
101
  def close_connection(self) -> None:
66
102
  try:
67
103
  self.jira_client.close()
@@ -69,7 +105,6 @@ class JiraBase(ABC):
69
105
  self.logger.exception(f"JIRAError \n{err}")
70
106
  except Exception as err:
71
107
  self.logger.exception(f"Exception \n{err}")
72
- self.check_client_connection()
73
108
 
74
109
  def check_client_connection(self) -> bool:
75
110
  try:
@@ -95,11 +130,8 @@ class JiraBase(ABC):
95
130
  if json_result:
96
131
  return jira_issue.raw
97
132
  return jira_issue
98
- except JIRAError as err:
99
- self.logger.exception(f"JIRAError \n{err}")
100
- return None
101
133
  except Exception as err:
102
- self.logger.exception(f"Exception \n{err}")
134
+ self._handle_error(f"Failed to get issue {key}", err)
103
135
  return None
104
136
 
105
137
  def get_objects_by_query(
@@ -107,31 +139,58 @@ class JiraBase(ABC):
107
139
  query: str,
108
140
  max_results: int = 5000,
109
141
  specific_fields: str | list[str] = "*all",
110
- jira_err_count: int = 3,
111
142
  json_result: bool = True,
143
+ limit: int | None = None,
112
144
  ) -> ResultList[Issue] | list:
145
+ """Run a JQL query and return all matches.
146
+
147
+ Args:
148
+ query: JQL string.
149
+ max_results: Page size (on-premise: also the total cap unless `limit` is given).
150
+ specific_fields: Fields to fetch.
151
+ json_result: Return raw dicts instead of `Issue` objects.
152
+ limit: Optional cap on the total number of issues returned.
153
+
154
+ Raises:
155
+ JIRAError: If Jira rejects the query or a request fails.
156
+ """
113
157
  return self._paginate(
114
158
  query=query,
115
159
  max_results=max_results,
116
160
  specific_fields=specific_fields,
117
- jira_err_limit=jira_err_count,
118
161
  json_result=json_result,
162
+ limit=limit,
119
163
  )
120
164
 
165
+ @staticmethod
166
+ def _find_issue_meta(meta: dict, project_key: str, issue_type: str) -> tuple[dict | None, dict | None]:
167
+ if (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)) is None:
168
+ return None, None
169
+ issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
170
+ return project_meta, issue_meta
171
+
121
172
  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)):
173
+ expand = "projects.issuetypes.fields"
174
+ meta = self.jira_client.createmeta(projectKeys=project_key, issuetypeNames=issue_type, expand=expand)
175
+ project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
176
+ if issue_meta is None:
177
+ # `issuetypeNames` is matched case-sensitively server-side, so a wrong-cased name comes back empty.
178
+ meta = self.jira_client.createmeta(projectKeys=project_key, expand=expand)
179
+ project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
180
+ if project_meta is None:
126
181
  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:
182
+ if issue_meta is None:
129
183
  raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
130
184
  return issue_meta["fields"]
131
185
 
132
186
  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)
187
+ issue_types = {t.name.lower(): t for t in self.jira_client.project_issue_types(project_key)}
188
+ if (matched := issue_types.get(issue_type.lower())) is None:
189
+ available = sorted(t.name for t in issue_types.values())
190
+ raise ValueError(
191
+ f"Issue type '{issue_type}' not found in project '{project_key}'. Available issue types: {available}"
192
+ )
193
+ field_list = self.jira_client.project_issue_fields(project_key, matched.id)
135
194
  return {f.fieldId: f.raw for f in field_list}
136
195
 
137
196
  def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
@@ -163,38 +222,52 @@ class JiraBase(ABC):
163
222
 
164
223
  @staticmethod
165
224
  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)
225
+ allowed = {av.get(key) for av in allowed_values for key in ALLOWED_VALUE_KEYS}
226
+ allowed.discard(None)
227
+ return describe_allowed_value(value, allowed_values=allowed)
170
228
 
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:
229
+ @staticmethod
230
+ def _check_field_type(field_id: str, field_type: str | None, value: Any) -> None:
231
+ if (check := FIELD_TYPE_CHECKS.get(field_type or "")) and not isinstance(value, check.python_types):
232
+ raise ValueError(f"Field '{field_id}' must be {check.label}")
233
+
234
+ def _validate_against_metadata(self, fields_metadata: dict, fields: dict, context: str) -> None:
235
+ for field_id, value in fields.items():
236
+ if field_id not in fields_metadata:
237
+ raise ValueError(f'Invalid field(s) for {context}: "{field_id}"')
238
+ field_meta = fields_metadata[field_id]
239
+ if (allowed_value := field_meta.get("allowedValues")) and not self._is_value_allowed(
240
+ value=value, allowed_values=allowed_value
241
+ ):
175
242
  raise ValueError(
176
- f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
243
+ f"Invalid value '{value}' for field '{field_id}' in {context}. "
244
+ f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
177
245
  )
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")
246
+ self._check_field_type(field_id, field_meta.get("schema", {}).get("type"), value)
247
+
248
+ def validate_fields(
249
+ self, project_key: str, issue_type: str, fields: dict, fields_metadata: dict | None = None
250
+ ) -> None:
251
+ """Validate `fields`; pass `fields_metadata` to reuse an already-fetched result."""
252
+ if fields_metadata is None:
253
+ fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
254
+ self._validate_against_metadata(
255
+ fields_metadata, fields, context=f'project "{project_key}", issue type "{issue_type}"'
256
+ )
257
+
258
+ @staticmethod
259
+ def _canonical_issue_type(fields_metadata: dict, issue_type: str) -> str:
260
+ """Return the issue type's real casing (e.g. 'task' -> 'Task'), or `issue_type` if metadata lacks it."""
261
+ allowed = fields_metadata.get("issuetype", {}).get("allowedValues", [])
262
+ return next((av["name"] for av in allowed if av.get("name", "").lower() == issue_type.lower()), issue_type)
263
+
264
+ @staticmethod
265
+ def _merge_custom_fields(issue_fields: dict[str, Any], custom_fields: dict | None) -> dict[str, Any]:
266
+ """Merge `custom_fields` in, refusing to let them override the dedicated arguments."""
267
+ for key, value in (custom_fields or {}).items():
268
+ if key in issue_fields and issue_fields[key] != value:
269
+ raise ValueError(f"custom_fields['{key}'] conflicts with the dedicated argument for '{key}'")
270
+ return {**(custom_fields or {}), **issue_fields}
198
271
 
199
272
  def create_issue(
200
273
  self,
@@ -213,79 +286,101 @@ class JiraBase(ABC):
213
286
  }
214
287
  if assignee:
215
288
  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)
289
+ issue_fields = self._merge_custom_fields(issue_fields, custom_fields)
290
+ fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
291
+ issue_fields["issuetype"] = {"name": self._canonical_issue_type(fields_metadata, issue_type)}
292
+ self.validate_fields(project_key, issue_type, issue_fields, fields_metadata=fields_metadata)
219
293
  issue = self.jira_client.create_issue(fields=issue_fields)
220
294
  self.logger.info(f"Issue {issue.key} created successfully.")
221
295
  return issue
222
296
 
223
297
  def update_issue(self, issue_key: str, fields_to_update: dict) -> Issue | None:
298
+ """Update an issue after validating `fields_to_update` against its edit metadata.
299
+
300
+ Raises:
301
+ ValueError: If a field is not editable or a value is invalid (always raised).
302
+ """
224
303
  try:
225
304
  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
- )
305
+ edit_fields = self.jira_client.editmeta(issue_key)["fields"]
306
+ self._validate_against_metadata(edit_fields, fields_to_update, context=f'issue "{issue_key}"')
229
307
  issue.update(fields=fields_to_update)
230
308
  self.logger.info(f"Issue {issue.key} updated successfully.")
231
309
  return issue
232
- except JIRAError as err:
233
- self.logger.error(f"JIRAError while updating issue: {err.text}")
234
- return None
310
+ except ValueError:
311
+ raise
235
312
  except Exception as err:
236
- self.logger.exception(f"Unexpected error: {err}")
313
+ self._handle_error(f"Failed to update issue {issue_key}", err)
237
314
  return None
238
315
 
239
316
  def delete_issue(self, issue_key: str) -> bool:
240
317
  try:
241
- issue = self.get_issue_by_key(key=issue_key, json_result=False)
318
+ issue = self.jira_client.issue(issue_key)
242
319
  issue.delete()
243
320
  self.logger.info(f"Issue {issue_key} deleted successfully.")
244
321
  return True
245
- except JIRAError as err:
246
- self.logger.error(f"JIRAError while deleting issue: {err.text}")
247
- return False
248
322
  except Exception as err:
249
- self.logger.exception(f"Unexpected error: {err}")
323
+ self._handle_error(f"Failed to delete issue {issue_key}", err)
250
324
  return False
251
325
 
252
326
  def add_comment(self, issue: str, comment: str) -> None:
253
327
  self.jira_client.add_comment(issue=issue, body=comment)
254
328
  self.logger.debug(f"Comment added to issue {issue} successfully.")
255
329
 
330
+ def get_link_types(self) -> list[dict[str, str]]:
331
+ """Return the instance's issue link types as `{name, inward, outward}` dicts."""
332
+ return [
333
+ {"name": link_type.name, "inward": link_type.inward, "outward": link_type.outward}
334
+ for link_type in self.jira_client.issue_link_types()
335
+ ]
336
+
256
337
  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()
338
+ return {link_type["name"] for link_type in self.get_link_types()}
262
339
 
263
340
  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}")
341
+ try:
342
+ available_link_types = self._get_available_link_types()
343
+ except Exception as err:
344
+ self._handle_error("Failed to retrieve link types from JIRA", err)
345
+ return False
346
+ if (canonical := {name.lower(): name for name in available_link_types}.get(link_type.lower())) is None:
347
+ raise ValueError(f"Invalid link type '{link_type}'. Available link types: {sorted(available_link_types)}")
267
348
  try:
268
349
  self.jira_client.create_issue_link(
269
- type=link_type,
350
+ type=canonical,
270
351
  inwardIssue=issue_key,
271
352
  outwardIssue=in_issue_key,
272
353
  )
273
354
  self.logger.info(f"Link between {issue_key} and {in_issue_key} was created")
274
355
  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
356
  except Exception as err:
279
- self.logger.exception(f"Exception - Link between {issue_key} and {in_issue_key} was not created\n{err}")
357
+ self._handle_error(f"Link between {issue_key} and {in_issue_key} was not created", err)
280
358
  return False
281
359
 
282
- def upload_attachment(self, issue_key: str, file_path: str) -> None:
360
+ def upload_attachment(self, issue_key: str, file_path: str) -> bool:
283
361
  try:
284
362
  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}")
363
+ return True
287
364
  except Exception as err:
288
- self.logger.exception(f"Exception - Attachment {file_path} was not uploaded to issue {issue_key}\n{err}")
365
+ self._handle_error(f"Attachment {file_path} was not uploaded to issue {issue_key}", err)
366
+ return False
367
+
368
+ def _fetch_transitions(self, issue_key: str) -> list[dict]:
369
+ """Single place that fetches transitions; `fields` is expanded so callers can inspect the screen."""
370
+ return self.jira_client.transitions(issue_key, expand="transitions.fields")
371
+
372
+ def get_transitions(self, issue_key: str) -> list[dict[str, str]]:
373
+ """Return the transitions currently available for an issue as `{id, name}` dicts."""
374
+ return [{"id": t["id"], "name": t["name"]} for t in self._fetch_transitions(issue_key)]
375
+
376
+ def _resolve_transition(self, issue_key: str, transition_name: str) -> dict:
377
+ transitions = self._fetch_transitions(issue_key)
378
+ if transition := next((t for t in transitions if t["name"].lower() == transition_name.lower()), None):
379
+ return transition
380
+ raise ValueError(
381
+ f"Transition '{transition_name}' not found for issue {issue_key}. "
382
+ f"Available transitions: {sorted(t['name'] for t in transitions)}"
383
+ )
289
384
 
290
385
  def transition_issue(
291
386
  self,
@@ -294,27 +389,33 @@ class JiraBase(ABC):
294
389
  fields: dict | None = None,
295
390
  comment: str | None = None,
296
391
  ) -> bool:
392
+ """Transition an issue by name (case-insensitive), optionally with a comment.
393
+
394
+ Jira silently drops a transition comment when the transition screen has no comment field,
395
+ so in that case the comment is added separately after the transition succeeds.
396
+ """
397
+ needs_separate_comment = False
297
398
  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
399
+ transition = self._resolve_transition(issue_key, transition_name)
400
+ needs_separate_comment = bool(comment) and "comment" not in transition.get("fields", {})
310
401
  self.jira_client.transition_issue(
311
- issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
402
+ issue=issue_key,
403
+ transition=transition["id"],
404
+ fields=fields or {},
405
+ comment=None if needs_separate_comment else comment or None,
312
406
  )
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
407
  except Exception as err:
319
- self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
408
+ self._handle_error(f"Failed to transition {issue_key} to '{transition_name}'", err)
320
409
  return False
410
+ self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
411
+ if needs_separate_comment:
412
+ self._comment_after_transition(issue_key, comment)
413
+ return True
414
+
415
+ def _comment_after_transition(self, issue_key: str, comment: str | None) -> None:
416
+ """The transition already happened, so a comment failure is reported but does not undo its success."""
417
+ try:
418
+ if comment:
419
+ self.add_comment(issue=issue_key, comment=comment)
420
+ except Exception as err:
421
+ 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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 3.2.0
3
+ Version: 4.0.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,11 @@
1
+ python_jira_plus/__init__.py,sha256=4gj7xYxp_zge3tO9F7Euq8ZY29nTHiK57LOg9ILJ9nc,708
2
+ python_jira_plus/consts.py,sha256=ozJ1Q1p0HIFwdhNAuonHQs5bs0dTFa3pUCaC4EC88A8,610
3
+ python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
4
+ python_jira_plus/jira_base.py,sha256=Ctm9Xt2KECYxCtCueTmEWOPkGlqPRPZf1rFIo0Hmrw0,18913
5
+ python_jira_plus/jira_cloud.py,sha256=CfkJTgf6j3wu7AnUI6BnykHRIL0XhDMtk0PejMiAsjg,3208
6
+ python_jira_plus/jira_on_premise.py,sha256=8GDUDzJ6FYQQgSjbZs01cP9WBvplU5myIG-x6Ykw0hs,3240
7
+ python_jira_plus-4.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
8
+ python_jira_plus-4.0.0.dist-info/METADATA,sha256=oIgsBhIH_Yh-eQ-jZhnuP0c8y6bYlVh4dn-RrLl_a_I,4836
9
+ python_jira_plus-4.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ python_jira_plus-4.0.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
11
+ python_jira_plus-4.0.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,,