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,8 @@ 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+ is_redirect_endpoint = any (url .startswith (prefix ) for prefix in REDIRECT_ENDPOINTS )
639647 while True :
640648 try :
641649 kwarg = "params" if method == "GET" else "data"
@@ -648,8 +656,9 @@ def end_error_retry(succeeded: bool) -> None:
648656 # Actually make the request!
649657 res = self .session .request (
650658 method ,
651- urllib .parse .urljoin (self . base_url , url ),
659+ urllib .parse .urljoin (base_url , url ),
652660 timeout = request_timeout ,
661+ allow_redirects = not is_redirect_endpoint ,
653662 ** kwargs ,
654663 )
655664
@@ -683,9 +692,7 @@ def end_error_retry(succeeded: bool) -> None:
683692 # go into retry logic, because the most likely scenario here is
684693 # that somebody just hasn't started their server, or they passed
685694 # in an invalid site.
686- raise UnrecoverableNetworkError (
687- "cannot connect to server " + self .base_url
688- ) from e
695+ raise UnrecoverableNetworkError ("cannot connect to server " + base_url ) from e
689696
690697 if error_retry ("" ):
691698 continue
@@ -695,6 +702,17 @@ def end_error_retry(succeeded: bool) -> None:
695702 # We'll split this out into more cases as we encounter new bugs.
696703 raise
697704
705+ # This is not a real object these endpoints would return. Redirect endpoints
706+ # have no JSON body; the resource URL lives in the "Location" header of the
707+ # 3xx response.
708+ if is_redirect_endpoint and res .is_redirect :
709+ end_error_retry (True )
710+ return {
711+ "result" : "success" ,
712+ "msg" : "" ,
713+ "url" : res .headers .get ("Location" ),
714+ }
715+
698716 try :
699717 json_result = res .json ()
700718 except Exception :
@@ -716,21 +734,23 @@ def call_endpoint(
716734 longpolling : bool = False ,
717735 files : Optional [List [IO [Any ]]] = None ,
718736 timeout : Optional [float ] = None ,
737+ non_api_or_json_url : bool = False ,
719738 ) -> Dict [str , Any ]:
720739 if request is None :
721740 request = dict ()
722741 marshalled_request = {}
723742 for k , v in request .items ():
724743 if v is not None :
725744 marshalled_request [k ] = v
726- versioned_url = API_VERSTRING + ( url if url is not None else "" )
745+ url = url or ""
727746 return self .do_api_query (
728747 marshalled_request ,
729- versioned_url ,
748+ url = url if non_api_or_json_url else API_VERSTRING + url ,
730749 method = method ,
731750 longpolling = longpolling ,
732751 files = files ,
733752 timeout = timeout ,
753+ non_api_or_json_url = non_api_or_json_url ,
734754 )
735755
736756 def call_on_each_event (
@@ -1771,6 +1791,38 @@ def move_topic(
17711791 request = request ,
17721792 )
17731793
1794+ def get_avatar_url_by_id (self , user_id : int , medium : bool = False ) -> str :
1795+ """
1796+ If `medium=False`, the default size will be returned. Avatar sizes:
1797+ - default `100x100` px
1798+ - medium `500x500` px
1799+
1800+ Example usage:
1801+
1802+ >>> client.get_avatar_by_id(user_id=8, medium=True)
1803+ """
1804+ url = f"avatar/{ user_id } "
1805+ if medium :
1806+ url += "/medium"
1807+ response = self .call_endpoint (url = url , method = "GET" , non_api_or_json_url = True )
1808+ return response ["url" ]
1809+
1810+ def get_avatar_url_by_email (self , email : str , medium : bool = False ) -> str :
1811+ """
1812+ If `medium=False`, the default size will be returned. Avatar sizes:
1813+ - default `100x100` px
1814+ - medium `500x500` px
1815+
1816+ Example usage:
1817+
1818+ >>> client.get_avatar_by_email(email="hamlet@zulip.com", medium=True)
1819+ """
1820+ url = f"avatar/{ email } "
1821+ if medium :
1822+ url += "/medium"
1823+ response = self .call_endpoint (url = url , method = "GET" , non_api_or_json_url = True )
1824+ return response ["url" ]
1825+
17741826
17751827class ZulipStream :
17761828 """
0 commit comments