Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion src/lettermint/_async/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down
17 changes: 16 additions & 1 deletion src/lettermint/_sync/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down
14 changes: 4 additions & 10 deletions tests/test_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand Down
82 changes: 81 additions & 1 deletion tests/test_team.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")
Loading