python-jira-plus 2.0.7__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {python_jira_plus-2.0.7/python_jira_plus.egg-info → python_jira_plus-3.0.0}/PKG-INFO +9 -7
  2. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/README.md +1 -1
  3. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/pyproject.toml +9 -4
  4. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus/__init__.py +4 -2
  5. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py +3 -3
  6. python_jira_plus-2.0.7/python_jira_plus/jira_plus.py → python_jira_plus-3.0.0/python_jira_plus/jira_base.py +43 -194
  7. python_jira_plus-3.0.0/python_jira_plus/jira_cloud.py +111 -0
  8. python_jira_plus-3.0.0/python_jira_plus/jira_on_premise.py +98 -0
  9. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0/python_jira_plus.egg-info}/PKG-INFO +9 -7
  10. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/SOURCES.txt +7 -2
  11. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/requires.txt +6 -3
  12. python_jira_plus-3.0.0/tests/test_describe_allowed_value.py +84 -0
  13. python_jira_plus-3.0.0/tests/test_jira_cloud_integration.py +146 -0
  14. python_jira_plus-3.0.0/tests/test_jira_plus.py +244 -0
  15. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/LICENSE +0 -0
  16. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/MANIFEST.in +0 -0
  17. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
  18. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
  19. {python_jira_plus-2.0.7 → python_jira_plus-3.0.0}/setup.cfg +0 -0
@@ -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
@@ -30,7 +30,7 @@ pip install python-jira-plus
30
30
  ---
31
31
 
32
32
  ## Requirements
33
- - Python 3.9+
33
+ - Python 3.12+
34
34
  - `jira` package
35
35
  - `retrying` package
