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.
- {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/publish-pypi.yml +7 -1
- {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/publish-testpypi.yml +7 -1
- {slack_objects-0.2.1 → slack_objects-0.4.0}/PKG-INFO +2 -1
- {slack_objects-0.2.1 → slack_objects-0.4.0}/README.md +1 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/_version.py +3 -3
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/config.py +4 -0
- slack_objects-0.4.0/src/slack_objects/idp_groups.py +360 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
- {slack_objects-0.2.1 → 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.2.1 → slack_objects-0.4.0}/tests/Smoke/_smoke_harness.py +4 -1
- slack_objects-0.4.0/tests/UnitTests/idp_groups_unit_test.py +581 -0
- slack_objects-0.2.1/src/slack_objects/idp_groups.py +0 -156
- slack_objects-0.2.1/src/slack_objects.egg-info/scm_version.json +0 -8
- slack_objects-0.2.1/tests/UnitTests/idp_groups_unit_test.py +0 -283
- {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/copilot-instructions.md +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/.github/workflows/ci.yml +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/.gitignore +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/LICENSE +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/pyproject.toml +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/pytest.ini +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/setup.cfg +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/__init__.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/api_caller.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/base.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/client.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/conversations.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/files.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/messages.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/rate_limits.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/scim_base.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/usergroups.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/users.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects/workspaces.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/SOURCES.txt +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/requires.txt +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/src/slack_objects.egg-info/top_level.txt +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/examples_config.example.json +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/users_search_by_email_example.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/conftest_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/run_all_scim_users_live_tests.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/conversations_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/files_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/messages_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/run_all_smoke.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/security_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/users_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/config_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/conversations_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/files_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/messages_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/usergroups_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/users_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/conftest_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/run_all_users_live_tests.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_admin_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_classification_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_input_validation_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/Users/test_users_read_live.py +0 -0
- {slack_objects-0.2.1 → slack_objects-0.4.0}/tests/conftest.py +0 -0
- {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.
|
|
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
|
"""
|
|
@@ -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.
|
|
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
|