python-jira-plus 2.0.8__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 (20) hide show
  1. {python_jira_plus-2.0.8/python_jira_plus.egg-info → python_jira_plus-3.0.0}/PKG-INFO +1 -1
  2. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/pyproject.toml +1 -1
  3. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus/__init__.py +4 -2
  4. python_jira_plus-2.0.8/python_jira_plus/jira_plus.py → python_jira_plus-3.0.0/python_jira_plus/jira_base.py +41 -197
  5. python_jira_plus-3.0.0/python_jira_plus/jira_cloud.py +111 -0
  6. python_jira_plus-3.0.0/python_jira_plus/jira_on_premise.py +98 -0
  7. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0/python_jira_plus.egg-info}/PKG-INFO +1 -1
  8. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/SOURCES.txt +3 -1
  9. python_jira_plus-3.0.0/tests/test_describe_allowed_value.py +84 -0
  10. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/tests/test_jira_cloud_integration.py +23 -27
  11. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/tests/test_jira_plus.py +79 -62
  12. python_jira_plus-2.0.8/tests/test_describe_allowed_value.py +0 -77
  13. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/LICENSE +0 -0
  14. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/MANIFEST.in +0 -0
  15. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/README.md +0 -0
  16. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus/describe_allowed_value.py +0 -0
  17. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
  18. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/requires.txt +0 -0
  19. {python_jira_plus-2.0.8 → python_jira_plus-3.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
  20. {python_jira_plus-2.0.8 → 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.8
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jira-plus"
7
- version = "2.0.8"
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"
@@ -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, UrlScheme
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", "UrlScheme", "BASIC_FIELDS"]
32
+ __all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
@@ -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,35 +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()
56
46
  self.server_version = self.get_server_version()
57
- self.use_legacy_api = self._resolve_use_legacy_api()
58
47
 
59
- @retry(stop_max_attempt_number=3, wait_fixed=180000)
60
- def create_connection(self, timeout: int = 580) -> JIRA:
61
- if self.server_type == ServerType.ON_PREMISE and not self.sso:
62
- jira_client = JIRA(
63
- token_auth=self.jira_token, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout
64
- )
65
- else:
66
- jira_client = JIRA(
67
- basic_auth=(self.jira_username, self.jira_token),
68
- options={"server": self.server, "verify": self.verify_ssl},
69
- timeout=timeout,
70
- )
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: ...
71
60
 
72
- if jira_client.projects():
73
- self.logger.info("Jira Connection Successful")
74
- return jira_client
75
- self.logger.error("Jira Connection Failed")
76
- raise JIRAError("Jira Connection Failed")
61
+ @abstractmethod
62
+ def _format_assignee(self, assignee: str) -> dict: ...
77
63
 
78
64
  def close_connection(self) -> None:
79
65
  try:
@@ -82,7 +68,6 @@ class JiraPlus:
82
68
  self.logger.exception(f"JIRAError \n{err}")
83
69
  except Exception as err:
84
70
  self.logger.exception(f"Exception \n{err}")
85
-
86
71
  self.check_client_connection()
87
72
 
88
73
  def check_client_connection(self) -> bool:
@@ -98,6 +83,9 @@ class JiraPlus:
98
83
  self.logger.exception(f"Exception \n{err}")
99
84
  return False
100
85
 
86
+ def get_server_version(self) -> tuple[int, ...]:
87
+ return tuple(self.jira_client.server_info()["versionNumbers"])
88
+
101
89
  def get_issue_by_key(
102
90
  self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
103
91
  ) -> None | Issue | dict[str, Any]:
@@ -120,12 +108,8 @@ class JiraPlus:
120
108
  specific_fields: str | list[str] = "*all",
121
109
  jira_err_count: int = 3,
122
110
  json_result: bool = True,
123
- ) -> ResultList[Issue] | dict | None:
124
- if self.server_type == ServerType.ON_PREMISE:
125
- _paginate_query_func = self._paginate_query
126
- else:
127
- _paginate_query_func = self._paginate_query_new
128
- return _paginate_query_func(
111
+ ) -> ResultList[Issue] | list:
112
+ return self._paginate(
129
113
  query=query,
130
114
  max_results=max_results,
131
115
  specific_fields=specific_fields,
@@ -133,107 +117,26 @@ class JiraPlus:
133
117
  json_result=json_result,
134
118
  )
135
119
 
136
- def _paginate_query_new(
137
- self,
138
- query: str,
139
- max_results: int,
140
- specific_fields: str | list[str],
141
- jira_err_limit: int,
142
- json_result: bool,
143
- ) -> ResultList[Issue] | list:
144
- all_issues = []
145
- retries = 0
146
- page = None
147
- while True:
148
- try:
149
- next_token = None
150
- if page is not None:
151
- next_token = page.get('nextPageToken') if json_result else page.nextPageToken
152
-
153
- self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
154
- page = self.jira_client.enhanced_search_issues(
155
- jql_str=query,
156
- nextPageToken=next_token,
157
- maxResults=max_results,
158
- fields=specific_fields,
159
- json_result=json_result,
160
- )
161
- if not page:
162
- break
163
-
164
- all_issues += page["issues"] if json_result else page
165
- is_last = page['isLast'] if json_result else page.isLast
166
- if is_last:
167
- break
168
- retries = 0 # reset on success
169
- except JIRAError as err:
170
- if err.status_code == 400:
171
- self.logger.error(f"Bad Request: {err.text}")
172
- raise JIRAError(f"Bad Request: {err.text}") from err
173
- retries += 1
174
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
175
- if retries >= jira_err_limit:
176
- self.logger.exception("Max retries exceeded.")
177
- return []
178
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
179
- return all_issues
180
-
181
- def _paginate_query(
182
- self,
183
- query: str,
184
- max_results: int,
185
- specific_fields: str | list[str],
186
- jira_err_limit: int,
187
- json_result: bool,
188
- ) -> ResultList[Issue] | list:
189
- start_at = 0
190
- all_issues = []
191
- retries = 0
192
- while True: # pylint: disable=W0149
193
- try:
194
- self.logger.debug(f"Fetching issues from startAt={start_at}")
195
- page = self.jira_client.search_issues(
196
- jql_str=query,
197
- startAt=start_at,
198
- maxResults=max_results,
199
- fields=specific_fields,
200
- json_result=json_result,
201
- )
202
- if not page:
203
- break
204
-
205
- all_issues += page["issues"] if json_result else page
206
- if json_result:
207
- start_at += page["maxResults"]
208
- else:
209
- start_at += page.maxResults
210
- if start_at >= max_results:
211
- break
212
- retries = 0 # reset on success
213
- except JIRAError as err:
214
- if err.status_code == 400:
215
- self.logger.error(f"Bad Request: {err.text}")
216
- raise JIRAError(f"Bad Request: {err.text}") from err
217
- retries += 1
218
- self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
219
- if retries >= jira_err_limit:
220
- self.logger.exception("Max retries exceeded.")
221
- return []
222
- time.sleep(300 if retries == jira_err_limit - 1 else 90)
223
- return all_issues
224
-
225
- 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:
226
121
  meta = self.jira_client.createmeta(
227
122
  projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
228
123
  )
229
-
230
124
  if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
231
125
  raise ValueError(f"Project {project_key} not found in metadata")
232
-
233
126
  issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
234
127
  if not issue_meta:
235
128
  raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
236
- 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)
237
140
 