36
36
  - `custom-python-logger` package
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jira-plus"
7
- version = "2.0.7"
7
+ version = "3.0.0"
8
8
  description = "Enhanced Python client for JIRA with better error handling, pagination, and metadata validation."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -22,12 +22,17 @@ classifiers = [
22
22
 
23
23
  dependencies = [
24
24
  "custom-python-logger>=2.0.10",
25
- "dotenv>=0.9.9",
25
+ "python-dotenv>=1.0.0",
26
26
  "jira>=3.10.5",
27
- "pre-commit>=4.5.0",
28
- "pytest>=9.0.1",
27
+ "packaging>=26.2",
29
28
  "requests>=2.32.5",
30
29
  "retrying>=1.4.2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pre-commit>=4.5.0",
35
+ "pytest>=9.0.1",
31
36
  "setuptools>=80.9.0",
32
37
  "wheel>=0.45.1",
33
38
  ]
@@ -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
@@ -4,9 +4,14 @@ README.md
4
4
  pyproject.toml
5
5
  python_jira_plus/__init__.py
6
6
  python_jira_plus/describe_allowed_value.py
7
- python_jira_plus/jira_plus.py
7
+ python_jira_plus/jira_base.py
8
+ python_jira_plus/jira_cloud.py
9
+ python_jira_plus/jira_on_premise.py
8
10
  python_jira_plus.egg-info/PKG-INFO
9
11
  python_jira_plus.egg-info/SOURCES.txt
10
12
  python_jira_plus.egg-info/dependency_links.txt
11
13
  python_jira_plus.egg-info/requires.txt
12
- python_jira_plus.egg-info/top_level.txt
14
+ python_jira_plus.egg-info/top_level.txt
15
+ tests/test_describe_allowed_value.py
16
+ tests/test_jira_cloud_integration.py
17
+ tests/test_jira_plus.py
@@ -1,9 +1,12 @@
1
1
  custom-python-logger>=2.0.10
2
- dotenv>=0.9.9
2
+ python-dotenv>=1.0.0
3
3
  jira>=3.10.5
4
- pre-commit>=4.5.0
5
- pytest>=9.0.1
4
+ packaging>=26.2
6
5
  requests>=2.32.5
7
6
  retrying>=1.4.2
7
+
8
+ [dev]
9
+ pre-commit>=4.5.0
10
+ pytest>=9.0.1
8
11
  setuptools>=80.9.0
9
12
  wheel>=0.45.1
@@ -0,0 +1,84 @@
1
+ # pylint: disable=no-self-use
2
+ import pytest
3
+
4
+ from python_jira_plus.describe_allowed_value import describe_allowed_value
5
+
6
+
7
+ class TestDescribeAllowedValueStr:
8
+ def test_str_in_allowed_set_returns_true(self) -> None:
9
+ assert describe_allowed_value(
10
+ "High", allowed_values={"High", "Medium", "Low"}
11
+ ), "Expected True for 'High' in allowed set"
12
+
13
+ def test_str_not_in_allowed_set_returns_false(self) -> None:
14
+ assert not describe_allowed_value(
15
+ "Critical", allowed_values={"High", "Medium", "Low"}
16
+ ), "Expected False for 'Critical' not in allowed set"
17
+
18
+
19
+ class TestDescribeAllowedValueInt:
20
+ def test_int_in_allowed_set_returns_true(self) -> None:
21
+ assert describe_allowed_value(1, allowed_values={1, 2, 3}), "Expected True for 1 in allowed set"
22
+
23
+ def test_int_not_in_allowed_set_returns_false(self) -> None:
24
+ assert not describe_allowed_value(9, allowed_values={1, 2, 3}), "Expected False for 9 not in allowed set"
25
+
26
+
27
+ class TestDescribeAllowedValueFloat:
28
+ def test_float_in_allowed_set_returns_true(self) -> None:
29
+ assert describe_allowed_value(1.5, allowed_values={1.5, 2.5}), "Expected True for 1.5 in allowed set"
30
+
31
+ def test_float_not_in_allowed_set_returns_false(self) -> None:
32
+ assert not describe_allowed_value(9.9, allowed_values={1.5, 2.5}), "Expected False for 9.9 not in allowed set"
33
+
34
+
35
+ class TestDescribeAllowedValueList:
36
+ def test_list_of_strings_with_match_returns_true(self) -> None:
37
+ assert describe_allowed_value(
38
+ ["High", "Unknown"], allowed_values={"High", "Medium"}
39
+ ), "Expected True when at least one item matches"
40
+
41
+ def test_list_of_strings_no_match_returns_false(self) -> None:
42
+ assert not describe_allowed_value(
43
+ ["Critical", "Unknown"], allowed_values={"High", "Medium"}
44
+ ), "Expected False when no items match"
45
+
46
+ def test_list_of_dicts_with_match_returns_true(self) -> None:
47
+ assert describe_allowed_value(
48
+ [{"name": "High"}], allowed_values={"High", "Medium"}
49
+ ), "Expected True when dict value matches"
50
+
51
+ def test_list_of_dicts_no_match_returns_false(self) -> None:
52
+ assert not describe_allowed_value(
53
+ [{"name": "Critical"}], allowed_values={"High", "Medium"}
54
+ ), "Expected False when dict value doesn't match"
55
+
56
+
57
+ class TestDescribeAllowedValueSet:
58
+ def test_set_with_match_returns_true(self) -> None:
59
+ assert describe_allowed_value(
60
+ {"High", "Unknown"}, allowed_values={"High", "Medium"}
61
+ ), "Expected True when set contains a match"
62
+
63
+ def test_set_no_match_returns_false(self) -> None:
64
+ assert not describe_allowed_value(
65
+ {"Critical"}, allowed_values={"High", "Medium"}
66
+ ), "Expected False when set has no match"
67
+
68
+
69
+ class TestDescribeAllowedValueDict:
70
+ def test_dict_value_in_allowed_set_returns_true(self) -> None:
71
+ assert describe_allowed_value(
72
+ {"name": "High"}, allowed_values={"High", "Medium"}
73
+ ), "Expected True when dict value matches"
74
+
75
+ def test_dict_value_not_in_allowed_set_returns_false(self) -> None:
76
+ assert not describe_allowed_value(
77
+ {"name": "Critical"}, allowed_values={"High", "Medium"}
78
+ ), "Expected False when dict value doesn't match"
79
+
80
+
81
+ class TestDescribeAllowedValueUnknownType:
82
+ def test_unknown_type_raises_not_implemented_error(self) -> None:
83
+ with pytest.raises(NotImplementedError):
84
+ describe_allowed_value(object(), allowed_values={"High"})
@@ -0,0 +1,146 @@
1
+ """
2
+ Integration tests against a real Jira Cloud instance.
3
+
4
+ Required env vars:
5
+ JIRA_USER_NAME your Jira email
6
+ JIRA_TOKEN your Jira API token
7
+ JIRA_BASE_URL e.g. your-instance.atlassian.net
8
+
9
+ Optional env vars (fall back to defaults if not set):
10
+ JIRA_TEST_PROJECT project key to query (default: SUP)
11
+ JIRA_TEST_ISSUE_TYPE issue type to query (default: [System] Service request)
12
+ JIRA_TEST_ISSUE_KEY a known issue key (default: SUP-1)
13
+ JIRA_TEST_FIELD field for allowed values check (default: Priority)
14
+
15
+ Run only integration tests:
16
+ uv run pytest -m integration -v
17
+ """
18
+
19
+ # pylint: disable=no-self-use,redefined-outer-name,consider-using-assignment-expr
20
+ import os
21
+
22
+ import pytest
23
+ from jira import Issue, JIRAError
24
+
25
+ from python_jira_plus import BASIC_FIELDS
26
+ from python_jira_plus.jira_cloud import JiraCloud
27
+
28
+ pytestmark = pytest.mark.integration
29
+
30
+ _REQUIRED_ENV = ("JIRA_USER_NAME", "JIRA_TOKEN", "JIRA_BASE_URL")
31
+
32
+
33
+ def _missing_env() -> list[str]:
34
+ return [var for var in _REQUIRED_ENV if not os.getenv(var)]
35
+
36
+
37
+ @pytest.fixture(scope="module")
38
+ def jira() -> JiraCloud:
39
+ if missing := _missing_env():
40
+ pytest.skip(f"Missing required env vars: {', '.join(missing)}")
41
+ return JiraCloud()
42
+
43
+
44
+ @pytest.fixture(scope="module")
45
+ def project_key() -> str:
46
+ return os.getenv("JIRA_TEST_PROJECT", "SUP")
47
+
48
+
49
+ @pytest.fixture(scope="module")
50
+ def issue_type() -> str:
51
+ return os.getenv("JIRA_TEST_ISSUE_TYPE", "[System] Service request")
52
+
53
+
54
+ @pytest.fixture(scope="module")
55
+ def issue_key() -> str:
56
+ return os.getenv("JIRA_TEST_ISSUE_KEY", "SUP-1")
57
+
58
+
59
+ @pytest.fixture(scope="module")
60
+ def test_field() -> str:
61
+ return os.getenv("JIRA_TEST_FIELD", "Priority")
62
+
63
+
64
+ class TestConnection:
65
+ def test_client_is_connected(self, jira: JiraCloud) -> None:
66
+ result = jira.check_client_connection()
67
+ assert result is True, "Expected active connection to Jira Cloud"
68
+
69
+ def test_server_version_is_tuple(self, jira: JiraCloud) -> None:
70
+ assert isinstance(jira.server_version, tuple), f"Expected tuple, got {type(jira.server_version)}"
71
+ assert len(jira.server_version) > 0, "Expected non-empty version tuple"
72
+
73
+ def test_cloud_uses_createmeta(self, jira: JiraCloud) -> None:
74
+ assert (
75
+ jira.use_createmeta is True
76
+ ), "Cloud always uses createmeta — project_issue_types is not supported for Cloud by the jira library"
77
+
78
+
79
+ class TestGetObjectsByQuery:
80
+ def test_returns_list_of_issues(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
81
+ query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
82
+ result = jira.get_objects_by_query(
83
+ query=query,
84
+ specific_fields=BASIC_FIELDS,
85
+ max_results=10,
86
+ json_result=True,
87
+ )
88
+ assert isinstance(result, list), f"Expected list, got {type(result)}"
89
+
90
+ def test_returned_issues_have_basic_fields(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
91
+ query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
92
+ result = jira.get_objects_by_query(
93
+ query=query,
94
+ specific_fields=BASIC_FIELDS,
95
+ max_results=5,
96
+ json_result=True,
97
+ )
98
+ if not result:
99
+ pytest.skip(f"No issues found in project '{project_key}' with issue type '{issue_type}'")
100
+
101
+ issue = result[0]
102
+ assert "key" in issue, f"Expected 'key' in issue, got keys: {list(issue.keys())}"
103
+ assert "fields" in issue, f"Expected 'fields' in issue, got keys: {list(issue.keys())}"
104
+
105
+ def test_invalid_jql_raises(self, jira: JiraCloud) -> None:
106
+ with pytest.raises(JIRAError):
107
+ jira.get_objects_by_query(query="INVALID JQL !!!", max_results=1)
108
+
109
+
110
+ class TestGetIssueByKey:
111
+ def test_json_result_true_returns_dict(self, jira: JiraCloud, issue_key: str) -> None:
112
+ result = jira.get_issue_by_key(key=issue_key, json_result=True)
113
+ if result is None:
114
+ pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
115
+ assert isinstance(result, dict), f"Expected dict, got {type(result)}"
116
+ assert "key" in result, "Expected 'key' in result"
117
+
118
+ def test_json_result_false_returns_issue_object(self, jira: JiraCloud, issue_key: str) -> None:
119
+ result = jira.get_issue_by_key(key=issue_key, json_result=False)
120
+ if result is None:
121
+ pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
122
+ assert isinstance(result, Issue), f"Expected Issue object, got {type(result)}"
123
+
124
+ def test_nonexistent_issue_returns_none(self, jira: JiraCloud) -> None:
125
+ result = jira.get_issue_by_key(key="NONEXISTENT-99999999")
126
+ assert result is None, "Expected None for a nonexistent issue key"
127
+
128
+
129
+ class TestGetAllowedValues:
130
+ def test_returns_list_for_valid_field(
131
+ self, jira: JiraCloud, project_key: str, issue_type: str, test_field: str
132
+ ) -> None:
133
+ result = jira.get_allowed_values(
134
+ project_key=project_key,
135
+ issue_type=issue_type,
136
+ field_id_or_name=test_field,
137
+ )
138
+ assert result is None or isinstance(result, list), f"Expected list or None, got {type(result)}"
139
+
140
+ def test_invalid_field_raises_value_error(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
141
+ with pytest.raises(ValueError, match="not found"):
142
+ jira.get_allowed_values(
143
+ project_key=project_key,
144
+ issue_type=issue_type,
145
+ field_id_or_name="nonexistent_field_xyz_999",
146
+ )
@@ -0,0 +1,244 @@
1
+ # pylint: disable=no-self-use,protected-access
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+ from jira import JIRAError
6
+
7
+ from python_jira_plus.jira_cloud import JiraCloud
8
+ from python_jira_plus.jira_on_premise import JiraOnPremise
9
+
10
+
11
+ def _make_issue_type_mock(name: str, issue_id: str) -> MagicMock:
12
+ mock = MagicMock()
13
+ mock.name = name
14
+ mock.id = issue_id
15
+ return mock
16
+
17
+
18
+ def _make_field_mock(field_id: str, raw: dict) -> MagicMock:
19
+ mock = MagicMock()
20
+ mock.fieldId = field_id
21
+ mock.raw = raw
22
+ return mock
23
+
24
+
25
+ class TestResolveLegacyApi:
26
+ def test_cloud_uses_legacy_api(self, jira_plus_cloud: JiraCloud) -> None:
27
+ assert (
28
+ jira_plus_cloud.use_createmeta is True
29
+ ), "Cloud always uses createmeta — project_issue_types is not supported for Cloud by the jira library"
30
+
31
+ def test_on_premise_below_9_uses_legacy_api(self, jira_plus_on_premise_legacy: JiraOnPremise) -> None:
32
+ assert jira_plus_on_premise_legacy.use_createmeta is True, "ON_PREMISE < 9.0 should use createmeta API"
33
+
34
+ def test_on_premise_9_and_above_uses_new_api(self, jira_plus_on_premise_new: JiraOnPremise) -> None:
35
+ assert (
36
+ jira_plus_on_premise_new.use_createmeta is False
37
+ ), "ON_PREMISE >= 9.0 should use project_issue_types / project_issue_fields API"
38
+
39
+
40
+ class TestGetIssueByKey:
41
+ def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraCloud) -> None:
42
+ mock_issue = MagicMock()
43
+ mock_issue.raw = {"key": "PROJ-1", "fields": {}}
44
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
45
+
46
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=True)
47
+
48
+ assert result == {"key": "PROJ-1", "fields": {}}, f"Expected raw dict, got {result}"
49
+
50
+ def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraCloud) -> None:
51
+ mock_issue = MagicMock()
52
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
53
+
54
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=False)
55
+
56
+ assert result is mock_issue, "Expected Issue object, not raw dict"
57
+
58
+ def test_jira_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
59
+ jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Not found", status_code=404)
60
+
61
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-999")
62
+
63
+ assert result is None, "Expected None on JIRAError"
64
+
65
+ def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
66
+ jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
67
+
68
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1")
69
+
70
+ assert result is None, "Expected None on unexpected error"
71
+
72
+
73
+ class TestDeleteIssue:
74
+ def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraCloud) -> None:
75
+ mock_issue = MagicMock()
76
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
77
+
78
+ result = jira_plus_cloud.delete_issue("PROJ-1")
79
+
80
+ assert result is True, "Expected True on successful delete"
81
+ mock_issue.delete.assert_called_once()
82
+
83
+ def test_jira_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
84
+ jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Forbidden", status_code=403)
85
+
86
+ result = jira_plus_cloud.delete_issue("PROJ-1")
87
+
88
+ assert result is False, "Expected False on JIRAError"
89
+
90
+ def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
91
+ jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
92
+
93
+ result = jira_plus_cloud.delete_issue("PROJ-1")
94
+
95
+ assert result is False, "Expected False on unexpected error"
96
+
97
+
98
+ class TestValidateFields:
99
+ def _setup_createmeta(self, jira_plus: JiraCloud, fields: dict) -> None:
100
+ jira_plus.jira_client.createmeta.return_value = {
101
+ "projects": [
102
+ {
103
+ "key": "PROJ",
104
+ "issuetypes": [{"name": "Story", "fields": fields}],
105
+ }
106
+ ]
107
+ }
108
+
109
+ def test_valid_string_field_raises_no_error(self, jira_plus_cloud: JiraCloud) -> None:
110
+ self._setup_createmeta(
111
+ jira_plus_cloud,
112
+ {
113
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
114
+ },
115
+ )
116
+
117
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": "Hello"})
118
+
119
+ def test_field_not_in_metadata_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
120
+ self._setup_createmeta(
121
+ jira_plus_cloud,
122
+ {
123
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
124
+ },
125
+ )
126
+
127
+ with pytest.raises(ValueError, match="Invalid field"):
128
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"nonexistent_field": "value"})
129
+
130
+ def test_wrong_type_for_string_field_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
131
+ self._setup_createmeta(
132
+ jira_plus_cloud,
133
+ {
134
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
135
+ },
136
+ )
137
+
138
+ with pytest.raises(ValueError, match="must be a string"):
139
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": 123})
140
+
141
+ def test_wrong_type_for_number_field_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
142
+ self._setup_createmeta(
143
+ jira_plus_cloud,
144
+ {
145
+ "story_points": {"schema": {"type": "number"}, "name": "Story Points"},
146
+ },
147
+ )
148
+
149
+ with pytest.raises(ValueError, match="must be a number"):
150
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"story_points": "five"})
151
+
152
+ def test_invalid_allowed_value_raises_value_error(self, jira_plus_cloud: JiraCloud) -> None:
153
+ self._setup_createmeta(
154
+ jira_plus_cloud,
155
+ {
156
+ "priority": {
157
+ "schema": {"type": "string"},
158
+ "name": "Priority",
159
+ "allowedValues": [{"name": "High"}, {"name": "Low"}],
160
+ },
161
+ },
162
+ )
163
+
164
+ with pytest.raises(ValueError, match="Invalid value"):
165
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"priority": "Critical"})
166
+
167
+
168
+ class TestPaginateQueryNew:
169
+ def test_single_page_returns_issues(self, jira_plus_cloud: JiraCloud) -> None:
170
+ issues = [{"key": "PROJ-1"}, {"key": "PROJ-2"}]
171
+ jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {
172
+ "issues": issues,
173
+ "isLast": True,
174
+ }
175
+
176
+ result = jira_plus_cloud._paginate(
177
+ query="project = PROJ",
178
+ max_results=50,
179
+ specific_fields="*all",
180
+ jira_err_limit=3,
181
+ json_result=True,
182
+ )
183
+
184
+ assert result == issues, f"Expected {issues}, got {result}"
185
+
186
+ def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraCloud) -> None:
187
+ page1 = {"issues": [{"key": "PROJ-1"}], "isLast": False, "nextPageToken": "token-2"}
188
+ page2 = {"issues": [{"key": "PROJ-2"}], "isLast": True}
189
+ jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = [page1, page2]
190
+
191
+ result = jira_plus_cloud._paginate(
192
+ query="project = PROJ",
193
+ max_results=50,
194
+ specific_fields="*all",
195
+ jira_err_limit=3,
196
+ json_result=True,
197
+ )
198
+
199
+ assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], f"Expected both pages merged, got {result}"
200
+
201
+ def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraCloud) -> None:
202
+ jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {}
203
+
204
+ result = jira_plus_cloud._paginate(
205
+ query="project = PROJ",
206
+ max_results=50,
207
+ specific_fields="*all",
208
+ jira_err_limit=3,
209
+ json_result=True,
210
+ )
211
+
212
+ assert result == [], f"Expected empty list, got {result}"
213
+
214
+ def test_bad_request_raises_jira_error(self, jira_plus_cloud: JiraCloud) -> None:
215
+ jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = JIRAError("Bad JQL", status_code=400)
216
+
217
+ with pytest.raises(JIRAError):
218
+ jira_plus_cloud._paginate(
219
+ query="invalid JQL !!!",
220
+ max_results=50,
221
+ specific_fields="*all",
222
+ jira_err_limit=3,
223
+ json_result=True,
224
+ )
225
+
226
+
227
+ class TestGetAccountData:
228
+ def test_success_returns_user_list(self, jira_plus_cloud: JiraCloud) -> None:
229
+ mock_response = MagicMock()
230
+ mock_response.json.return_value = [{"accountId": "abc123", "displayName": "Test User"}]
231
+
232
+ with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
233
+ result = jira_plus_cloud.get_account_data("test@test.com")
234
+
235
+ assert result[0]["accountId"] == "abc123", f"Expected 'abc123', got {result[0]['accountId']}"
236
+ mock_response.raise_for_status.assert_called_once()
237
+
238
+ def test_http_error_raises(self, jira_plus_cloud: JiraCloud) -> None:
239
+ mock_response = MagicMock()
240
+ mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
241
+
242
+ with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
243
+ with pytest.raises(Exception, match="401"):
244
+ jira_plus_cloud.get_account_data("bad@test.com")