scim2-server 0.8.0__tar.gz → 0.8.2__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 (31) hide show
  1. {scim2_server-0.8.0 → scim2_server-0.8.2}/PKG-INFO +4 -1
  2. {scim2_server-0.8.0 → scim2_server-0.8.2}/README.md +2 -0
  3. {scim2_server-0.8.0 → scim2_server-0.8.2}/pyproject.toml +8 -1
  4. {scim2_server-0.8.0 → scim2_server-0.8.2}/pyproject.toml.orig +5 -1
  5. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/testing.py +214 -7
  6. {scim2_server-0.8.0 → scim2_server-0.8.2}/LICENSE +0 -0
  7. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/__init__.py +0 -0
  8. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/applications/__init__.py +0 -0
  9. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/applications/asgi.py +0 -0
  10. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/applications/base.py +0 -0
  11. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/applications/wsgi.py +0 -0
  12. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/bulk.py +0 -0
  13. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/conditions.py +0 -0
  14. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/cursor.py +0 -0
  15. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/errors.py +0 -0
  16. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/handler.py +0 -0
  17. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/memory.py +0 -0
  18. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/py.typed +0 -0
  19. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/requests.py +0 -0
  20. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/resources/default-resource-types.json +0 -0
  21. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/resources/default-schemas.json +0 -0
  22. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/resources/default-service-provider-config.json +0 -0
  23. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/responses.py +0 -0
  24. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/routing.py +0 -0
  25. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/service.py +0 -0
  26. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/storage.py +0 -0
  27. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/testserver/__init__.py +0 -0
  28. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/testserver/application.py +0 -0
  29. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/testserver/cli.py +0 -0
  30. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/testserver/precis.py +0 -0
  31. {scim2_server-0.8.0 → scim2_server-0.8.2}/scim2_server/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: SCIM server core for any storage and web framework
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -212,6 +212,7 @@ Classifier: Programming Language :: Python :: 3.11
212
212
  Classifier: Programming Language :: Python :: 3.12
213
213
  Classifier: Programming Language :: Python :: 3.13
214
214
  Classifier: Programming Language :: Python :: 3.14
215
+ Classifier: Programming Language :: Python :: 3.15
215
216
  Classifier: Programming Language :: Python :: Implementation :: CPython
216
217
  Classifier: License :: OSI Approved :: Apache Software License
217
218
  Classifier: Environment :: Web Environment
@@ -229,6 +230,8 @@ Provides-Extra: precis
229
230
  Provides-Extra: testing
230
231
  Description-Content-Type: text/markdown
231
232
 
233
+ <img alt="python-scim logo" src="https://raw.githubusercontent.com/python-scim/.github/refs/heads/main/python-scim.svg" height="72" align="right">
234
+
232
235
  # scim2-server
233
236
 
234
237
  A Python library that serves the SCIM protocol over any storage, built upon
@@ -1,3 +1,5 @@
1
+ <img alt="python-scim logo" src="https://raw.githubusercontent.com/python-scim/.github/refs/heads/main/python-scim.svg" height="72" align="right">
2
+
1
3
  # scim2-server
2
4
 
3
5
  A Python library that serves the SCIM protocol over any storage, built upon
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Programming Language :: Python :: 3.13",
23
23
  "Programming Language :: Python :: 3.14",
24
+ "Programming Language :: Python :: 3.15",
24
25
  "Programming Language :: Python :: Implementation :: CPython",
25
26
  "License :: OSI Approved :: Apache Software License",
26
27
  "Environment :: Web Environment",
@@ -79,6 +80,7 @@ doc = [
79
80
  ]
80
81
 
81
82
  [tool.uv]
83
+ required-version = ">=0.12.13"
82
84
  exclude-newer = "14 days"
83
85
 
84
86
  [tool.uv.exclude-newer-package]
