slack-objects 0.0.2.dev0__tar.gz → 0.0.3__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 (51) hide show
  1. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.gitignore +2 -1
  2. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/PKG-INFO +8 -7
  3. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/README.md +4 -3
  4. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/pyproject.toml +3 -3
  5. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/_version.py +3 -3
  6. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/api_caller.py +9 -7
  7. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/base.py +19 -0
  8. slack_objects-0.0.3/src/slack_objects/config.py +57 -0
  9. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/conversations.py +2 -2
  10. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/idp_groups.py +15 -69
  11. slack_objects-0.0.3/src/slack_objects/scim_base.py +106 -0
  12. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/users.py +47 -82
  13. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/PKG-INFO +8 -7
  14. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/SOURCES.txt +3 -0
  15. slack_objects-0.0.3/src/slack_objects.egg-info/requires.txt +8 -0
  16. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/_smoke_harness.py +40 -9
  17. slack_objects-0.0.3/tests/api_caller_smoke_test.py +166 -0
  18. slack_objects-0.0.3/tests/idp_groups_smoke_test.py +70 -0
  19. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/run_all_smoke.py +6 -2
  20. slack_objects-0.0.3/tests/security_smoke_test.py +109 -0
  21. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_smoke_test.py +9 -7
  22. slack_objects-0.0.2.dev0/src/slack_objects/config.py +0 -29
  23. slack_objects-0.0.2.dev0/src/slack_objects.egg-info/requires.txt +0 -8
  24. slack_objects-0.0.2.dev0/tests/idp_groups_smoke_test.py +0 -37
  25. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/ci.yml +0 -0
  26. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/publish-pypi.yml +0 -0
  27. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/publish-testpypi.yml +0 -0
  28. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.vs/VSWorkspaceState.json +0 -0
  29. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.vs/slnx.sqlite +0 -0
  30. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/LICENSE +0 -0
  31. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/pytest.ini +0 -0
  32. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/setup.cfg +0 -0
  33. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/__init__.py +0 -0
  34. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/client.py +0 -0
  35. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/files.py +0 -0
  36. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/messages.py +0 -0
  37. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/rate_limits.py +0 -0
  38. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/workspaces.py +0 -0
  39. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  40. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/top_level.txt +0 -0
  41. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/conversations_example_test.py +0 -0
  42. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/conversations_smoke_test.py +0 -0
  43. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/files_example_test.py +0 -0
  44. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/files_smoke_test.py +0 -0
  45. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/idp_groups_example_test.py +0 -0
  46. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/messages_example_test.py +0 -0
  47. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/messages_smoke_test.py +0 -0
  48. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_example_test.py +0 -0
  49. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_test_AzureKeyVault.py +0 -0
  50. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/workspaces_example_test.py +0 -0
  51. {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/workspaces_smoke_test.py +0 -0
@@ -16,4 +16,5 @@
16
16
  # Python packaging artifacts
17
17
  dist/
18
18
  build/
19
- *.egg-info/
19
+ *.egg-info/
20
+ src/slack_objects/_version.py
@@ -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.3
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,8 +67,9 @@ 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
71
 
72
+ cfg = SlackObjectsConfig(bot_token="xoxb-...", user_token="xoxp-...", scim_token="xoxp-...", ...)
72
73
  slack = SlackObjectsClient(cfg)
73
74
 
74
75
  users = slack.users()
@@ -157,6 +158,6 @@ python -m tests.run_all_smoke
157
158
 
158
159
  ## Notes
159
160
 
160
- - SCIM v1 is the default; v2 is supported where applicable
161
- - Guest expiration dates use `PC_Utils.Datetime` if installed
161
+ - SCIM v2 is the default; v1 is supported where applicable
162
+ - `PC_Utils` is a required dependency (used for datetime handling, e.g., guest expiration dates)
162
163
  - This package is intended for automation and administration workflows
@@ -46,8 +46,9 @@ 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
50
 
51
+ cfg = SlackObjectsConfig(bot_token="xoxb-...", user_token="xoxp-...", scim_token="xoxp-...", ...)
51
52
  slack = SlackObjectsClient(cfg)
52
53
 
53
54
  users = slack.users()
@@ -136,6 +137,6 @@ python -m tests.run_all_smoke
136
137
 
137
138
  ## Notes
138
139
 
139
- - SCIM v1 is the default; v2 is supported where applicable
140
- - Guest expiration dates use `PC_Utils.Datetime` if installed
140
+ - SCIM v2 is the default; v1 is supported where applicable
141
+ - `PC_Utils` is a required dependency (used for datetime handling, e.g., guest expiration dates)
141
142
  - 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.3'
32
+ __version_tuple__ = version_tuple = (0, 0, 3)
33
33
 
34
- __commit_id__ = commit_id = 'ga1f28b971'
34
+ __commit_id__ = commit_id = 'g51cb2b9df'
@@ -17,7 +17,8 @@ class SlackApiCaller:
17
17
  self.cfg = cfg
18
18
  self.policy = policy
19
19
 
20
- def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, **kwargs) -> dict:
20
+ def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, _retry_count: int = 0, **kwargs) -> dict:
21
+ MAX_RETRIES = 5
21
22
  tier = rate_tier or self.policy.tier_for(method) or self.cfg.default_rate_tier
