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.
Files changed (75) hide show
  1. {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/publish-pypi.yml +7 -1
  2. {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/publish-testpypi.yml +7 -1
  3. {slack_objects-0.3.0 → slack_objects-0.4.0}/PKG-INFO +2 -1
  4. {slack_objects-0.3.0 → slack_objects-0.4.0}/README.md +1 -0
  5. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/_version.py +3 -3
  6. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/config.py +4 -0
  7. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/idp_groups.py +100 -4
  8. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
  9. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/scm_file_list.json +50 -50
  10. slack_objects-0.4.0/src/slack_objects.egg-info/scm_version.json +8 -0
  11. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/_smoke_harness.py +4 -1
  12. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/idp_groups_unit_test.py +170 -0
  13. slack_objects-0.3.0/src/slack_objects.egg-info/scm_version.json +0 -8
  14. {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/copilot-instructions.md +0 -0
  15. {slack_objects-0.3.0 → slack_objects-0.4.0}/.github/workflows/ci.yml +0 -0
  16. {slack_objects-0.3.0 → slack_objects-0.4.0}/.gitignore +0 -0
  17. {slack_objects-0.3.0 → slack_objects-0.4.0}/LICENSE +0 -0
  18. {slack_objects-0.3.0 → slack_objects-0.4.0}/pyproject.toml +0 -0
  19. {slack_objects-0.3.0 → slack_objects-0.4.0}/pytest.ini +0 -0
  20. {slack_objects-0.3.0 → slack_objects-0.4.0}/setup.cfg +0 -0
  21. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/__init__.py +0 -0
  22. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/api_caller.py +0 -0
  23. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/base.py +0 -0
  24. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/client.py +0 -0
  25. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/conversations.py +0 -0
  26. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/files.py +0 -0
  27. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/messages.py +0 -0
  28. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/rate_limits.py +0 -0
  29. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/scim_base.py +0 -0
  30. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/usergroups.py +0 -0
  31. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/users.py +0 -0
  32. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects/workspaces.py +0 -0
  33. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/SOURCES.txt +0 -0
  34. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  35. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/requires.txt +0 -0
  36. {slack_objects-0.3.0 → slack_objects-0.4.0}/src/slack_objects.egg-info/top_level.txt +0 -0
  37. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/examples_config.example.json +0 -0
  38. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/users_search_by_email_example.py +0 -0
  39. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  40. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/conftest_live.py +0 -0
  41. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/run_all_scim_users_live_tests.py +0 -0
  42. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  43. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
  44. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  45. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
  46. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  47. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  48. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
  49. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
  50. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
  51. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/conversations_smoke_test.py +0 -0
  52. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/files_smoke_test.py +0 -0
  53. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  54. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/messages_smoke_test.py +0 -0
  55. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/run_all_smoke.py +0 -0
  56. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/security_smoke_test.py +0 -0
  57. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
  58. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/users_smoke_test.py +0 -0
  59. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
  60. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/config_unit_test.py +0 -0
  61. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/conversations_unit_test.py +0 -0
  62. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/files_unit_test.py +0 -0
  63. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/messages_unit_test.py +0 -0
  64. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  65. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/usergroups_test.py +0 -0
  66. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/users_unit_test.py +0 -0
  67. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
  68. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/conftest_live.py +0 -0
  69. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/run_all_users_live_tests.py +0 -0
  70. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_admin_live.py +0 -0
  71. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_classification_live.py +0 -0
  72. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_input_validation_live.py +0 -0
  73. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/Users/test_users_read_live.py +0 -0
  74. {slack_objects-0.3.0 → slack_objects-0.4.0}/tests/conftest.py +0 -0
  75. {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.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.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g88ac46367'
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
- members: List[Dict[str, str]] = list(group.get("members") or [])
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
- page_members = self._scim_group_get(gid, count=fetch_count, start_index=start_index).data.get("members") or []
226
- # An empty page means the server has nothing more; stop rather than loop forever.
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
- break
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.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
- "pyproject.toml",
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
- ".gitignore",
8
- ".github/copilot-instructions.md",
9
- ".github/workflows/publish-testpypi.yml",
10
- ".github/workflows/publish-pypi.yml",
11
- ".github/workflows/ci.yml",
12
- "tests/live_test_config.example.json",
13
- "tests/conftest.py",
14
- "tests/UnitTests/usergroups_test.py",
15
- "tests/UnitTests/rate_limits_unit_test.py",
16
- "tests/UnitTests/files_unit_test.py",
17
- "tests/UnitTests/idp_groups_unit_test.py",
18
- "tests/UnitTests/messages_unit_test.py",
19
- "tests/UnitTests/workspaces_unit_test.py",
20
- "tests/UnitTests/users_unit_test.py",
21
- "tests/UnitTests/conversations_unit_test.py",
22
- "tests/UnitTests/config_unit_test.py",
23
- "tests/SCIM/test_scim_users_make_guest_live.py",
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/test_scim_idp_groups_live.py",
33
- "tests/Smoke/idp_groups_smoke_test.py",
34
- "tests/Smoke/security_smoke_test.py",
35
- "tests/Smoke/api_caller_smoke_test.py",
36
- "tests/Smoke/run_all_smoke.py",
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/users_smoke_test.py",
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/Users/test_users_read_live.py",
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/Examples/users_search_by_email_example.py",
51
- "tests/Examples/users_test_AzureKeyVault.py",
52
- "tests/Examples/examples_config.example.json",
53
- "src/slack_objects/workspaces.py",
54
- "src/slack_objects/usergroups.py",
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
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.4.0",
3
+ "distance": 0,
4
+ "node": "g9f01c906cc9ebefca926498b5941219e987efe6a",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-11"
8
+ }
@@ -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({"members": [{"value": "U1", "display": "User One"}, {"value": "U2", "display": "User Two"}]})
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
  # -----------------------------
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.0",
3
- "distance": 0,
4
- "node": "g88ac46367071865a9bb07d9ae48e7cc6116f5b72",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-06"
8
- }
File without changes
File without changes
File without changes
File without changes