python-jira-plus 2.0.7__py3-none-any.whl → 3.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.
@@ -8,7 +8,9 @@ pagination, metadata validation, and more.
8
8
  from dotenv import load_dotenv
9
9
 
10
10
  from python_jira_plus.describe_allowed_value import describe_allowed_value
11
- from python_jira_plus.jira_plus import JiraPlus, ServerType
11
+ from python_jira_plus.jira_base import JiraBase, UrlScheme
12
+ from python_jira_plus.jira_cloud import JiraCloud
13
+ from python_jira_plus.jira_on_premise import JiraOnPremise
12
14
 
13
15
  load_dotenv()
14
16
 
@@ -27,4 +29,4 @@ BASIC_FIELDS = [
27
29
  "resolution",
28
30
  "parent",
29
31
  ]
30
- __all__ = ["JiraPlus", "ServerType", "BASIC_FIELDS"]
32
+ __all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
@@ -11,17 +11,17 @@ def describe_allowed_value(value: Any, allowed_values: set = None) -> bool:
11
11
 
12
12
  @describe_allowed_value.register
13
13
  def _(value: int, allowed_values: list = None) -> bool:
14
- return value == allowed_values
14
+ return value in allowed_values
15
15
 
16
16
 
17
17
  @describe_allowed_value.register
18
18
  def _(value: float, allowed_values: list = None) -> bool:
19
- return value == allowed_values
19
+ return value in allowed_values
20
20
 
21
21
 
22
22
  @describe_allowed_value.register
23
23
  def _(value: str, allowed_values: list = None) -> bool:
24
- return value == allowed_values
24
+ return value in allowed_values
25
25
 
26
26
 
27
27
  @describe_allowed_value.register
@@ -1,18 +1,16 @@
1
- # jira_plus.py
1
+ # jira_base.py
2
2
 
3
3
  import json
4
4
  import logging
5
5
  import os
6
- import time
6
+ from abc import ABC, abstractmethod
7
7
  from enum import Enum
8
8
  from typing import Any
9
9
 
10
- import requests
11
10
  from custom_python_logger import get_logger
12
11
  from jira import JIRA, Issue, JIRAError
13
12
  from jira.client import ResultList
14
- from retrying import retry
15
- from packaging.version import Version
13
+
16
14
  from python_jira_plus.describe_allowed_value import describe_allowed_value
17
15
 
18
16
 
@@ -21,21 +19,16 @@ class UrlScheme(Enum):
21
19
  HTTPS = "https"
22
20
 
23
21
 
24
- class ServerType(Enum):
25
- CLOUD = "cloud"
26
- ON_PREMISE = "on_premise"
27
-
22
+ class JiraBase(ABC):
23
+ use_createmeta: bool
28
24
 
29
- class JiraPlus:
30
25
  def __init__(
31
26
  self,
32
27
  base_url: str | None = None,
33
- server_type: ServerType = ServerType.CLOUD,
34
28
  urllib3_log_level: int = logging.WARNING,
35
29
  jira_username: str | None = None,
36
- jira_token: str | None = None, # Use API token (not PAT)
30
+ jira_token: str | None = None,
37
31
  verify_ssl: bool = True,
38
- sso: bool = False,
39
32
  url_scheme: UrlScheme = UrlScheme.HTTPS,
40
33
  ) -> None:
41
34
  self.logger = get_logger(self.__class__.__name__)
@@ -45,33 +38,28 @@ class JiraPlus:
45
38
  self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
46
39
  self.base_url = base_url or os.getenv("JIRA_BASE_URL")
47
40
  self.url_scheme = url_scheme.value
48
-
49
- self.server_type = server_type
50
41
  self.server = f"{self.url_scheme}://{self.base_url}"
51
42
  self.verify_ssl = verify_ssl
52
- self.sso = sso
53
43
 
54
44
  self.jira_client = self.create_connection()
55
45
  self.check_client_connection()
46
+ self.server_version = self.get_server_version()
56
47
 
