slack-objects 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. slack_objects-0.2.0/.github/copilot-instructions.md +8 -0
  2. {slack_objects-0.1.1 → slack_objects-0.2.0}/PKG-INFO +2 -1
  3. {slack_objects-0.1.1 → slack_objects-0.2.0}/README.md +2 -1
  4. slack_objects-0.2.0/pytest.ini +15 -0
  5. slack_objects-0.2.0/src/slack_objects/_version.py +24 -0
  6. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/client.py +1 -1
  7. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/users.py +39 -8
  8. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
  9. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects.egg-info/SOURCES.txt +3 -0
  10. slack_objects-0.2.0/src/slack_objects.egg-info/scm_file_list.json +68 -0
  11. slack_objects-0.2.0/src/slack_objects.egg-info/scm_version.json +8 -0
  12. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/run_all_scim_users_live_tests.py +2 -1
  13. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_update_attribute_live.py +15 -0
  14. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/users_smoke_test.py +19 -7
  15. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/users_unit_test.py +262 -0
  16. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/run_all_users_live_tests.py +2 -1
  17. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/test_users_read_live.py +20 -9
  18. slack_objects-0.2.0/tests/conftest.py +31 -0
  19. slack_objects-0.1.1/.github/copilot-instructions.md +0 -5
  20. slack_objects-0.1.1/pytest.ini +0 -3
  21. slack_objects-0.1.1/src/slack_objects/_version.py +0 -34
  22. {slack_objects-0.1.1 → slack_objects-0.2.0}/.github/workflows/ci.yml +0 -0
  23. {slack_objects-0.1.1 → slack_objects-0.2.0}/.github/workflows/publish-pypi.yml +0 -0
  24. {slack_objects-0.1.1 → slack_objects-0.2.0}/.github/workflows/publish-testpypi.yml +0 -0
  25. {slack_objects-0.1.1 → slack_objects-0.2.0}/.gitignore +0 -0
  26. {slack_objects-0.1.1 → slack_objects-0.2.0}/LICENSE +0 -0
  27. {slack_objects-0.1.1 → slack_objects-0.2.0}/pyproject.toml +0 -0
  28. {slack_objects-0.1.1 → slack_objects-0.2.0}/setup.cfg +0 -0
  29. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/__init__.py +0 -0
  30. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/api_caller.py +0 -0
  31. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/base.py +0 -0
  32. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/config.py +0 -0
  33. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/conversations.py +0 -0
  34. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/files.py +0 -0
  35. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/idp_groups.py +0 -0
  36. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/messages.py +0 -0
  37. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/rate_limits.py +0 -0
  38. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/scim_base.py +0 -0
  39. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/usergroups.py +0 -0
  40. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects/workspaces.py +0 -0
  41. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  42. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects.egg-info/requires.txt +0 -0
  43. {slack_objects-0.1.1 → slack_objects-0.2.0}/src/slack_objects.egg-info/top_level.txt +0 -0
  44. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Examples/examples_config.example.json +0 -0
  45. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Examples/users_search_by_email_example.py +0 -0
  46. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  47. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/conftest_live.py +0 -0
  48. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  49. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
  50. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  51. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
  52. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  53. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  54. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
  55. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/_smoke_harness.py +0 -0
  56. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
  57. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/conversations_smoke_test.py +0 -0
  58. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/files_smoke_test.py +0 -0
  59. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  60. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/messages_smoke_test.py +0 -0
  61. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/run_all_smoke.py +0 -0
  62. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/security_smoke_test.py +0 -0
  63. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
  64. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
  65. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/config_unit_test.py +0 -0
  66. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/conversations_unit_test.py +0 -0
  67. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/files_unit_test.py +0 -0
  68. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/idp_groups_unit_test.py +0 -0
  69. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/messages_unit_test.py +0 -0
  70. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  71. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/usergroups_test.py +0 -0
  72. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
  73. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/conftest_live.py +0 -0
  74. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/test_users_admin_live.py +0 -0
  75. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/test_users_classification_live.py +0 -0
  76. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/Users/test_users_input_validation_live.py +0 -0
  77. {slack_objects-0.1.1 → slack_objects-0.2.0}/tests/live_test_config.example.json +0 -0
