Skip to content

Commit e4dcbd9

Browse files
committed
api: Add bindings for fetching user avatar.
1 parent b172347 commit e4dcbd9

2 files changed

Lines changed: 82 additions & 5 deletions

File tree

zulip/zulip/__init__.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646

4747
API_VERSTRING = "v1/"
4848

49+
# Endpoints that respond with an HTTP 302 redirect to a resource (e.g. the
50+
# avatar endpoints redirect to the actual avatar image) rather than a JSON
51+
# body.
52+
REDIRECT_ENDPOINTS = ("avatar/",)
53+
4954
# An optional parameter to `move_topic` and `update_message` actions
5055
# See eg. https://zulip.com/api/update-message#parameter-propagate_mode
5156
EditPropagateMode = Literal["change_one", "change_all", "change_later"]
@@ -583,6 +588,7 @@ def do_api_query(
583588
longpolling: bool = False,
584589
files: Optional[List[IO[Any]]] = None,
585590
timeout: Optional[float] = None,
591+
non_api_or_json_url: bool = False,
586592
) -> Dict[str, Any]:
587593
if files is None:
588594
files = []
@@ -636,6 +642,10 @@ def end_error_retry(succeeded: bool) -> None:
636642
else:
637643
print("Failed!")
638644

645+
base_url = self.base_url.removesuffix("api/") if non_api_or_json_url else self.base_url
646+
# Some endpoints (e.g. avatars) respond with a redirect to a resource
647+
# rather than a JSON body; for those we must not follow the redirect.
648+
is_redirect_endpoint = any(url.startswith(prefix) for prefix in REDIRECT_ENDPOINTS)
639649
while True:
640650
try:
641651
kwarg = "params" if method == "GET" else "data"
@@ -648,8 +658,9 @@ def end_error_retry(succeeded: bool) -> None:
648658
# Actually make the request!
649659
res = self.session.request(
650660
method,
651-
urllib.parse.urljoin(self.base_url, url),
661+
urllib.parse.urljoin(base_url, url),
652662
timeout=request_timeout,
663+
allow_redirects=not is_redirect_endpoint,
653664
**kwargs,
654665
)
655666

@@ -683,9 +694,7 @@ def end_error_retry(succeeded: bool) -> None:
683694
# go into retry logic, because the most likely scenario here is
684695
# that somebody just hasn't started their server, or they passed
685696
# in an invalid site.
686-
raise UnrecoverableNetworkError(
687-
"cannot connect to server " + self.base_url
688-
) from e
697+
raise UnrecoverableNetworkError("cannot connect to server " + base_url) from e
689698

690699
if error_retry(""):
691700
continue
@@ -695,6 +704,16 @@ def end_error_retry(succeeded: bool) -> None:
695704
# We'll split this out into more cases as we encounter new bugs.
696705
raise
697706

707+
# Redirect endpoints have no JSON body; the resource URL lives in
708+
# the "Location" header of the 3xx response.
709+
if is_redirect_endpoint and res.is_redirect:
710+
end_error_retry(True)
711+
return {
712+
"result": "success",
713+
"msg": "",
714+
"url": res.headers.get("Location"),
715+
}
716+
698717
try:
699718
json_result = res.json()
700719
except Exception:
@@ -716,6 +735,7 @@ def call_endpoint(
716735
longpolling: bool = False,
717736
files: Optional[List[IO[Any]]] = None,
718737
timeout: Optional[float] = None,
738+
non_api_or_json_url: bool = False,
719739
) -> Dict[str, Any]:
720740
if request is None:
721741
request = dict()
@@ -726,11 +746,12 @@ def call_endpoint(
726746
versioned_url = API_VERSTRING + (url if url is not None else "")
727747
return self.do_api_query(
728748
marshalled_request,
729-
versioned_url,
749+
url if non_api_or_json_url else versioned_url,
730750
method=method,
731751
longpolling=longpolling,
732752
files=files,
733753
timeout=timeout,
754+
non_api_or_json_url=non_api_or_json_url,
734755
)
735756

736757
def call_on_each_event(
@@ -1771,6 +1792,38 @@ def move_topic(
17711792
request=request,
17721793
)
17731794

1795+
def get_avatar_url_by_id(self, user_id: int, medium: bool = False) -> Dict[str, Any]:
1796+
"""
1797+
If `medium=False`, the default size will be returned. Avatar sizes:
1798+
- default `100x100` px
1799+
- medium `500x500` px
1800+
1801+
Example usage:
1802+
1803+
>>> client.get_avatar_by_id(user_id=8, medium=True)
1804+
"""
1805+
url = f"avatar/{user_id}"
1806+
if medium:
1807+
url += "/medium"
1808+
response = self.call_endpoint(url=url, method="GET", non_api_or_json_url=True)
1809+
return response["url"]
1810+
1811+
def get_avatar_url_by_email(self, email: str, medium: bool = False) -> Dict[str, Any]:
1812+
"""
1813+
If `medium=False`, the default size will be returned. Avatar sizes:
1814+
- default `100x100` px
1815+
- medium `500x500` px
1816+
1817+
Example usage:
1818+
1819+
>>> client.get_avatar_by_email(email="hamlet@zulip.com", medium=True)
1820+
"""
1821+
url = f"avatar/{email}"
1822+
if medium:
1823+
url += "/medium"
1824+
response = self.call_endpoint(url=url, method="GET", non_api_or_json_url=True)
1825+
return response["url"]
1826+
17741827

17751828
class ZulipStream:
17761829
"""
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
5+
usage = """get-user-avatar --user_id=<user_id> --email=<email address> [options]
6+
7+
Get the avatar URL for a user.
8+
"""
9+
10+
import zulip
11+
12+
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
13+
parser.add_argument("--user_id", required=True)
14+
parser.add_argument("--email", required=True)
15+
options = parser.parse_args()
16+
17+
client = zulip.init_from_options(options)
18+
19+
print("Get user avatar by ID:")
20+
print(client.get_avatar_url_by_id(user_id=options.user_id))
21+
print(client.get_avatar_url_by_id(user_id=options.user_id, medium=True))
22+
print("Get user avatar by email:")
23+
print(client.get_avatar_url_by_email(email=options.email))
24+
print(client.get_avatar_url_by_email(email=options.email, medium=True))

0 commit comments

Comments
 (0)