57
- @retry(stop_max_attempt_number=3, wait_fixed=180000)
58
- def create_connection(self, timeout: int = 580) -> JIRA | None:
59
- if self.server_type == ServerType.ON_PREMISE and not self.sso: # pylint: disable=W0160:
60
- jira_client = JIRA(
61
- token_auth=self.jira_token, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout
62
- )
63
- else:
64
- jira_client = JIRA(
65
- basic_auth=(self.jira_username, self.jira_token),
66
- options={"server": self.server, "verify": self.verify_ssl},
67
- timeout=timeout,
68
- )
48
+ @abstractmethod
49
+ def create_connection(self, timeout: int = 580) -> JIRA: ...
50
+
51
+ @abstractmethod
52
+ def _paginate(
53
+ self,
54
+ query: str,
55
+ max_results: int,
56
+ specific_fields: str | list[str],
57
+ jira_err_limit: int,
58
+ json_result: bool,
59
+ ) -> ResultList[Issue] | list: ...
69
60
 
70
- if jira_client.projects():
71
- self.logger.info("Jira Connection Successful")
72
- return jira_client
73
- self.logger.error("Jira Connection Failed")
74
- raise JIRAError("Jira Connection Failed")
61
+ @abstractmethod
62
+ def _format_assignee(self, assignee: str) -> dict: ...
75
63
 
76
64
  def close_connection(self) -> None:
77
65
  try:
@@ -80,7 +68,6 @@ class JiraPlus:
80
68
  self.logger.exception(f"JIRAError \n{err}")
81
69
  except Exception as err:
82
70
  self.logger.exception(f"Exception \n{err}")
83
-
84
71
  self.check_client_connection()
85
72
 
86
73
  def check_client_connection(self) -> bool:
@@ -96,6 +83,9 @@ class JiraPlus:
96
83
  self.logger.exception(f"Exception \n{err}")
97
84
  return False
98
85
 
86
+ def get_server_version(self) -> tuple[int, ...]:
87
+ return tuple(self.jira_client.server_info()["versionNumbers"])
88
+
99
89
  def get_issue_by_key(
100
90
  self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
101
91
  ) -> None | Issue | dict[str, Any]:
@@ -118,12 +108,8 @@ class JiraPlus:
118
108
  specific_fields: str | list[str] = "*all",
119
109
  jira_err_count: int = 3,
120
110
  json_result: bool = True,
121
- ) -> ResultList[Issue] | dict | None:
122
- if self.server_type == ServerType.ON_PREMISE:
123
- _paginate_query_func = self._paginate_query
124
- else:
125
- _paginate_query_func = self._paginate_query_new
126
- return _paginate_query_func(
111
+ ) -> ResultList[Issue] | list:
112
+ return self._paginate(
127
113
  query=query,
128
114
  max_results=max_results,
129
115
  specific_fields=specific_fields,
@@ -131,102 +117,26 @@ class JiraPlus:
131
117
  json_result=json_result,
132
118
  )
133
119
 