@@ -0,0 +1,8 @@
1
+ # Copilot Instructions
2
+
3
+ ## Project Guidelines
4
+ - Follow Unix philosophy: build small, composable functions where higher-level methods leverage lower-level primitives. Don't mix concerns (e.g., don't embed identifier resolution inside a SCIM CRUD method). Keep signatures consistent across sibling methods.
5
+ - Prefer consistency with the `Users.is_active`/`is_active_scim` pattern when structuring related methods (separate explicit SCIM method from higher-level orchestration).
6
+
7
+ ## Testing Guidelines
8
+ - Unit test files in `tests/UnitTests` must follow the naming convention `*_unit_test.py` (not `test_*` prefix). The following files should adhere to this convention: `users_unit_test.py`, `conversations_unit_test.py`, `messages_unit_test.py`, `files_unit_test.py`, `workspaces_unit_test.py`, `idp_groups_unit_test.py`, `config_unit_test.py`, and two others with the `_unit_test.py` suffix.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -198,3 +198,4 @@ explicit per-call tier → method-specific override → prefix rule → `default
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
200
  - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
201
+ - `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,4 +176,5 @@ 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
- - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
179
+ - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
180
+ - `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
@@ -0,0 +1,15 @@
1
+ [pytest]
2
+ pythonpath =
3
+ src
4
+
5
+ # Test tiers
6
+ # ----------
7
+ # live: hits real Azure Key Vault + real Slack APIs. Applied automatically to
8
+ # every '*_live.py' file by tests/conftest.py. Some live tests (tests/SCIM)
9
+ # mutate real users, so they are NOT run by default.
10
+ markers =
11
+ live: requires real credentials and makes live Slack API calls (deselected by default)
12
+
13
+ # Deselect the live tier by default so a bare 'pytest' is safe to run.
14
+ # Override with '-m live' (only live tests) or '-m "live or not live"' (everything).
15
+ addopts = -m "not live"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = 'g7fa644b5a'
@@ -18,7 +18,7 @@ class SlackObjectsClient:
18
18
  Owns config, Slack client, and rate-limited API caller.
19
19
  """
20
20
 
21
- def __init__(self, cfg: SlackObjectsConfig, logger: logging.Logger | None = None):
21
+ def __init__(self, cfg: SlackObjectsConfig, logger: Optional[logging.Logger] = None):
22
22
  self.cfg = cfg
23
23
  self.logger = logger or logging.getLogger("slack-objects")
24
24
 
@@ -404,19 +404,39 @@ class Users(ScimMixin, SlackObjectBase):
404
404
 
405
405
  # ---------- discovery helper ----------
406
406
 
407
- def get_channels(self, user_id: str, active_only: bool = True) -> List[Dict[str, Any]]:
407
+ def get_channels(self, user_id: str, include_channels_user_left: bool = False) -> List[Dict[str, Any]]:
408
408
  """
409
409
  discovery.user.conversations, paginated by offset.
410
410
 
411
+ Args:
412
+ user_id: The Slack user whose conversations should be listed.
413
+ include_channels_user_left: When True, also return channels the user
414
+ has left (those with a non-zero ``date_left``). Mirrors
415
+ ``Conversations.get_members(include_members_who_left=...)``.
416
+
417
+ Note:
418
+ Slack only returns channels the user has left when ``include_historical``
419
+ is sent, so this flag controls the *request* and not just local filtering.
420
+ Leaving it False keeps the common case cheaper: Slack returns fewer
421
+ channels, which also means fewer paginated calls.
422
+
411
423
  Returns:
412
424
  - If errors occur: a list of error dicts (legacy behavior preserved)
413
- - Else: list of channels (active_only controls whether it filters to channels with date_left == 0)
425
+ - Else: the user's channels, including left channels only when
426
+ ``include_channels_user_left`` is True
414
427
  """
415
428
  current_channels: List[Dict[str, Any]] = []
416
429
  all_channels: List[Dict[str, Any]] = []
417
430
  errors: List[Dict[str, Any]] = []
418
431
 
419
- payload: Dict[str, Any] = {"user": user_id, "limit": 1000}
432
+ payload: Dict[str, Any] = {
433
+ "user": user_id,
434
+ "limit": 1000,
435
+ # Sent explicitly in both states so the behavior does not depend on
436
+ # Slack's server-side default. The Slack SDK serializes booleans to
437
+ # "0"/"1", so False is transmitted correctly.
438
+ "include_historical": include_channels_user_left,
439
+ }
420
440
 
421
441
  while True:
422
442
  resp = self._discovery_user_conversations(payload)
@@ -428,6 +448,7 @@ class Users(ScimMixin, SlackObjectBase):
428
448
  channels = resp.get("channels") or []
429
449
  for ch in channels:
430
450
  all_channels.append(ch)
451
+ # date_left is 0 for channels the user is still a member of.
431
452
  if ch.get("date_left", 0) == 0:
432
453
  current_channels.append(ch)
433
454
 
@@ -440,7 +461,9 @@ class Users(ScimMixin, SlackObjectBase):
440
461
  if errors:
441
462
  return errors # preserve legacy behavior
442
463
 
443
- return current_channels if active_only else all_channels
464
+ # The filter is a safety net: when include_historical is False Slack
465
+ # should not return left channels in the first place.
466
+ return all_channels if include_channels_user_left else current_channels
444
467
 
445
468
  # ============================================================
446
469
  # SCIM public methods (use inherited ScimMixin._scim_request)
@@ -626,12 +649,20 @@ class Users(ScimMixin, SlackObjectBase):
626
649
  def scim_update_user_attribute(
627
650
  self,
628
651
  *,
629
- user_id: str,
652
+ user_id: Optional[str] = None,
630
653
  attribute: str,
631
654
  new_value: Any,
632
655
  ) -> ScimResponse:
633
- """SCIM PATCH Users/<id>"""
634
- validate_scim_id(user_id, "user_id")
656
+ """SCIM PATCH Users/<id> for simple scalar attributes.
657
+
658
+ For email updates, use :meth:`scim_update_email` instead — it handles
659
+ the version-specific payload formatting needed to avoid multi-primary
660
+ conflicts.
661
+ """
662
+ uid = user_id or self.user_id
663
+ if not uid:
664
+ raise ValueError("scim_update_user_attribute requires user_id (passed or bound)")
665
+ validate_scim_id(uid, "user_id")
635
666
 
636
667
  scim_version = self.cfg.scim_version
637
668
  if scim_version == "v2":
@@ -644,7 +675,7 @@ class Users(ScimMixin, SlackObjectBase):
644
675
  else:
645
676
  raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
646
677
 
647
- return self._scim_request(path=f"Users/{user_id}", method="PATCH", payload=payload)
678
+ return self._scim_request(path=f"Users/{uid}", method="PATCH", payload=payload)
648
679
 
649
680
  def scim_update_email(
650
681
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -198,3 +198,4 @@ explicit per-call tier → method-specific override → prefix rule → `default
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
200
  - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
