slack-objects 0.0.2.dev0__tar.gz → 0.0.4__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.0.4/.github/copilot-instructions.md +4 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.gitignore +7 -2
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/PKG-INFO +27 -12
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/README.md +23 -8
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/pyproject.toml +3 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/_version.py +3 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/api_caller.py +13 -10
- slack_objects-0.0.4/src/slack_objects/base.py +54 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/client.py +11 -12
- slack_objects-0.0.4/src/slack_objects/config.py +58 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/conversations.py +3 -4
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/files.py +1 -2
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/idp_groups.py +18 -71
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/messages.py +3 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/rate_limits.py +6 -2
- slack_objects-0.0.4/src/slack_objects/scim_base.py +117 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/users.py +49 -92
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/PKG-INFO +27 -12
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/SOURCES.txt +15 -1
- slack_objects-0.0.4/src/slack_objects.egg-info/requires.txt +8 -0
- slack_objects-0.0.4/tests/SCIM/conftest_live.py +183 -0
- slack_objects-0.0.4/tests/SCIM/live_test_config.example.json +41 -0
- slack_objects-0.0.4/tests/SCIM/run_all_scim_users_live_tests.py +81 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_idp_groups_live.py +369 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_create_live.py +102 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_deactivate_live.py +173 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_input_validation_live.py +102 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_make_guest_live.py +207 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_reactivate_live.py +199 -0
- slack_objects-0.0.4/tests/SCIM/test_scim_users_update_attribute_live.py +248 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/_smoke_harness.py +40 -9
- slack_objects-0.0.4/tests/api_caller_smoke_test.py +166 -0
- slack_objects-0.0.4/tests/idp_groups_smoke_test.py +63 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/run_all_smoke.py +6 -2
- slack_objects-0.0.4/tests/security_smoke_test.py +109 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_smoke_test.py +9 -7
- slack_objects-0.0.2.dev0/src/slack_objects/base.py +0 -30
- slack_objects-0.0.2.dev0/src/slack_objects/config.py +0 -29
- slack_objects-0.0.2.dev0/src/slack_objects.egg-info/requires.txt +0 -8
- slack_objects-0.0.2.dev0/tests/idp_groups_smoke_test.py +0 -37
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/ci.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/publish-pypi.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/publish-testpypi.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.vs/VSWorkspaceState.json +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.vs/slnx.sqlite +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/LICENSE +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/pytest.ini +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/setup.cfg +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/__init__.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/workspaces.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/dependency_links.txt +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/top_level.txt +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/conversations_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/conversations_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/files_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/files_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/idp_groups_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/messages_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/messages_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_test_AzureKeyVault.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/workspaces_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/workspaces_smoke_test.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
################################################################################
|
|
2
|
-
# This .gitignore file was automatically created by Microsoft(R) Visual Studio.
|
|
2
|
+
# This .gitignore file was automatically created by Microsoft(R) Visual Studio. Modified by user.
|
|
3
3
|
################################################################################
|
|
4
4
|
|
|
5
5
|
/.vs/slack-objects.slnx
|
|
@@ -16,4 +16,9 @@
|
|
|
16
16
|
# Python packaging artifacts
|
|
17
17
|
dist/
|
|
18
18
|
build/
|
|
19
|
-
*.egg-info/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
src/slack_objects/_version.py
|
|
21
|
+
|
|
22
|
+
# Live-test config (contains real Slack user IDs)
|
|
23
|
+
/tests/SCIM/live_test_config.json
|
|
24
|
+
/tests/SCIM/__pycache__
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-objects
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
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
|
|
@@ -10,9 +10,9 @@ Classifier: Operating System :: OS Independent
|
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: slack-sdk
|
|
14
|
-
Requires-Dist: PC_Utils
|
|
15
|
-
Requires-Dist: requests
|
|
13
|
+
Requires-Dist: slack-sdk<4,>=3.27
|
|
14
|
+
Requires-Dist: PC_Utils>=0.1
|
|
15
|
+
Requires-Dist: requests<3,>=2.31
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: pytest; extra == "dev"
|
|
18
18
|
Requires-Dist: build; extra == "dev"
|
|
@@ -67,15 +67,30 @@ Instead, it focuses on higher-level object operations that typically require:
|
|
|
67
67
|
All object helpers are created from a single entry point:
|
|
68
68
|
|
|
69
69
|
```python
|
|
70
|
-
from slack_objects
|
|
70
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig
|
|
71
|
+
|
|
72
|
+
cfg = SlackObjectsConfig(
|
|
73
|
+
bot_token="xoxb-...",
|
|
74
|
+
user_token="xoxp-...",
|
|
75
|
+
scim_token="xoxp-...",
|
|
76
|
+
# see SlackObjectsConfig for additional options (scim_base_url, http_timeout_seconds, etc.)
|
|
77
|
+
)
|
|
71
78
|
|
|
72
79
|
slack = SlackObjectsClient(cfg)
|
|
73
80
|
|
|
74
|
-
users = slack.users()
|
|
75
|
-
alice = slack.users("U123")
|
|
81
|
+
users = slack.users() # unbound
|
|
82
|
+
alice = slack.users("U123") # bound to user_id
|
|
76
83
|
|
|
77
84
|
conversations = slack.conversations()
|
|
78
|
-
|
|
85
|
+
conversations = slack.conversations("C123") # bound to channel_id
|
|
86
|
+
|
|
87
|
+
files = slack.files("F123") # bound to file_id
|
|
88
|
+
|
|
89
|
+
msgs = slack.messages(channel_id="C123", ts="...") # bound to message
|
|
90
|
+
|
|
91
|
+
ws = slack.workspaces("T123") # bound to workspace_id
|
|
92
|
+
|
|
93
|
+
idp = slack.idp_groups("S123") # bound to group_id
|
|
79
94
|
```
|
|
80
95
|
|
|
81
96
|
This avoids global state while keeping usage concise and consistent.
|
|
@@ -133,13 +148,13 @@ pip install slack-objects
|
|
|
133
148
|
## Configuration
|
|
134
149
|
|
|
135
150
|
```python
|
|
136
|
-
from slack_objects
|
|
151
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig, RateTier
|
|
137
152
|
|
|
138
153
|
cfg = SlackObjectsConfig(
|
|
139
154
|
bot_token="xoxb-...",
|
|
140
155
|
user_token="xoxp-...",
|
|
141
156
|
scim_token="xoxp-...",
|
|
142
|
-
default_rate_tier=RateTier.TIER_3,
|
|
157
|
+
default_rate_tier=RateTier.TIER_3, # fallback sleep between API calls when no specific tier matches
|
|
143
158
|
)
|
|
144
159
|
```
|
|
145
160
|
|
|
@@ -157,6 +172,6 @@ python -m tests.run_all_smoke
|
|
|
157
172
|
|
|
158
173
|
## Notes
|
|
159
174
|
|
|
160
|
-
- SCIM
|
|
161
|
-
-
|
|
175
|
+
- SCIM v2 is the default; v1 is supported where applicable
|
|
176
|
+
- `PC_Utils` is an optional dependency (used for datetime handling in `set_guest_expiration_date`)
|
|
162
177
|
- This package is intended for automation and administration workflows
|
|
@@ -46,15 +46,30 @@ Instead, it focuses on higher-level object operations that typically require:
|
|
|
46
46
|
All object helpers are created from a single entry point:
|
|
47
47
|
|
|
48
48
|
```python
|
|
49
|
-
from slack_objects
|
|
49
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig
|
|
50
|
+
|
|
51
|
+
cfg = SlackObjectsConfig(
|
|
52
|
+
bot_token="xoxb-...",
|
|
53
|
+
user_token="xoxp-...",
|
|
54
|
+
scim_token="xoxp-...",
|
|
55
|
+
# see SlackObjectsConfig for additional options (scim_base_url, http_timeout_seconds, etc.)
|
|
56
|
+
)
|
|
50
57
|
|
|
51
58
|
slack = SlackObjectsClient(cfg)
|
|
52
59
|
|
|
53
|
-
users = slack.users()
|
|
54
|
-
alice = slack.users("U123")
|
|
60
|
+
users = slack.users() # unbound
|
|
61
|
+
alice = slack.users("U123") # bound to user_id
|
|
55
62
|
|
|
56
63
|
conversations = slack.conversations()
|
|
57
|
-
|
|
64
|
+
conversations = slack.conversations("C123") # bound to channel_id
|
|
65
|
+
|
|
66
|
+
files = slack.files("F123") # bound to file_id
|
|
67
|
+
|
|
68
|
+
msgs = slack.messages(channel_id="C123", ts="...") # bound to message
|
|
69
|
+
|
|
70
|
+
ws = slack.workspaces("T123") # bound to workspace_id
|
|
71
|
+
|
|
72
|
+
idp = slack.idp_groups("S123") # bound to group_id
|
|
58
73
|
```
|
|
59
74
|
|
|
60
75
|
This avoids global state while keeping usage concise and consistent.
|
|
@@ -112,13 +127,13 @@ pip install slack-objects
|
|
|
112
127
|
## Configuration
|
|
113
128
|
|
|
114
129
|
```python
|
|
115
|
-
from slack_objects
|
|
130
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig, RateTier
|
|
116
131
|
|
|
117
132
|
cfg = SlackObjectsConfig(
|
|
118
133
|
bot_token="xoxb-...",
|
|
119
134
|
user_token="xoxp-...",
|
|
120
135
|
scim_token="xoxp-...",
|
|
121
|
-
default_rate_tier=RateTier.TIER_3,
|
|
136
|
+
default_rate_tier=RateTier.TIER_3, # fallback sleep between API calls when no specific tier matches
|
|
122
137
|
)
|
|
123
138
|
```
|
|
124
139
|
|
|
@@ -136,6 +151,6 @@ python -m tests.run_all_smoke
|
|
|
136
151
|
|
|
137
152
|
## Notes
|
|
138
153
|
|
|
139
|
-
- SCIM
|
|
140
|
-
-
|
|
154
|
+
- SCIM v2 is the default; v1 is supported where applicable
|
|
155
|
+
- `PC_Utils` is an optional dependency (used for datetime handling in `set_guest_expiration_date`)
|
|
141
156
|
- This package is intended for automation and administration workflows
|
|
@@ -24,9 +24,9 @@ classifiers=[
|
|
|
24
24
|
keywords = ["slack", "objects", "classes", "slack objects", "utilities", "slack utilities", "slack object types", "slack types", "types"]
|
|
25
25
|
|
|
26
26
|
dependencies = [
|
|
27
|
-
"slack-sdk",
|
|
28
|
-
"PC_Utils",
|
|
29
|
-
"requests",
|
|
27
|
+
"slack-sdk>=3.27,<4",
|
|
28
|
+
"PC_Utils>=0.1",
|
|
29
|
+
"requests>=2.31,<3",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.4'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 4)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gdc15695f3'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Optional
|
|
3
3
|
|
|
4
4
|
from slack_sdk.errors import SlackApiError
|
|
5
5
|
|
|
@@ -15,10 +15,12 @@ class SlackApiCaller:
|
|
|
15
15
|
"""
|
|
16
16
|
def __init__(self, cfg: SlackObjectsConfig, policy: RateLimitPolicy = DEFAULT_RATE_POLICY):
|
|
17
17
|
self.cfg = cfg
|
|
18
|
-
|
|
18
|
+
# Respect cfg.default_rate_tier as the policy's fallback tier
|
|
19
|
+
self.policy = policy.with_default(cfg.default_rate_tier)
|
|
19
20
|
|
|
20
|
-
def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, **kwargs) -> dict:
|
|
21
|
-
|
|
21
|
+
def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, _retry_count: int = 0, **kwargs) -> dict:
|
|
22
|
+
MAX_RETRIES = 5
|
|
23
|
+
tier = rate_tier or self.policy.tier_for(method)
|
|
22
24
|
|
|
23
25
|
try:
|
|
24
26
|
if use_json:
|
|
@@ -27,16 +29,17 @@ class SlackApiCaller:
|
|
|
27
29
|
resp = client.api_call(method, params=kwargs)
|
|
28
30
|
|
|
29
31
|
data = resp.data if hasattr(resp, "data") else resp
|
|
30
|
-
|
|
31
|
-
# Space out subsequent calls
|
|
32
32
|
time.sleep(float(tier))
|
|
33
33
|
return data
|
|
34
34
|
|
|
35
35
|
except SlackApiError as e:
|
|
36
|
-
# Handle rate limiting properly
|
|
37
36
|
if e.response is not None and e.response.status_code == 429:
|
|
38
|
-
|
|
37
|
+
if _retry_count >= MAX_RETRIES:
|
|
38
|
+
raise RuntimeError(f"Rate-limited {MAX_RETRIES} times on {method}; giving up.") from e
|
|
39
|
+
try:
|
|
40
|
+
retry_after = int(e.response.headers.get("Retry-After", tier))
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
retry_after = int(float(tier))
|
|
39
43
|
time.sleep(retry_after)
|
|
40
|
-
return self.call(client, method, rate_tier=tier, **kwargs)
|
|
41
|
-
|
|
44
|
+
return self.call(client, method, rate_tier=tier, use_json=use_json, _retry_count=_retry_count + 1, **kwargs)
|
|
42
45
|
raise
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
from slack_sdk import WebClient
|
|
5
|
+
|
|
6
|
+
from .api_caller import SlackApiCaller
|
|
7
|
+
from .config import SlackObjectsConfig
|
|
8
|
+
from .rate_limits import DEFAULT_RATE_POLICY, RateLimitPolicy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Keys that are safe to include in error messages (never contain tokens)
|
|
12
|
+
_SAFE_ERROR_KEYS = frozenset({"ok", "error", "needed", "provided", "response_metadata"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def safe_error_context(resp: Any, *, max_len: int = 300) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Return a truncated, token-free summary of an API response for use in exception messages.
|
|
18
|
+
|
|
19
|
+
Only well-known diagnostic keys are kept. The result is capped at *max_len* characters
|
|
20
|
+
to prevent massive payloads from flooding logs or error-tracking systems.
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(resp, dict):
|
|
23
|
+
summary = {k: v for k, v in resp.items() if k in _SAFE_ERROR_KEYS}
|
|
24
|
+
else:
|
|
25
|
+
summary = repr(resp)
|
|
26
|
+
text = str(summary)
|
|
27
|
+
return text[:max_len] + ("..." if len(text) > max_len else "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SlackObjectBase:
|
|
32
|
+
"""
|
|
33
|
+
Base class that all object helpers inherit from.
|
|
34
|
+
|
|
35
|
+
Holds shared context so every object doesn't need to reinvent plumbing (i.e., don't need to pass cfg, client, logger, and the apicaller).
|
|
36
|
+
Logging is optional; a default package logger will be used if none is provided.
|
|
37
|
+
"""
|
|
38
|
+
cfg: SlackObjectsConfig
|
|
39
|
+
client: WebClient
|
|
40
|
+
api: SlackApiCaller
|
|
41
|
+
logger: logging.Logger = field(default_factory=lambda: logging.getLogger("slack-objects")) # logger is guaranteed to exist via default_factory
|
|
42
|
+
rate_policy: RateLimitPolicy = field(default=None)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
# Required dependencies check
|
|
46
|
+
if self.cfg is None:
|
|
47
|
+
raise ValueError("cfg is required")
|
|
48
|
+
if self.client is None:
|
|
49
|
+
raise ValueError("client is required")
|
|
50
|
+
if self.api is None:
|
|
51
|
+
raise ValueError("api is required")
|
|
52
|
+
# Default rate_policy respects cfg.default_rate_tier as the fallback
|
|
53
|
+
if self.rate_policy is None:
|
|
54
|
+
self.rate_policy = DEFAULT_RATE_POLICY.with_default(self.cfg.default_rate_tier)
|
|
@@ -31,20 +31,19 @@ class SlackObjectsClient:
|
|
|
31
31
|
self.api = SlackApiCaller(cfg)
|
|
32
32
|
|
|
33
33
|
def users(self, user_id: Optional[str] = None) -> Users:
|
|
34
|
-
|
|
35
|
-
return base if user_id is None else base.with_user(user_id)
|
|
34
|
+
return Users(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, user_id=user_id)
|
|
36
35
|
|
|
37
|
-
def conversations(self) -> Conversations:
|
|
38
|
-
return Conversations(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
|
|
36
|
+
def conversations(self, channel_id: Optional[str] = None) -> Conversations:
|
|
37
|
+
return Conversations(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, channel_id=channel_id)
|
|
39
38
|
|
|
40
|
-
def files(self) -> Files:
|
|
41
|
-
return Files(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
|
|
39
|
+
def files(self, file_id: Optional[str] = None) -> Files:
|
|
40
|
+
return Files(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, file_id=file_id)
|
|
42
41
|
|
|
43
|
-
def messages(self) -> Messages:
|
|
44
|
-
return Messages(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
|
|
42
|
+
def messages(self, channel_id: Optional[str] = None, ts: Optional[str] = None) -> Messages:
|
|
43
|
+
return Messages(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, channel_id=channel_id, ts=ts)
|
|
45
44
|
|
|
46
|
-
def workspaces(self) -> Workspaces:
|
|
47
|
-
return Workspaces(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
|
|
45
|
+
def workspaces(self, workspace_id: Optional[str] = None) -> Workspaces:
|
|
46
|
+
return Workspaces(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, workspace_id=workspace_id)
|
|
48
47
|
|
|
49
|
-
def idp_groups(self) -> IDP_groups:
|
|
50
|
-
return IDP_groups(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
|
|
48
|
+
def idp_groups(self, group_id: Optional[str] = None) -> IDP_groups:
|
|
49
|
+
return IDP_groups(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger, group_id=group_id)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RateTier(float, Enum):
|
|
7
|
+
"""
|
|
8
|
+
Slack API rate-tier backoff defaults (seconds). These are defined to conform to Slack Web API rate limits.
|
|
9
|
+
https://docs.slack.dev/apis/web-api/rate-limits/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
TIER_1 = 60.0 # 1+ per minute
|
|
13
|
+
TIER_2 = 3.0 # 20+ per minute
|
|
14
|
+
TIER_3 = 1.2 # 50+ per minute
|
|
15
|
+
TIER_4 = 0.6 # 100+ per minute
|
|
16
|
+
TIER_D = 0.05 # 1200+ per minute
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SlackObjectsConfig:
|
|
21
|
+
"""
|
|
22
|
+
Configuration settings for slack-objects.
|
|
23
|
+
|
|
24
|
+
Tokens are optional at construction time.
|
|
25
|
+
Individual methods will raise clear errors if a required token is missing.
|
|
26
|
+
"""
|
|
27
|
+
bot_token: Optional[str] = field(default=None, repr=False)
|
|
28
|
+
user_token: Optional[str] = field(default=None, repr=False)
|
|
29
|
+
scim_token: Optional[str] = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
default_rate_tier: RateTier = RateTier.TIER_2
|
|
32
|
+
|
|
33
|
+
auth_idp_groups_read_access: dict[str, list[str]] = field(default_factory=dict)
|
|
34
|
+
auth_idp_groups_write_access: dict[str, list[str]] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
# SCIM settings
|
|
37
|
+
scim_base_url: str = "https://api.slack.com/scim"
|
|
38
|
+
scim_version: str = "v2"
|
|
39
|
+
|
|
40
|
+
# HTTP timeout for SCIM and file-download requests (seconds)
|
|
41
|
+
http_timeout_seconds: int = 30
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
""" Modifying the default dataclass __repr__ to mask token values for security. """
|
|
45
|
+
def _mask(val: Optional[str]) -> str:
|
|
46
|
+
return "***" if val else "None"
|
|
47
|
+
return (
|
|
48
|
+
f"SlackObjectsConfig("
|
|
49
|
+
f"bot_token={_mask(self.bot_token)}, "
|
|
50
|
+
f"user_token={_mask(self.user_token)}, "
|
|
51
|
+
f"scim_token={_mask(self.scim_token)}, "
|
|
52
|
+
f"default_rate_tier={self.default_rate_tier}, "
|
|
53
|
+
f"auth_idp_groups_read_access={self.auth_idp_groups_read_access}, "
|
|
54
|
+
f"auth_idp_groups_write_access={self.auth_idp_groups_write_access}, "
|
|
55
|
+
f"scim_base_url={self.scim_base_url}, "
|
|
56
|
+
f"scim_version={self.scim_version}, "
|
|
57
|
+
f"http_timeout_seconds={self.http_timeout_seconds})"
|
|
58
|
+
)
|
|
@@ -30,10 +30,9 @@ Design goals:
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
from dataclasses import dataclass, field
|
|
33
|
-
from typing import Any, Dict, List, Optional
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
from .base import SlackObjectBase
|
|
35
|
+
from .base import SlackObjectBase, safe_error_context
|
|
37
36
|
from .config import RateTier
|
|
38
37
|
from .messages import Messages
|
|
39
38
|
|
|
@@ -75,7 +74,7 @@ class Conversations(SlackObjectBase):
|
|
|
75
74
|
|
|
76
75
|
resp = self.get_conversation_info(self.channel_id)
|
|
77
76
|
if not resp.get("ok"):
|
|
78
|
-
raise RuntimeError(f"Conversations.get_conversation_info() failed: {resp}")
|
|
77
|
+
raise RuntimeError(f"Conversations.get_conversation_info() failed: {safe_error_context(resp)}")
|
|
79
78
|
|
|
80
79
|
self.attributes = resp.get("channel") or {}
|
|
81
80
|
return self.attributes
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# src/slack_objects/files.py
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
"""
|
|
@@ -25,7 +24,7 @@ This module provides file-centric behaviors:
|
|
|
25
24
|
"""
|
|
26
25
|
|
|
27
26
|
from dataclasses import dataclass, field
|
|
28
|
-
from typing import Any, Dict, Optional,
|
|
27
|
+
from typing import Any, Dict, Optional, Union
|
|
29
28
|
|
|
30
29
|
import requests
|
|
31
30
|
|
|
@@ -17,7 +17,7 @@ This module implements the following functionality:
|
|
|
17
17
|
|
|
18
18
|
Design decisions
|
|
19
19
|
----------------
|
|
20
|
-
- SCIM REST calls are centralized in
|
|
20
|
+
- SCIM REST calls are centralized in ScimMixin._scim_request(); all public methods call endpoint wrappers.
|
|
21
21
|
- Uses an injectable `requests.Session` (`scim_session`) so tests can pass a fake session.
|
|
22
22
|
- Keeps legacy output shapes: lists of dicts for groups and members.
|
|
23
23
|
"""
|
|
@@ -25,16 +25,14 @@ Design decisions
|
|
|
25
25
|
from dataclasses import dataclass, field
|
|
26
26
|
from typing import Any, Dict, List, Optional
|
|
27
27
|
|
|
28
|
-
import json
|
|
29
|
-
import time
|
|
30
28
|
import requests
|
|
31
29
|
|
|
32
30
|
from .base import SlackObjectBase
|
|
33
|
-
from .
|
|
31
|
+
from .scim_base import ScimMixin, ScimResponse, validate_scim_id
|
|
34
32
|
|
|
35
33
|
|
|
36
34
|
@dataclass
|
|
37
|
-
class IDP_groups(SlackObjectBase):
|
|
35
|
+
class IDP_groups(ScimMixin, SlackObjectBase):
|
|
38
36
|
"""
|
|
39
37
|
IdP (SCIM) groups helper.
|
|
40
38
|
|
|
@@ -60,78 +58,26 @@ class IDP_groups(SlackObjectBase):
|
|
|
60
58
|
scim_session=self.scim_session,
|
|
61
59
|
)
|
|
62
60
|
|
|
63
|
-
# ---------- SCIM request wrapper ----------
|
|
64
|
-
def _scim_base_url(self, scim_version: str) -> str:
|
|
65
|
-
"""Return configurable SCIM base URL; default to Slack SCIM endpoints when not overridden."""
|
|
66
|
-
if scim_version == "v2" and getattr(self.cfg, "scim_base_url_v2", None):
|
|
67
|
-
return self.cfg.scim_base_url_v2.rstrip("/") + "/"
|
|
68
|
-
if scim_version == "v1" and getattr(self.cfg, "scim_base_url_v1", None):
|
|
69
|
-
return self.cfg.scim_base_url_v1.rstrip("/") + "/"
|
|
70
|
-
return f"https://api.slack.com/scim/{scim_version}/"
|
|
71
|
-
|
|
72
|
-
def _scim_request(
|
|
73
|
-
self,
|
|
74
|
-
*,
|
|
75
|
-
path: str,
|
|
76
|
-
method: str = "GET",
|
|
77
|
-
payload: Optional[Dict[str, Any]] = None,
|
|
78
|
-
scim_version: str = "v1",
|
|
79
|
-
token: Optional[str] = None,
|
|
80
|
-
params: Optional[Dict[str, Any]] = None,
|
|
81
|
-
) -> Dict[str, Any]:
|
|
82
|
-
"""
|
|
83
|
-
Low-level SCIM request. Returns parsed JSON dict.
|
|
84
|
-
|
|
85
|
-
It raises ValueError when token is missing. Network/HTTP errors will raise requests exceptions.
|
|
86
|
-
We add a small sleep based on RateTier to reduce burstiness (keeps legacy cautious behavior).
|
|
87
|
-
"""
|
|
88
|
-
tok = token or getattr(self.cfg, "scim_token", None)
|
|
89
|
-
if not tok:
|
|
90
|
-
raise ValueError("SCIM request requires cfg.scim_token (or token override)")
|
|
91
|
-
|
|
92
|
-
# conservative sleep to avoid bursts (can be tuned)
|
|
93
|
-
time.sleep(float(RateTier.TIER_2))
|
|
94
|
-
|
|
95
|
-
url = self._scim_base_url(scim_version) + path.lstrip("/")
|
|
96
|
-
headers = {
|
|
97
|
-
"Authorization": f"Bearer {tok}",
|
|
98
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
resp = self.scim_session.request(
|
|
102
|
-
method=method.upper(),
|
|
103
|
-
url=url,
|
|
104
|
-
headers=headers,
|
|
105
|
-
params=params,
|
|
106
|
-
json=payload,
|
|
107
|
-
timeout=getattr(self.cfg, "http_timeout_seconds", 30),
|
|
108
|
-
)
|
|
109
|
-
resp.raise_for_status()
|
|
110
|
-
# best-effort JSON parse; return empty dict if no body
|
|
111
|
-
try:
|
|
112
|
-
return resp.json() if resp.text else {}
|
|
113
|
-
except Exception:
|
|
114
|
-
return {"_raw_text": resp.text or ""}
|
|
115
|
-
|
|
116
61
|
# ---------- endpoint wrappers (only these call _scim_request) ----------
|
|
117
62
|
|
|
118
|
-
def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None
|
|
63
|
+
def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None) -> ScimResponse:
|
|
119
64
|
"""
|
|
120
65
|
Wrapper for GET Groups (paginated).
|
|
121
66
|
Accepts pagination params as query parameters according to Slack SCIM docs.
|
|
122
67
|
"""
|
|
123
|
-
params = {"count": count}
|
|
124
|
-
if start_index:
|
|
68
|
+
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
|
|
69
|
+
if start_index is not None:
|
|
125
70
|
params["startIndex"] = start_index
|
|
126
|
-
return self._scim_request(path="Groups", method="GET", params=params
|
|
71
|
+
return self._scim_request(path="Groups", method="GET", params=params) # https://docs.slack.dev/reference/scim-api/#get-groups
|
|
127
72
|
|
|
128
|
-
def _scim_group_get(self, group_id: str
|
|
73
|
+
def _scim_group_get(self, group_id: str) -> ScimResponse:
|
|
129
74
|
"""Wrapper for GET Groups/{id}"""
|
|
130
|
-
|
|
75
|
+
validate_scim_id(group_id, "group_id")
|
|
76
|
+
return self._scim_request(path=f"Groups/{group_id}", method="GET") # https://docs.slack.dev/reference/scim-api/#get-groups-id
|
|
131
77
|
|
|
132
78
|
# ---------- public helpers ----------
|
|
133
79
|
|
|
134
|
-
def get_groups(self,
|
|
80
|
+
def get_groups(self, fetch_count: int = 1000) -> List[Dict[str, str]]:
|
|
135
81
|
"""
|
|
136
82
|
Return a list of IdP groups visible to the SCIM token.
|
|
137
83
|
|
|
@@ -147,7 +93,8 @@ class IDP_groups(SlackObjectBase):
|
|
|
147
93
|
retrieved = 0
|
|
148
94
|
|
|
149
95
|
while True:
|
|
150
|
-
|
|
96
|
+
scim_resp = self._scim_groups_list(count=fetch_count, start_index=start_index)
|
|
97
|
+
resp = scim_resp.data
|
|
151
98
|
|
|
152
99
|
# Slack SCIM returns 'Resources' (list) and 'totalResults' and 'startIndex' values.
|
|
153
100
|
resources = resp.get("Resources", []) or []
|
|
@@ -173,7 +120,7 @@ class IDP_groups(SlackObjectBase):
|
|
|
173
120
|
|
|
174
121
|
return groups_out
|
|
175
122
|
|
|
176
|
-
def get_members(self, group_id: Optional[str] = None
|
|
123
|
+
def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
|
|
177
124
|
"""
|
|
178
125
|
Return the members of a group as a list of dicts `{'value': <user_id>, 'display': <name>}`.
|
|
179
126
|
|
|
@@ -183,16 +130,16 @@ class IDP_groups(SlackObjectBase):
|
|
|
183
130
|
if not gid:
|
|
184
131
|
raise ValueError("get_members requires group_id (passed or bound)")
|
|
185
132
|
|
|
186
|
-
|
|
133
|
+
scim_resp = self._scim_group_get(gid)
|
|
187
134
|
# In the legacy scripts, group members are at `members` in the response body
|
|
188
|
-
return
|
|
135
|
+
return scim_resp.data.get("members", [])
|
|
189
136
|
|
|
190
|
-
def is_member(self, user_id: str, group_id: Optional[str] = None
|
|
137
|
+
def is_member(self, user_id: str, group_id: Optional[str] = None) -> bool:
|
|
191
138
|
"""
|
|
192
139
|
Return True if `user_id` is a member of `group_id`.
|
|
193
140
|
Preserves legacy semantics (scans the members list).
|
|
194
141
|
"""
|
|
195
|
-
members = self.get_members(group_id=group_id
|
|
142
|
+
members = self.get_members(group_id=group_id)
|
|
196
143
|
for member in members:
|
|
197
144
|
# member dicts historically had 'value' for id
|
|
198
145
|
if member.get("value") == user_id:
|
|
@@ -16,15 +16,15 @@ Design goals:
|
|
|
16
16
|
slack = SlackObjectsClient(cfg)
|
|
17
17
|
msgs = slack.messages() # unbound
|
|
18
18
|
msgs_c = slack.messages(channel_id="C123") # bound to channel
|
|
19
|
-
msg = slack.messages("C123", "1700.12")
|
|
19
|
+
msg = slack.messages(channel_id="C123", ts="1700.12") # bound to message
|
|
20
20
|
- Modular:
|
|
21
21
|
Only endpoint wrapper methods call self.api.call(...)
|
|
22
22
|
- Practical:
|
|
23
23
|
Provide common message operations: update/delete, thread replies, and block replacement.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
from dataclasses import dataclass
|
|
27
|
-
from typing import Any, Dict, List, Optional
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
29
|
from .base import SlackObjectBase
|
|
30
30
|
from .config import RateTier
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
from typing import
|
|
1
|
+
from dataclasses import dataclass, replace
|
|
2
|
+
from typing import Mapping
|
|
3
3
|
|
|
4
4
|
from .config import RateTier
|
|
5
5
|
|
|
@@ -15,6 +15,10 @@ class RateLimitPolicy:
|
|
|
15
15
|
# fallback
|
|
16
16
|
default: RateTier = RateTier.TIER_3
|
|
17
17
|
|
|
18
|
+
def with_default(self, tier: RateTier) -> "RateLimitPolicy":
|
|
19
|
+
"""Return a copy of this policy with a different fallback tier."""
|
|
20
|
+
return replace(self, default=tier)
|
|
21
|
+
|
|
18
22
|
def tier_for(self, method: str) -> RateTier:
|
|
19
23
|
# 1) exact match wins
|
|
20
24
|
if method in self.method_overrides:
|