@@ -166,6 +168,7 @@ env_list = [
166
168
  "py312",
167
169
  "py313",
168
170
  "py314",
171
+ "py315",
169
172
  "minversions",
170
173
  "doc",
171
174
  "coverage",
@@ -196,6 +199,10 @@ uv_resolution = "lowest-direct"
196
199
 
197
200
  [tool.tox.env.doc]
198
201
  dependency_groups = ["doc"]
202
+ extras = [
203
+ "cursor",
204
+ "testing",
205
+ ]
199
206
  commands = [[
200
207
  "sphinx-build",
201
208
  "-W",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -20,6 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.12",
21
21
  "Programming Language :: Python :: 3.13",
22
22
  "Programming Language :: Python :: 3.14",
23
+ "Programming Language :: Python :: 3.15",
23
24
  "Programming Language :: Python :: Implementation :: CPython",
24
25
  "License :: OSI Approved :: Apache Software License",
25
26
  "Environment :: Web Environment",
@@ -76,6 +77,7 @@ doc = [
76
77
  ]
77
78
 
78
79
  [tool.uv]
80
+ required-version = ">=0.12.13"
79
81
  exclude-newer = "14 days"
80
82
  exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-server = false, scim2-cli = false, scim2-tester = false, pytest-scim2-server = false }
81
83
 
@@ -155,6 +157,7 @@ env_list = [
155
157
  "py312",
156
158
  "py313",
157
159
  "py314",
160
+ "py315",
158
161
  "minversions",
159
162
  "doc",
160
163
  "coverage",
@@ -179,6 +182,7 @@ uv_resolution = "lowest-direct"
179
182
 
180
183
  [tool.tox.env.doc]
181
184
  dependency_groups = ["doc"]
185
+ extras = ["cursor", "testing"]
182
186
  commands = [
183
187
  ["sphinx-build", "-W", "--keep-going", "--builder", "html", "doc", "build/sphinx/html"],
184
188
  ]
@@ -29,12 +29,16 @@ from typing import Union
29
29
  from typing import cast
30
30
 
31
31
  import pytest
32
+ from pydantic import ValidationError
32
33
  from scim2_models import Context
34
+ from scim2_models import InvalidFilterException
35
+ from scim2_models import InvalidPathException
33
36
  from scim2_models import NotFoundException
34
37
  from scim2_models import PreconditionFailedException
35
38
  from scim2_models import Resource
36
39
  from scim2_models import ResourceType
37
40
  from scim2_models import ResponseParameters
41
+ from scim2_models import ScimFilter
38
42
  from scim2_models import ScimProvider
39
43
  from scim2_models import SearchRequest
40
44
  from scim2_models import UniquenessException
@@ -50,6 +54,100 @@ from scim2_server.utils import parametrize
50
54
 
51
55
  T = TypeVar("T")
52
56
 
57
+ ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
58
+
59
+ SEARCH_USERS: list[dict[str, Any]] = [
60
+ {
61
+ "userName": "alice",
62
+ "displayName": "Alice",
63
+ "title": "Boss",
64
+ "name": {"givenName": "Alice", "familyName": "Liddell"},
65
+ "active": True,
66
+ "emails": [
67
+ {"value": "alice@home.example", "type": "home"},
68
+ {"value": "alice@work.example", "type": "work", "primary": True},
69
+ ],
70
+ ENTERPRISE: {"employeeNumber": "100"},
71
+ },
72
+ {
73
+ "userName": "Bob",
74
+ "active": False,
75
+ "emails": [{"value": "bob@work.example", "type": "work"}],
76
+ },
77
+ {
78
+ "userName": "carol",
79
+ "title": "",
80
+ "name": {"familyName": "smith"},
81
+ ENTERPRISE: {"employeeNumber": "2"},
82
+ },
83
+ {
84
+ "userName": "dave_%",
85
+ "title": "50% off",
86
+ "emails": [{"value": "dave@home.example"}],
87
+ },
88
+ ]
89
+ """The users the filter and sort tests search, with the case, missing values and
90
+ ``LIKE`` wildcards a translation into a query tends to get wrong."""
91
+
92
+ SEARCH_FILTERS = [
93
+ 'userName eq "ALICE"',
94
+ 'userName ne "alice"',
95
+ 'userName sw "dave_"',
96
+ 'userName ew "_%"',
97
+ 'userName co "a"',
98
+ 'userName gt "b"',
99
+ 'userName le "carol"',
100
+ 'title eq "boss"',
101
+ 'title ne "Boss"',
102
+ 'not (title eq "Boss")',
103
+ "title eq null",
104
+ "title ne null",
105
+ "title gt null",
106
+ "title pr",
107
+ 'title co "%"',
108
+ 'title lt "c"',
109
+ "displayName pr",
110
+ "name pr",
111
+ "name.givenName pr",
112
+ 'name.familyName eq "SMITH"',
113
+ "active eq true",
114
+ "active eq false",
115
+ "not (active eq true)",
116
+ "active ne true",
117
+ 'meta.created co "20"',
118
+ "active pr",
119
+ "emails pr",
120
+ 'emails eq "alice@work.example"',
121
+ 'emails ne "alice@work.example"',
122
+ 'emails co "WORK"',
123
+ 'emails.type eq "work"',
124
+ "emails.type pr",
125
+ "emails.primary eq true",
126
+ 'emails[type eq "work" and value co "alice"]',
127
+ 'emails[type eq "home" and primary eq true]',
128
+ 'emails[value ne "alice@work.example"]',
129
+ 'emails[not (type eq "work")]',
130
+ "emails[type pr]",
131
+ 'meta.created gt "2000-01-01T00:00:00Z"',
132
+ 'meta.lastModified lt "2000-01-01T00:00:00Z"',
133
+ f'{ENTERPRISE}:employeeNumber eq "100"',
134
+ f'{ENTERPRISE}:employeeNumber gt "10"',
135
+ 'title eq "Boss" or userName eq "Bob"',
136
+ 'title eq "Boss" and active eq false',
137
+ ]
138
+
139
+ SEARCH_SORTS = [
140
+ "userName",
141
+ "title",
142
+ "displayName",
143
+ "name.familyName",
144
+ "active",
145
+ "emails",
146
+ "emails.type",
147
+ "meta.created",
148
+ f"{ENTERPRISE}:employeeNumber",
149
+ ]
150
+
53
151
 
54
152
  class ScimStorageContract:
55
153
  """The rules every :class:`~scim2_server.storage.ScimStorage` follows.
@@ -135,6 +233,18 @@ class ScimStorageContract:
135
233
  for user_name in user_names
136
234
  ]
137
235
 
236
+ def create_group(
237
+ self,
238
+ storage: ScimStorage,
239
+ group_type: ResourceType,
240
+ group_model: Any,
241
+ member: Any,
242
+ ) -> Any:
243
+ return storage.create(
244
+ group_type,
245
+ group_model(display_name="admins", members=[{"value": member.id}]),
246
+ )
247
+
138
248
  def test_create_fills_the_identifier_and_the_meta(
139
249
  self, storage: Any, user_type: ResourceType, user_model: Any
140
250
  ) -> None:
@@ -400,8 +510,8 @@ class ScimStorageContract:
400
510
  group_model: Any,
401
511
  ) -> None:
402
512
  """A search on a resource type returns its resources only, and counts them."""
403
- self.create_users(storage, user_type, user_model, "alice", "bob")
404
- storage.create(group_type, group_model(display_name="admins"))
513
+ users = self.create_users(storage, user_type, user_model, "alice", "bob")
514
+ self.create_group(storage, group_type, group_model, users[0])
405
515
 
406
516
  page = self.search(storage, [user_type], self.search_request([user_model]))
407
517
 
@@ -497,8 +607,8 @@ class ScimStorageContract:
497
607
  ) -> None:
498
608
  """A search on several resource types returns their resources as one collection."""
499
609
  self.require(self.supports_root_search, "searching at the root")
500
- self.create_users(storage, user_type, user_model, "alice")
501
- storage.create(group_type, group_model(display_name="admins"))
610
+ (user,) = self.create_users(storage, user_type, user_model, "alice")
611
+ self.create_group(storage, group_type, group_model, user)
502
612
 
503
613
  page = self.search(
504
614
  storage,
@@ -524,8 +634,8 @@ class ScimStorageContract:
524
634
  """An attribute a resource type does not declare matches none of its resources."""
525
635
  self.require(self.supports_root_search, "searching at the root")
526
636
  self.require(self.announces(provider, "filter"), "filtering")
527
- self.create_users(storage, user_type, user_model, "alice")
528
- storage.create(group_type, group_model(display_name="admins"))
637
+ (user,) = self.create_users(storage, user_type, user_model, "alice")
638
+ self.create_group(storage, group_type, group_model, user)
529
639
 
530
640
  page = self.search(
531
641
  storage,
@@ -536,6 +646,94 @@ class ScimStorageContract:
536
646
  assert page.total == 1
537
647
  assert page.resources[0].user_name == "alice"
538
648
 
649
+ def search_users(self) -> list[dict[str, Any]]:
650
+ """Return the users the filter and sort tests search.
651
+
652
+ Override it to leave out the attributes the storage does not store.
653
+ """
654
+ return SEARCH_USERS
655
+
656
+ def search_request_or_skip(
657
+ self, models: list[type[Resource[Any]]], **parameters: Any
658
+ ) -> SearchRequest[Any]:
659
+ """Build a search request, and skip the current test when the models do not declare an attribute of the filter or the sort."""
660
+ try:
661
+ return self.search_request(models, **parameters)
662
+ except ValidationError as exception:
663
+ errors = exception.errors()
664
+ if any(
665
+ error["type"] not in ("scim_invalidFilter", "scim_invalidPath")
666
+ for error in errors
667
+ ):
668
+ raise
669
+ pytest.skip(f"The models refuse the search: {errors[0]['msg']}")
670
+
671
+ def search_or_skip(
672
+ self,
673
+ storage: Any,
674
+ resource_types: list[ResourceType],
675
+ search_request: SearchRequest[Any],
676
+ ) -> Any:
677
+ """Search a storage, and skip the current test when the storage refuses the filter or the sort."""
678
+ try:
679
+ return self.search(storage, resource_types, search_request)
680
+ except (InvalidFilterException, InvalidPathException) as exception:
681
+ pytest.skip(f"The storage refuses the search: {exception.detail}")
682
+
683
+ @pytest.mark.parametrize("scim_filter", SEARCH_FILTERS)
684
+ def test_search_filters_as_the_filter_evaluator(
685
+ self,
686
+ storage: Any,
687
+ provider: ScimProvider,
688
+ user_type: ResourceType,
689
+ user_model: Any,
690
+ scim_filter: str,
691
+ ) -> None:
692
+ """A filter keeps the resources that ScimFilter.match keeps, and the total counts them."""
693
+ self.require(self.announces(provider, "filter"), "filtering")
694
+ for user in self.search_users():
695
+ storage.create(user_type, user_model.model_validate(user))
696
+ stored = self.search(storage, [user_type], self.search_request([user_model]))
697
+ search_request = self.search_request_or_skip([user_model], filter=scim_filter)
698
+
699
+ page = self.search_or_skip(storage, [user_type], search_request)
700
+
701
+ evaluator = cast(ScimFilter[Any], search_request.filter)
702
+ expected = [r.id for r in stored.resources if evaluator.match(r)]
703
+ assert page.total == len(expected)
704
+ assert sorted(r.id for r in page.resources) == sorted(expected)
705
+
706
+ @pytest.mark.parametrize("sort_order", ["ascending", "descending"])
707
+ @pytest.mark.parametrize("sort_by", SEARCH_SORTS)
708
+ def test_search_sorts_as_search_request_sort(
709
+ self,
710
+ storage: Any,
711
+ provider: ScimProvider,
712
+ user_type: ResourceType,
713
+ user_model: Any,
714
+ sort_by: str,
715
+ sort_order: str,
716
+ ) -> None:
717
+ """A sorted search orders the resources as SearchRequest.sort does.
718
+
719
+ Resources with the same sort value may come in any order.
720
+ """
721
+ self.require(self.announces(provider, "sort"), "sorting")
722
+ for user in self.search_users():
723
+ storage.create(user_type, user_model.model_validate(user))
724
+ stored = self.search(storage, [user_type], self.search_request([user_model]))
725
+ search_request = self.search_request_or_skip(
726
+ [user_model], sort_by=sort_by, sort_order=sort_order
727
+ )
728
+
729
+ page = self.search_or_skip(storage, [user_type], search_request)
730
+
731
+ expected = search_request.sort(stored.resources)
732
+ assert sorted(r.id for r in page.resources) == sorted(r.id for r in expected)
733
+ assert [search_request.sort_value(r) for r in page.resources] == [
734
+ search_request.sort_value(r) for r in expected
735
+ ]
736
+
539
737
  def walk(
540
738
  self,
541
739
  storage: Any,
@@ -573,6 +771,7 @@ class ScimStorageContract:
573
771
  self, storage: Any, user_type: ResourceType, user_model: Any
574
772
  ) -> None:
575
773
  """Following the next cursors returns every resource once, count by count."""
774
+ self.require(storage.supports_cursors, "cursor pagination")
576
775
  names = ["alice", "bob", "carol", "dave", "erin"]
577
776
  self.create_users(storage, user_type, user_model, *names)
578
777
 
@@ -593,6 +792,7 @@ class ScimStorageContract:
593
792
  user_model: Any,
594
793
  ) -> None:
595
794
  """The pages of a sorted search follow each other in the sort order."""
795
+ self.require(storage.supports_cursors, "cursor pagination")
596
796
  self.require(self.announces(provider, "sort"), "sorting")
597
797
  self.create_users(
598
798
  storage, user_type, user_model, "dave", "alice", "carol", "bob"
@@ -625,7 +825,9 @@ class ScimStorageContract:
625
825
  user_model: Any,
626
826
  ) -> None:
627
827
  """Resources with the same sort value, or without one, are returned once, even across pages."""
828
+ self.require(storage.supports_cursors, "cursor pagination")
628
829
  self.require(self.announces(provider, "sort"), "sorting")
830
+ self.require("nick_name" in user_model.model_fields, "nickName")
629
831
  for user_name, nick_name in [
630
832
  ("alice", "twin"),
631
833
  ("bob", None),
@@ -651,6 +853,7 @@ class ScimStorageContract:
651
853
  self, storage: Any, user_type: ResourceType, user_model: Any
652
854
  ) -> None:
653
855
  """Following the previous cursors from the last page returns the same pages, back to the first."""
856
+ self.require(storage.supports_cursors, "cursor pagination")
654
857
  self.create_users(
655
858
  storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin"
656
859
  )
@@ -669,6 +872,7 @@ class ScimStorageContract:
669
872
  self, storage: Any, user_type: ResourceType, user_model: Any, deleted: str
670
873
  ) -> None:
671
874
  """Deleting a resource already returned does not skip nor repeat the next ones."""
875
+ self.require(storage.supports_cursors, "cursor pagination")
672
876
  self.create_users(
673
877
  storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin"
674
878
  )
@@ -690,6 +894,7 @@ class ScimStorageContract:
690
894
  self, storage: Any, user_type: ResourceType, user_model: Any
691
895
  ) -> None:
692
896
  """Creating a resource between two pages does not skip nor repeat the existing ones."""
897
+ self.require(storage.supports_cursors, "cursor pagination")
693
898
  self.create_users(
694
899
  storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin"
695
900
  )
@@ -711,6 +916,7 @@ class ScimStorageContract:
711
916
  self, storage: Any, user_type: ResourceType, user_model: Any
712
917
  ) -> None:
713
918
  """Deleting the first resource of a page does not change the page before it."""
919
+ self.require(storage.supports_cursors, "cursor pagination")
714
920
  self.create_users(
715
921
  storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin"
716
922
  )
@@ -750,9 +956,10 @@ class ScimStorageContract:
750
956
  group_model: Any,
751
957
  ) -> None:
752
958
  """A cursor pages the resources of several resource types as one collection."""
959
+ self.require(storage.supports_cursors, "cursor pagination")
753
960
  self.require(self.supports_root_search, "searching at the root")
754
961
  users = self.create_users(storage, user_type, user_model, "alice", "bob")
755
- group = storage.create(group_type, group_model(display_name="admins"))
962
+ group = self.create_group(storage, group_type, group_model, users[0])
756
963
 
757
964
  pages = self.walk(
758
965
  storage,
File without changes