slack-objects 0.3.0__tar.gz → 0.4.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.
- {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/publish-pypi.yml +7 -1
- {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/publish-testpypi.yml +7 -1
- {slack_objects-0.3.0 → slack_objects-0.4.0}/PKG-INFO +2 -1
- {slack_objects-0.3.0 → slack_objects-0.4.0}/README.md +1 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/_version.py +3 -3
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/config.py +4 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/idp_groups.py +100 -4
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/scm_file_list.json +50 -50
- slack_objects-0.4.0/src/slack_objects.egg-info/scm_version.json +8 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/_smoke_harness.py +4 -1
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/idp_groups_unit_test.py +170 -0
- slack_objects-0.3.0/src/slack_objects.egg-info/scm_version.json +0 -8
- {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/copilot-instructions.md +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/ci.yml +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/.gitignore +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/LICENSE +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/pyproject.toml +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/pytest.ini +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/setup.cfg +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/__init__.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/api_caller.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/base.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/client.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/conversations.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/files.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/messages.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/rate_limits.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/scim_base.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/usergroups.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/users.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/workspaces.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/SOURCES.txt +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/requires.txt +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/top_level.txt +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/examples_config.example.json +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/users_search_by_email_example.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/conftest_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/run_all_scim_users_live_tests.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/conversations_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/files_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/messages_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/run_all_smoke.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/security_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/users_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/config_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/conversations_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/files_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/messages_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/usergroups_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/users_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/conftest_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/run_all_users_live_tests.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_admin_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_classification_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_input_validation_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_read_live.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/conftest.py +0 -0
- {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/live_test_config.example.json +0 -0
|
@@ -3,6 +3,12 @@ name: Publish to PyPI
|
|
|
3
3
|
on:
|
|
4
4
|
release:
|
|
5
5
|
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
tag:
|
|
9
|
+
description: "Tag to build and publish (e.g. v0.3.0). Run this from the default branch."
|
|
10
|
+
required: true
|
|
11
|
+
type: string
|
|
6
12
|
|
|
7
13
|
jobs:
|
|
8
14
|
build-and-publish:
|
|
@@ -19,7 +25,7 @@ jobs:
|
|
|
19
25
|
- uses: actions/checkout@v4
|
|
20
26
|
with:
|
|
21
27
|
fetch-depth: 0
|
|
22
|
-
ref: ${{ github.event.release.tag_name }}
|
|
28
|
+
ref: ${{ github.event.release.tag_name || inputs.tag }}
|
|
23
29
|
|
|
24
30
|
- uses: actions/setup-python@v5
|
|
25
31
|
with:
|
|
@@ -3,6 +3,12 @@ name: Publish to TestPyPI
|
|
|
3
3
|
on:
|
|
4
4
|
release:
|
|
5
5
|
types: [prereleased]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
tag:
|
|
9
|
+
description: "Tag to build and publish to TestPyPI (e.g. v0.4.0-rc1). Run this from the default branch."
|
|
10
|
+
required: true
|
|
11
|
+
type: string
|
|
6
12
|
|
|
7
13
|
jobs:
|
|
8
14
|
build-and-publish:
|
|
@@ -19,7 +25,7 @@ jobs:
|
|
|
19
25
|
- uses: actions/checkout@v4
|
|
20
26
|
with:
|
|
21
27
|
fetch-depth: 0
|
|
22
|
-
ref: ${{ github.event.release.tag_name }}
|
|
28
|
+
ref: ${{ github.event.release.tag_name || inputs.tag }}
|
|
23
29
|
|
|
24
30
|
- uses: actions/setup-python@v5
|
|
25
31
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-objects
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
|
|
5
5
|
Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
|
|
6
6
|
Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
|
|
@@ -197,5 +197,6 @@ explicit per-call tier → method-specific override → prefix rule → `default
|
|
|
197
197
|
- `resolve_user_id` accepts flexible identifiers (user ID, email, or @username) and verifies existence via Web API + SCIM fallback
|
|
198
198
|
- `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
|
|
199
199
|
- SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
|
|
200
|
+
- SCIM group operations are available on `IDP_groups`: list groups, get a group and its members, check membership, and remove selected members. `get_group()` (and so `members`, `get_members()`, and `is_member()`) raises `RuntimeError` when Slack returns an incomplete or malformed member list, rather than returning a short list that would make users look like non-members. `remove_members(["U111", "U222"])` sends a targeted PATCH (v1 or v2) that leaves other members and group fields untouched. It accepts a non-empty list of up to 5,000 unique user IDs (Slack's recommended SCIM batch size), raises on HTTP errors, and returns a `ScimResponse` whose `ok` callers must check. It changes the group in Slack only, not in Entra/Okta, so upstream provisioning may restore members
|
|
200
201
|
- Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
|
|
201
202
|
- `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
|
|
@@ -176,5 +176,6 @@ explicit per-call tier → method-specific override → prefix rule → `default
|
|
|
176
176
|
- `resolve_user_id` accepts flexible identifiers (user ID, email, or @username) and verifies existence via Web API + SCIM fallback
|
|
177
177
|
- `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
|
|
178
178
|
- SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
|
|
179
|
+
- SCIM group operations are available on `IDP_groups`: list groups, get a group and its members, check membership, and remove selected members. `get_group()` (and so `members`, `get_members()`, and `is_member()`) raises `RuntimeError` when Slack returns an incomplete or malformed member list, rather than returning a short list that would make users look like non-members. `remove_members(["U111", "U222"])` sends a targeted PATCH (v1 or v2) that leaves other members and group fields untouched. It accepts a non-empty list of up to 5,000 unique user IDs (Slack's recommended SCIM batch size), raises on HTTP errors, and returns a `ScimResponse` whose `ok` callers must check. It changes the group in Slack only, not in Entra/Okta, so upstream provisioning may restore members
|
|
179
180
|
- Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
|
|
180
181
|
- `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g9f01c906c'
|
|
@@ -14,6 +14,10 @@ CONVERSATION_ID_RE = re.compile(r"^[CGD][A-Z0-9]+$")
|
|
|
14
14
|
# "+" precedes "\-" in the character class so it is not read as a range.
|
|
15
15
|
EMAIL_RE = re.compile(r"^[\w.+\-]+@[\w.\-]+\.\w+$")
|
|
16
16
|
|
|
17
|
+
# Slack accepts up to 15,000 members per SCIM Groups write but recommends batches
|
|
18
|
+
# of no more than 5,000. https://docs.slack.dev/reference/scim-api/#patch-groups-id
|
|
19
|
+
SCIM_GROUP_MEMBER_BATCH_LIMIT = 5000
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
class RateTier(float, Enum):
|
|
19
23
|
"""
|
|
@@ -15,6 +15,7 @@ This module implements the following functionality:
|
|
|
15
15
|
- get a single group's full SCIM record (name, members, metadata)
|
|
16
16
|
- get members of a given group
|
|
17
17
|
- check whether a user is a member of a group
|
|
18
|
+
- remove selected members without replacing the group's membership
|
|
18
19
|
|
|
19
20
|
Design decisions
|
|
20
21
|
----------------
|
|
@@ -24,6 +25,8 @@ Design decisions
|
|
|
24
25
|
- ``get_group()`` is the single read path for GET Groups/{id}; ``get_members()``,
|
|
25
26
|
``is_member()`` and the ``display_name``/``members`` properties all compose on it
|
|
26
27
|
so the endpoint is only ever called from one place.
|
|
28
|
+
- ``get_group()`` raises on incomplete or malformed member lists instead of returning
|
|
29
|
+
them, so callers never mistake missing data for non-membership.
|
|
27
30
|
- Attributes are loaded lazily (like ``Users``): binding a group_id does no network I/O,
|
|
28
31
|
the fetch happens on first property access or on ``refresh()``.
|
|
29
32
|
- This module is SCIM-only. For Slack-native usergroups, see ``usergroups.py``.
|
|
@@ -35,6 +38,7 @@ from typing import Any, Dict, List, Optional
|
|
|
35
38
|
import requests
|
|
36
39
|
|
|
37
40
|
from .base import SlackObjectBase
|
|
41
|
+
from .config import SCIM_GROUP_MEMBER_BATCH_LIMIT, USER_ID_RE
|
|
38
42
|
from .scim_base import ScimMixin, ScimResponse, validate_scim_id
|
|
39
43
|
|
|
40
44
|
|
|
@@ -146,6 +150,44 @@ class IDP_groups(ScimMixin, SlackObjectBase):
|
|
|
146
150
|
# Pass params only when set, so the default call is byte-for-byte the legacy request.
|
|
147
151
|
return self._scim_request(path=f"Groups/{group_id}", method="GET", params=params or None) # https://docs.slack.dev/reference/scim-api/#get-groups-id
|
|
148
152
|
|
|
153
|
+
def _scim_group_remove_members(self, group_id: str, user_ids: List[str]) -> ScimResponse:
|
|
154
|
+
"""
|
|
155
|
+
Wrapper for PATCH Groups/{id} that removes only the specified members.
|
|
156
|
+
Builds the version-specific payload (v2 PatchOp ``remove`` or v1 ``operation: delete``).
|
|
157
|
+
"""
|
|
158
|
+
validate_scim_id(group_id, "group_id")
|
|
159
|
+
if not isinstance(user_ids, list) or not user_ids:
|
|
160
|
+
raise ValueError("user_ids must be a non-empty list of Slack user IDs")
|
|
161
|
+
for user_id in user_ids:
|
|
162
|
+
if not isinstance(user_id, str) or not USER_ID_RE.fullmatch(user_id):
|
|
163
|
+
raise ValueError(f"Invalid user_id: {user_id!r}")
|
|
164
|
+
unique_ids = list(dict.fromkeys(user_ids))
|
|
165
|
+
if len(unique_ids) > SCIM_GROUP_MEMBER_BATCH_LIMIT:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"Remove no more than {SCIM_GROUP_MEMBER_BATCH_LIMIT} unique members per call "
|
|
168
|
+
"(Slack's recommended SCIM batch size)"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
scim_version = self.cfg.scim_version
|
|
172
|
+
if scim_version == "v2":
|
|
173
|
+
payload: Dict[str, Any] = {
|
|
174
|
+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
175
|
+
"Operations": [{
|
|
176
|
+
"op": "remove",
|
|
177
|
+
"path": "members",
|
|
178
|
+
"value": [{"value": uid} for uid in unique_ids],
|
|
179
|
+
}],
|
|
180
|
+
}
|
|
181
|
+
elif scim_version == "v1":
|
|
182
|
+
payload = {
|
|
183
|
+
"schemas": ["urn:scim:schemas:core:1.0"],
|
|
184
|
+
"members": [{"value": uid, "operation": "delete"} for uid in unique_ids],
|
|
185
|
+
}
|
|
186
|
+
else:
|
|
187
|
+
raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
|
|
188
|
+
|
|
189
|
+
return self._scim_request(path=f"Groups/{group_id}", method="PATCH", payload=payload) # https://docs.slack.dev/reference/scim-api/#patch-groups-id
|
|
190
|
+
|
|
149
191
|
# ---------- public helpers ----------
|
|
150
192
|
|
|
151
193
|
def get_groups(self, fetch_count: int = 1000) -> List[Dict[str, str]]:
|
|
@@ -204,15 +246,23 @@ class IDP_groups(ScimMixin, SlackObjectBase):
|
|
|
204
246
|
the remaining member pages are fetched and merged, so large groups are not
|
|
205
247
|
silently truncated.
|
|
206
248
|
|
|
249
|
+
The record is validated before it is returned, because a short or malformed
|
|
250
|
+
member list would otherwise look like users are not members. A missing
|
|
251
|
+
``members`` field is treated as an empty group.
|
|
252
|
+
|
|
207
253
|
Raises:
|
|
208
254
|
requests.HTTPError on non-2xx responses.
|
|
255
|
+
RuntimeError when the response is for a different group, a member list is
|
|
256
|
+
malformed, or the members stop before ``totalResults``.
|
|
209
257
|
"""
|
|
210
258
|
gid = self._resolve_group_id(group_id)
|
|
211
259
|
|
|
212
260
|
# First request sends no pagination params, so it is identical to the legacy call.
|
|
213
261
|
# Copy so callers mutating the result cannot corrupt our cached attributes.
|
|
214
262
|
group = dict(self._scim_group_get(gid).data)
|
|
215
|
-
|
|
263
|
+
if group.get("id") != gid:
|
|
264
|
+
raise RuntimeError(f"SCIM GET Groups/{gid} returned a record for {group.get('id')!r}")
|
|
265
|
+
members = self._validated_members(gid, group.get("members"))
|
|
216
266
|
|
|
217
267
|
# totalResults is only present when the server actually paged the members;
|
|
218
268
|
# when it is absent the single response is complete and we skip the loop.
|
|
@@ -222,15 +272,31 @@ class IDP_groups(ScimMixin, SlackObjectBase):
|
|
|
222
272
|
# SCIM startIndex is 1-based, and the server chooses the first page size,
|
|
223
273
|
# so derive the cursor from what we already have rather than from fetch_count.
|
|
224
274
|
start_index = len(members) + 1
|
|
225
|
-
|
|
226
|
-
|
|
275
|
+
page = self._scim_group_get(gid, count=fetch_count, start_index=start_index).data
|
|
276
|
+
page_members = self._validated_members(gid, page.get("members"))
|
|
277
|
+
# An empty page before totalResults means the list is incomplete; raise
|
|
278
|
+
# rather than loop forever or return a list that is silently short.
|
|
227
279
|
if not page_members:
|
|
228
|
-
|
|
280
|
+
raise RuntimeError(
|
|
281
|
+
f"Incomplete member list for group {gid}: got {len(members)} of {total_results}"
|
|
282
|
+
)
|
|
229
283
|
members.extend(page_members)
|
|
230
284
|
|
|
231
285
|
group["members"] = members
|
|
232
286
|
return group
|
|
233
287
|
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _validated_members(group_id: str, members: Any) -> List[Dict[str, str]]:
|
|
290
|
+
"""Return a copy of a SCIM ``members`` page, raising RuntimeError if it is malformed."""
|
|
291
|
+
if members is None:
|
|
292
|
+
return []
|
|
293
|
+
if not isinstance(members, list):
|
|
294
|
+
raise RuntimeError(f"Malformed member list for group {group_id}: expected a list")
|
|
295
|
+
for member in members:
|
|
296
|
+
if not isinstance(member, dict) or not isinstance(member.get("value"), str) or not member["value"]:
|
|
297
|
+
raise RuntimeError(f"Malformed member entry for group {group_id}: {member!r}")
|
|
298
|
+
return list(members)
|
|
299
|
+
|
|
234
300
|
def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
|
|
235
301
|
"""
|
|
236
302
|
Return group members via SCIM (GET Groups/{id}) as a list of dicts `{'value': <user_id>, 'display': <name>}`.
|
|
@@ -255,6 +321,9 @@ class IDP_groups(ScimMixin, SlackObjectBase):
|
|
|
255
321
|
|
|
256
322
|
Each call fetches the group unless its attributes are already cached, so when
|
|
257
323
|
testing many users bind the group once (or hoist ``get_members()`` out of the loop).
|
|
324
|
+
|
|
325
|
+
Raises the same errors as ``get_group()``, so an incomplete member list is never
|
|
326
|
+
reported as "not a member".
|
|
258
327
|
"""
|
|
259
328
|
members = self.get_members(group_id=group_id)
|
|
260
329
|
for member in members:
|
|
@@ -262,3 +331,30 @@ class IDP_groups(ScimMixin, SlackObjectBase):
|
|
|
262
331
|
if member.get("value") == user_id:
|
|
263
332
|
return True
|
|
264
333
|
return False
|
|
334
|
+
|
|
335
|
+
def remove_members(self, user_ids: List[str], group_id: Optional[str] = None) -> ScimResponse:
|
|
336
|
+
"""
|
|
337
|
+
Remove selected Slack users from a passed or bound IdP group (via SCIM PATCH).
|
|
338
|
+
|
|
339
|
+
Never uses PUT or DELETE Groups, so other members and group fields are left
|
|
340
|
+
untouched. Accepts at most ``SCIM_GROUP_MEMBER_BATCH_LIMIT`` unique user IDs;
|
|
341
|
+
an empty list is rejected so a call can never be an unqualified removal.
|
|
342
|
+
This changes Slack's group, not the upstream identity provider.
|
|
343
|
+
|
|
344
|
+
Cached attributes for the group are invalidated after every attempt, including
|
|
345
|
+
transport failures where the server may still have applied the change.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
ScimResponse; callers must check ``ok``.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError on invalid IDs or batch size.
|
|
352
|
+
NotImplementedError on an unsupported SCIM version.
|
|
353
|
+
requests.HTTPError on non-2xx responses.
|
|
354
|
+
"""
|
|
355
|
+
gid = self._resolve_group_id(group_id)
|
|
356
|
+
try:
|
|
357
|
+
return self._scim_group_remove_members(gid, user_ids)
|
|
358
|
+
finally:
|
|
359
|
+
if self.attributes.get("id") == gid:
|
|
360
|
+
self.attributes = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-objects
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
|
|
5
5
|
Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
|
|
6
6
|
Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
|
|
@@ -197,5 +197,6 @@ explicit per-call tier → method-specific override → prefix rule → `default
|
|
|
197
197
|
- `resolve_user_id` accepts flexible identifiers (user ID, email, or @username) and verifies existence via Web API + SCIM fallback
|
|
198
198
|
- `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
|
|
199
199
|
- SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
|
|
200
|
+
- SCIM group operations are available on `IDP_groups`: list groups, get a group and its members, check membership, and remove selected members. `get_group()` (and so `members`, `get_members()`, and `is_member()`) raises `RuntimeError` when Slack returns an incomplete or malformed member list, rather than returning a short list that would make users look like non-members. `remove_members(["U111", "U222"])` sends a targeted PATCH (v1 or v2) that leaves other members and group fields untouched. It accepts a non-empty list of up to 5,000 unique user IDs (Slack's recommended SCIM batch size), raises on HTTP errors, and returns a `ScimResponse` whose `ok` callers must check. It changes the group in Slack only, not in Entra/Okta, so upstream provisioning may restore members
|
|
200
201
|
- Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
|
|
201
202
|
- `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
|
|
@@ -1,68 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
|
-
"
|
|
3
|
+
".github/copilot-instructions.md",
|
|
4
|
+
".github/workflows/ci.yml",
|
|
5
|
+
".github/workflows/publish-pypi.yml",
|
|
6
|
+
".github/workflows/publish-testpypi.yml",
|
|
7
|
+
".gitignore",
|
|
4
8
|
"LICENSE",
|
|
5
9
|
"README.md",
|
|
10
|
+
"pyproject.toml",
|
|
6
11
|
"pytest.ini",
|
|
7
|
-
".
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"tests/
|
|
22
|
-
"tests/
|
|
23
|
-
"tests/
|
|
24
|
-
"tests/SCIM/test_scim_users_update_email_live.py",
|
|
25
|
-
"tests/SCIM/test_scim_users_reactivate_live.py",
|
|
12
|
+
"src/slack_objects/__init__.py",
|
|
13
|
+
"src/slack_objects/api_caller.py",
|
|
14
|
+
"src/slack_objects/base.py",
|
|
15
|
+
"src/slack_objects/client.py",
|
|
16
|
+
"src/slack_objects/config.py",
|
|
17
|
+
"src/slack_objects/conversations.py",
|
|
18
|
+
"src/slack_objects/files.py",
|
|
19
|
+
"src/slack_objects/idp_groups.py",
|
|
20
|
+
"src/slack_objects/messages.py",
|
|
21
|
+
"src/slack_objects/rate_limits.py",
|
|
22
|
+
"src/slack_objects/scim_base.py",
|
|
23
|
+
"src/slack_objects/usergroups.py",
|
|
24
|
+
"src/slack_objects/users.py",
|
|
25
|
+
"src/slack_objects/workspaces.py",
|
|
26
|
+
"tests/Examples/examples_config.example.json",
|
|
27
|
+
"tests/Examples/users_search_by_email_example.py",
|
|
28
|
+
"tests/Examples/users_test_AzureKeyVault.py",
|
|
26
29
|
"tests/SCIM/conftest_live.py",
|
|
27
|
-
"tests/SCIM/test_scim_users_update_attribute_live.py",
|
|
28
|
-
"tests/SCIM/test_scim_users_input_validation_live.py",
|
|
29
30
|
"tests/SCIM/run_all_scim_users_live_tests.py",
|
|
31
|
+
"tests/SCIM/test_scim_idp_groups_live.py",
|
|
30
32
|
"tests/SCIM/test_scim_users_create_live.py",
|
|
31
33
|
"tests/SCIM/test_scim_users_deactivate_live.py",
|
|
32
|
-
"tests/SCIM/
|
|
33
|
-
"tests/
|
|
34
|
-
"tests/
|
|
35
|
-
"tests/
|
|
36
|
-
"tests/
|
|
34
|
+
"tests/SCIM/test_scim_users_input_validation_live.py",
|
|
35
|
+
"tests/SCIM/test_scim_users_make_guest_live.py",
|
|
36
|
+
"tests/SCIM/test_scim_users_reactivate_live.py",
|
|
37
|
+
"tests/SCIM/test_scim_users_update_attribute_live.py",
|
|
38
|
+
"tests/SCIM/test_scim_users_update_email_live.py",
|
|
37
39
|
"tests/Smoke/_smoke_harness.py",
|
|
38
|
-
"tests/Smoke/
|
|
39
|
-
"tests/Smoke/files_smoke_test.py",
|
|
40
|
-
"tests/Smoke/workspaces_smoke_test.py",
|
|
40
|
+
"tests/Smoke/api_caller_smoke_test.py",
|
|
41
41
|
"tests/Smoke/conversations_smoke_test.py",
|
|
42
|
+
"tests/Smoke/files_smoke_test.py",
|
|
43
|
+
"tests/Smoke/idp_groups_smoke_test.py",
|
|
42
44
|
"tests/Smoke/messages_smoke_test.py",
|
|
45
|
+
"tests/Smoke/run_all_smoke.py",
|
|
46
|
+
"tests/Smoke/security_smoke_test.py",
|
|
43
47
|
"tests/Smoke/usergroups_smoke_test.py",
|
|
44
|
-
"tests/
|
|
48
|
+
"tests/Smoke/users_smoke_test.py",
|
|
49
|
+
"tests/Smoke/workspaces_smoke_test.py",
|
|
50
|
+
"tests/UnitTests/config_unit_test.py",
|
|
51
|
+
"tests/UnitTests/conversations_unit_test.py",
|
|
52
|
+
"tests/UnitTests/files_unit_test.py",
|
|
53
|
+
"tests/UnitTests/idp_groups_unit_test.py",
|
|
54
|
+
"tests/UnitTests/messages_unit_test.py",
|
|
55
|
+
"tests/UnitTests/rate_limits_unit_test.py",
|
|
56
|
+
"tests/UnitTests/usergroups_test.py",
|
|
57
|
+
"tests/UnitTests/users_unit_test.py",
|
|
58
|
+
"tests/UnitTests/workspaces_unit_test.py",
|
|
45
59
|
"tests/Users/conftest_live.py",
|
|
46
|
-
"tests/Users/test_users_input_validation_live.py",
|
|
47
60
|
"tests/Users/run_all_users_live_tests.py",
|
|
48
|
-
"tests/Users/test_users_classification_live.py",
|
|
49
61
|
"tests/Users/test_users_admin_live.py",
|
|
50
|
-
"tests/
|
|
51
|
-
"tests/
|
|
52
|
-
"tests/
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"src/slack_objects/conversations.py",
|
|
56
|
-
"src/slack_objects/messages.py",
|
|
57
|
-
"src/slack_objects/users.py",
|
|
58
|
-
"src/slack_objects/config.py",
|
|
59
|
-
"src/slack_objects/__init__.py",
|
|
60
|
-
"src/slack_objects/client.py",
|
|
61
|
-
"src/slack_objects/files.py",
|
|
62
|
-
"src/slack_objects/api_caller.py",
|
|
63
|
-
"src/slack_objects/scim_base.py",
|
|
64
|
-
"src/slack_objects/rate_limits.py",
|
|
65
|
-
"src/slack_objects/base.py",
|
|
66
|
-
"src/slack_objects/idp_groups.py"
|
|
62
|
+
"tests/Users/test_users_classification_live.py",
|
|
63
|
+
"tests/Users/test_users_input_validation_live.py",
|
|
64
|
+
"tests/Users/test_users_read_live.py",
|
|
65
|
+
"tests/conftest.py",
|
|
66
|
+
"tests/live_test_config.example.json"
|
|
67
67
|
]
|
|
68
68
|
}
|
|
@@ -286,7 +286,10 @@ class FakeScimSession:
|
|
|
286
286
|
# --- SCIM Groups endpoints (used by IDP_groups) ---
|
|
287
287
|
# Group detail (IDP_groups.get_members / is_member)
|
|
288
288
|
if "Groups/" in url:
|
|
289
|
-
return Resp({
|
|
289
|
+
return Resp({
|
|
290
|
+
"id": url.rsplit("/", 1)[-1],
|
|
291
|
+
"members": [{"value": "U1", "display": "User One"}, {"value": "U2", "display": "User Two"}],
|
|
292
|
+
})
|
|
290
293
|
|
|
291
294
|
# Group list
|
|
292
295
|
# SCIM startIndex is 1-based; return all in one page here
|
|
@@ -24,7 +24,9 @@ from typing import Any, Dict, Optional, Tuple
|
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
26
|
|
|
27
|
+
import pytest
|
|
27
28
|
import requests
|
|
29
|
+
from slack_objects.config import SCIM_GROUP_MEMBER_BATCH_LIMIT
|
|
28
30
|
from slack_objects.rate_limits import RateLimitPolicy
|
|
29
31
|
|
|
30
32
|
|
|
@@ -353,6 +355,53 @@ def test_get_group_merges_paginated_members():
|
|
|
353
355
|
assert calls[1]["startIndex"] == 3
|
|
354
356
|
|
|
355
357
|
|
|
358
|
+
def _group_with_pages(*pages):
|
|
359
|
+
"""Return an IDP_groups whose successive GET Groups/{id} calls return *pages* in order."""
|
|
360
|
+
from slack_objects.idp_groups import IDP_groups
|
|
361
|
+
|
|
362
|
+
sess = FakeScimSession({})
|
|
363
|
+
responses = iter(pages)
|
|
364
|
+
sess.request = lambda method, url, **kwargs: FakeResponse(200, next(responses)) # type: ignore[method-assign]
|
|
365
|
+
idp = IDP_groups(cfg=DummyConfig(), client=DummySlackClient(), logger=logging.getLogger("test"), api=DummyApiCaller(), scim_session=sess)
|
|
366
|
+
idp.rate_policy = RateLimitPolicy(method_overrides={}, prefix_rules={}, default=0.0)
|
|
367
|
+
return idp
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_get_group_raises_when_member_pages_stop_early():
|
|
371
|
+
idp = _group_with_pages(
|
|
372
|
+
{"id": "S123", "totalResults": 3, "members": [{"value": "U111"}, {"value": "U222"}]},
|
|
373
|
+
{"id": "S123", "totalResults": 3, "members": []},
|
|
374
|
+
)
|
|
375
|
+
with pytest.raises(RuntimeError, match="Incomplete member list for group S123: got 2 of 3"):
|
|
376
|
+
idp.get_group("S123", fetch_count=2)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_is_member_raises_instead_of_reporting_non_member_on_incomplete_list():
|
|
380
|
+
idp = _group_with_pages(
|
|
381
|
+
{"id": "S123", "totalResults": 2, "members": [{"value": "U111"}]},
|
|
382
|
+
{"id": "S123", "totalResults": 2},
|
|
383
|
+
)
|
|
384
|
+
with pytest.raises(RuntimeError, match="Incomplete"):
|
|
385
|
+
idp.is_member("U222", group_id="S123")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@pytest.mark.parametrize("payload,match", [
|
|
389
|
+
({"id": "S999", "members": []}, "returned a record for 'S999'"),
|
|
390
|
+
({}, "returned a record for None"),
|
|
391
|
+
({"id": "S123", "members": "U111"}, "expected a list"),
|
|
392
|
+
({"id": "S123", "members": [{"display": "No ID"}]}, "Malformed member entry"),
|
|
393
|
+
({"id": "S123", "members": ["U111"]}, "Malformed member entry"),
|
|
394
|
+
])
|
|
395
|
+
def test_get_group_rejects_malformed_records(payload, match):
|
|
396
|
+
with pytest.raises(RuntimeError, match=match):
|
|
397
|
+
_group_with_pages(payload).get_group("S123")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_get_group_treats_missing_members_as_empty_group():
|
|
401
|
+
group = _group_with_pages({"id": "S123", "displayName": "Empty"}).get_group("S123")
|
|
402
|
+
assert group["members"] == []
|
|
403
|
+
|
|
404
|
+
|
|
356
405
|
# -----------------------------
|
|
357
406
|
# Optional "factory-style" demo
|
|
358
407
|
# -----------------------------
|
|
@@ -397,6 +446,127 @@ def test_factory_style_if_available():
|
|
|
397
446
|
assert bound.is_member("U1") is True
|
|
398
447
|
|
|
399
448
|
|
|
449
|
+
# -----------------------------
|
|
450
|
+
# Member removal
|
|
451
|
+
# -----------------------------
|
|
452
|
+
|
|
453
|
+
@pytest.fixture
|
|
454
|
+
def removal_group():
|
|
455
|
+
from slack_objects.idp_groups import IDP_groups
|
|
456
|
+
|
|
457
|
+
cfg = DummyConfig()
|
|
458
|
+
group = IDP_groups(
|
|
459
|
+
cfg=cfg, client=DummySlackClient(), logger=logging.getLogger("test"),
|
|
460
|
+
api=DummyApiCaller(), scim_session=FakeScimSession({}), group_id="S123",
|
|
461
|
+
)
|
|
462
|
+
group.rate_policy = RateLimitPolicy(method_overrides={}, prefix_rules={}, default=0.0)
|
|
463
|
+
return group
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@pytest.mark.parametrize("version", ["v1", "v2"])
|
|
467
|
+
def test_remove_members_exact_payload_and_cache_invalidation(removal_group, version):
|
|
468
|
+
group = removal_group
|
|
469
|
+
group.cfg.scim_version = version
|
|
470
|
+
url = f"{_scim_base(group.cfg)}Groups/S123"
|
|
471
|
+
remaining = {"id": "S123", "displayName": "Admins", "members": [{"value": "U333"}]}
|
|
472
|
+
group.scim_session.routes = {("PATCH", url): (200, remaining), ("GET", url): (200, remaining)}
|
|
473
|
+
group.attributes = {"id": "S123", "members": [{"value": "U111"}, {"value": "U333"}]}
|
|
474
|
+
|
|
475
|
+
response = group.remove_members(["U111", "U222", "U111"])
|
|
476
|
+
|
|
477
|
+
expected = (
|
|
478
|
+
{"schemas": ["urn:scim:schemas:core:1.0"], "members": [
|
|
479
|
+
{"value": "U111", "operation": "delete"}, {"value": "U222", "operation": "delete"},
|
|
480
|
+
]} if version == "v1" else
|
|
481
|
+
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{
|
|
482
|
+
"op": "remove", "path": "members", "value": [{"value": "U111"}, {"value": "U222"}],
|
|
483
|
+
}]}
|
|
484
|
+
)
|
|
485
|
+
assert response.ok
|
|
486
|
+
assert group.scim_session.calls == [("PATCH", url, None, expected)]
|
|
487
|
+
assert group.attributes == {}
|
|
488
|
+
assert group.members == [{"value": "U333"}]
|
|
489
|
+
assert len(group.scim_session.calls) == 2
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@pytest.mark.parametrize("user_ids", [[], "U111", ["../U111"], [""], [None], ["S123"], ["U111", "bad\"id"]])
|
|
493
|
+
def test_remove_members_rejects_invalid_input_without_http(removal_group, user_ids):
|
|
494
|
+
with pytest.raises(ValueError):
|
|
495
|
+
removal_group.remove_members(user_ids)
|
|
496
|
+
assert removal_group.scim_session.calls == []
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_remove_members_requires_valid_group_and_version(removal_group):
|
|
500
|
+
with pytest.raises(ValueError):
|
|
501
|
+
removal_group.remove_members(["U111"], "../S123")
|
|
502
|
+
removal_group.group_id = None
|
|
503
|
+
with pytest.raises(ValueError):
|
|
504
|
+
removal_group.remove_members(["U111"])
|
|
505
|
+
removal_group.group_id = "S123"
|
|
506
|
+
removal_group.cfg.scim_version = "v3"
|
|
507
|
+
with pytest.raises(NotImplementedError, match="Invalid SCIM version"):
|
|
508
|
+
removal_group.remove_members(["U111"])
|
|
509
|
+
assert removal_group.scim_session.calls == []
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_remove_members_enforces_batch_limit(removal_group):
|
|
513
|
+
with pytest.raises(ValueError, match=str(SCIM_GROUP_MEMBER_BATCH_LIMIT)):
|
|
514
|
+
removal_group.remove_members([f"U{i}" for i in range(SCIM_GROUP_MEMBER_BATCH_LIMIT + 1)])
|
|
515
|
+
assert removal_group.scim_session.calls == []
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@pytest.mark.parametrize("version", ["v1", "v2"])
|
|
519
|
+
def test_remove_members_accepts_exactly_batch_limit_unique_ids(removal_group, version):
|
|
520
|
+
group = removal_group
|
|
521
|
+
group.cfg.scim_version = version
|
|
522
|
+
url = f"{_scim_base(group.cfg)}Groups/S123"
|
|
523
|
+
group.scim_session.routes = {("PATCH", url): (204, {})}
|
|
524
|
+
ids = [f"U{i}" for i in range(SCIM_GROUP_MEMBER_BATCH_LIMIT)]
|
|
525
|
+
assert group.remove_members(ids + ids).ok
|
|
526
|
+
payload = group.scim_session.calls[0][3]
|
|
527
|
+
members = payload["members"] if version == "v1" else payload["Operations"][0]["value"]
|
|
528
|
+
assert len(members) == SCIM_GROUP_MEMBER_BATCH_LIMIT
|
|
529
|
+
assert [member["value"] for member in members] == ids
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@pytest.mark.parametrize("status,payload", [(400, {"Errors": [{"description": "denied"}]}), (200, {"Errors": [{"description": "failed"}]})])
|
|
533
|
+
def test_remove_members_surfaces_errors_and_invalidates_cache(removal_group, status, payload):
|
|
534
|
+
group = removal_group
|
|
535
|
+
group.attributes = {"id": "S123", "members": [{"value": "U111"}]}
|
|
536
|
+
url = f"{_scim_base(group.cfg)}Groups/S123"
|
|
537
|
+
group.scim_session.routes = {("PATCH", url): (status, payload)}
|
|
538
|
+
if status == 400:
|
|
539
|
+
with pytest.raises(requests.HTTPError):
|
|
540
|
+
group.remove_members(["U111"])
|
|
541
|
+
else:
|
|
542
|
+
assert not group.remove_members(["U111"]).ok
|
|
543
|
+
assert group.attributes == {}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_remove_members_explicit_group_does_not_invalidate_other_group(removal_group):
|
|
547
|
+
group = removal_group
|
|
548
|
+
cached = {"id": "S123", "members": [{"value": "U111"}]}
|
|
549
|
+
group.attributes = cached
|
|
550
|
+
url = f"{_scim_base(group.cfg)}Groups/S456"
|
|
551
|
+
group.scim_session.routes = {("PATCH", url): (204, {})}
|
|
552
|
+
assert group.remove_members(["U111"], group_id="S456").ok
|
|
553
|
+
assert group.attributes == cached
|
|
554
|
+
assert group.scim_session.calls[0][1] == url
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def test_remove_members_transport_failure_invalidates_cache(removal_group, monkeypatch):
|
|
558
|
+
group = removal_group
|
|
559
|
+
group.attributes = {"id": "S123", "members": [{"value": "U111"}]}
|
|
560
|
+
|
|
561
|
+
def timeout(*args, **kwargs):
|
|
562
|
+
raise requests.Timeout("Timed out after sending PATCH")
|
|
563
|
+
|
|
564
|
+
monkeypatch.setattr(group.scim_session, "request", timeout)
|
|
565
|
+
with pytest.raises(requests.Timeout):
|
|
566
|
+
group.remove_members(["U111"])
|
|
567
|
+
assert group.attributes == {}
|
|
568
|
+
|
|
569
|
+
|
|
400
570
|
# -----------------------------
|
|
401
571
|
# Script mode
|
|
402
572
|
# -----------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_input_validation_live.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_attribute_live.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|