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.
- {zabel_elements-1.47.0/zabel_elements.egg-info → zabel_elements-1.47.2}/PKG-INFO +2 -2
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/pyproject.toml +2 -2
- zabel_elements-1.47.2/zabel/elements/clients/base/atlassian.py +600 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/githubcloud.py +89 -8
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jira.py +32 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/confluencecloud.py +8 -7
- {zabel_elements-1.47.0 → zabel_elements-1.47.2/zabel_elements.egg-info}/PKG-INFO +2 -2
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/requires.txt +1 -1
- zabel_elements-1.47.0/zabel/elements/clients/base/atlassian.py +0 -291
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/LICENSE.txt +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/README.md +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/setup.cfg +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/__init__.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/actito.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/artifactory.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/atlassian.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/__init__.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/actito.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/artifactory.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/confluence.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/confluencecloud.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/github.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/gitlab.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jenkins.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/jiracloud.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/kubernetes.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/okta.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/sonarqube.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/sonatypenexus.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/base/squashtm.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/confluence.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/github.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/githubcloud.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/gitlab.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jenkins.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jira.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/jiracloud.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/kubehelpers.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/kubernetes.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/okta.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/sonarqube.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/sonatypenexus.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/clients/squashtm.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/__init__.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/managedserviceapp.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel/elements/images/utilityapp.py +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/SOURCES.txt +0 -0
- {zabel_elements-1.47.0 → zabel_elements-1.47.2}/zabel_elements.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|