22
23
 
23
24
  try:
@@ -27,16 +28,17 @@ class SlackApiCaller:
27
28
  resp = client.api_call(method, params=kwargs)
28
29
 
29
30
  data = resp.data if hasattr(resp, "data") else resp
30
-
31
- # Space out subsequent calls
32
31
  time.sleep(float(tier))
33
32
  return data
34
33
 
35
34
  except SlackApiError as e:
36
- # Handle rate limiting properly
37
35
  if e.response is not None and e.response.status_code == 429:
38
- retry_after = int(e.response.headers.get("Retry-After", tier))
36
+ if _retry_count >= MAX_RETRIES:
37
+ raise RuntimeError(f"Rate-limited {MAX_RETRIES} times on {method}; giving up.") from e
38
+ try:
39
+ retry_after = int(e.response.headers.get("Retry-After", tier))
40
+ except (ValueError, TypeError):
41
+ retry_after = int(float(tier))
39
42
  time.sleep(retry_after)
40
- return self.call(client, method, rate_tier=tier, **kwargs)
41
-
43
+ return self.call(client, method, rate_tier=tier, use_json=use_json, _retry_count=_retry_count + 1, **kwargs)
42
44
  raise
@@ -7,6 +7,25 @@ from .api_caller import SlackApiCaller
7
7
  from .config import SlackObjectsConfig
8
8
 
9
9
 
10
+ # Keys that are safe to include in error messages (never contain tokens)
11
+ _SAFE_ERROR_KEYS = frozenset({"ok", "error", "needed", "provided", "response_metadata"})
12
+
13
+
14
+ def safe_error_context(resp: Any, *, max_len: int = 300) -> str:
15
+ """
16
+ Return a truncated, token-free summary of an API response for use in exception messages.
17
+
18
+ Only well-known diagnostic keys are kept. The result is capped at *max_len* characters
19
+ to prevent massive payloads from flooding logs or error-tracking systems.
20
+ """
21
+ if isinstance(resp, dict):
22
+ summary = {k: v for k, v in resp.items() if k in _SAFE_ERROR_KEYS}
23
+ else:
24
+ summary = repr(resp)
25
+ text = str(summary)
26
+ return text[:max_len] + ("..." if len(text) > max_len else "")
27
+
28
+
10
29
  @dataclass
11
30
  class SlackObjectBase:
12
31
  """
@@ -0,0 +1,57 @@
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
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SlackObjectsConfig:
20
+ """
21
+ Configuration settings for slack-objects.
22
+
23
+ Tokens are optional at construction time.
24
+ Individual methods will raise clear errors if a required token is missing.
25
+ """
26
+ bot_token: Optional[str] = field(default=None, repr=False)
27
+ user_token: Optional[str] = field(default=None, repr=False)
28
+ scim_token: Optional[str] = field(default=None, repr=False)
29
+
30
+ default_rate_tier: RateTier = RateTier.TIER_2
31
+
32
+ auth_idp_groups_read_access: dict[str, list[str]] = field(default_factory=dict)
33
+ auth_idp_groups_write_access: dict[str, list[str]] = field(default_factory=dict)
34
+
35
+ # SCIM settings
36
+ scim_base_url: str = "https://api.slack.com/scim"
37
+ scim_version: str = "v2"
38
+
39
+ # HTTP timeout for SCIM and file-download requests (seconds)
40
+ http_timeout_seconds: int = 30
41
+
42
+ def __repr__(self) -> str:
43
+ """ Modifying the default dataclass __repr__ to mask token values for security. """
44
+ def _mask(val: Optional[str]) -> str:
45
+ return "***" if val else "None"
46
+ return (
47
+ f"SlackObjectsConfig("
48
+ f"bot_token={_mask(self.bot_token)}, "
49
+ f"user_token={_mask(self.user_token)}, "
50
+ f"scim_token={_mask(self.scim_token)}, "
51
+ f"default_rate_tier={self.default_rate_tier}, "
52
+ f"auth_idp_groups_read_access={self.auth_idp_groups_read_access}, "
53
+ f"auth_idp_groups_write_access={self.auth_idp_groups_write_access}, "
54
+ f"scim_base_url={self.scim_base_url}, "
55
+ f"scim_version={self.scim_version}, "
56
+ f"http_timeout_seconds={self.http_timeout_seconds})"
57
+ )
@@ -33,7 +33,7 @@ from dataclasses import dataclass, field
33
33
  from typing import Any, Dict, List, Optional, Sequence, Union
34
34
 
35
35
 
36
- from .base import SlackObjectBase
36
+ from .base import SlackObjectBase, safe_error_context
37
37
  from .config import RateTier
38
38
  from .messages import Messages
39
39
 
@@ -75,7 +75,7 @@ class Conversations(SlackObjectBase):
75
75
 
76
76
  resp = self.get_conversation_info(self.channel_id)
77
77
  if not resp.get("ok"):
78
- raise RuntimeError(f"Conversations.get_conversation_info() failed: {resp}")
78
+ raise RuntimeError(f"Conversations.get_conversation_info() failed: {safe_error_context(resp)}")
79
79
 
80
80
  self.attributes = resp.get("channel") or {}
81
81
  return self.attributes
@@ -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) -> Dict[str, Any]:
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}
68
+ params: Dict[str, Any] = {"count": count}
124
69
  if start_index:
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).data
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) -> Dict[str, Any]:
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").data
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,7 @@ 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
+ resp = self._scim_groups_list(count=fetch_count, start_index=start_index)
151
97
 
152
98
  # Slack SCIM returns 'Resources' (list) and 'totalResults' and 'startIndex' values.
153
99
  resources = resp.get("Resources", []) or []
@@ -173,7 +119,7 @@ class IDP_groups(SlackObjectBase):
173
119
 
174
120
  return groups_out
175
121
 
176
- def get_members(self, group_id: Optional[str] = None, scim_version: str = "v1") -> List[Dict[str, str]]:
122
+ def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
177
123
  """
178
124
  Return the members of a group as a list of dicts `{'value': <user_id>, 'display': <name>}`.
179
125
 
@@ -183,16 +129,16 @@ class IDP_groups(SlackObjectBase):
183
129
  if not gid:
184
130
  raise ValueError("get_members requires group_id (passed or bound)")
185
131
 
186
- resp = self._scim_group_get(gid, scim_version=scim_version)
132
+ resp = self._scim_group_get(gid)
187
133
  # In the legacy scripts, group members are at `members` in the response body
188
134
  return resp.get("members", [])
189
135
 
