From dd0b13ee892e88ef1ab6491117879d90b35f3cd2 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Tue, 20 Oct 2026 22:44:10 +0200 Subject: [PATCH] feat: add team member removal and a project filter for the member list - New team.members.remove(user_id) removes a non-Owner member from the team and returns a MessageResponse. It uses the team token and sends no Idempotency-Key header. A 409 raises ConflictError with the error code. - team.members.list() and team.members.iterate() pass the new filter[project_id] query, which returns only the members who can access that project: members with access to all projects and members selected for it. - Remove the pending list, so the coverage test checks every operation again. - Document both in the README and test the request, the token, the user ID checks, the 409 and the filter on every page. Co-Authored-By: Claude --- README.md | 9 +++- src/lettermint/_async/resources.py | 17 ++++++- src/lettermint/_sync/resources.py | 17 ++++++- tests/test_surface.py | 14 ++--- tests/test_team.py | 82 +++++++++++++++++++++++++++++- 5 files changed, 125 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31ff537..ea5184d 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ print(resent["message_id"]) # the new message; it links back through resent_fro | `stats` | `retrieve` | | `suppressions` | `list`, `iterate`, `create`, `delete` | | `team` | `retrieve`, `update`, `usage`, `roles`, `retrieve_role`, `create_role`, `update_role`, `delete_role`, `duplicate_role` | -| `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` | +| `team.members` | `list`, `iterate`, `retrieve`, `update_assignment`, `remove` | | `team.invites` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` | | `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` | | `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)`, `retry(webhook_id, delivery_id)` | @@ -308,6 +308,13 @@ invite = lettermint.team.invites.create({ lettermint.team.invites.revoke(invite["id"]) ``` +`team.members.remove()` removes a member from the team; the Owner cannot be removed. `team.members.list()` and `team.members.iterate()` take a `filter[project_id]` that returns only the members who can access that project: members with access to all projects and members selected for it. + +```python +for member in lettermint.team.members.iterate({"filter": {"project_id": "project-id"}}): + print(member["email"]) +``` + `team.create_role()` creates a custom role; it needs a plan with custom roles. The permissions must include the permissions they depend on, and role names are unique. `team.update_role()` replaces a custom role's name, description and permissions, and `team.duplicate_role()` copies a role under a new name (without a `description`, the copy keeps the source role's). Built-in roles cannot be changed or deleted, and the Owner role cannot be duplicated. While a role is still assigned to members, invites or SCIM groups, `team.delete_role()` needs a `replacement_role_id` that receives those assignments: ```python diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py index 8443731..4256707 100644 --- a/src/lettermint/_async/resources.py +++ b/src/lettermint/_async/resources.py @@ -768,7 +768,10 @@ class AsyncTeamMembers(AsyncResource): async def list( self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None ) -> ListTeamMembersResponse: - """Lists team members, one page at a time.""" + """Lists team members, one page at a time. + + ``{"filter": {"project_id": ...}}`` returns only the members who can access that project. + """ return cast( ListTeamMembersResponse, await self._request( @@ -810,6 +813,18 @@ async def update_assignment( ), ) + async def remove(self, user_id: str, *, timeout: float | None = None) -> MessageResponse: + """Removes a member from the team. The Owner cannot be removed.""" + return cast( + MessageResponse, + await self._request( + "DELETE /team/members/{userId}", + "team.members.remove", + path={"userId": user_id}, + timeout=timeout, + ), + ) + class AsyncTeamInvites(AsyncResource): """Pending invitations to the team. Team token.""" diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py index 8013243..c869b2f 100644 --- a/src/lettermint/_sync/resources.py +++ b/src/lettermint/_sync/resources.py @@ -769,7 +769,10 @@ class TeamMembers(Resource): def list( self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None ) -> ListTeamMembersResponse: - """Lists team members, one page at a time.""" + """Lists team members, one page at a time. + + ``{"filter": {"project_id": ...}}`` returns only the members who can access that project. + """ return cast( ListTeamMembersResponse, self._request( @@ -811,6 +814,18 @@ def update_assignment( ), ) + def remove(self, user_id: str, *, timeout: float | None = None) -> MessageResponse: + """Removes a member from the team. The Owner cannot be removed.""" + return cast( + MessageResponse, + self._request( + "DELETE /team/members/{userId}", + "team.members.remove", + path={"userId": user_id}, + timeout=timeout, + ), + ) + class TeamInvites(Resource): """Pending invitations to the team. Team token.""" diff --git a/tests/test_surface.py b/tests/test_surface.py index a1c9cf0..67f3cce 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -137,6 +137,7 @@ ("u1", {"role": "admin"}), "PUT /team/members/{userId}/assignment", ), + ("team.members.remove", ("u1",), "DELETE /team/members/{userId}"), ("team.invites.list", (), "GET /team/invites"), ("team.invites.iterate", (), "GET /team/invites"), ("team.invites.retrieve", ("i1",), "GET /team/invites/{inviteId}"), @@ -177,11 +178,6 @@ ), ] -# Operations without a method yet: added by the next PR in this stack. -PENDING = { - "DELETE /team/members/{userId}", -} - LAYOUT = { "domains": [ "create", @@ -232,7 +228,7 @@ "usage", ], "team.invites": ["create", "iterate", "list", "retrieve", "revoke", "update"], - "team.members": ["iterate", "list", "retrieve", "update_assignment"], + "team.members": ["iterate", "list", "remove", "retrieve", "update_assignment"], "webhooks": [ "create", "delete", @@ -293,8 +289,7 @@ def test_every_operation_is_reachable_from_the_sync_client( assert list(result) == [{} if dotted == "analytics_pages" else {"id": "x"}] assert [operation_of(r) for r in api.requests[before:]] == [key], dotted reached.add(key) - assert set(OPERATIONS) >= PENDING - assert reached == set(OPERATIONS) - PENDING + assert reached == set(OPERATIONS) @pytest.mark.anyio @@ -314,8 +309,7 @@ async def test_every_operation_is_reachable_from_the_async_client( await result assert [operation_of(r) for r in api.requests[before:]] == [key], dotted reached.add(key) - assert set(OPERATIONS) >= PENDING - assert reached == set(OPERATIONS) - PENDING + assert reached == set(OPERATIONS) def _public_methods(obj: Any) -> list[str]: diff --git a/tests/test_team.py b/tests/test_team.py index 5024090..b0fdf1c 100644 --- a/tests/test_team.py +++ b/tests/test_team.py @@ -1,4 +1,4 @@ -"""``team.invites`` and the team role methods: the requests, the token, the bodies and the errors.""" +"""``team.invites``, the team role methods and ``team.members.remove`` with the project filter.""" from __future__ import annotations @@ -344,3 +344,83 @@ async def test_async_roles(api: MockAPI, aclient: AsyncLettermint) -> None: api.json(201, ROLE) await aclient.team.duplicate_role("r1", {"name": "Copy"}) assert_team_request(api, "POST", "/team/roles/r1/duplicate") + + +MEMBER = { + "id": "u1", + "name": "Jane", + "email": "jane@acme.test", + "role": {"id": "r1", "name": "Member"}, + "project_access": {"scope": "all", "projects": []}, + "joined_at": "2026-10-01T09:00:00Z", +} + + +def test_remove_member(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"message": "Member removed."}) + result: MessageResponse = client.team.members.remove("u1") + assert result == {"message": "Member removed."} + assert_team_request(api, "DELETE", "/team/members/u1") + assert api.last.content == b"" + + +def test_remove_member_checks_and_encodes_the_id(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"message": "Member removed."}) + for invalid in ("", ".."): + with pytest.raises(LettermintConfigError, match=r"^team\.members\.remove: userId"): + client.team.members.remove(invalid) + assert api.requests == [] + client.team.members.remove("a/b") + assert api.last.url.raw_path == b"/v1/team/members/a%2Fb" + + +def test_remove_member_needs_the_team_token(api: MockAPI) -> None: + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises(LettermintConfigError, match=r"^team\.members\.remove needs team_token"): + sending_only.team.members.remove("u1") + assert api.requests == [] + + +def test_removing_the_owner_is_a_conflict_error(api: MockAPI, client: Lettermint) -> None: + body = {"error": {"code": "owner_protected", "message": "The Owner cannot be removed."}} + api.json(409, body) + with pytest.raises(ConflictError) as caught: + client.team.members.remove("u1") + assert (caught.value.status, caught.value.code, caught.value.body) == ( + 409, + "owner_protected", + body, + ) + + +def test_members_can_be_filtered_by_project(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"data": [MEMBER], "next_cursor": None}) + page = client.team.members.list({"filter": {"project_id": "p1"}, "page": {"size": 5}}) + assert page["data"] == [MEMBER] + assert api.last.url.params["filter[project_id]"] == "p1" + assert api.last.url.params["page[size]"] == "5" + assert list(client.team.members.iterate({"filter": {"project_id": "p1"}})) == [MEMBER] + assert_team_request(api, "GET", "/team/members?filter%5Bproject_id%5D=p1") + + +def test_member_filter_follows_every_page(api: MockAPI, client: Lettermint) -> None: + pages = { + None: {"data": [MEMBER], "next_cursor": "c2"}, + "c2": {"data": [{**MEMBER, "id": "u2"}], "next_cursor": None}, + } + api.respond( + lambda request: httpx.Response(200, json=pages[request.url.params.get("page[cursor]")]) + ) + members = list(client.team.members.iterate({"filter": {"project_id": "p1"}})) + assert [member["id"] for member in members] == ["u1", "u2"] + assert all(r.url.params["filter[project_id]"] == "p1" for r in api.requests) + + +@pytest.mark.anyio +async def test_async_remove_member(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(200, {"message": "Member removed."}) + assert await aclient.team.members.remove("u1") == {"message": "Member removed."} + assert_team_request(api, "DELETE", "/team/members/u1") + api.json(200, {"data": [MEMBER], "next_cursor": None}) + await aclient.team.members.list({"filter": {"project_id": "p1"}}) + assert_team_request(api, "GET", "/team/members?filter%5Bproject_id%5D=p1")