134
- def _paginate_query_new(
135
- self,
136
- query: str,
137
- max_results: int,
138
- specific_fields: str | list[str],
139
- jira_err_limit: int,
140
- json_result: bool,
141
- ) -> ResultList[Issue] | list:
142
- all_issues = []
143
- retries = 0
144
- page = None
145
- while True: # pylint: disable=W0149
146
- try:
147
- self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
148
- page = self.jira_client.enhanced_search_issues(
149
- jql_str=query,
150
- nextPageToken=page['nextPageToken'] if page else None,
151
- maxResults=max_results,
152
- fields=specific_fields,
153
- json_result=json_result,
154
- )
155
- if not page:
156
- break
157
-
158
- all_issues += page["issues"] if json_result else page
159
- if page['isLast']:
160
- break
161
- retries = 0 # reset on success
162
- except JIRAError as err:
163
- if err.status_code == 400:
164
- self.logger.error(f"Bad Request: {err.text}")
165
- raise JIRAError(f"Bad Request: {err.text}") from err
166
- retries += 1
167
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
168
- if retries >= jira_err_limit:
169
- self.logger.exception("Max retries exceeded.")
170
- return []
171
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
172
- return all_issues
173
-
174
- def _paginate_query(
175
- self,
176
- query: str,
177
- max_results: int,
178
- specific_fields: str | list[str],
179
- jira_err_limit: int,
180
- json_result: bool,
181
- ) -> ResultList[Issue] | list:
182
- start_at = 0
183
- all_issues = []
184
- retries = 0
185
- while True: # pylint: disable=W0149
186
- try:
187
- self.logger.debug(f"Fetching issues from startAt={start_at}")
188
- page = self.jira_client.search_issues(
189
- jql_str=query,
190
- startAt=start_at,
191
- maxResults=max_results,
192
- fields=specific_fields,
193
- json_result=json_result,
194
- )
195
- if not page:
196
- break
197
-
198
- all_issues += page["issues"] if json_result else page
199
- if json_result:
200
- start_at += page["maxResults"]
201
- else:
202
- start_at += page.maxResults
203
- if start_at >= max_results:
204
- break
205
- retries = 0 # reset on success
206
- except JIRAError as err:
207
- if err.status_code == 400:
208
- self.logger.error(f"Bad Request: {err.text}")
209
- raise JIRAError(f"Bad Request: {err.text}") from err
210
- retries += 1
211
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
212
- if retries >= jira_err_limit:
213
- self.logger.exception("Max retries exceeded.")
214
- return []
215
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
216
- return all_issues
217
-
218
- def _fetch_metadata(self, project_key: str, issue_type: str) -> tuple[dict, dict]:
120
+ def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
219
121
  meta = self.jira_client.createmeta(
220
122
  projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
221
123
  )
222
-
223
124
  if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
224
125
  raise ValueError(f"Project {project_key} not found in metadata")
225
-
226
126
  issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
227
127
  if not issue_meta:
228
128
  raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
229
- return meta, issue_meta
129
+ return issue_meta["fields"]
130
+
131
+ def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
132
+ issue_types = {t.name: t for t in self.jira_client.project_issue_types(project_key)}
133
+ field_list = self.jira_client.project_issue_fields(project_key, issue_types[issue_type].id)
134
+ return {f.fieldId: f.raw for f in field_list}
135
+
136
+ def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
137
+ if self.use_createmeta:
138
+ return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
139
+ return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
230
140
 
231
141
  def get_allowed_values(
232
142
  self,
@@ -234,28 +144,20 @@ class JiraPlus:
234
144
  issue_type: str,
235
145
  field_id_or_name: str,
236
146
  ) -> list | None:
237
- """
238
- Returns the allowed values for a specific field in a project + issue type context.
239
- """
147
+ """Returns the allowed values for a specific field in a project + issue type context."""
240
148
  fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
241
-
242
- # Try to find by ID or fallback to case-insensitive name
243
149
  if not (field := fields_metadata.get(field_id_or_name)):
244
- # Try matching by name (case-insensitive)
245
150
  for _, fmeta in fields_metadata.items():
246
151
  if fmeta["name"].lower() == field_id_or_name.lower():
247
152
  field = fmeta
248
153
  break
249
-
250
154
  if not field:
251
155
  raise ValueError(
252
156
  f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
253
157
  )
254
-
255
158
  if not (allowed := field.get("allowedValues")):
256
159
  self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
257
160
  return None
258
-
259
161
  return allowed
260
162
 
261
163
  @staticmethod
@@ -265,41 +167,13 @@ class JiraPlus:
265
167
  allowed_values = allowed_names | allowed_keys
266
168
  return describe_allowed_value(value, allowed_values=allowed_values)
267
169
 