238
141
  def get_allowed_values(
239
142
  self,
@@ -241,28 +144,20 @@ class JiraPlus:
241
144
  issue_type: str,
242
145
  field_id_or_name: str,
243
146
  ) -> list | None:
244
- """
245
- Returns the allowed values for a specific field in a project + issue type context.
246
- """
147
+ """Returns the allowed values for a specific field in a project + issue type context."""
247
148
  fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
248
-
249
- # Try to find by ID or fallback to case-insensitive name
250
149
  if not (field := fields_metadata.get(field_id_or_name)):
251
- # Try matching by name (case-insensitive)
252
150
  for _, fmeta in fields_metadata.items():
253
151
  if fmeta["name"].lower() == field_id_or_name.lower():
254
152
  field = fmeta
255
153
  break
256
-
257
154
  if not field:
258
155
  raise ValueError(
259
156
  f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
260
157
  )
261
-
262
158
  if not (allowed := field.get("allowedValues")):
263
159
  self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
264
160
  return None
265
-
266
161
  return allowed
267
162
 
268
163
  @staticmethod
@@ -272,36 +167,13 @@ class JiraPlus:
272
167
  allowed_values = allowed_names | allowed_keys
273
168
  return describe_allowed_value(value, allowed_values=allowed_values)
274
169
 
