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.
Files changed (63) hide show
  1. slack_objects-0.0.4/.github/copilot-instructions.md +4 -0
  2. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.gitignore +7 -2
  3. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/PKG-INFO +27 -12
  4. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/README.md +23 -8
  5. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/pyproject.toml +3 -3
  6. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/_version.py +3 -3
  7. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/api_caller.py +13 -10
  8. slack_objects-0.0.4/src/slack_objects/base.py +54 -0
  9. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/client.py +11 -12
  10. slack_objects-0.0.4/src/slack_objects/config.py +58 -0
  11. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/conversations.py +3 -4
  12. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/files.py +1 -2
  13. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/idp_groups.py +18 -71
  14. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/messages.py +3 -3
  15. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/rate_limits.py +6 -2
  16. slack_objects-0.0.4/src/slack_objects/scim_base.py +117 -0
  17. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/users.py +49 -92
  18. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/PKG-INFO +27 -12
  19. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/SOURCES.txt +15 -1
  20. slack_objects-0.0.4/src/slack_objects.egg-info/requires.txt +8 -0
  21. slack_objects-0.0.4/tests/SCIM/conftest_live.py +183 -0
  22. slack_objects-0.0.4/tests/SCIM/live_test_config.example.json +41 -0
  23. slack_objects-0.0.4/tests/SCIM/run_all_scim_users_live_tests.py +81 -0
  24. slack_objects-0.0.4/tests/SCIM/test_scim_idp_groups_live.py +369 -0
  25. slack_objects-0.0.4/tests/SCIM/test_scim_users_create_live.py +102 -0
  26. slack_objects-0.0.4/tests/SCIM/test_scim_users_deactivate_live.py +173 -0
  27. slack_objects-0.0.4/tests/SCIM/test_scim_users_input_validation_live.py +102 -0
  28. slack_objects-0.0.4/tests/SCIM/test_scim_users_make_guest_live.py +207 -0
  29. slack_objects-0.0.4/tests/SCIM/test_scim_users_reactivate_live.py +199 -0
  30. slack_objects-0.0.4/tests/SCIM/test_scim_users_update_attribute_live.py +248 -0
  31. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/_smoke_harness.py +40 -9
  32. slack_objects-0.0.4/tests/api_caller_smoke_test.py +166 -0
  33. slack_objects-0.0.4/tests/idp_groups_smoke_test.py +63 -0
  34. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/run_all_smoke.py +6 -2
  35. slack_objects-0.0.4/tests/security_smoke_test.py +109 -0
  36. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_smoke_test.py +9 -7
  37. slack_objects-0.0.2.dev0/src/slack_objects/base.py +0 -30
  38. slack_objects-0.0.2.dev0/src/slack_objects/config.py +0 -29
  39. slack_objects-0.0.2.dev0/src/slack_objects.egg-info/requires.txt +0 -8
  40. slack_objects-0.0.2.dev0/tests/idp_groups_smoke_test.py +0 -37
  41. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/ci.yml +0 -0
  42. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/publish-pypi.yml +0 -0
  43. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.github/workflows/publish-testpypi.yml +0 -0
  44. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.vs/VSWorkspaceState.json +0 -0
  45. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/.vs/slnx.sqlite +0 -0
  46. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/LICENSE +0 -0
  47. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/pytest.ini +0 -0
  48. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/setup.cfg +0 -0
  49. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/__init__.py +0 -0
  50. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects/workspaces.py +0 -0
  51. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  52. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/src/slack_objects.egg-info/top_level.txt +0 -0
  53. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/conversations_example_test.py +0 -0
  54. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/conversations_smoke_test.py +0 -0
  55. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/files_example_test.py +0 -0
  56. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/files_smoke_test.py +0 -0
  57. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/idp_groups_example_test.py +0 -0
  58. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/messages_example_test.py +0 -0
  59. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/messages_smoke_test.py +0 -0
  60. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_example_test.py +0 -0
  61. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/users_test_AzureKeyVault.py +0 -0
  62. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/workspaces_example_test.py +0 -0
  63. {slack_objects-0.0.2.dev0 → slack_objects-0.0.4}/tests/workspaces_smoke_test.py +0 -0
@@ -0,0 +1,4 @@
1
+ # Copilot Instructions
2
+
3
+ ## Project Guidelines
4
+ - SCIM users live test file naming convention: test files use prefix `test_scim_users_*_live.py`, runner uses `run_all_scim_users_live_tests.py`. The plural "users" is used consistently across all files.
@@ -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.2.dev0
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.client import SlackObjectsClient
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
- general = slack.conversations("C123")
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.config import SlackObjectsConfig, RateTier
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 v1 is the default; v2 is supported where applicable
161
- - Guest expiration dates use `PC_Utils.Datetime` if installed
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.client import SlackObjectsClient
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
- general = slack.conversations("C123")
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.config import SlackObjectsConfig, RateTier
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 v1 is the default; v2 is supported where applicable
140
- - Guest expiration dates use `PC_Utils.Datetime` if installed
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.2.dev0'
32
- __version_tuple__ = version_tuple = (0, 0, 2, 'dev0')
31
+ __version__ = version = '0.0.4'
32
+ __version_tuple__ = version_tuple = (0, 0, 4)
33
33
 
34
- __commit_id__ = commit_id = 'ga1f28b971'
34
+ __commit_id__ = commit_id = 'gdc15695f3'
@@ -1,5 +1,5 @@
1
1
  import time
2
- from typing import Any, Optional
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
- self.policy = policy
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
- tier = rate_tier or self.policy.tier_for(method) or self.cfg.default_rate_tier
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
- retry_after = int(e.response.headers.get("Retry-After", tier))
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
- base = Users(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
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, Sequence, Union
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, Sequence, Union, List
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 `_scim_request()`; all public methods call those wrappers (keeps code modular and testable).
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 .config import RateTier
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, scim_version: str = "v1") -> Dict[str, Any]:
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, scim_version=scim_version)
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, scim_version: str = "v1") -> Dict[str, Any]:
73
+ def _scim_group_get(self, group_id: str) -> ScimResponse:
129
74
  """Wrapper for GET Groups/{id}"""
130
- return self._scim_request(path=f"Groups/{group_id}", method="GET", scim_version=scim_version)
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, scim_version: str = "v1", fetch_count: int = 1000) -> List[Dict[str, str]]:
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
- resp = self._scim_groups_list(count=fetch_count, start_index=start_index, scim_version=scim_version)
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, scim_version: str = "v1") -> List[Dict[str, str]]:
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
- resp = self._scim_group_get(gid, scim_version=scim_version)
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 resp.get("members", [])
135
+ return scim_resp.data.get("members", [])
189
136
 
190
- def is_member(self, user_id: str, group_id: Optional[str] = None, scim_version: str = "v1") -> bool:
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, scim_version=scim_version)
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") # bound to message
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, field
27
- from typing import Any, Dict, List, Optional, Sequence
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 Optional, Mapping
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: