slack-objects 0.2.1__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 (77) hide show
  1. {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/publish-pypi.yml +7 -1
  2. {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/publish-testpypi.yml +7 -1
  3. {slack_objects-0.2.1 → slack_objects-0.4.0}/PKG-INFO +2 -1
  4. {slack_objects-0.2.1 → slack_objects-0.4.0}/README.md +1 -0
  5. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/_version.py +3 -3
  6. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/config.py +4 -0
  7. slack_objects-0.4.0/src/slack_objects/idp_groups.py +360 -0
  8. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
  9. {slack_objects-0.2.1 → 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.2.1 → slack_objects-0.4.0}/tests/Smoke/_smoke_harness.py +4 -1
  12. slack_objects-0.4.0/tests/UnitTests/idp_groups_unit_test.py +581 -0
  13. slack_objects-0.2.1/src/slack_objects/idp_groups.py +0 -156
  14. slack_objects-0.2.1/src/slack_objects.egg-info/scm_version.json +0 -8
  15. slack_objects-0.2.1/tests/UnitTests/idp_groups_unit_test.py +0 -283
  16. {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/copilot-instructions.md +0 -0
  17. {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/ci.yml +0 -0
  18. {slack_objects-0.2.1 → slack_objects-0.4.0}/.gitignore +0 -0
  19. {slack_objects-0.2.1 → slack_objects-0.4.0}/LICENSE +0 -0
  20. {slack_objects-0.2.1 → slack_objects-0.4.0}/pyproject.toml +0 -0
  21. {slack_objects-0.2.1 → slack_objects-0.4.0}/pytest.ini +0 -0
  22. {slack_objects-0.2.1 → slack_objects-0.4.0}/setup.cfg +0 -0
  23. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/__init__.py +0 -0
  24. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/api_caller.py +0 -0
  25. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/base.py +0 -0
  26. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/client.py +0 -0
  27. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/conversations.py +0 -0
  28. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/files.py +0 -0
  29. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/messages.py +0 -0
  30. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/rate_limits.py +0 -0
  31. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/scim_base.py +0 -0
  32. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/usergroups.py +0 -0
  33. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/users.py +0 -0
  34. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/workspaces.py +0 -0
  35. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/SOURCES.txt +0 -0
  36. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  37. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/requires.txt +0 -0
  38. {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/top_level.txt +0 -0
  39. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/examples_config.example.json +0 -0
  40. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/users_search_by_email_example.py +0 -0
  41. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  42. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/conftest_live.py +0 -0
  43. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/run_all_scim_users_live_tests.py +0 -0
  44. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  45. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
  46. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  47. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
  48. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  49. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  50. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
  51. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
  52. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
  53. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/conversations_smoke_test.py +0 -0
  54. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/files_smoke_test.py +0 -0
  55. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  56. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/messages_smoke_test.py +0 -0
  57. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/run_all_smoke.py +0 -0
  58. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/security_smoke_test.py +0 -0
  59. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
  60. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/users_smoke_test.py +0 -0
  61. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
  62. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/config_unit_test.py +0 -0
  63. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/conversations_unit_test.py +0 -0
  64. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/files_unit_test.py +0 -0
  65. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/messages_unit_test.py +0 -0
  66. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  67. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/usergroups_test.py +0 -0
  68. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/users_unit_test.py +0 -0
  69. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
  70. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/conftest_live.py +0 -0
  71. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/run_all_users_live_tests.py +0 -0
  72. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_admin_live.py +0 -0
  73. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_classification_live.py +0 -0
  74. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_input_validation_live.py +0 -0
  75. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_read_live.py +0 -0
  76. {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/conftest.py +0 -0
  77. {slack_objects-0.2.1 → 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.2.1
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.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g19585aaad'
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
  """
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ slack_objects.idp_groups
5
+ ========================
6
+
7
+ IDP_groups helper for the `slack-objects` package.
8
+
9
+ Purpose
10
+ -------
11
+ Manage Identity Provider (IdP) groups synced into Slack via SCIM.
12
+ This module implements the following functionality:
13
+
14
+ - list groups (paginated)
15
+ - get a single group's full SCIM record (name, members, metadata)
16
+ - get members of a given group
17
+ - check whether a user is a member of a group
18
+ - remove selected members without replacing the group's membership
19
+
20
+ Design decisions
21
+ ----------------
22
+ - SCIM REST calls are centralized in ScimMixin._scim_request(); all public methods call endpoint wrappers.
23
+ - Uses an injectable `requests.Session` (`scim_session`) so tests can pass a fake session.
24
+ - Keeps legacy output shapes: lists of dicts for groups and members.
25
+ - ``get_group()`` is the single read path for GET Groups/{id}; ``get_members()``,
26
+ ``is_member()`` and the ``display_name``/``members`` properties all compose on it
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.
30
+ - Attributes are loaded lazily (like ``Users``): binding a group_id does no network I/O,
31
+ the fetch happens on first property access or on ``refresh()``.
32
+ - This module is SCIM-only. For Slack-native usergroups, see ``usergroups.py``.
33
+ """
34
+
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ import requests
39
+
40
+ from .base import SlackObjectBase
41
+ from .config import SCIM_GROUP_MEMBER_BATCH_LIMIT, USER_ID_RE
42
+ from .scim_base import ScimMixin, ScimResponse, validate_scim_id
43
+
44
+
45
+ @dataclass
46
+ class IDP_groups(ScimMixin, SlackObjectBase):
47
+ """
48
+ IdP (SCIM) groups helper.
49
+
50
+ Factory usage:
51
+ slack = SlackObjectsClient(cfg)
52
+ idp = slack.idp_groups() # unbound
53
+ bound = slack.idp_groups("S123") # bound to a group_id
54
+
55
+ Binding a group_id performs no API call. `attributes` are loaded lazily via
56
+ `_require_attributes()` on first property access, so a single GET Groups/{id}
57
+ serves both the name and the members:
58
+
59
+ group = slack.idp_groups("S123")
60
+ name = group.display_name # triggers the fetch
61
+ members = group.members # served from the same cached response
62
+
63
+ The SCIM session can be replaced for unit tests by passing scim_session argument.
64
+ """
65
+ group_id: Optional[str] = None
66
+ attributes: Dict[str, Any] = field(default_factory=dict)
67
+ scim_session: requests.Session = field(default_factory=requests.Session, repr=False)
68
+
69
+ # ---------- factory ----------
70
+ def with_group(self, group_id: str) -> "IDP_groups":
71
+ """Return a new instance bound to a particular group_id, sharing cfg/client/logger/api."""
72
+ return IDP_groups(
73
+ cfg=self.cfg,
74
+ client=self.client,
75
+ logger=self.logger,
76
+ api=self.api,
77
+ group_id=group_id,
78
+ scim_session=self.scim_session,
79
+ )
80
+
81
+ # ---------- identifier resolution ----------
82
+
83
+ def _resolve_group_id(self, group_id: Optional[str] = None) -> str:
84
+ """Resolve group_id from argument or bound instance value."""
85
+ gid = group_id or self.group_id
86
+ if not gid:
87
+ raise ValueError("group_id is required (passed or bound)")
88
+ return gid
89
+
90
+ # ---------- lazily loaded attributes ----------
91
+
92
+ def refresh(self, group_id: Optional[str] = None) -> Dict[str, Any]:
93
+ """
94
+ Refresh cached attributes for group_id (or self.group_id) using GET Groups/{id}.
95
+
96
+ Layered like ``Users.refresh()``: it calls the public ``get_group()``, which
97
+ calls the endpoint wrapper.
98
+ """
99
+ if group_id:
100
+ self.group_id = group_id
101
+ if not self.group_id:
102
+ raise ValueError("refresh() requires group_id (passed or already set)")
103
+
104
+ self.attributes = self.get_group(self.group_id)
105
+ return self.attributes
106
+
107
+ def _require_attributes(self) -> Dict[str, Any]:
108
+ """Ensure attributes are loaded (via refresh) before using helpers that read group fields."""
109
+ if self.attributes:
110
+ return self.attributes
111
+ if self.group_id:
112
+ return self.refresh()
113
+ raise ValueError("Group attributes not loaded and no group_id set (call refresh() or bind a group_id).")
114
+
115
+ @property
116
+ def display_name(self) -> str:
117
+ """The group's SCIM ``displayName`` (loaded lazily)."""
118
+ return self._require_attributes().get("displayName", "")
119
+
120
+ @property
121
+ def members(self) -> List[Dict[str, str]]:
122
+ """The group's members as ``{'value': <user id>, 'display': <name>}`` dicts (loaded lazily)."""
123
+ return self._require_attributes().get("members", []) or []
124
+
125
+ # ---------- endpoint wrappers (only these call _scim_request) ----------
126
+
127
+ def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None) -> ScimResponse:
128
+ """
129
+ Wrapper for GET Groups (paginated).
130
+ Accepts pagination params as query parameters according to Slack SCIM docs.
131
+ """
132
+ params: Dict[str, Any] = {"count": count} # Number or records to return at a time. Maximum is 1000 https://api.slack.com/changelog/2019-06-have-scim-will-paginate#what
133
+ if start_index is not None:
134
+ params["startIndex"] = start_index
135
+ return self._scim_request(path="Groups", method="GET", params=params) # https://docs.slack.dev/reference/scim-api/#get-groups
136
+
137
+ def _scim_group_get(self, group_id: str, *, count: Optional[int] = None, start_index: Optional[int] = None) -> ScimResponse:
138
+ """
139
+ Wrapper for GET Groups/{id}.
140
+
141
+ count/start_index page over the group's *members* sub-list, which matters for
142
+ groups larger than the server-side page size.
143
+ """
144
+ validate_scim_id(group_id, "group_id")
145
+ params: Dict[str, Any] = {}
146
+ if count is not None:
147
+ params["count"] = count
148
+ if start_index is not None:
149
+ params["startIndex"] = start_index
150
+ # Pass params only when set, so the default call is byte-for-byte the legacy request.
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
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
+
191
+ # ---------- public helpers ----------
192
+
193
+ def get_groups(self, fetch_count: int = 1000) -> List[Dict[str, str]]:
194
+ """
195
+ Return a list of IdP groups visible to the SCIM token.
196
+
197
+ Legacy behavior: returns a list of maps containing only 'group id' and 'group name'.
198
+ Pagination is respected; this method aggregates all pages.
199
+
200
+ Raises:
201
+ requests.HTTPError on non-2xx responses.
202
+ """
203
+ groups_out: List[Dict[str, str]] = []
204
+ start_index = None
205
+ total_results = None
206
+ retrieved = 0
207
+
208
+ while True:
209
+ scim_resp = self._scim_groups_list(count=fetch_count, start_index=start_index)
210
+ resp = scim_resp.data
211
+
212
+ # Slack SCIM returns 'Resources' (list) and 'totalResults' and 'startIndex' values.
213
+ resources = resp.get("Resources", []) or []
214
+ for grp in resources:
215
+ groups_out.append({"group id": grp.get("id"), "group name": grp.get("displayName")})
216
+ retrieved += 1
217
+
218
+ total_results = resp.get("totalResults", total_results)
219
+ # Calculate next page: SCIM uses startIndex + count
220
+ if total_results is None:
221
+ # If API doesn't give a total, break to avoid infinite loop
222
+ break
223
+
224
+ # Determine if we fetched all
225
+ if retrieved >= int(total_results):
226
+ break
227
+
228
+ # Move cursor forward; SCIM startIndex is 1-based
229
+ if start_index is None:
230
+ start_index = fetch_count + 1
231
+ else:
232
+ start_index = start_index + fetch_count
233
+
234
+ return groups_out
235
+
236
+ def get_group(self, group_id: Optional[str] = None, *, fetch_count: int = 1000) -> Dict[str, Any]:
237
+ """
238
+ Return the full SCIM record for a single group (GET Groups/{id}).
239
+
240
+ One call yields both ``displayName`` and ``members``, so callers that need
241
+ the name do not have to pay for a second request. The complete payload is
242
+ returned (``id``, ``displayName``, ``members``, ``meta``, ``schemas``, ...) so
243
+ new SCIM fields are reachable without adding a method here.
244
+
245
+ When the response reports a ``totalResults`` larger than the members returned,
246
+ the remaining member pages are fetched and merged, so large groups are not
247
+ silently truncated.
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
+
253
+ Raises:
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``.
257
+ """
258
+ gid = self._resolve_group_id(group_id)
259
+
260
+ # First request sends no pagination params, so it is identical to the legacy call.
261
+ # Copy so callers mutating the result cannot corrupt our cached attributes.
262
+ group = dict(self._scim_group_get(gid).data)
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"))
266
+
267
+ # totalResults is only present when the server actually paged the members;
268
+ # when it is absent the single response is complete and we skip the loop.
269
+ total_results = group.get("totalResults")
270
+ if total_results is not None:
271
+ while len(members) < int(total_results):
272
+ # SCIM startIndex is 1-based, and the server chooses the first page size,
273
+ # so derive the cursor from what we already have rather than from fetch_count.
274
+ start_index = len(members) + 1
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.
279
+ if not page_members:
280
+ raise RuntimeError(
281
+ f"Incomplete member list for group {gid}: got {len(members)} of {total_results}"
282
+ )
283
+ members.extend(page_members)
284
+
285
+ group["members"] = members
286
+ return group
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
+
300
+ def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
301
+ """
302
+ Return group members via SCIM (GET Groups/{id}) as a list of dicts `{'value': <user_id>, 'display': <name>}`.
303
+
304
+ Composes on ``get_group()``; the cached attributes are reused when they already
305
+ describe the requested group, so property access and this method share one call.
306
+
307
+ This module is SCIM-only. For Slack-native usergroups, see
308
+ ``Usergroups.get_members()`` in ``usergroups.py``.
309
+ """
310
+ gid = self._resolve_group_id(group_id)
311
+ if self.attributes and self.attributes.get("id") == gid:
312
+ return self.attributes.get("members", []) or []
313
+ return self.get_group(gid).get("members", []) or []
314
+
315
+ def is_member(self, user_id: str, group_id: Optional[str] = None) -> bool:
316
+ """
317
+ Return True if ``user_id`` is a member of ``group_id`` (via SCIM).
318
+
319
+ Higher-level convenience that composes on ``get_members()``.
320
+ Preserves legacy semantics (scans the members list).
321
+
322
+ Each call fetches the group unless its attributes are already cached, so when
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".
327
+ """
328
+ members = self.get_members(group_id=group_id)
329
+ for member in members:
330
+ # member dicts historically had 'value' for id
331
+ if member.get("value") == user_id:
332
+ return True
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.2.1
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