201
+ - `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
@@ -26,7 +26,10 @@ src/slack_objects.egg-info/PKG-INFO
26
26
  src/slack_objects.egg-info/SOURCES.txt
27
27
  src/slack_objects.egg-info/dependency_links.txt
28
28
  src/slack_objects.egg-info/requires.txt
29
+ src/slack_objects.egg-info/scm_file_list.json
30
+ src/slack_objects.egg-info/scm_version.json
29
31
  src/slack_objects.egg-info/top_level.txt
32
+ tests/conftest.py
30
33
  tests/live_test_config.example.json
31
34
  tests/Examples/examples_config.example.json
32
35
  tests/Examples/users_search_by_email_example.py
@@ -0,0 +1,68 @@
1
+ {
2
+ "files": [
3
+ "pyproject.toml",
4
+ "LICENSE",
5
+ "README.md",
6
+ "pytest.ini",
7
+ ".gitignore",
8
+ ".github/copilot-instructions.md",
9
+ ".github/workflows/publish-testpypi.yml",
10
+ ".github/workflows/publish-pypi.yml",
11
+ ".github/workflows/ci.yml",
12
+ "tests/live_test_config.example.json",
13
+ "tests/conftest.py",
14
+ "tests/UnitTests/usergroups_test.py",
15
+ "tests/UnitTests/rate_limits_unit_test.py",
16
+ "tests/UnitTests/files_unit_test.py",
17
+ "tests/UnitTests/idp_groups_unit_test.py",
18
+ "tests/UnitTests/messages_unit_test.py",
19
+ "tests/UnitTests/workspaces_unit_test.py",
20
+ "tests/UnitTests/users_unit_test.py",
21
+ "tests/UnitTests/conversations_unit_test.py",
22
+ "tests/UnitTests/config_unit_test.py",
23
+ "tests/SCIM/test_scim_users_make_guest_live.py",
24
+ "tests/SCIM/test_scim_users_update_email_live.py",
25
+ "tests/SCIM/test_scim_users_reactivate_live.py",
26
+ "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
+ "tests/SCIM/run_all_scim_users_live_tests.py",
30
+ "tests/SCIM/test_scim_users_create_live.py",
31
+ "tests/SCIM/test_scim_users_deactivate_live.py",
32
+ "tests/SCIM/test_scim_idp_groups_live.py",
33
+ "tests/Smoke/idp_groups_smoke_test.py",
34
+ "tests/Smoke/security_smoke_test.py",
35
+ "tests/Smoke/api_caller_smoke_test.py",
36
+ "tests/Smoke/run_all_smoke.py",
37
+ "tests/Smoke/_smoke_harness.py",
38
+ "tests/Smoke/users_smoke_test.py",
39
+ "tests/Smoke/files_smoke_test.py",
40
+ "tests/Smoke/workspaces_smoke_test.py",
41
+ "tests/Smoke/conversations_smoke_test.py",
42
+ "tests/Smoke/messages_smoke_test.py",
43
+ "tests/Smoke/usergroups_smoke_test.py",
44
+ "tests/Users/test_users_read_live.py",
45
+ "tests/Users/conftest_live.py",
46
+ "tests/Users/test_users_input_validation_live.py",
47
+ "tests/Users/run_all_users_live_tests.py",
48
+ "tests/Users/test_users_classification_live.py",
49
+ "tests/Users/test_users_admin_live.py",
50
+ "tests/Examples/users_search_by_email_example.py",
51
+ "tests/Examples/users_test_AzureKeyVault.py",
52
+ "tests/Examples/examples_config.example.json",
53
+ "src/slack_objects/workspaces.py",
54
+ "src/slack_objects/usergroups.py",
55
+ "src/slack_objects/conversations.py",
56
+ "src/slack_objects/messages.py",
57
+ "src/slack_objects/users.py",
58
+ "src/slack_objects/config.py",
59
+ "src/slack_objects/__init__.py",
60
+ "src/slack_objects/client.py",
61
+ "src/slack_objects/files.py",
62
+ "src/slack_objects/api_caller.py",
63
+ "src/slack_objects/scim_base.py",
64
+ "src/slack_objects/rate_limits.py",
65
+ "src/slack_objects/base.py",
66
+ "src/slack_objects/idp_groups.py"
67
+ ]
68
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.0",
3
+ "distance": 0,
4
+ "node": "g7fa644b5a865631efd38f980ea488ba704bda19a",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-31"
8
+ }
@@ -49,7 +49,8 @@ def main() -> int:
49
49
  print(f"{'═' * 70}\n")
50
50
 
51
51
  rc = subprocess.call(
52
- [sys.executable, "-m", "pytest", str(filepath), *extra_args],
52
+ # '-m live' re-enables the live tier, which pytest.ini deselects by default.
53
+ [sys.executable, "-m", "pytest", "-m", "live", str(filepath), *extra_args],
53
54
  cwd=str(_HERE.parent.parent), # workspace root
54
55
  )
55
56
  results[filename] = rc
@@ -190,6 +190,21 @@ class TestScimUpdateUserAttribute:
190
190
  )
191
191
  _pause()
192
192
 
193
+ # ----- bound user_id (no explicit user_id) -----
194
+
195
+ def test_update_attribute_bound_user(self, ctx, users):
196
+ """Update attribute using bound user_id (no explicit user_id arg)."""
197
+ bound = users.with_user(ctx.active_member_id)
198
+ resp = bound.scim_update_user_attribute(
199
+ attribute="title",
200
+ new_value="Bound Test Title",
201
+ )
202
+ assert resp.ok, f"Expected ok for bound user: {resp.data}"
203
+ _pause()
204
+
205
+ bound.scim_update_user_attribute(attribute="title", new_value="")
206
+ _pause()
207
+
193
208
  # ----- deactivated user -----
194
209
 
195
210
  def test_update_attribute_deactivated_user(self, ctx, users):
@@ -110,17 +110,18 @@ def main() -> None:
110
110
  CallSpec("get_user_id_from_email(miss)", lambda: users.get_user_id_from_email("missing@example.com")),
111
111
 
112
112
  # Profile helpers
113
- CallSpec("get_user_profile(bound)", lambda: bound.get_user_profile()),
113
+ CallSpec("get_user_profile()", lambda: bound.get_user_profile()),
114
114
  CallSpec("get_user_profile(by id)", lambda: users.get_user_profile("U1")),
115
- CallSpec("set_user_profile_field(bound)", lambda: bound.set_user_profile_field("status_text", "hello")),
115
+ CallSpec("set_user_profile_field()", lambda: bound.set_user_profile_field("status_text", "hello")),
116
116
  CallSpec("set_user_profile_field(by id)", lambda: users.set_user_profile_field("status_text", "hello", user_id="U1")),
117
117
 
118
118
  # Classification helpers (need attributes)
119
119
  CallSpec("is_contingent_worker()", lambda: (_refresh_bound(), bound.is_contingent_worker())),
120
120
  CallSpec("is_guest()", lambda: (_refresh_bound(), bound.is_guest())),
121
121
  CallSpec("is_active()", lambda: (_refresh_bound(), bound.is_active())),
122
- CallSpec("is_active(user_id override)", lambda: (_refresh_bound(), bound.is_active(user_id="U2"))),
122
+ CallSpec("is_active(by id)", lambda: (_refresh_bound(), bound.is_active(user_id="U2"))),
123
123
  CallSpec("is_active_scim()", lambda: bound.is_active_scim()),
124
+ CallSpec("is_active_scim(by id)", lambda: bound.is_active_scim(user_id="U1")),
124
125
 
125
126
  # Admin helpers
126
127
  CallSpec(
@@ -128,14 +129,15 @@ def main() -> None:
128
129
  lambda: bound.invite_user(channel_ids=["C1", "C2"], email="new@example.com", team_id="T1"),
129
130
  ),
130
131
  CallSpec("wipe_all_sessions()", lambda: bound.wipe_all_sessions()),
132
+ CallSpec("wipe_all_sessions(by id)", lambda: bound.wipe_all_sessions(user_id="U1")),
131
133
  CallSpec("add_to_workspace()", lambda: bound.add_to_workspace("U1", "T1")),
132
134
  CallSpec("remove_from_workspace()", lambda: bound.remove_from_workspace("U1", "T1")),
133
135
  CallSpec("add_to_conversation()", lambda: bound.add_to_conversation(["U1"], "C1")),
134
136
  CallSpec("remove_from_conversation()", lambda: bound.remove_from_conversation("U1", "C1")),
135
137
 
136
138
  # Discovery helper
137
- CallSpec("get_channels(active_only)", lambda: bound.get_channels("U1", active_only=True)),
138
- CallSpec("get_channels(all)", lambda: bound.get_channels("U1", active_only=False)),
139
+ CallSpec("get_channels(current only)", lambda: bound.get_channels("U1")),
140
+ CallSpec("get_channels(including left)", lambda: bound.get_channels("U1", include_channels_user_left=True)),
139
141
 
140
142
  # Authorization helper
141
143
  CallSpec(
@@ -152,24 +154,34 @@ def main() -> None:
152
154
  "set_guest_expiration_date()",
153
155
  lambda: bound.set_guest_expiration_date("2030-01-01", workspace_id="T1"),
154
156
  ),
157
+ CallSpec(
158
+ "set_guest_expiration_date(by id)",
159
+ lambda: bound.set_guest_expiration_date("2030-01-01", user_id="U1", workspace_id="T1"),
160
+ ),
155
161
 
156
162
  # SCIM helpers (scim_version now comes from cfg, not a kwarg)
157
163
  CallSpec("scim_create_user()", lambda: bound.scim_create_user("testuser", "test@example.com")),
158
164
  CallSpec("scim_deactivate_user()", lambda: bound.scim_deactivate_user("U1")),
159
165
  CallSpec("scim_reactivate_user()", lambda: bound.scim_reactivate_user()),
166
+ CallSpec("scim_reactivate_user(by id)", lambda: bound.scim_reactivate_user(user_id="U1")),
160
167
  CallSpec(
161
- "scim_update_user_attribute()",
168
+ "scim_update_user_attribute(by id)",
162
169
  lambda: bound.scim_update_user_attribute(user_id="U1", attribute="active", new_value=False),
163
170
  ),
171
+ CallSpec(
172
+ "scim_update_user_attribute()",
173
+ lambda: bound.scim_update_user_attribute(attribute="active", new_value=False),
174
+ ),
164
175
  CallSpec(
165
176
  "scim_update_email(by id)",
166
177
  lambda: bound.scim_update_email(user_id="U1", new_email="newemail@example.com"),
167
178
  ),
168
179
  CallSpec(
169
- "scim_update_email(bound)",
180
+ "scim_update_email()",
170
181
  lambda: bound.scim_update_email(new_email="newemail@example.com"),
171
182
  ),
172
183
  CallSpec("make_multi_channel_guest()", lambda: bound.make_multi_channel_guest()),
184
+ CallSpec("make_multi_channel_guest(by id)", lambda: bound.make_multi_channel_guest(user_id="U1")),
173
185
 
174
186
  # SCIM search primitives
175
187
  CallSpec("scim_search_user_by_email()", lambda: bound.scim_search_user_by_email("test@example.com")),
@@ -573,3 +573,265 @@ class TestScimUpdateEmail:
573
573
  users = _make_users()
574
574
  with pytest.raises(ValueError):
575
575
  users.scim_update_email(user_id="", new_email="x@example.com")
576
+
577
+
578
+ # ═══════════════════════════════════════════════════════════════════════════
579
+ # scim_update_user_attribute
580
+ # ═══════════════════════════════════════════════════════════════════════════
581
+
582
+ class TestScimUpdateUserAttribute:
583
+ """scim_update_user_attribute — version-aware SCIM PATCH for scalar attributes."""
584
+
585
+ def test_v2_payload_structure(self):
586
+ """v2 uses PatchOp schema with replace operation."""
587
+ users = _make_users()
588
+ bound = users.with_user("U1")
589
+ bound._scim_request = MagicMock(
590
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
591
+ )
592
+ bound.scim_update_user_attribute(user_id="U1", attribute="active", new_value=False)
593
+
594
+ bound._scim_request.assert_called_once()
595
+ _, kwargs = bound._scim_request.call_args
596
+ op = kwargs["payload"]["Operations"][0]
597
+ assert op["op"] == "replace"
598
+ assert op["path"] == "active"
599
+ assert op["value"] is False
600
+
601
+ def test_v1_payload_structure(self):
602
+ """v1 sets attribute directly on payload root."""
603
+ users = _make_users()
604
+ bound = users.with_user("U1")
605
+ object.__setattr__(bound.cfg, "scim_version", "v1")
606
+ bound._scim_request = MagicMock(
607
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
608
+ )
609
+ bound.scim_update_user_attribute(user_id="U1", attribute="active", new_value=False)
610
+
611
+ bound._scim_request.assert_called_once()
612
+ _, kwargs = bound._scim_request.call_args
613
+ assert kwargs["payload"]["active"] is False
614
+
615
+ def test_falls_back_to_bound_user_id(self):
616
+ """Omitting user_id uses self.user_id."""
617
+ users = _make_users()
618
+ bound = users.with_user("U1")
619
+ bound._scim_request = MagicMock(
620
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
621
+ )
622
+ bound.scim_update_user_attribute(attribute="displayName", new_value="Alice")
623
+
624
+ bound._scim_request.assert_called_once()
625
+ _, kwargs = bound._scim_request.call_args
626
+ assert kwargs["path"] == "Users/U1"
627
+
628
+ def test_explicit_user_id_overrides_bound(self):
629
+ """Passing user_id explicitly takes precedence over bound user_id."""
630
+ users = _make_users()
631
+ bound = users.with_user("U_IGNORED")
632
+ bound._scim_request = MagicMock(
633
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
634
+ )
635
+ bound.scim_update_user_attribute(user_id="U_OTHER", attribute="active", new_value=True)
636
+
637
+ bound._scim_request.assert_called_once()
638
+ _, kwargs = bound._scim_request.call_args
639
+ assert kwargs["path"] == "Users/U_OTHER"
640
+
641
+ def test_no_user_id_raises(self):
642
+ """Unbound instance with no user_id raises ValueError."""
643
+ users = _make_users()
644
+ with pytest.raises(ValueError, match="requires user_id"):
645
+ users.scim_update_user_attribute(attribute="active", new_value=False)
646
+
647
+ def test_invalid_scim_version_raises(self):
648
+ """Unsupported scim_version raises NotImplementedError."""
649
+ users = _make_users()
650
+ bound = users.with_user("U1")
651
+ object.__setattr__(bound.cfg, "scim_version", "v99")
652
+ with pytest.raises(NotImplementedError, match="Invalid SCIM version"):
653
+ bound.scim_update_user_attribute(attribute="active", new_value=False)
654
+
655
+ def test_invalid_user_id_raises(self):
656
+ """Path-traversal user_id is rejected by validate_scim_id."""
657
+ users = _make_users()
658
+ with pytest.raises(ValueError):
659
+ users.scim_update_user_attribute(user_id="../../bad", attribute="active", new_value=False)
660
+
661
+ def test_empty_user_id_raises(self):
662
+ """Empty user_id is rejected by validate_scim_id."""
663
+ users = _make_users()
664
+ with pytest.raises(ValueError):
665
+ users.scim_update_user_attribute(user_id="", attribute="active", new_value=False)
666
+
667
+
668
+ # ═══════════════════════════════════════════════════════════════════════════
669
+ # get_channels (discovery.user.conversations)
670
+ # ═══════════════════════════════════════════════════════════════════════════
671
+
672
+ class FakeDiscoveryWebClient:
673
+ """Fake discovery.user.conversations that honors include_historical.
674
+
675
+ Mirrors real Slack behavior confirmed against the Discovery API: channels the
676
+ user has left are returned ONLY when include_historical is truthy. The Slack
677
+ SDK serializes booleans to "0"/"1", so this accepts both forms.
678
+
679
+ Also records every payload it receives so tests can assert on the request.
680
+ """
681
+
682
+ CURRENT = [
683
+ {"id": "C001", "date_joined": 1665776256, "date_left": 0},
684
+ {"id": "C002", "date_joined": 1665776257, "date_left": 0},
685
+ ]
686
+ LEFT = [
687
+ {"id": "C900", "date_joined": 1665776258, "date_left": 1700000000},
688
+ ]
689
+
690
+ def __init__(self, paginate: bool = False):
691
+ self.payloads: list = []
692
+ self.paginate = paginate
693
+
694
+ @staticmethod
695
+ def _is_truthy(value) -> bool:
696
+ # Accept True, "1", and 1; the SDK converts booleans to "0"/"1".
697
+ return value in (True, 1, "1")
698
+
699
+ def api_call(self, method: str, json=None, params=None):
700
+ payload = json if json is not None else (params or {})
701
+
702
+ if method != "discovery.user.conversations":
703
+ return {"ok": True}
704
+
705
+ self.payloads.append(dict(payload))
706
+
707
+ include_historical = self._is_truthy(payload.get("include_historical"))
708
+ channels = list(self.CURRENT)
709
+ if include_historical:
710
+ channels += self.LEFT
711
+
712
+ # Emit two pages on the first call when pagination is being exercised.
713
+ if self.paginate and "offset" not in payload:
714
+ return {"ok": True, "channels": channels, "offset": "page2"}
715
+
716
+ return {"ok": True, "channels": channels}
717
+
718
+
719
+ class FakeDiscoveryErrorWebClient:
720
+ """Fake that always fails, to exercise the legacy error-list behavior."""
721
+
722
+ def api_call(self, method: str, json=None, params=None):
723
+ if method == "discovery.user.conversations":
724
+ return {"ok": False, "error": "user_not_found"}
725
+ return {"ok": True}
726
+
727
+
728
+ def _make_users_with_client(web_client):
729
+ """Helper: build a Users instance wired to a specific fake web client."""
730
+ cfg = SlackObjectsConfig(
731
+ bot_token="xoxb-fake",
732
+ user_token="xoxp-fake",
733
+ scim_token="xoxp-fake",
734
+ default_rate_tier=RateTier.TIER_3,
735
+ )
736
+ slack = SlackObjectsClient(cfg, logger=logging.getLogger("test"))
737
+ slack.web_client = web_client
738
+ slack.api = FakeApiCaller(cfg)
739
+ slack._users = None
740
+ return slack.users()
741
+
742
+
743
+ class TestGetChannels:
744
+ """get_channels — include_channels_user_left controls the request, not just filtering."""
745
+
746
+ def test_default_does_not_request_historical(self):
747
+ """Default call must send include_historical=False.
748
+
749
+ This is the efficiency guarantee: the common case should not make Slack
750
+ return channels the user left only for them to be filtered out locally.
751
+ """
752
+ fake = FakeDiscoveryWebClient()
753
+ users = _make_users_with_client(fake)
754
+
755
+ users.get_channels("U1")
756
+
757
+ assert fake.payloads[0]["include_historical"] is False
758
+
759
+ def test_include_left_requests_historical(self):
760
+ """include_channels_user_left=True must send include_historical=True."""
761
+ fake = FakeDiscoveryWebClient()
762
+ users = _make_users_with_client(fake)
763
+
764
+ users.get_channels("U1", include_channels_user_left=True)
765
+
766
+ assert fake.payloads[0]["include_historical"] is True
767
+
768
+ def test_default_returns_only_current_channels(self):
769
+ """Default call returns just the channels the user is still in."""
770
+ users = _make_users_with_client(FakeDiscoveryWebClient())
771
+
772
+ result = users.get_channels("U1")
773
+
774
+ assert [c["id"] for c in result] == ["C001", "C002"]
775
+
776
+ def test_include_left_returns_current_and_left(self):
777
+ """include_channels_user_left=True returns left channels too."""
778
+ users = _make_users_with_client(FakeDiscoveryWebClient())
779
+
780
+ result = users.get_channels("U1", include_channels_user_left=True)
781
+
782
+ assert [c["id"] for c in result] == ["C001", "C002", "C900"]
783
+
784
+ def test_include_left_returns_more_than_default(self):
785
+ """The two modes must not return identical data (the original bug)."""
786
+ users = _make_users_with_client(FakeDiscoveryWebClient())
787
+
788
+ current = users.get_channels("U1")
789
+ including_left = users.get_channels("U1", include_channels_user_left=True)
790
+
791
+ assert len(including_left) > len(current)
792
+
793
+ def test_request_includes_user_and_limit(self):
794
+ """Payload keeps the documented user/limit parameters."""
795
+ fake = FakeDiscoveryWebClient()
796
+ users = _make_users_with_client(fake)
797
+
798
+ users.get_channels("U1")
799
+
800
+ assert fake.payloads[0]["user"] == "U1"
801
+ assert fake.payloads[0]["limit"] == 1000
802
+
803
+ def test_pagination_follows_offset(self):
804
+ """An offset in the response triggers another call and results accumulate."""
805
+ fake = FakeDiscoveryWebClient(paginate=True)
806
+ users = _make_users_with_client(fake)
807
+
808
+ result = users.get_channels("U1")
809
+
810
+ assert len(fake.payloads) == 2
811
+ assert fake.payloads[1]["offset"] == "page2"
812
+ assert len(result) == 4 # two current channels per page
813
+
814
+ def test_pagination_preserves_historical_flag(self):
815
+ """include_historical must persist across paginated requests."""
816
+ fake = FakeDiscoveryWebClient(paginate=True)
817
+ users = _make_users_with_client(fake)
818
+
819
+ users.get_channels("U1", include_channels_user_left=True)
820
+
821
+ assert all(p["include_historical"] is True for p in fake.payloads)
822
+
823
+ def test_api_error_returns_error_list(self):
824
+ """Legacy behavior: API failures come back as a list of error dicts."""
825
+ users = _make_users_with_client(FakeDiscoveryErrorWebClient())
826
+
827
+ result = users.get_channels("U1")
828
+
829
+ assert isinstance(result, list)
830
+ assert result[0]["message"] == "user_not_found"
831
+
832
+ def test_active_only_no_longer_accepted(self):
833
+ """The old active_only parameter was replaced and must not silently work."""
834
+ users = _make_users_with_client(FakeDiscoveryWebClient())
835
+
836
+ with pytest.raises(TypeError):
837
+ users.get_channels("U1", active_only=False)
@@ -45,7 +45,8 @@ def main() -> int:
45
45
  print(f"{'═' * 70}\n")
46
46
 
47
47
  rc = subprocess.call(
48
- [sys.executable, "-m", "pytest", str(filepath)] + extra_args
48
+ # '-m live' re-enables the live tier, which pytest.ini deselects by default.
49
+ [sys.executable, "-m", "pytest", "-m", "live", str(filepath)] + extra_args
49
50
  )
50
51
  results[filename] = rc
51
52
 
@@ -311,22 +311,33 @@ class TestGetChannels:
311
311
  assert isinstance(result, list)
312
312
  _pause()
313
313
 
314
- def test_active_only_true_subset_of_all(self, ctx, users):
315
- """active_only=True should return ≤ active_only=False."""
316
- active = users.get_channels(ctx.active_member_id, active_only=True)
314
+ def test_current_only_subset_of_including_left(self, ctx, users):
315
+ """Default (current only) should return ≤ include_channels_user_left=True."""
316
+ current = users.get_channels(ctx.active_member_id)
317
317
  _pause()
318
- all_ch = users.get_channels(ctx.active_member_id, active_only=False)
318
+ including_left = users.get_channels(ctx.active_member_id, include_channels_user_left=True)
319
319
  _pause()
320
- assert len(active) <= len(all_ch), (
321
- f"active_only ({len(active)}) should be ≤ all ({len(all_ch)})"
320
+ assert len(current) <= len(including_left), (
321
+ f"current only ({len(current)}) should be ≤ including left ({len(including_left)})"
322
322
  )
323
323
 
324
- def test_active_only_false_returns_list(self, ctx, users):
325
- """active_only=False should still return a list."""
326
- result = users.get_channels(ctx.active_member_id, active_only=False)
324
+ def test_including_left_returns_list(self, ctx, users):
325
+ """include_channels_user_left=True should still return a list."""
326
+ result = users.get_channels(ctx.active_member_id, include_channels_user_left=True)
327
327
  assert isinstance(result, list)
328
328
  _pause()
329
329
 
330
+ def test_default_excludes_left_channels(self, ctx, users):
331
+ """Default call must not return channels the user has left.
332
+
333
+ Guards the regression where include_historical was never sent: the flag
334
+ controls the request, so a default call should contain no date_left != 0.
335
+ """
336
+ result = users.get_channels(ctx.active_member_id)
337
+ left = [c for c in result if c.get("date_left", 0) != 0]
338
+ assert not left, f"Default call returned {len(left)} channel(s) the user left"
339
+ _pause()
340
+
330
341
 
331
342
  # ═══════════════════════════════════════════════════════════════════════════
332
343
  # 6. refresh
@@ -0,0 +1,31 @@
1
+ """
2
+ Shared pytest configuration for the whole test suite.
3
+
4
+ The suite has two tiers:
5
+
6
+ Offline tier - tests/UnitTests/* (fakes only, no network, no credentials)
7
+ Live tier - tests/**/*_live.py (real Azure Key Vault tokens + real Slack API calls)
8
+
9
+ Every live test file follows the '*_live.py' naming convention, so instead of
10
+ decorating hundreds of tests by hand, this hook marks them automatically at
11
+ collection time.
12
+
13
+ pytest.ini deselects the 'live' marker by default, which makes a bare 'pytest'
14
+ run safe: it collects only the offline tier. The live tier is opt-in, since some
15
+ of it (notably tests/SCIM) performs mutating operations against real users.
16
+
17
+ To run the live tier explicitly:
18
+
19
+ pytest -m live tests/Users/test_users_read_live.py
20
+ python tests/Users/run_all_users_live_tests.py
21
+ """
22
+
23
+ import pytest
24
+
25
+
26
+ def pytest_collection_modifyitems(config, items):
27
+ """Automatically apply the 'live' marker to tests in *_live.py files."""
28
+ for item in items:
29
+ # item.path is the file the test was collected from.
30
+ if item.path is not None and item.path.name.endswith("_live.py"):
31
+ item.add_marker(pytest.mark.live)
@@ -1,5 +0,0 @@
1
- # Copilot Instructions
2
-
3
- ## Project Guidelines
4
- - Follow Unix philosophy: build small, composable functions where higher-level methods leverage lower-level primitives. Don't mix concerns (e.g., don't embed identifier resolution inside a SCIM CRUD method). Keep signatures consistent across sibling methods.
5
- - Prefer consistency with the `Users.is_active`/`is_active_scim` pattern when structuring related methods (separate explicit SCIM method from higher-level orchestration).
@@ -1,3 +0,0 @@
1
- [pytest]
2
- pythonpath =
3
- src
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '0.1.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
33
-
34
- __commit_id__ = commit_id = 'g74ff4e47f'
File without changes
File without changes
File without changes