@@ -274,3 +274,311 @@ def replace_client_path(client: Client, base_path: str) -> Client:
274274 parsed = urllib .parse .urlparse (client .base_url )
275275 # _replace is not private, it's part of the NamedTuple API but prefixed _ to avoid conflicts
276276 updated_url = parsed ._replace (path = base_path )
277+ return client .with_base_url (updated_url .geturl ())
278+
279+
280+ def v3_stable_client (client : Client ) -> Client :
281+ """Override a client's base URL with a v2 stable path."""
282+ return replace_client_path (client , "api/v3-draft" )
283+
284+
285+ def v3_alpha_client (client : Client ) -> Client :
286+ """Override a client's base URL with a v2-alpha path."""
287+ return replace_client_path (client , "api/v3-alpha" )
288+
289+
290+ def v3_beta_client (client : Client ) -> Client :
291+ """Override a client's base URL with a v2-beta path."""
292+ return replace_client_path (client , "api/v3-beta" )
293+ import ssl
294+ from typing import Any , Optional , Union
295+
296+ import httpx
297+ from attrs import define , evolve , field
298+ import urllib .parse
299+
300+
301+ @define
302+ class Client :
303+ """A class for keeping track of data related to the API
304+
305+ The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
306+
307+ ``base_url``: The base URL for the API, all requests are made to a relative path to this URL
308+
309+ ``cookies``: A dictionary of cookies to be sent with every request
310+
311+ ``headers``: A dictionary of headers to be sent with every request
312+
313+ ``timeout``: The maximum amount of a time a request can take. API functions will raise
314+ httpx.TimeoutException if this is exceeded.
315+
316+ ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
317+ but can be set to False for testing purposes.
318+
319+ ``follow_redirects``: Whether or not to follow redirects. Default value is False.
320+
321+ ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
322+
323+
324+ Attributes:
325+ raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
326+ status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
327+ argument to the constructor.
328+ """
329+
330+ raise_on_unexpected_status : bool = field (default = False , kw_only = True )
331+ _base_url : str = field (alias = "base_url" )
332+ _cookies : dict [str , str ] = field (factory = dict , kw_only = True , alias = "cookies" )
333+ _headers : dict [str , str ] = field (factory = dict , kw_only = True , alias = "headers" )
334+ _timeout : Optional [httpx .Timeout ] = field (default = None , kw_only = True , alias = "timeout" )
335+ _verify_ssl : Union [str , bool , ssl .SSLContext ] = field (default = True , kw_only = True , alias = "verify_ssl" )
336+ _follow_redirects : bool = field (default = False , kw_only = True , alias = "follow_redirects" )
337+ _httpx_args : dict [str , Any ] = field (factory = dict , kw_only = True , alias = "httpx_args" )
338+ _client : Optional [httpx .Client ] = field (default = None , init = False )
339+ _async_client : Optional [httpx .AsyncClient ] = field (default = None , init = False )
340+
341+ def with_headers (self , headers : dict [str , str ]) -> "Client" :
342+ """Get a new client matching this one with additional headers"""
343+ if self ._client is not None :
344+ self ._client .headers .update (headers )
345+ if self ._async_client is not None :
346+ self ._async_client .headers .update (headers )
347+ return evolve (self , headers = {** self ._headers , ** headers })
348+
349+ def with_cookies (self , cookies : dict [str , str ]) -> "Client" :
350+ """Get a new client matching this one with additional cookies"""
351+ if self ._client is not None :
352+ self ._client .cookies .update (cookies )
353+ if self ._async_client is not None :
354+ self ._async_client .cookies .update (cookies )
355+ return evolve (self , cookies = {** self ._cookies , ** cookies })
356+
357+ def with_timeout (self , timeout : httpx .Timeout ) -> "Client" :
358+ """Get a new client matching this one with a new timeout (in seconds)"""
359+ if self ._client is not None :
360+ self ._client .timeout = timeout
361+ if self ._async_client is not None :
362+ self ._async_client .timeout = timeout
363+ return evolve (self , timeout = timeout )
364+
365+ def set_httpx_client (self , client : httpx .Client ) -> "Client" :
366+ """Manually set the underlying httpx.Client
367+
368+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
369+ """
370+ self ._client = client
371+ return self
372+
373+ def get_httpx_client (self ) -> httpx .Client :
374+ """Get the underlying httpx.Client, constructing a new one if not previously set"""
375+ if self ._client is None :
376+ self ._client = httpx .Client (
377+ base_url = self ._base_url ,
378+ cookies = self ._cookies ,
379+ headers = self ._headers ,
380+ timeout = self ._timeout ,
381+ verify = self ._verify_ssl ,
382+ follow_redirects = self ._follow_redirects ,
383+ ** self ._httpx_args ,
384+ )
385+ return self ._client
386+
387+ def __enter__ (self ) -> "Client" :
388+ """Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
389+ self .get_httpx_client ().__enter__ ()
390+ return self
391+
392+ def __exit__ (self , * args : Any , ** kwargs : Any ) -> None :
393+ """Exit a context manager for internal httpx.Client (see httpx docs)"""
394+ self .get_httpx_client ().__exit__ (* args , ** kwargs )
395+
396+ def set_async_httpx_client (self , async_client : httpx .AsyncClient ) -> "Client" :
397+ """Manually the underlying httpx.AsyncClient
398+
399+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
400+ """
401+ self ._async_client = async_client
402+ return self
403+
404+ def get_async_httpx_client (self ) -> httpx .AsyncClient :
405+ """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
406+ if self ._async_client is None :
407+ self ._async_client = httpx .AsyncClient (
408+ base_url = self ._base_url ,
409+ cookies = self ._cookies ,
410+ headers = self ._headers ,
411+ timeout = self ._timeout ,
412+ verify = self ._verify_ssl ,
413+ follow_redirects = self ._follow_redirects ,
414+ ** self ._httpx_args ,
415+ )
416+ return self ._async_client
417+
418+ async def __aenter__ (self ) -> "Client" :
419+ """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
420+ await self .get_async_httpx_client ().__aenter__ ()
421+ return self
422+
423+ async def __aexit__ (self , * args : Any , ** kwargs : Any ) -> None :
424+ """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
425+ await self .get_async_httpx_client ().__aexit__ (* args , ** kwargs )
426+
427+
428+ @define
429+ class AuthenticatedClient :
430+ """A Client which has been authenticated for use on secured endpoints
431+
432+ The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
433+
434+ ``base_url``: The base URL for the API, all requests are made to a relative path to this URL
435+
436+ ``cookies``: A dictionary of cookies to be sent with every request
437+
438+ ``headers``: A dictionary of headers to be sent with every request
439+
440+ ``timeout``: The maximum amount of a time a request can take. API functions will raise
441+ httpx.TimeoutException if this is exceeded.
442+
443+ ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
444+ but can be set to False for testing purposes.
445+
446+ ``follow_redirects``: Whether or not to follow redirects. Default value is False.
447+
448+ ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
449+
450+
451+ Attributes:
452+ raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
453+ status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
454+ argument to the constructor.
455+ token: The token to use for authentication
456+ prefix: The prefix to use for the Authorization header
457+ auth_header_name: The name of the Authorization header
458+ """
459+
460+ raise_on_unexpected_status : bool = field (default = False , kw_only = True )
461+ _base_url : str = field (alias = "base_url" )
462+ _cookies : dict [str , str ] = field (factory = dict , kw_only = True , alias = "cookies" )
463+ _headers : dict [str , str ] = field (factory = dict , kw_only = True , alias = "headers" )
464+ _timeout : Optional [httpx .Timeout ] = field (default = None , kw_only = True , alias = "timeout" )
465+ _verify_ssl : Union [str , bool , ssl .SSLContext ] = field (default = True , kw_only = True , alias = "verify_ssl" )
466+ _follow_redirects : bool = field (default = False , kw_only = True , alias = "follow_redirects" )
467+ _httpx_args : dict [str , Any ] = field (factory = dict , kw_only = True , alias = "httpx_args" )
468+ _client : Optional [httpx .Client ] = field (default = None , init = False )
469+ _async_client : Optional [httpx .AsyncClient ] = field (default = None , init = False )
470+
471+ token : str
472+ prefix : str = "Bearer"
473+ auth_header_name : str = "Authorization"
474+
475+ def with_headers (self , headers : dict [str , str ]) -> "AuthenticatedClient" :
476+ """Get a new client matching this one with additional headers"""
477+ if self ._client is not None :
478+ self ._client .headers .update (headers )
479+ if self ._async_client is not None :
480+ self ._async_client .headers .update (headers )
481+ return evolve (self , headers = {** self ._headers , ** headers })
482+
483+ def with_cookies (self , cookies : dict [str , str ]) -> "AuthenticatedClient" :
484+ """Get a new client matching this one with additional cookies"""
485+ if self ._client is not None :
486+ self ._client .cookies .update (cookies )
487+ if self ._async_client is not None :
488+ self ._async_client .cookies .update (cookies )
489+ return evolve (self , cookies = {** self ._cookies , ** cookies })
490+
491+ def with_timeout (self , timeout : httpx .Timeout ) -> "AuthenticatedClient" :
492+ """Get a new client matching this one with a new timeout (in seconds)"""
493+ if self ._client is not None :
494+ self ._client .timeout = timeout
495+ if self ._async_client is not None :
496+ self ._async_client .timeout = timeout
497+ return evolve (self , timeout = timeout )
498+
499+ def set_httpx_client (self , client : httpx .Client ) -> "AuthenticatedClient" :
500+ """Manually set the underlying httpx.Client
501+
502+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
503+ """
504+ self ._client = client
505+ return self
506+
507+ def get_httpx_client (self ) -> httpx .Client :
508+ """Get the underlying httpx.Client, constructing a new one if not previously set"""
509+ if self ._client is None :
510+ self ._headers [self .auth_header_name ] = f"{ self .prefix } { self .token } " if self .prefix else self .token
511+ self ._client = httpx .Client (
512+ base_url = self ._base_url ,
513+ cookies = self ._cookies ,
514+ headers = self ._headers ,
515+ timeout = self ._timeout ,
516+ verify = self ._verify_ssl ,
517+ follow_redirects = self ._follow_redirects ,
518+ ** self ._httpx_args ,
519+ )
520+ return self ._client
521+
522+ def __enter__ (self ) -> "AuthenticatedClient" :
523+ """Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
524+ self .get_httpx_client ().__enter__ ()
525+ return self
526+
527+ def __exit__ (self , * args : Any , ** kwargs : Any ) -> None :
528+ """Exit a context manager for internal httpx.Client (see httpx docs)"""
529+ self .get_httpx_client ().__exit__ (* args , ** kwargs )
530+
531+ def set_async_httpx_client (self , async_client : httpx .AsyncClient ) -> "AuthenticatedClient" :
532+ """Manually the underlying httpx.AsyncClient
533+
534+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
535+ """
536+ self ._async_client = async_client
537+ return self
538+
539+ def get_async_httpx_client (self ) -> httpx .AsyncClient :
540+ """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
541+ if self ._async_client is None :
542+ self ._headers [self .auth_header_name ] = f"{ self .prefix } { self .token } " if self .prefix else self .token
543+ self ._async_client = httpx .AsyncClient (
544+ base_url = self ._base_url ,
545+ cookies = self ._cookies ,
546+ headers = self ._headers ,
547+ timeout = self ._timeout ,
548+ verify = self ._verify_ssl ,
549+ follow_redirects = self ._follow_redirects ,
550+ ** self ._httpx_args ,
551+ )
552+ return self ._async_client
553+
554+ async def __aenter__ (self ) -> "AuthenticatedClient" :
555+ """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
556+ await self .get_async_httpx_client ().__aenter__ ()
557+ return self
558+
559+ async def __aexit__ (self , * args : Any , ** kwargs : Any ) -> None :
560+ """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
561+ await self .get_async_httpx_client ().__aexit__ (* args , ** kwargs )
562+
563+
564+ def replace_client_path (client : Client , base_path : str ) -> Client :
565+ """Override a client's base URL with a new path. Does not update scheme, host, or other URL parts."""
566+ parsed = urllib .parse .urlparse (client .base_url )
567+ # _replace is not private, it's part of the NamedTuple API but prefixed _ to avoid conflicts
568+ updated_url = parsed ._replace (path = base_path )
569+ return client .with_base_url (updated_url .geturl ())
570+
571+
572+ def v3_stable_client (client : Client ) -> Client :
573+ """Override a client's base URL with a v2 stable path."""
574+ return replace_client_path (client , "api/v3-draft" )
575+
576+
577+ def v3_alpha_client (client : Client ) -> Client :
578+ """Override a client's base URL with a v2-alpha path."""
579+ return replace_client_path (client , "api/v3-alpha" )
580+
581+
582+ def v3_beta_client (client : Client ) -> Client :
583+ """Override a client's base URL with a v2-beta path."""
584+ return replace_client_path (client , "api/v3-beta" )
0 commit comments