268
- def get_server_version(self) -> tuple[int, ...]:
269
- return tuple(self.jira_client.server_info()["versionNumbers"])
270
-
271
- def check_server_createmeta_compatibility(self) -> bool:
272
- server_version = self.get_server_version()
273
- if (
274
- self.server_type == ServerType.ON_PREMISE and
275
- Version(".".join(str(x) for x in server_version)) > Version("9.0.0")
276
- ):
277
- self.logger.warning(
278
- f"JIRA server version {server_version} is not fully compatible with createmeta-based field validation. "
279
- "Some features may not work as expected."
280
- )
281
- return False
282
- return True
283
-
284
- def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
285
- if self.check_server_createmeta_compatibility():
286
- _, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
287
- return issue_meta["fields"]
288
- _project_issue_types = self.jira_client.project_issue_types(project_key)
289
- _issue_types = {t.name: t for t in _project_issue_types}
290
- field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
291
- return {f.fieldId: f.raw for f in field_list}
292
-
293
- def validate_fields( # pylint: disable=R1260
294
- self, project_key: str, issue_type: str, fields: dict | None = None
295
- ) -> None:
170
+ def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
296
171
  fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
297
172
  for custom_field, value in fields.items():
298
173
  if custom_field not in fields_metadata:
299
174
  raise ValueError(
300
175
  f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
301
176
  )
302
-
303
177
  if allowed_value := fields_metadata[custom_field].get("allowedValues"):
304
178
  if not self._is_value_allowed(value=value, allowed_values=allowed_value):
305
179
  raise ValueError(
@@ -307,7 +181,6 @@ class JiraPlus:
307
181
  f"with issue type '{issue_type}'."
308
182
  f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
309
183
  )
310
-
311
184
  field_type = fields_metadata[custom_field]["schema"]["type"]
312
185
  if field_type == "string" and not isinstance(value, str):
313
186
  raise ValueError(f"Field '{custom_field}' must be a string")
@@ -321,7 +194,6 @@ class JiraPlus:
321
194
  raise ValueError(f"Field '{custom_field}' must be an object")
322
195
  if field_type == "issuetype" and not isinstance(value, dict):
323
196
  raise ValueError(f"Field '{custom_field}' must be an issue type")
324
- # Add more field type checks as needed
325
197
 
326
198
  def create_issue(
327
199
  self,
@@ -332,21 +204,17 @@ class JiraPlus:
332
204
  assignee: str | None = None,
333
205
  custom_fields: dict | None = None,
334
206
  ) -> Issue | None:
