Skip to content

Commit ddd3401

Browse files
committed
feat(client): add members related endpoints
1 parent 391408f commit ddd3401

File tree

6 files changed

+532
-3
lines changed

6 files changed

+532
-3
lines changed

pygitguardian/client.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
)
2727
from .models import (
2828
APITokensResponse,
29+
CursorPaginatedResponse,
30+
DeleteMember,
2931
Detail,
3032
Document,
3133
DocumentSchema,
@@ -34,13 +36,16 @@
3436
HoneytokenWithContextResponse,
3537
JWTResponse,
3638
JWTService,
39+
Member,
40+
MembersParameters,
3741
MultiScanResult,
3842
QuotaResponse,
3943
RemediationMessages,
4044
ScanResult,
4145
SecretIncident,
4246
SecretScanPreferences,
4347
ServerMetadata,
48+
UpdateMember,
4449
)
4550
from .sca_models import (
4651
ComputeSCAFilesResult,
@@ -335,6 +340,40 @@ def post(
335340
**kwargs,
336341
)
337342

343+
def patch(
344+
self,
345+
endpoint: str,
346+
data: Union[Dict[str, Any], List[Dict[str, Any]], None] = None,
347+
version: str = DEFAULT_API_VERSION,
348+
extra_headers: Optional[Dict[str, str]] = None,
349+
**kwargs: Any,
350+
) -> Response:
351+
return self.request(
352+
"patch",
353+
endpoint=endpoint,
354+
json=data,
355+
version=version,
356+
extra_headers=extra_headers,
357+
**kwargs,
358+
)
359+
360+
def delete(
361+
self,
362+
endpoint: str,
363+
data: Union[Dict[str, Any], List[Dict[str, Any]], None] = None,
364+
version: str = DEFAULT_API_VERSION,
365+
extra_headers: Optional[Dict[str, str]] = None,
366+
**kwargs: Any,
367+
) -> Response:
368+
return self.request(
369+
"delete",
370+
endpoint=endpoint,
371+
json=data,
372+
version=version,
373+
extra_headers=extra_headers,
374+
**kwargs,
375+
)
376+
338377
def health_check(self) -> HealthCheckResponse:
339378
"""
340379
health_check handles the /health endpoint of the API
@@ -859,3 +898,84 @@ def scan_diff(
859898
result = load_detail(response)
860899
result.status_code = response.status_code
861900
return result
901+
902+
def list_members(
903+
self,
904+
query_parameters: Optional[MembersParameters] = None,
905+
extra_headers: Optional[Dict[str, str]] = None,
906+
) -> Union[Detail, CursorPaginatedResponse[Member]]:
907+
908+
response = self.get(
909+
endpoint="members",
910+
data=query_parameters.to_dict() if query_parameters else {},
911+
extra_headers=extra_headers,
912+
)
913+
914+
obj: Union[Detail, CursorPaginatedResponse[Member]]
915+
if is_ok(response):
916+
obj = CursorPaginatedResponse[Member].from_response(response, Member)
917+
else:
918+
obj = load_detail(response)
919+
920+
obj.status_code = response.status_code
921+
return obj
922+
923+
def get_member(
924+
self,
925+
member_id: int,
926+
extra_headers: Optional[Dict[str, str]] = None,
927+
) -> Union[Detail, Member]:
928+
response = self.get(
929+
endpoint=f"members/{member_id}",
930+
extra_headers=extra_headers,
931+
)
932+
obj: Union[Detail, Member]
933+
if is_ok(response):
934+
obj = Member.from_dict(response.json())
935+
else:
936+
obj = load_detail(response)
937+
938+
obj.status_code = response.status_code
939+
return obj
940+
941+
def update_member(
942+
self,
943+
payload: UpdateMember,
944+
extra_headers: Optional[Dict[str, str]] = None,
945+
) -> Union[Detail, Member]:
946+
947+
member_id = payload.id
948+
data = UpdateMember.to_dict(payload)
949+
del data["id"]
950+
951+
response = self.patch(
952+
f"members/{member_id}", data=data, extra_headers=extra_headers
953+
)
954+
obj: Union[Detail, Member]
955+
if is_ok(response):
956+
obj = Member.from_dict(response.json())
957+
print("Member : ", obj)
958+
else:
959+
obj = load_detail(response)
960+
961+
obj.status_code = response.status_code
962+
return obj
963+
964+
def delete_member(
965+
self,
966+
member: DeleteMember,
967+
extra_headers: Optional[Dict[str, str]] = None,
968+
) -> Union[Detail, int]:
969+
member_id = member.id
970+
data = member.to_dict()
971+
del data["id"]
972+
973+
response = self.delete(
974+
f"members/{member_id}", params=data, extra_headers=extra_headers
975+
)
976+
977+
# We bypass `is_ok` because the response content type is none
978+
if response.status_code == 204:
979+
return 204
980+
981+
return load_detail(response)

pygitguardian/models.py

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,19 @@
44
from dataclasses import dataclass, field
55
from datetime import date, datetime
66
from enum import Enum
7-
from typing import Any, ClassVar, Dict, List, Literal, Optional, Type, cast
7+
from typing import (
8+
TYPE_CHECKING,
9+
Any,
10+
ClassVar,
11+
Dict,
12+
Generic,
13+
List,
14+
Literal,
15+
Optional,
16+
Type,
17+
TypeVar,
18+
cast,
19+
)
820
from uuid import UUID
921

1022
import marshmallow_dataclass
@@ -13,6 +25,7 @@
1325
Schema,
1426
ValidationError,
1527
fields,
28+
post_dump,
1629
post_load,
1730
pre_load,
1831
validate,
@@ -28,6 +41,10 @@
2841
)
2942

3043

44+
if TYPE_CHECKING:
45+
import requests
46+
47+
3148
class ToDictMixin:
3249
"""
3350
Provides a type-safe `to_dict()` method for classes using Marshmallow
@@ -54,8 +71,8 @@ class FromDictMixin:
5471
SCHEMA: ClassVar[Schema]
5572

5673
@classmethod
57-
def from_dict(cls, dct: Dict[str, Any]) -> Self:
58-
return cast(Self, cls.SCHEMA.load(dct))
74+
def from_dict(cls, dct: Dict[str, Any], many: Optional[bool] = None) -> Self:
75+
return cast(Self, cls.SCHEMA.load(dct, many=many))
5976

6077

6178
class BaseSchema(Schema):
@@ -1077,3 +1094,146 @@ def __repr__(self) -> str:
10771094
marshmallow_dataclass.class_schema(SecretIncident, base_schema=BaseSchema),
10781095
)
10791096
SecretIncident.SCHEMA = SecretIncidentSchema()
1097+
1098+
1099+
class AccessLevel(str, Enum):
1100+
OWNER = "owner"
1101+
MANAGER = "manager"
1102+
MEMBER = "member"
1103+
RESTRICTED = "restricted"
1104+
1105+
1106+
class PaginationParameter(Base, FromDictMixin):
1107+
"""Pagination mixin used for endpoints that support pagination."""
1108+
1109+
cursor: str = ""
1110+
per_page: int = 20
1111+
1112+
1113+
class SearchParameter(Base, FromDictMixin):
1114+
search: Optional[str] = None
1115+
1116+
1117+
PaginatedData = TypeVar("PaginatedData", bound=FromDictMixin)
1118+
1119+
1120+
@dataclass
1121+
class CursorPaginatedResponse(Generic[PaginatedData]):
1122+
status_code: int
1123+
data: list[PaginatedData]
1124+
prev: Optional[str] = None
1125+
next: Optional[str] = None
1126+
1127+
@classmethod
1128+
def from_response(
1129+
cls, response: "requests.Response", data_type: Type[PaginatedData]
1130+
) -> "CursorPaginatedResponse[PaginatedData]":
1131+
data = cast(
1132+
list[PaginatedData], data_type.from_dict(response.json(), many=True)
1133+
)
1134+
paginated_response = cls(status_code=response.status_code, data=data)
1135+
1136+
if previous_page := response.links.get("prev"):
1137+
paginated_response.prev = previous_page["url"]
1138+
if next_page := response.links.get("next"):
1139+
paginated_response.prev = next_page["url"]
1140+
1141+
return paginated_response
1142+
1143+
1144+
@dataclass
1145+
class MembersParameters(PaginationParameter, SearchParameter, Base, FromDictMixin):
1146+
"""
1147+
Members query parameters
1148+
"""
1149+
1150+
access_level: Optional[AccessLevel] = None
1151+
active: Optional[bool] = None
1152+
ordering: Optional[
1153+
Literal["id", "-id", "created_at", "-created_at", "last_login", "-last_login"]
1154+
] = None
1155+
1156+
1157+
MembersParametersSchema = cast(
1158+
Type[BaseSchema],
1159+
marshmallow_dataclass.class_schema(MembersParameters, base_schema=BaseSchema),
1160+
)
1161+
MembersParameters.SCHEMA = MembersParametersSchema()
1162+
1163+
1164+
@dataclass
1165+
class Member(Base, FromDictMixin):
1166+
"""
1167+
Member represents a user in a GitGuardian account.
1168+
"""
1169+
1170+
id: int
1171+
access_level: AccessLevel
1172+
email: str
1173+
name: str
1174+
created_at: datetime
1175+
last_login: Optional[datetime]
1176+
active: bool
1177+
1178+
1179+
class MemberSchema(BaseSchema):
1180+
id = fields.Int(required=True)
1181+
access_level = fields.Enum(AccessLevel, by_value=True, required=True)
1182+
email = fields.Str(required=True)
1183+
name = fields.Str(required=True)
1184+
created_at = fields.AwareDateTime(required=True)
1185+
last_login = fields.AwareDateTime(allow_none=True)
1186+
active = fields.Bool(required=True)
1187+
1188+
@post_load
1189+
def return_member(
1190+
self,
1191+
data: list[dict[str, Any]] | dict[str, Any],
1192+
**kwargs: dict[str, Any],
1193+
):
1194+
data = cast(dict[str, Any], data)
1195+
return Member(**data)
1196+
1197+
1198+
Member.SCHEMA = MemberSchema()
1199+
1200+
1201+
class UpdateMemberSchema(BaseSchema):
1202+
id = fields.Int(required=True)
1203+
access_level = fields.Enum(AccessLevel, by_value=True, allow_none=True)
1204+
active = fields.Bool(allow_none=True)
1205+
1206+
@post_dump
1207+
def access_level_value(
1208+
self, data: dict[str, Any], **kwargs: dict[str, Any]
1209+
) -> dict[str, Any]:
1210+
if "access_level" in data:
1211+
data["access_level"] = AccessLevel(data["access_level"]).value
1212+
return data
1213+
1214+
1215+
@dataclass
1216+
class UpdateMember(Base, FromDictMixin):
1217+
"""
1218+
UpdateMember represnets the payload to update a member
1219+
"""
1220+
1221+
id: int
1222+
access_level: Optional[AccessLevel] = None
1223+
active: Optional[bool] = None
1224+
1225+
1226+
UpdateMember.SCHEMA = UpdateMemberSchema()
1227+
1228+
1229+
@dataclass
1230+
class DeleteMember(Base, FromDictMixin):
1231+
id: int
1232+
send_email: Optional[bool] = None
1233+
1234+
1235+
DeleteMemberSchema = cast(
1236+
Type[BaseSchema],
1237+
marshmallow_dataclass.class_schema(DeleteMember, base_schema=BaseSchema),
1238+
)
1239+
DeleteMember.SCHEMA = DeleteMemberSchema()

0 commit comments

Comments
 (0)