190
- def is_member(self, user_id: str, group_id: Optional[str] = None, scim_version: str = "v1") -> bool:
136
+ def is_member(self, user_id: str, group_id: Optional[str] = None) -> bool:
191
137
  """
192
138
  Return True if `user_id` is a member of `group_id`.
193
139
  Preserves legacy semantics (scans the members list).
194
140
  """
195
- members = self.get_members(group_id=group_id, scim_version=scim_version)
141
+ members = self.get_members(group_id=group_id)
196
142
  for member in members:
197
143
  # member dicts historically had 'value' for id
198
144
  if member.get("value") == user_id:
@@ -0,0 +1,106 @@
1
+ """
2
+ Shared SCIM plumbing for any object helper that makes SCIM REST calls.
3
+
4
+ Centralizes:
5
+ - ID validation (path-injection defense)
6
+ - Base URL construction
7
+ - Token-guarded HTTP request + JSON parsing
8
+ - Rate-tier sleep
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, Optional
18
+
19
+ import requests
20
+
21
+ from .config import RateTier
22
+
23
+ # Slack IDs are alphanumeric with hyphens/underscores.
24
+ _SLACK_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
25
+
26
+
27
+ def validate_scim_id(value: str, label: str = "id") -> str:
28
+ """Raise ValueError if *value* contains path-traversal or unexpected characters."""
29
+ if not value or not _SLACK_ID_RE.match(value):
30
+ raise ValueError(f"Invalid {label}: {value!r}")
31
+ return value
32
+
33
+
34
+ @dataclass
35
+ class ScimResponse:
36
+ """Structured result for SCIM calls (no Slack 'ok' boolean)."""
37
+ ok: bool
38
+ status_code: int
39
+ data: Dict[str, Any]
40
+ text: str
41
+
42
+
43
+ class ScimMixin:
44
+ """
45
+ Mixin providing SCIM REST helpers.
46
+
47
+ Requirements on the host class (satisfied by SlackObjectBase subclasses):
48
+ - self.cfg (SlackObjectsConfig)
49
+ - self.scim_session (requests.Session)
50
+ """
51
+
52
+ # --- URL ---
53
+
54
+ def _scim_base_url(self) -> str:
55
+ return f"{self.cfg.scim_base_url.rstrip('/')}/{self.cfg.scim_version}/"
56
+
57
+ # --- Low-level request ---
58
+
59
+ def _scim_request(
60
+ self,
61
+ *,
62
+ path: str,
63
+ method: str = "GET",
64
+ payload: Optional[Dict[str, Any]] = None,
65
+ token: Optional[str] = None,
66
+ params: Optional[Dict[str, Any]] = None,
67
+ raise_for_status: bool = True,
68
+ ) -> ScimResponse:
69
+ """
70
+ Perform a SCIM REST request and return a ScimResponse.
71
+
72
+ Raises ValueError when the token is missing.
73
+ Raises requests.HTTPError on non-2xx when raise_for_status is True.
74
+ """
75
+ tok = token or self.cfg.scim_token
76
+ if not tok:
77
+ raise ValueError("SCIM request requires cfg.scim_token (or token override)")
78
+
79
+ url = self._scim_base_url() + path.lstrip("/")
80
+ headers = {
81
+ "Authorization": f"Bearer {tok}",
82
+ "Content-Type": "application/json; charset=utf-8",
83
+ }
84
+
85
+ resp = self.scim_session.request(
86
+ method=method.upper(),
87
+ url=url,
88
+ headers=headers,
89
+ params=params,
90
+ json=payload,
91
+ timeout=self.cfg.http_timeout_seconds,
92
+ )
93
+
94
+ if raise_for_status:
95
+ resp.raise_for_status()
96
+
97
+ text = resp.text or ""
98
+ try:
99
+ data = resp.json() if text else {}
100
+ except Exception:
101
+ data = {"_raw_text": text}
102
+
103
+ ok = resp.ok and (data.get("Errors") is None)
104
+
105
+ time.sleep(float(RateTier.TIER_2))
106
+ return ScimResponse(ok=ok, status_code=resp.status_code, data=data, text=text)