zabel-elements 1.47.0__tar.gz → 1.47.2__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 (49) hide show
  1. {zabel_elements-1.47.0/zabel_elements.egg-info → zabel_elements-1.47.2}/PKG-INFO +2 -2
  2. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/pyproject.toml +2 -2
  3. zabel_elements-1.47.2/zabel/elements/clients/base/atlassian.py +600 -0
  4. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/githubcloud.py +89 -8
  5. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jira.py +32 -0
  6. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/confluencecloud.py +8 -7
  7. {zabel_elements-1.47.0 → zabel_elements-1.47.2/zabel_elements.egg-info}/PKG-INFO +2 -2
  8. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/requires.txt +1 -1
  9. zabel_elements-1.47.0/zabel/elements/clients/base/atlassian.py +0 -291
  10. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/LICENSE.txt +0 -0
  11. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/README.md +0 -0
  12. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/setup.cfg +0 -0
  13. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/__init__.py +0 -0
  14. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/actito.py +0 -0
  15. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/artifactory.py +0 -0
  16. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/atlassian.py +0 -0
  17. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/__init__.py +0 -0
  18. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/actito.py +0 -0
  19. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/artifactory.py +0 -0
  20. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/confluence.py +0 -0
  21. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/confluencecloud.py +0 -0
  22. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/github.py +0 -0
  23. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/gitlab.py +0 -0
  24. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jenkins.py +0 -0
  25. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jiracloud.py +0 -0
  26. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/kubernetes.py +0 -0
  27. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/okta.py +0 -0
  28. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/sonarqube.py +0 -0
  29. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/sonatypenexus.py +0 -0
  30. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/squashtm.py +0 -0
  31. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/confluence.py +0 -0
  32. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/github.py +0 -0
  33. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/githubcloud.py +0 -0
  34. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/gitlab.py +0 -0
  35. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jenkins.py +0 -0
  36. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jira.py +0 -0
  37. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jiracloud.py +0 -0
  38. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/kubehelpers.py +0 -0
  39. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/kubernetes.py +0 -0
  40. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/okta.py +0 -0
  41. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/sonarqube.py +0 -0
  42. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/sonatypenexus.py +0 -0
  43. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/squashtm.py +0 -0
  44. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/__init__.py +0 -0
  45. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/managedserviceapp.py +0 -0
  46. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/utilityapp.py +0 -0
  47. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/SOURCES.txt +0 -0
  48. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/dependency_links.txt +0 -0
  49. {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zabel-elements
3
- Version: 1.47.0
3
+ Version: 1.47.2
4
4
  Summary: TThe Zabel default clients and images
5
5
  Author-email: Martin Lafaix <martin.lafaix@external.engie.com>
6
6
  License-Expression: EPL-2.0
@@ -18,7 +18,7 @@ Requires-Dist: python-gitlab>=8.0; extra == "gitlab"
18
18
  Provides-Extra: jira
19
19
  Requires-Dist: Jira>=3.0; extra == "jira"
20
20
  Provides-Extra: kubernetes
21
- Requires-Dist: kubernetes>=35.0; extra == "kubernetes"
21
+ Requires-Dist: kubernetes<36,>=35.0; extra == "kubernetes"
22
22
  Provides-Extra: okta
23
23
  Requires-Dist: okta<=2.9.10,>=2.9; extra == "okta"
24
24
  Provides-Extra: pynacl
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zabel-elements"
7
- version = "1.47.0"
7
+ version = "1.47.2"
8
8
  description = "TThe Zabel default clients and images"
9
9
  readme = "README.md"
10
10
  license = "EPL-2.0"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "Jira>=3.0",
31
31
  ]
32
32
  "kubernetes" = [
33
- "kubernetes>=35.0",
33
+ "kubernetes>=35.0,<36",
34
34
  ]