275
- def get_server_version(self) -> tuple[int, ...]:
276
- return tuple(self.jira_client.server_info()["versionNumbers"])
277
-
278
- def _resolve_use_legacy_api(self) -> bool:
279
- if self.server_type == ServerType.ON_PREMISE:
280
- use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
281
- self.logger.debug(f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API (ON_PREMISE {self.server_version})")
282
- return use_legacy
283
- self.logger.debug("Using legacy createmeta API (Cloud)")
284
- return True
285
-
286
- def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
287
- if self.use_legacy_api:
288
- _, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
289
- return issue_meta["fields"]
290
- _project_issue_types = self.jira_client.project_issue_types(project_key)
291
- _issue_types = {t.name: t for t in _project_issue_types}
292
- field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
293
- return {f.fieldId: f.raw for f in field_list}
294
-
295
- def validate_fields( # pylint: disable=R1260
296
- self, project_key: str, issue_type: str, fields: dict
297
- ) -> None:
170
+ def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
298
171
  fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
299
172
  for custom_field, value in fields.items():
300
173
  if custom_field not in fields_metadata:
301
174
  raise ValueError(
302
175
  f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
303
176
  )
304
-
305
177
  if allowed_value := fields_metadata[custom_field].get("allowedValues"):
306
178
  if not self._is_value_allowed(value=value, allowed_values=allowed_value):
307
179
  raise ValueError(
@@ -309,7 +181,6 @@ class JiraPlus:
309
181
  f"with issue type '{issue_type}'."
310
182
  f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
311
183
  )
312
-
313
184
  field_type = fields_metadata[custom_field]["schema"]["type"]
314
185
  if field_type == "string" and not isinstance(value, str):
315
186
  raise ValueError(f"Field '{custom_field}' must be a string")
@@ -323,7 +194,6 @@ class JiraPlus:
323
194
  raise ValueError(f"Field '{custom_field}' must be an object")
324
195
  if field_type == "issuetype" and not isinstance(value, dict):
325
196
  raise ValueError(f"Field '{custom_field}' must be an issue type")
326
- # Add more field type checks as needed
327
197
 
328
198
  def create_issue(
329
199
  self,
@@ -334,21 +204,17 @@ class JiraPlus:
334
204
  assignee: str | None = None,
335
205
  custom_fields: dict | None = None,
336
206
  ) -> Issue | None:
337
- issue_fields = {
207
+ issue_fields: dict[str, Any] = {
338
208
  "project": {"key": project_key},
339
209
  "issuetype": {"name": issue_type},
340
210
  "summary": summary,
341
211
  "description": description,
342
212
  }
343
213
  if assignee:
344
- issue_fields["assignee"] = (
345
- {"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
346
- )
347
-
214
+ issue_fields["assignee"] = self._format_assignee(assignee)
348
215
  if custom_fields:
349
216
  issue_fields = issue_fields | custom_fields
350
217
  self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
351
-
352
218
  issue = self.jira_client.create_issue(fields=issue_fields)
353
219
  self.logger.info(f"Issue {issue.key} created successfully.")
354
220
  return issue
@@ -359,7 +225,6 @@ class JiraPlus:
359
225
  self.validate_fields(
360
226
  project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
361
227
  )
362
-
363
228
  issue.update(fields=fields_to_update)
364
229
  self.logger.info(f"Issue {issue.key} updated successfully.")
365
230
  return issue
@@ -398,7 +263,6 @@ class JiraPlus:
398
263
  available_link_types = self._get_available_link_types()
399
264
  if link_type not in available_link_types:
400
265
  raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
401
-
402
266
  try:
403
267
  self.jira_client.create_issue_link(
404
268
  type=link_type,
@@ -438,12 +302,10 @@ class JiraPlus:
438
302
  f"\nAvailable transitions: {transition_names}"
439
303
  )
440
304
  return False
441
-
442
305
  transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
443
306
  if not transition_id:
444
307
  self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
445
308
  return False
446
-
447
309
  self.jira_client.transition_issue(
448
310
  issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
449
311
  )
@@ -455,21 +317,3 @@ class JiraPlus:
455
317
  except Exception as err:
456
318
  self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
457
319
  return False
458
-
459
- def get_account_data(self, user_mail: str) -> list[dict]:
460
- response = requests.get(
461
- f"{self.server}/rest/api/3/user/search",
462
- params={"query": user_mail},
463
- auth=(self.jira_username, self.jira_token),
464
- timeout=30,
465
- )
466
- response.raise_for_status()
467
- return response.json()
468
-
469
- def get_account_id(self, user_mail: str) -> str:
470
- data = self.get_account_data(user_mail=user_mail)
471
- return data[0]["accountId"]
472
-
473
- def get_account_display_name(self, user_display_name: str) -> str:
474
- data = self.get_account_data(user_mail=user_display_name)
475
- 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.8
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
@@ -4,7 +4,9 @@ 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
@@ -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"})
@@ -16,12 +16,14 @@ Run only integration tests:
16
16
  uv run pytest -m integration -v
17
17
  """
18
18
 
19
+ # pylint: disable=no-self-use,redefined-outer-name,consider-using-assignment-expr
19
20
  import os
20
21
 
21
22
  import pytest
23
+ from jira import Issue, JIRAError
22
24
 
23
25
  from python_jira_plus import BASIC_FIELDS
24
- from python_jira_plus.jira_plus import JiraPlus
26
+ from python_jira_plus.jira_cloud import JiraCloud
25
27
 
26
28
  pytestmark = pytest.mark.integration
27
29
 
@@ -33,11 +35,10 @@ def _missing_env() -> list[str]:
33
35
 
34
36
 
35
37
  @pytest.fixture(scope="module")
36
- def jira() -> JiraPlus:
37
- missing = _missing_env()
38
- if missing:
38
+ def jira() -> JiraCloud:
39
+ if missing := _missing_env():
39
40
  pytest.skip(f"Missing required env vars: {', '.join(missing)}")
40
- return JiraPlus()
41
+ return JiraCloud()
41
42
 
42
43
 
43
44
  @pytest.fixture(scope="module")
@@ -61,22 +62,22 @@ def test_field() -> str:
61
62
 
62
63
 
63
64
  class TestConnection:
64
- def test_client_is_connected(self, jira: JiraPlus) -> None:
65
+ def test_client_is_connected(self, jira: JiraCloud) -> None:
65
66
  result = jira.check_client_connection()
66
67
  assert result is True, "Expected active connection to Jira Cloud"
67
68
 
68
- def test_server_version_is_tuple(self, jira: JiraPlus) -> None:
69
- assert isinstance(jira.server_version, tuple), \
70
- f"Expected tuple, got {type(jira.server_version)}"
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
71
  assert len(jira.server_version) > 0, "Expected non-empty version tuple"
72
72
 
73
- def test_cloud_uses_legacy_api(self, jira: JiraPlus) -> None:
74
- assert jira.use_legacy_api is True, \
75
- "Cloud uses legacy createmeta API — project_issue_types is not supported for Cloud by the jira library"
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"
76
77
 
77
78
 
78
79
  class TestGetObjectsByQuery:
79
- def test_returns_list_of_issues(self, jira: JiraPlus, project_key: str, issue_type: str) -> None:
80
+ def test_returns_list_of_issues(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
80
81
  query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
81
82
  result = jira.get_objects_by_query(
82
83
  query=query,
@@ -86,7 +87,7 @@ class TestGetObjectsByQuery:
86
87
  )
87
88
  assert isinstance(result, list), f"Expected list, got {type(result)}"
88
89
 
89
- def test_returned_issues_have_basic_fields(self, jira: JiraPlus, project_key: str, issue_type: str) -> None:
90
+ def test_returned_issues_have_basic_fields(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
90
91
  query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
91
92
  result = jira.get_objects_by_query(
92
93
  query=query,
@@ -101,47 +102,42 @@ class TestGetObjectsByQuery:
101
102
  assert "key" in issue, f"Expected 'key' in issue, got keys: {list(issue.keys())}"
102
103
  assert "fields" in issue, f"Expected 'fields' in issue, got keys: {list(issue.keys())}"
103
104
 
104
- def test_invalid_jql_raises(self, jira: JiraPlus) -> None:
105
- from jira import JIRAError
105
+ def test_invalid_jql_raises(self, jira: JiraCloud) -> None:
106
106
  with pytest.raises(JIRAError):
107
107
  jira.get_objects_by_query(query="INVALID JQL !!!", max_results=1)
108
108
 
109
109
 
110
110
  class TestGetIssueByKey:
111
- def test_json_result_true_returns_dict(self, jira: JiraPlus, issue_key: str) -> None:
111
+ def test_json_result_true_returns_dict(self, jira: JiraCloud, issue_key: str) -> None:
112
112
  result = jira.get_issue_by_key(key=issue_key, json_result=True)
113
113
  if result is None:
114
114
  pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
115
115
  assert isinstance(result, dict), f"Expected dict, got {type(result)}"
116
- assert "key" in result, f"Expected 'key' in result"
116
+ assert "key" in result, "Expected 'key' in result"
117
117
 
118
- def test_json_result_false_returns_issue_object(self, jira: JiraPlus, issue_key: str) -> None:
119
- from jira import Issue
118
+ def test_json_result_false_returns_issue_object(self, jira: JiraCloud, issue_key: str) -> None:
120
119
  result = jira.get_issue_by_key(key=issue_key, json_result=False)
121
120
  if result is None:
122
121
  pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
123
122
  assert isinstance(result, Issue), f"Expected Issue object, got {type(result)}"
124
123
 
125
- def test_nonexistent_issue_returns_none(self, jira: JiraPlus) -> None:
124
+ def test_nonexistent_issue_returns_none(self, jira: JiraCloud) -> None:
126
125
  result = jira.get_issue_by_key(key="NONEXISTENT-99999999")
127
126
  assert result is None, "Expected None for a nonexistent issue key"
128
127
 
129
128
 
130
129
  class TestGetAllowedValues:
131
130
  def test_returns_list_for_valid_field(
132
- self, jira: JiraPlus, project_key: str, issue_type: str, test_field: str
131
+ self, jira: JiraCloud, project_key: str, issue_type: str, test_field: str
133
132
  ) -> None:
134
133
  result = jira.get_allowed_values(
135
134
  project_key=project_key,
136
135
  issue_type=issue_type,
137
136
  field_id_or_name=test_field,
138
137
  )
139
- assert result is None or isinstance(result, list), \
140
- f"Expected list or None, got {type(result)}"
138
+ assert result is None or isinstance(result, list), f"Expected list or None, got {type(result)}"
141
139
 
142
- def test_invalid_field_raises_value_error(
143
- self, jira: JiraPlus, project_key: str, issue_type: str
144
- ) -> None:
140
+ def test_invalid_field_raises_value_error(self, jira: JiraCloud, project_key: str, issue_type: str) -> None:
145
141
  with pytest.raises(ValueError, match="not found"):
146
142
  jira.get_allowed_values(
147
143
  project_key=project_key,
@@ -1,9 +1,11 @@
1
+ # pylint: disable=no-self-use,protected-access
1
2
  from unittest.mock import MagicMock, patch
2
3
 
3
4
  import pytest
4
5
  from jira import JIRAError
5
6
 
6
- from python_jira_plus.jira_plus import JiraPlus
7
+ from python_jira_plus.jira_cloud import JiraCloud
8
+ from python_jira_plus.jira_on_premise import JiraOnPremise
7
9
 
8
10
 
9
11
  def _make_issue_type_mock(name: str, issue_id: str) -> MagicMock:
@@ -21,21 +23,22 @@ def _make_field_mock(field_id: str, raw: dict) -> MagicMock:
21
23
 
22
24
 
23
25
  class TestResolveLegacyApi:
24
- def test_cloud_uses_legacy_api(self, jira_plus_cloud: JiraPlus) -> None:
25
- assert jira_plus_cloud.use_legacy_api is True, \
26
- "Cloud uses legacy createmeta API — project_issue_types is not supported for Cloud by the jira library"
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"
27
30
 
28
- def test_on_premise_below_9_uses_legacy_api(self, jira_plus_on_premise_legacy: JiraPlus) -> None:
29
- assert jira_plus_on_premise_legacy.use_legacy_api is True, \
30
- "ON_PREMISE < 9.0 should use legacy createmeta API"
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"
31
33
 
32
- def test_on_premise_9_and_above_uses_new_api(self, jira_plus_on_premise_new: JiraPlus) -> None:
33
- assert jira_plus_on_premise_new.use_legacy_api is False, \
34
- "ON_PREMISE >= 9.0 should use project_issue_types API"
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"
35
38
 
36
39
 
37
40
  class TestGetIssueByKey:
38
- def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraPlus) -> None:
41
+ def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraCloud) -> None:
39
42
  mock_issue = MagicMock()
40
43
  mock_issue.raw = {"key": "PROJ-1", "fields": {}}
41
44
  jira_plus_cloud.jira_client.issue.return_value = mock_issue
@@ -44,7 +47,7 @@ class TestGetIssueByKey:
44
47
 
45
48
  assert result == {"key": "PROJ-1", "fields": {}}, f"Expected raw dict, got {result}"
46
49
 
47
- def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraPlus) -> None:
50
+ def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraCloud) -> None:
48
51
  mock_issue = MagicMock()
49
52
  jira_plus_cloud.jira_client.issue.return_value = mock_issue
50
53
 
@@ -52,14 +55,14 @@ class TestGetIssueByKey:
52
55
 
53
56
  assert result is mock_issue, "Expected Issue object, not raw dict"
54
57
 
55
- def test_jira_error_returns_none(self, jira_plus_cloud: JiraPlus) -> None:
58
+ def test_jira_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
56
59
  jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Not found", status_code=404)
57
60
 
58
61
  result = jira_plus_cloud.get_issue_by_key(key="PROJ-999")
59
62
 
60
63
  assert result is None, "Expected None on JIRAError"
61
64
 
62
- def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraPlus) -> None:
65
+ def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraCloud) -> None:
63
66
  jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
64
67
 
65
68
  result = jira_plus_cloud.get_issue_by_key(key="PROJ-1")
@@ -68,7 +71,7 @@ class TestGetIssueByKey:
68
71
 
69
72
 
70
73
  class TestDeleteIssue:
71
- def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraPlus) -> None:
74
+ def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraCloud) -> None:
72
75
  mock_issue = MagicMock()
73
76
  jira_plus_cloud.jira_client.issue.return_value = mock_issue
74
77
 
@@ -77,14 +80,14 @@ class TestDeleteIssue:
77
80
  assert result is True, "Expected True on successful delete"
78
81
  mock_issue.delete.assert_called_once()
79
82
 
80
- def test_jira_error_returns_false(self, jira_plus_cloud: JiraPlus) -> None:
83
+ def test_jira_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
81
84
  jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Forbidden", status_code=403)
82
85
 
83
86
  result = jira_plus_cloud.delete_issue("PROJ-1")
84
87
 
85
88
  assert result is False, "Expected False on JIRAError"
86
89
 
87
- def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraPlus) -> None:
90
+ def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraCloud) -> None:
88
91
  jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
89
92
 
90
93
  result = jira_plus_cloud.delete_issue("PROJ-1")
@@ -93,67 +96,84 @@ class TestDeleteIssue:
93
96
 
94
97
 
95
98
  class TestValidateFields:
96
- def _setup_createmeta(self, jira_plus: JiraPlus, fields: dict) -> None:
99
+ def _setup_createmeta(self, jira_plus: JiraCloud, fields: dict) -> None:
97
100
  jira_plus.jira_client.createmeta.return_value = {
98
- "projects": [{
99
- "key": "PROJ",
100
- "issuetypes": [{"name": "Story", "fields": fields}],
101
- }]
101
+ "projects": [
102
+ {
103
+ "key": "PROJ",
104
+ "issuetypes": [{"name": "Story", "fields": fields}],
105
+ }
106
+ ]
102
107
  }
103
108
 
104
- def test_valid_string_field_raises_no_error(self, jira_plus_cloud: JiraPlus) -> None:
105
- self._setup_createmeta(jira_plus_cloud, {
106
- "summary": {"schema": {"type": "string"}, "name": "Summary"},
107
- })
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
+ )
108
116
 
109
117
  jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": "Hello"})
110
118
 
111
- def test_field_not_in_metadata_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
112
- self._setup_createmeta(jira_plus_cloud, {
113
- "summary": {"schema": {"type": "string"}, "name": "Summary"},
114
- })
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
+ )
115
126
 
116
127
  with pytest.raises(ValueError, match="Invalid field"):
117
128
  jira_plus_cloud.validate_fields("PROJ", "Story", {"nonexistent_field": "value"})
118
129
 
119
- def test_wrong_type_for_string_field_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
120
- self._setup_createmeta(jira_plus_cloud, {
121
- "summary": {"schema": {"type": "string"}, "name": "Summary"},
122
- })
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
+ )
123
137
 
124
138
  with pytest.raises(ValueError, match="must be a string"):
125
139
  jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": 123})
126
140
 
127
- def test_wrong_type_for_number_field_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
128
- self._setup_createmeta(jira_plus_cloud, {
129
- "story_points": {"schema": {"type": "number"}, "name": "Story Points"},
130
- })
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
+ )
131
148
 
132
149
  with pytest.raises(ValueError, match="must be a number"):
133
150
  jira_plus_cloud.validate_fields("PROJ", "Story", {"story_points": "five"})
134
151
 
135
- def test_invalid_allowed_value_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
136
- self._setup_createmeta(jira_plus_cloud, {
137
- "priority": {
138
- "schema": {"type": "string"},
139
- "name": "Priority",
140
- "allowedValues": [{"name": "High"}, {"name": "Low"}],
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
+ },
141
161
  },
142
- })
162
+ )
143
163
 
144
164
  with pytest.raises(ValueError, match="Invalid value"):
145
165
  jira_plus_cloud.validate_fields("PROJ", "Story", {"priority": "Critical"})
146
166
 
147
167
 
148
168
  class TestPaginateQueryNew:
149
- def test_single_page_returns_issues(self, jira_plus_cloud: JiraPlus) -> None:
169
+ def test_single_page_returns_issues(self, jira_plus_cloud: JiraCloud) -> None:
150
170
  issues = [{"key": "PROJ-1"}, {"key": "PROJ-2"}]
151
171
  jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {
152
172
  "issues": issues,
153
173
  "isLast": True,
154
174
  }
155
175
 
156
- result = jira_plus_cloud._paginate_query_new(
176
+ result = jira_plus_cloud._paginate(
157
177
  query="project = PROJ",
158
178
  max_results=50,
159
179
  specific_fields="*all",
@@ -163,12 +183,12 @@ class TestPaginateQueryNew:
163
183
 
164
184
  assert result == issues, f"Expected {issues}, got {result}"
165
185
 
166
- def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraPlus) -> None:
186
+ def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraCloud) -> None:
167
187
  page1 = {"issues": [{"key": "PROJ-1"}], "isLast": False, "nextPageToken": "token-2"}
168
188
  page2 = {"issues": [{"key": "PROJ-2"}], "isLast": True}
169
189
  jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = [page1, page2]
170
190
 
171
- result = jira_plus_cloud._paginate_query_new(
191
+ result = jira_plus_cloud._paginate(
172
192
  query="project = PROJ",
173
193
  max_results=50,
174
194
  specific_fields="*all",
@@ -176,13 +196,12 @@ class TestPaginateQueryNew:
176
196
  json_result=True,
177
197
  )
178
198
 
179
- assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], \
180
- f"Expected both pages merged, got {result}"
199
+ assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], f"Expected both pages merged, got {result}"
181
200
 
182
- def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraPlus) -> None:
201
+ def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraCloud) -> None:
183
202
  jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {}
184
203
 
185
- result = jira_plus_cloud._paginate_query_new(
204
+ result = jira_plus_cloud._paginate(
186
205
  query="project = PROJ",
187
206
  max_results=50,
188
207
  specific_fields="*all",
@@ -192,13 +211,11 @@ class TestPaginateQueryNew:
192
211
 
193
212
  assert result == [], f"Expected empty list, got {result}"
194
213
 
195
- def test_bad_request_raises_jira_error(self, jira_plus_cloud: JiraPlus) -> None:
196
- jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = JIRAError(
197
- "Bad JQL", status_code=400
198
- )
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)
199
216
 
200
217
  with pytest.raises(JIRAError):
201
- jira_plus_cloud._paginate_query_new(
218
+ jira_plus_cloud._paginate(
202
219
  query="invalid JQL !!!",
203
220
  max_results=50,
204
221
  specific_fields="*all",
@@ -208,20 +225,20 @@ class TestPaginateQueryNew:
208
225
 
209
226
 
210
227
  class TestGetAccountData:
211
- def test_success_returns_user_list(self, jira_plus_cloud: JiraPlus) -> None:
228
+ def test_success_returns_user_list(self, jira_plus_cloud: JiraCloud) -> None:
212
229
  mock_response = MagicMock()
213
230
  mock_response.json.return_value = [{"accountId": "abc123", "displayName": "Test User"}]
214
231
 
215
- with patch("python_jira_plus.jira_plus.requests.get", return_value=mock_response):
232
+ with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
216
233
  result = jira_plus_cloud.get_account_data("test@test.com")
217
234
 
218
235
  assert result[0]["accountId"] == "abc123", f"Expected 'abc123', got {result[0]['accountId']}"
219
236
  mock_response.raise_for_status.assert_called_once()
220
237
 
221
- def test_http_error_raises(self, jira_plus_cloud: JiraPlus) -> None:
238
+ def test_http_error_raises(self, jira_plus_cloud: JiraCloud) -> None:
222
239
  mock_response = MagicMock()
223
240
  mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
224
241
 
225
- with patch("python_jira_plus.jira_plus.requests.get", return_value=mock_response):
242
+ with patch("python_jira_plus.jira_cloud.requests.get", return_value=mock_response):
226
243
  with pytest.raises(Exception, match="401"):
227
244
  jira_plus_cloud.get_account_data("bad@test.com")
@@ -1,77 +0,0 @@
1
- import pytest
2
-
3
- from python_jira_plus.describe_allowed_value import describe_allowed_value
4
-
5
-
6
- class TestDescribeAllowedValueStr:
7
- def test_str_in_allowed_set_returns_true(self) -> None:
8
- assert describe_allowed_value("High", allowed_values={"High", "Medium", "Low"}), \
9
- "Expected True for 'High' in allowed set"
10
-
11
- def test_str_not_in_allowed_set_returns_false(self) -> None:
12
- assert not describe_allowed_value("Critical", allowed_values={"High", "Medium", "Low"}), \
13
- "Expected False for 'Critical' not in allowed set"
14
-
15
-
16
- class TestDescribeAllowedValueInt:
17
- def test_int_in_allowed_set_returns_true(self) -> None:
18
- assert describe_allowed_value(1, allowed_values={1, 2, 3}), \
19
- "Expected True for 1 in allowed set"
20
-
21
- def test_int_not_in_allowed_set_returns_false(self) -> None:
22
- assert not describe_allowed_value(9, allowed_values={1, 2, 3}), \
23
- "Expected False for 9 not in allowed set"
24
-
25
-
26
- class TestDescribeAllowedValueFloat:
27
- def test_float_in_allowed_set_returns_true(self) -> None:
28
- assert describe_allowed_value(1.5, allowed_values={1.5, 2.5}), \
29
- "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}), \
33
- "Expected False for 9.9 not in allowed set"
34
-
35
-
36
- class TestDescribeAllowedValueList:
37
- def test_list_of_strings_with_match_returns_true(self) -> None:
38
- assert describe_allowed_value(["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(["Critical", "Unknown"], allowed_values={"High", "Medium"}), \
43
- "Expected False when no items match"
44
-
45
- def test_list_of_dicts_with_match_returns_true(self) -> None:
46
- assert describe_allowed_value([{"name": "High"}], allowed_values={"High", "Medium"}), \
47
- "Expected True when dict value matches"
48
-
49
- def test_list_of_dicts_no_match_returns_false(self) -> None:
50
- assert not describe_allowed_value([{"name": "Critical"}], allowed_values={"High", "Medium"}), \
51
- "Expected False when dict value doesn't match"
52
-
53
-
54
- class TestDescribeAllowedValueSet:
55
- def test_set_with_match_returns_true(self) -> None:
56
- assert describe_allowed_value({"High", "Unknown"}, allowed_values={"High", "Medium"}), \
57
- "Expected True when set contains a match"
58
-
59
- def test_set_no_match_returns_false(self) -> None:
60
- assert not describe_allowed_value({"Critical"}, allowed_values={"High", "Medium"}), \
61
- "Expected False when set has no match"
62
-
63
-
64
- class TestDescribeAllowedValueDict:
65
- def test_dict_value_in_allowed_set_returns_true(self) -> None:
66
- assert describe_allowed_value({"name": "High"}, allowed_values={"High", "Medium"}), \
67
- "Expected True when dict value matches"
68
-
69
- def test_dict_value_not_in_allowed_set_returns_false(self) -> None:
70
- assert not describe_allowed_value({"name": "Critical"}, allowed_values={"High", "Medium"}), \
71
- "Expected False when dict value doesn't match"
72
-
73
-
74
- class TestDescribeAllowedValueUnknownType:
75
- def test_unknown_type_raises_not_implemented_error(self) -> None:
76
- with pytest.raises(NotImplementedError):
77
- describe_allowed_value(object(), allowed_values={"High"})