335
- issue_fields = {
207
+ issue_fields: dict[str, Any] = {
336
208
  "project": {"key": project_key},
337
209
  "issuetype": {"name": issue_type},
338
210
  "summary": summary,
339
211
  "description": description,
340
212
  }
341
213
  if assignee:
342
- issue_fields["assignee"] = (
343
- {"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
344
- )
345
-
214
+ issue_fields["assignee"] = self._format_assignee(assignee)
346
215
  if custom_fields:
347
216
  issue_fields = issue_fields | custom_fields
348
217
  self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
349
-
350
218
  issue = self.jira_client.create_issue(fields=issue_fields)
351
219
  self.logger.info(f"Issue {issue.key} created successfully.")
352
220
  return issue
@@ -357,7 +225,6 @@ class JiraPlus:
357
225
  self.validate_fields(
358
226
  project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
359
227
  )
360
-
361
228
  issue.update(fields=fields_to_update)
362
229
  self.logger.info(f"Issue {issue.key} updated successfully.")
363
230
  return issue
@@ -370,7 +237,7 @@ class JiraPlus:
370
237
 
371
238
  def delete_issue(self, issue_key: str) -> bool:
372
239
  try:
373
- issue = self.get_issue_by_key(key=issue_key)
240
+ issue = self.get_issue_by_key(key=issue_key, json_result=False)
374
241
  issue.delete()
375
242
  self.logger.info(f"Issue {issue_key} deleted successfully.")
376
243
  return True
@@ -396,7 +263,6 @@ class JiraPlus:
396
263
  available_link_types = self._get_available_link_types()
397
264
  if link_type not in available_link_types:
398
265
  raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
399
-
400
266
  try:
401
267
  self.jira_client.create_issue_link(
402
268
  type=link_type,
@@ -436,12 +302,10 @@ class JiraPlus:
436
302
  f"\nAvailable transitions: {transition_names}"
437
303
  )
438
304
  return False
439
-
440
305
  transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
441
306
  if not transition_id:
442
307
  self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
443
308
  return False
444
-
445
309
  self.jira_client.transition_issue(
446
310
  issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
447
311
  )
@@ -453,18 +317,3 @@ class JiraPlus:
453
317
  except Exception as err:
454
318
  self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
455
319
  return False
456
-
457
- def get_account_data(self, user_mail: str) -> list[dict]:
458
- jira_rst_url = (
459
- f"https://{self.jira_username}:{self.jira_token}@{self.base_url}/rest/api/{3}/user/search?query={user_mail}"
460
- )
461
- response = requests.get(jira_rst_url)
462
- return response.json()
463
-
464
- def get_account_id(self, user_mail: str) -> str:
465
- data = self.get_account_data(user_mail=user_mail)
466
- return data[0]["accountId"]
467
-
468
- def get_account_display_name(self, user_display_name: str) -> str:
469
- data = self.get_account_data(user_mail=user_display_name)
470
- return data[0]["displayName"]
@@ -0,0 +1,111 @@
1
+ # jira_cloud.py
2
+
3
+ import logging
4
+ import time
5
+
6
+ import requests
7
+ from jira import JIRA, Issue, JIRAError
8
+ from jira.client import ResultList
9
+ from retrying import retry
10
+
11
+ from python_jira_plus.jira_base import JiraBase, UrlScheme
12
+
13
+
14
+ class JiraCloud(JiraBase):
15
+ use_createmeta = True
16
+
17
+ def __init__(
18
+ self,
19
+ base_url: str | None = None,
20
+ urllib3_log_level: int = logging.WARNING,
21
+ jira_username: str | None = None,
22
+ jira_token: str | None = None,
23
+ verify_ssl: bool = True,
24
+ url_scheme: UrlScheme = UrlScheme.HTTPS,
25
+ ) -> None:
26
+ super().__init__(
27
+ base_url=base_url,
28
+ urllib3_log_level=urllib3_log_level,
29
+ jira_username=jira_username,
30
+ jira_token=jira_token,
31
+ verify_ssl=verify_ssl,
32
+ url_scheme=url_scheme,
33
+ )
34
+
35
+ @retry(stop_max_attempt_number=3, wait_fixed=180000)
36
+ def create_connection(self, timeout: int = 580) -> JIRA:
37
+ jira_client = JIRA(
38
+ basic_auth=(self.jira_username, self.jira_token),
39
+ options={"server": self.server, "verify": self.verify_ssl},
40
+ timeout=timeout,
41
+ )
42
+ if jira_client.projects():
43
+ self.logger.info("Jira Connection Successful")
44
+ return jira_client
45
+ self.logger.error("Jira Connection Failed")
46
+ raise JIRAError("Jira Connection Failed")
47
+
48
+ def _format_assignee(self, assignee: str) -> dict:
49
+ return {"accountId": assignee}
50
+
51
+ def _paginate(
52
+ self,
53
+ query: str,
54
+ max_results: int,
55
+ specific_fields: str | list[str],
56
+ jira_err_limit: int,
57
+ json_result: bool,
58
+ ) -> ResultList[Issue] | list:
59
+ all_issues = []
60
+ retries = 0
61
+ page = None
62
+ while True: # pylint: disable=while-used
63
+ try:
64
+ next_token = None
65
+ if page is not None:
66
+ next_token = page.get("nextPageToken") if json_result else page.nextPageToken
67
+
68
+ self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
69
+ page = self.jira_client.enhanced_search_issues(
70
+ jql_str=query,
71
+ nextPageToken=next_token,
72
+ maxResults=max_results,
73
+ fields=specific_fields,
74
+ json_result=json_result,
75
+ )
76
+ if not page:
77
+ break
78
+
79
+ all_issues += page["issues"] if json_result else page
80
+ if page["isLast"] if json_result else page.isLast:
81
+ break
82
+ retries = 0
83
+ except JIRAError as err:
84
+ if err.status_code == 400:
85
+ self.logger.error(f"Bad Request: {err.text}")
86
+ raise JIRAError(f"Bad Request: {err.text}") from err
87
+ retries += 1
88
+ self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
89
+ if retries >= jira_err_limit:
90
+ self.logger.exception("Max retries exceeded.")
91
+ return []
92
+ time.sleep(300 if retries == jira_err_limit - 1 else 90)
93
+ return all_issues
94
+
95
+ def get_account_data(self, user_mail: str) -> list[dict]:
96
+ response = requests.get(
97
+ f"{self.server}/rest/api/3/user/search",
98
+ params={"query": user_mail},
99
+ auth=(self.jira_username, self.jira_token),
100
+ timeout=30,
101
+ )
102
+ response.raise_for_status()
103
+ return response.json()
104
+
105
+ def get_account_id(self, user_mail: str) -> str:
106
+ data = self.get_account_data(user_mail=user_mail)
107
+ return data[0]["accountId"]
108
+
109
+ def get_account_display_name(self, user_display_name: str) -> str:
110
+ data = self.get_account_data(user_mail=user_display_name)
111
+ return data[0]["displayName"]
@@ -0,0 +1,98 @@
1
+ # jira_on_premise.py
2
+
3
+ import logging
4
+ import time
5
+
6
+ from jira import JIRA, Issue, JIRAError
7
+ from jira.client import ResultList
8
+ from packaging.version import Version
9
+ from retrying import retry
10
+
11
+ from python_jira_plus.jira_base import JiraBase, UrlScheme
12
+
13
+
14
+ class JiraOnPremise(JiraBase):
15
+ def __init__(
16
+ self,
17
+ base_url: str | None = None,
18
+ urllib3_log_level: int = logging.WARNING,
19
+ jira_username: str | None = None,
20
+ jira_token: str | None = None,
21
+ verify_ssl: bool = True,
22
+ sso: bool = False,
23
+ url_scheme: UrlScheme = UrlScheme.HTTPS,
24
+ ) -> None:
25
+ self.sso = sso # must be set before super().__init__ — create_connection reads it
26
+ super().__init__(
27
+ base_url=base_url,
28
+ urllib3_log_level=urllib3_log_level,
29
+ jira_username=jira_username,
30
+ jira_token=jira_token,
31
+ verify_ssl=verify_ssl,
32
+ url_scheme=url_scheme,
33
+ )
34
+ use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
35
+ self.logger.debug(
36
+ f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API "
37
+ f"(ON_PREMISE {self.server_version})"
38
+ )
39
+ self.use_createmeta = use_legacy
40
+
41
+ @retry(stop_max_attempt_number=3, wait_fixed=180000)
42
+ def create_connection(self, timeout: int = 580) -> JIRA:
43
+ auth = (
44
+ {"token_auth": self.jira_token} if not self.sso else {"basic_auth": (self.jira_username, self.jira_token)}
45
+ )
46
+ jira_client = JIRA(**auth, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout)
47
+ if jira_client.projects():
48
+ self.logger.info("Jira Connection Successful")
49
+ return jira_client
50
+ self.logger.error("Jira Connection Failed")
51
+ raise JIRAError("Jira Connection Failed")
52
+
53
+ def _format_assignee(self, assignee: str) -> dict:
54
+ return {"name": assignee}
55
+
56
+ def _paginate(
57
+ self,
58
+ query: str,
59
+ max_results: int,
60
+ specific_fields: str | list[str],
61
+ jira_err_limit: int,
62
+ json_result: bool,
63
+ ) -> ResultList[Issue] | list:
64
+ start_at = 0
65
+ all_issues = []
66
+ retries = 0
67
+ while True: # pylint: disable=while-used
68
+ try:
69
+ self.logger.debug(f"Fetching issues from startAt={start_at}")
70
+ page = self.jira_client.search_issues(
71
+ jql_str=query,
72
+ startAt=start_at,
73
+ maxResults=max_results,
74
+ fields=specific_fields,
75
+ json_result=json_result,
76
+ )
77
+ if not page:
78
+ break
79
+
80
+ all_issues += page["issues"] if json_result else page
81
+ if json_result:
82
+ start_at += page["maxResults"]
83
+ else:
84
+ start_at += page.maxResults
85
+ if start_at >= max_results:
86
+ break
87
+ retries = 0
88
+ except JIRAError as err:
89
+ if err.status_code == 400:
90
+ self.logger.error(f"Bad Request: {err.text}")
91
+ raise JIRAError(f"Bad Request: {err.text}") from err
92
+ retries += 1
93
+ self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
94
+ if retries >= jira_err_limit:
95
+ self.logger.exception("Max retries exceeded.")
96
+ return []
97
+ time.sleep(300 if retries == jira_err_limit - 1 else 90)
98
+ return all_issues
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 2.0.7
3
+ Version: 3.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
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: custom-python-logger>=2.0.10
20
- Requires-Dist: dotenv>=0.9.9
20
+ Requires-Dist: python-dotenv>=1.0.0
21
21
  Requires-Dist: jira>=3.10.5
22
- Requires-Dist: pre-commit>=4.5.0
23
- Requires-Dist: pytest>=9.0.1
22
+ Requires-Dist: packaging>=26.2
24
23
  Requires-Dist: requests>=2.32.5
25
24
  Requires-Dist: retrying>=1.4.2
26
- Requires-Dist: setuptools>=80.9.0
27
- Requires-Dist: wheel>=0.45.1
25
+ Provides-Extra: dev
26
+ Requires-Dist: pre-commit>=4.5.0; extra == "dev"
27
+ Requires-Dist: pytest>=9.0.1; extra == "dev"
28
+ Requires-Dist: setuptools>=80.9.0; extra == "dev"
29
+ Requires-Dist: wheel>=0.45.1; extra == "dev"
28
30
  Dynamic: license-file
29
31
 
30
32
  ![PyPI version](https://img.shields.io/pypi/v/python-jira-plus)
@@ -59,7 +61,7 @@ pip install python-jira-plus
59
61
  ---
60
62
 
61
63
  ## Requirements
62
- - Python 3.9+
64
+ - Python 3.12+
63
65
  - `jira` package
64
66
  - `retrying` package
65
67
  - `custom-python-logger` package
@@ -0,0 +1,10 @@
1
+ python_jira_plus/__init__.py,sha256=HMlPp5HOfdEO3VYE7Zc2qfhLsIPpPJ9wQaUojhCOp0k,729
2
+ python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
3
+ python_jira_plus/jira_base.py,sha256=OKRm5ArLdQRLoQQuIz7rgKv_jKb2S23ADM_RSKghxjw,13839
4
+ python_jira_plus/jira_cloud.py,sha256=pD5w2i3g7FpK-xk8dT9pHsNanAm257PlRt3O2glAFvA,3879
5
+ python_jira_plus/jira_on_premise.py,sha256=NWWOgKmX5M8h58m1j4Rr3MRWK1vRIH2Itgae_76ezAA,3633
6
+ python_jira_plus-3.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
7
+ python_jira_plus-3.0.0.dist-info/METADATA,sha256=A81DGwl6RL2VC70xOkibt1VtaIs1b6P1j3g47eu3OVo,4285
8
+ python_jira_plus-3.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ python_jira_plus-3.0.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
10
+ python_jira_plus-3.0.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- python_jira_plus/__init__.py,sha256=hi4PadXJu9vYDqslH0zxAm-UsU_zfVFHCImqbchHbDU,592
2
- python_jira_plus/describe_allowed_value.py,sha256=ZU3SN0d3Xy_4qPaVhkhqQYabt3Q_tN64J2yJ3mV0Abc,1287
3
- python_jira_plus/jira_plus.py,sha256=JcVeWBOHXQdfRbt13C8PH3fMSLxhKtCC1LtNRlS9eSc,19368
4
- python_jira_plus-2.0.7.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
5
- python_jira_plus-2.0.7.dist-info/METADATA,sha256=jKNN8D42R_VO_D2WDSHV0I9e470npEOrDxHJVphr9yQ,4162
6
- python_jira_plus-2.0.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
- python_jira_plus-2.0.7.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
8
- python_jira_plus-2.0.7.dist-info/RECORD,,