35
35
  "okta" = [
36
36
  "okta>=2.9,<=2.9.10",
@@ -0,0 +1,600 @@
1
+ """Atlassian.
2
+
3
+ A base class wrapping Atlassian cloud APIs.
4
+
5
+ This module depends on the public **requests** library. It also depends
6
+ on three **zabel-commons** modules, #::zabel.commons.exceptions,
7
+ #::zabel.commons.sessions, and #::zabel.commons.utils.
8
+
9
+ A base class wrapper only implements 'simple' API requests. It handles
10
+ pagination if appropriate, but does not process the results or compose
11
+ API requests.
12
+ """
13
+
14
+ from typing import Any, Iterable, Mapping
15
+
16
+ import random
17
+ import time
18
+
19
+ from contextvars import ContextVar
20
+ from functools import wraps
21
+
22
+ import requests
23
+
24
+ from zabel.commons.exceptions import ApiError
25
+ from zabel.commons.sessions import prepare_session
26
+ from zabel.commons.utils import (
27
+ api_call,
28
+ ensure_nonemptystring,
29
+ ensure_noneorinstance,
30
+ ensure_noneornonemptystring,
31
+ join_url,
32
+ BearerAuth,
33
+ )
34
+
35
+ ########################################################################
36
+ ########################################################################
37
+
38
+ # Atlassian Cloud low-level API
39
+
40
+ _V2_PAGE_SIZE = 100
41
+ _V2_MAX_RETRIES = 5
42
+
43
+ # Thread-safe context variable to force basic auth for specific endpoints
44
+ _force_basic_auth: ContextVar[bool] = ContextVar(
45
+ 'force_basic_auth', default=False
46
+ )
47
+
48
+
49
+ def require_basic_auth(func):
50
+ """Decorator to force basic auth for site-level API endpoints.
51
+
52
+ Some Atlassian site APIs (e.g. rest/api/3/user/search) require basic
53
+ auth instead of bearer auth. Uses ContextVar for thread-safety.
54
+ """
55
+
56
+ @wraps(func)
57
+ def wrapper(self, *args, **kwargs):
58
+ prev = _force_basic_auth.set(True)
59
+ try:
60
+ return func(self, *args, **kwargs)
61
+ finally:
62
+ _force_basic_auth.reset(prev)
63
+
64
+ return wrapper
65
+
66
+
67
+ class Atlassian:
68
+ """Atlassian Low-Level Wrapper.
69
+
70
+ ## Reference URLs
71
+
72
+ - <https://developer.atlassian.com/cloud/admin>
73
+ - <https://developer.atlassian.com/cloud/admin/rest-apis/>
74
+
75
+ Some methods target the underlying product (Confluence Cloud,
76
+ Jira Cloud, ...)
77
+
78
+ - <https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro>
79
+ - <https://developer.atlassian.com/cloud/confluence/rest/v2/intro>
80
+
81
+ ## Implemented features
82
+
83
+ - users
84
+
85
+ ## Examples
86
+
87
+ ```python
88
+ from zabel.elements.clients import Atlassian
89
+
90
+ url = 'https://api.atlassian.com/admin/v1/'
91
+ token = '...'
92
+ atlassian = Atlassian(url, bearer_auth=token)
93
+ atlassian.list_organization_users('your-organization-id')
94
+ ```
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ url: str,
100
+ *,
101
+ basic_auth: tuple[str, str] | None = None,
102
+ bearer_auth: str | None = None,
103
+ ) -> None:
104
+ """Create an Atlassian instance object.
105
+
106
+ You can specify `basic_auth`, `bearer_auth`, or both.
107
+
108
+ When both are provided, `bearer_auth` is used as the default
109
+ session (for admin APIs) and `basic_auth` is available for
110
+ site-level endpoints via the `@require_basic_auth` decorator.
111
+
112
+ # Required parameters
113
+
114
+ At least one of `basic_auth` or `bearer_auth` must be provided.
115
+
116
+ - url: a non-empty string
117
+ - basic_auth: a tuple of (user, token)
118
+ - bearer_auth: a string
119
+
120
+ # Usage
121
+
122
+ `url` is the top-level API endpoint. For example:
123
+
124
+ 'https://api.atlassian.com/admin/v1/'
125
+ """
126
+ ensure_nonemptystring('url')
127
+ ensure_noneornonemptystring('bearer_auth')
128
+ ensure_noneorinstance('basic_auth', tuple)
129
+ if not url.endswith('/v1/'):
130
+ raise ValueError(f'url must end with /v1/: {url}')
131
+
132
+ if basic_auth is None and bearer_auth is None:
133
+ raise ValueError(
134
+ 'Provide at least one of bearer_auth or basic_auth'
135
+ )
136
+
137
+ self.url = url
138
+ self.base_url = url.removesuffix('/v1/')
139
+ self.basic_auth = basic_auth
140
+ self.bearer_auth = bearer_auth
141
+
142
+ if bearer_auth:
143
+ self.session = prepare_session(BearerAuth(bearer_auth))
144
+ if basic_auth:
145
+ self._basic_session = prepare_session(basic_auth)
146
+ else:
147
+ self._basic_session = self.session
148
+ else:
149
+ self._basic_session = self.session = prepare_session(basic_auth)
150
+
151
+ def __str__(self) -> str:
152
+ return f'{self.__class__.__name__}: {self.url}'
153
+
154
+ def __repr__(self) -> str:
155
+ rep = []
156
+ if self.basic_auth:
157
+ rep.append('basic_auth={self.basic_auth[0]!r}')
158
+ if self.bearer_auth:
159
+ rep.append('bearer_auth=***{self.bearer_auth[-6:]!r}')
160
+ return f'<{self.__class__.__name__}: {self.url!r}, {', '.join(rep)}>'
161
+
162
+ def _get_session(self) -> requests.Session:
163
+ """Return the appropriate session based on current auth context."""
164
+ if _force_basic_auth.get():
165
+ return self._basic_session()
166
+ return self.session()
167
+
168
+ ####################################################################
169
+ # atlassian users
170
+ #
171
+ # list_organization_users
172
+
173
+ @api_call
174
+ def list_organization_users(self, org_id: str) -> list[dict[str, Any]]:
175
+ """List organization users.
176
+
177
+ # Required parameters
178
+
179
+ - org_id: a string
180
+
181
+ # Returned value
182
+
183
+ A list of _users_. Each user is a dictionary with the
184
+ following entries:
185
+
186
+ - `account_id`: a string
187
+ - `account_type`: a string
188
+ - `account_status`: a string
189
+ - `name`: a string
190
+ - `picture`: a string
191
+ - `email`: a string
192
+ - `access_billable`: a boolean
193
+ - `last_active`: a string
194
+ - `product_access`: a list of dictionaries
195
+ - `links`: a dictionary (with `self`)
196
+ """
197
+ ensure_nonemptystring('org_id')
198
+
199
+ session = self._get_session()
200
+ api_url = join_url(self.url, f'orgs/{org_id}/users')
201
+
202
+ collected: list[dict[str, Any]] = []
203
+ while True:
204
+ response = session.get(api_url).json()
205
+ collected.extend(response['data'])
206
+ links = response['links']
207
+
208
+ if 'next' in links:
209
+ api_url = links['next']
210
+ else:
211
+ break
212
+
213
+ return collected
214
+
215
+ ####################################################################
216
+ # atlassian organization directories / groups / role assignments / users
217
+ #
218
+ # list_organization_directories
219
+ # list_directory_groups
220
+ # list_directory_users
221
+ # list_group_roleassignments
222
+
223
+ @api_call
224
+ def list_organization_directories(
225
+ self, org_id: str
226
+ ) -> list[dict[str, Any]]:
227
+ """List directories in an organization.
228
+
229
+ Each directory typically maps to one Atlassian Cloud site.
230
+
231
+ # Required parameters
232
+
233
+ - org_id: a non-empty string
234
+
235
+ # Returned value
236
+
237
+ A list of _directories_. Each directory is a dictionary with
238
+ entries such as `id`, `name`, `type`, `managementAccess`.
239
+ """
240
+ ensure_nonemptystring('org_id')
241
+
242
+ return self._collect_v2(f'orgs/{org_id}/directories')
243
+
244
+ @api_call
245
+ def list_directory_groups(
246
+ self, org_id: str, directory_id: str
247
+ ) -> list[dict[str, Any]]:
248
+ """List groups in a directory of an organization.
249
+
250
+ # Required parameters
251
+
252
+ - org_id: a non-empty string
253
+ - directory_id: a non-empty string
254
+
255
+ # Returned value
256
+
257
+ A list of _groups_. Each group is a dictionary with entries
258
+ such as `id`, `name`, `description`, `directoryId`,
259
+ `managementAccess`, `externalSynced`.
260
+ """
261
+ ensure_nonemptystring('org_id')
262
+ ensure_nonemptystring('directory_id')
263
+
264
+ return self._collect_v2(
265
+ f'orgs/{org_id}/directories/{directory_id}/groups'
266
+ )
267
+
268
+ @api_call
269
+ def list_group_roleassignments(
270
+ self, org_id: str, directory_id: str, group_id: str
271
+ ) -> list[dict[str, Any]]:
272
+ """List role assignments for a group.
273
+
274
+ Reveals which product (jira, confluence, cmdb, avp...) and role
275
+ (atlassian/user, atlassian/admin, atlassian/guest) the group
276
+ grants on which resource (site). This is the mapping that
277
+ Atlassian's v1 API doesn't expose (CONFCLOUD-74996 workaround).
278
+
279
+ # Required parameters
280
+
281
+ - org_id: a non-empty string
282
+ - directory_id: a non-empty string
283
+ - group_id: a non-empty string
284
+
285
+ # Returned value
286
+
287
+ A list of _role assignments_. Each role assignment is a
288
+ dictionary with:
289
+
290
+ - `resourceId`: a string (e.g., `ari:cloud:confluence::site/<id>`)
291
+ - `resourceOwner`: a string (`jira`, `confluence`, `cmdb`, ...)
292
+ - `roles`: a list of strings (`atlassian/user`, `atlassian/admin`,
293
+ `atlassian/guest`)
294
+ - `defaultRole`: a string
295
+ """
296
+ ensure_nonemptystring('org_id')
297
+ ensure_nonemptystring('directory_id')
298
+ ensure_nonemptystring('group_id')
299
+
300
+ return self._collect_v2(
301
+ f'orgs/{org_id}/directories/{directory_id}'
302
+ f'/groups/{group_id}/role-assignments'
303
+ )
304
+
305
+ @api_call
306
+ def list_directory_users(
307
+ self,
308
+ org_id: str,
309
+ directory_id: str,
310
+ params: Mapping[str, Any] | None = None,
311
+ ) -> list[dict[str, Any]]:
312
+ """List users in a directory of an organization.
313
+
314
+ Returns full user info including the org-level `status` field
315
+ (`active` / `suspended` / `deactivated` / `not_invited` /
316
+ `for_deletion`), which is the source of truth for billing.
317
+ Note the difference with `accountStatus` returned by site-level
318
+ APIs: the latter can report `active` for users actually
319
+ suspended at the org level.
320
+
321
+ # Required parameters
322
+
323
+ - org_id: a non-empty string
324
+ - directory_id: a non-empty string
325
+
326
+ # Optional parameters
327
+
328
+ - params: a dictionary of query parameters passed verbatim as
329
+ server-side filters or None (None by default)
330
+
331
+ # Returned value
332
+
333
+ A list of _users_. Each user is a dictionary with entries
334
+ such as:
335
+
336
+ - `accountId`: a string
337
+ - `accountType`: a string (`atlassian`, `app`, `customer`)
338
+ - `status`: a string (`active`, `suspended`, `deactivated`)
339
+ - `accountStatus`: a string (often `active` even when status is
340
+ suspended -- prefer `status` for billing decisions)
341
+ - `membershipStatus`: a string (per-directory membership)
342
+ - `name`, `nickname`, `email`, `claimStatus`, `addedToOrg`,
343
+ `deactivatedOn`, ...
344
+
345
+ # Usage
346
+
347
+ ```python
348
+ params = {
349
+ 'status': ['active'],
350
+ 'claimStatus': 'unmanaged',
351
+ 'roleIds': ['atlassian/user', 'atlassian/admin'],
352
+ 'resourceIds': ['ari:cloud:confluence::site/<siteId>'],
353
+ }
354
+ users = atlassian.list_directory_users(org_id, directory_id, params)
355
+ ```
356
+
357
+ For billing, combining `roleIds` + `resourceIds` returns the
358
+ exact billable user set for a given (site, product) couple --
359
+ matching admin UI counts.
360
+ """
361
+ ensure_nonemptystring('org_id')
362
+ ensure_nonemptystring('directory_id')
363
+
364
+ return self._collect_v2(
365
+ f'orgs/{org_id}/directories/{directory_id}/users', params=params
366
+ )
367
+
368
+ ####################################################################
369
+ # atlassian sites
370
+
371
+ @api_call
372
+ @require_basic_auth
373
+ def list_site_users(self, site_url: str) -> list[dict[str, Any]]:
374
+ """List site users.
375
+
376
+ # Required parameters
377
+
378
+ - site_url: a non-empty string (of the form `https://...`)
379
+
380
+ # Returned value
381
+
382
+ A list of _users_. Each user is a dictionary with the
383
+ following entries:
384
+
385
+ - `accountId`: a string
386
+ - `accountType`: a string
387
+ - `emailAddress`: a string
388
+ - `avatarUrls`: a dictionary
389
+ - `displayName`: a string
390
+ - `active`: a boolean
391
+ - `locale`: a string
392
+ """
393
+ ensure_nonemptystring('site_url')
394
+
395
+ session = self._get_session()
396
+ api_url = join_url(site_url, 'rest/api/3/users/search')
397
+
398
+ params = {'maxResults': 1000}
399
+
400
+ start = 0
401
+ collected: list[Any] = []
402
+ while True:
403
+ params['startAt'] = start
404
+ response = session.get(api_url, params=params).json()
405
+ if not response:
406
+ break
407
+ if not isinstance(response, list):
408
+ raise ApiError(
409
+ f'list_site_users({site_url}): unexpected response {response!r}'
410
+ )
411
+
412
+ collected.extend(response)
413
+
414
+ start += len(response)
415
+
416
+ return collected
417
+
418
+ @api_call
419
+ @require_basic_auth
420
+ def search_site_user(
421
+ self, site_url: str, query: str
422
+ ) -> list[dict[str, Any]]:
423
+ """Search for site user details.
424
+
425
+ Uses basic auth as required by the Atlassian site REST API.
426
+
427
+ # Required parameters
428
+
429
+ - site_url: a non-empty string
430
+ - query: a non-empty string
431
+
432
+ # Returned value
433
+
434
+ A possibly empty list of _users_. See #list_site_users() for
435
+ details on its structure.
436
+ """
437
+ ensure_nonemptystring('site_url')
438
+ ensure_nonemptystring('query')
439
+
440
+ session = self._get_session()
441
+ api_url = join_url(site_url, 'rest/api/3/user/search')
442
+
443
+ params = {'query': query}
444
+
445
+ return session.get(api_url, params=params) # type: ignore
446
+
447
+ @api_call
448
+ @require_basic_auth
449
+ def list_site_application_roles(
450
+ self, site_url: str
451
+ ) -> list[dict[str, Any]]:
452
+ """List Jira application roles on a site.
453
+
454
+ Each role describes one Jira product available on the site
455
+ (e.g. `jira-software`, `jira-servicedesk`, `jira-core`,
456
+ `jira-product-discovery`) and exposes the groups whose members
457
+ are granted access to that product.
458
+
459
+ # Required parameters
460
+
461
+ - site_url: a non-empty string
462
+
463
+ # Returned value
464
+
465
+ A list of _roles_. Each role is a dictionary with entries
466
+ such as:
467
+
468
+ - `key`: a string (the product key)
469
+ - `name`: a string
470
+ - `groups`: a list of strings (group names)
471
+ - `groupDetails`: a list of dictionaries (with `name`, `groupId`)
472
+ - `defaultGroups`: a list of strings
473
+ - `numberOfSeats`: an integer
474
+ - `userCount`: an integer
475
+ - `hasUnlimitedSeats`: a boolean
476
+ """
477
+ ensure_nonemptystring('site_url')
478
+
479
+ session = self._get_session()
480
+ api_url = join_url(site_url, 'rest/api/3/applicationrole')
481
+
482
+ return session.get(api_url).json() # type: ignore
483
+
484
+ @api_call
485
+ @require_basic_auth
486
+ def list_site_group_members(
487
+ self,
488
+ site_url: str,
489
+ group_name: str,
490
+ include_inactive: bool = False,
491
+ ) -> list[dict[str, Any]]:
492
+ """List members of a group on a Jira site.
493
+
494
+ Works for any group on the site, including the groups that
495
+ gate Confluence access (e.g. `confluence-users`).
496
+
497
+ # Required parameters
498
+
499
+ - site_url: a non-empty string
500
+ - group_name: a non-empty string
501
+
502
+ # Optional parameters
503
+
504
+ - include_inactive: a boolean (default False)
505
+
506
+ # Returned value
507
+
508
+ A list of _users_. Each user is a dictionary with entries:
509
+
510
+ - `accountId`: a string
511
+ - `accountType`: a string
512
+ - `displayName`: a string
513
+ - `active`: a boolean
514
+ - `emailAddress`: a string (omitted when the caller does not
515
+ have the relevant privacy permission)
516
+ """
517
+ ensure_nonemptystring('site_url')
518
+ ensure_nonemptystring('group_name')
519
+
520
+ session = self._get_session()
521
+ api_url = join_url(site_url, 'rest/api/3/group/member')
522
+
523
+ params: dict[str, Any] = {
524
+ 'groupname': group_name,
525
+ 'includeInactiveUsers': 'true' if include_inactive else 'false',
526
+ 'maxResults': 50,
527
+ 'startAt': 0,
528
+ }
529
+
530
+ collected: list[dict[str, Any]] = []
531
+ while True:
532
+ page = session.get(api_url, params=params).json()
533
+ values = page.get('values', [])
534
+ collected.extend(values)
535
+ if not values or page.get('isLast', False):
536
+ break
537
+ params['startAt'] += len(values)
538
+
539
+ return collected
540
+
541
+ ####################################################################
542
+ # atlassian private helpers
543
+
544
+ def _get(
545
+ self,
546
+ api: str,
547
+ params: Mapping[str, str | Iterable[str] | int | bool] | None = None,
548
+ ) -> requests.Response:
549
+ """Return atlassian api call results, as Response."""
550
+ session = self._get_session()
551
+ api_url = join_url(self.url, api)
552
+ return session.get(api_url, params=params)
553
+
554
+ def _collect_v2(
555
+ self,
556
+ api: str,
557
+ params: Mapping[str, Any] | None = None,
558
+ ) -> list[dict[str, Any]]:
559
+ """Return admin/v2 GET api call results, collected.
560
+
561
+ First page sends `params` (server-side filters); subsequent
562
+ pages send the cursor alone -- it encodes the filters, and
563
+ mixing them again causes inconsistent results on some
564
+ endpoints (e.g. directory `users/`). Retries on HTTP 429.
565
+ """
566
+ session = self._get_session()
567
+ api_url = join_url(join_url(self.base_url, '/v2'), api)
568
+
569
+ _params: dict[str, Any] = {
570
+ 'limit': _V2_PAGE_SIZE,
571
+ **(params or {}),
572
+ }
573
+
574
+ collected: list[dict[str, Any]] = []
575
+ while True:
576
+ for attempt in range(_V2_MAX_RETRIES + 1):
577
+ response = session.get(api_url, params=_params)
578
+ if response.status_code != 429:
579
+ break
580
+ if attempt == _V2_MAX_RETRIES:
581
+ raise ApiError(
582
+ f'{api}: rate-limited after {_V2_MAX_RETRIES + 1} attempts'
583
+ )
584
+ try:
585
+ retry_after = int(response.headers.get('Retry-After', 0))
586
+ except (TypeError, ValueError):
587
+ retry_after = 0
588
+ wait = max(retry_after, 10 * (attempt + 1))
589
+ time.sleep(wait + random.uniform(0, 5))
590
+
591
+ if response.status_code // 100 != 2:
592
+ raise ApiError(response.text)
593
+ workload = response.json()
594
+ collected += workload.get('data', [])
595
+ cursor = (workload.get('links') or {}).get('next')
596
+ if not cursor:
597
+ break
598
+ _params = {'cursor': cursor}
599
+
600
+ return collected