4646
4747API_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
5156EditPropagateMode = 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
17751828class ZulipStream :
17761829 """
0 commit comments