Skip to content

Commit

Permalink
❇️ Add base_url setting in Session/AsyncSession (#180)
Browse files Browse the repository at this point in the history
close #179

answer a long demanded feature, straightforward to implement and test.
  • Loading branch information
Ousret authored Nov 17, 2024
1 parent d14091e commit 5d9b5e3
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 8 deletions.
13 changes: 13 additions & 0 deletions docs/user/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ The ``Session`` class takes two (optional) named arguments for your convenience.

.. _request-and-response-objects:

Setting a Base URL
------------------

.. note:: Available in version 3.11+

You can avoid repetitive URL basic concatenation if your sole purpose of Session instance
is to reach a particular server and/or base path.

Setup it like follow::

with niquests.Session(base_url="https://httpbin.org") as s:
s.get('/headers') # internally will become "https://httpbin.org/headers"

Request and Response Objects
----------------------------

Expand Down
4 changes: 2 additions & 2 deletions src/niquests/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
__url__: str = "https://niquests.readthedocs.io"

__version__: str
__version__ = "3.10.3"
__version__ = "3.11.0"

__build__: int = 0x031003
__build__: int = 0x031100
__author__: str = "Kenneth Reitz"
__author_email__: str = "[email protected]"
__license__: str = "Apache-2.0"
Expand Down
5 changes: 5 additions & 0 deletions src/niquests/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def __init__(
happy_eyeballs: bool | int = False,
keepalive_delay: float | int | None = 300.0,
keepalive_idle_window: float | int | None = 60.0,
base_url: str | None = None,
):
if [disable_ipv4, disable_ipv6].count(True) == 2:
raise RuntimeError("Cannot disable both IPv4 and IPv6")
Expand Down Expand Up @@ -224,6 +225,9 @@ def __init__(
#: authentication and similar.
self.trust_env: bool = True

#: Automatically set a URL prefix to every emitted request.
self.base_url: str | None = base_url

#: A CookieJar containing all currently outstanding cookies set on this
#: session. By default it is a
#: :class:`RequestsCookieJar <requests.cookies.RequestsCookieJar>`, but
Expand Down Expand Up @@ -815,6 +819,7 @@ async def request( # type: ignore[override]
auth=auth,
cookies=cookies,
hooks=hooks,
base_url=self.base_url,
)

prep: PreparedRequest = self.prepare_request(req)
Expand Down
23 changes: 21 additions & 2 deletions src/niquests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def __init__(
cookies: CookiesType | None = None,
hooks: HookType | None = None,
json: typing.Any | None = None,
base_url: str | None = None,
):
# Default empty dicts for dict params.
data = [] if data is None else data
Expand All @@ -241,6 +242,7 @@ def __init__(
self.params = params
self.auth = auth
self.cookies = cookies
self.base_url = base_url

@property
def oheaders(self) -> Headers:
Expand Down Expand Up @@ -293,6 +295,7 @@ def prepare(self) -> PreparedRequest:
auth=self.auth,
cookies=self.cookies,
hooks=self.hooks,
base_url=self.base_url,
)

return p
Expand Down Expand Up @@ -362,11 +365,12 @@ def prepare(
cookies: CookiesType | None = None,
hooks: HookType[Response | PreparedRequest] | None = None,
json: typing.Any | None = None,
base_url: str | None = None,
) -> None:
"""Prepares the entire request with the given parameters."""

self.prepare_method(method)
self.prepare_url(url, params)
self.prepare_url(url, params, base_url=base_url)
self.prepare_headers(headers)
self.prepare_cookies(cookies)
self.prepare_body(data, files, json)
Expand Down Expand Up @@ -396,10 +400,25 @@ def prepare_method(self, method: HttpMethodType | None) -> None:
"""Prepares the given HTTP method."""
self.method = method.upper() if method else method

def prepare_url(self, url: str | None, params: QueryParameterType | None) -> None:
def prepare_url(
self,
url: str | None,
params: QueryParameterType | None,
*,
base_url: str | None = None,
) -> None:
"""Prepares the given HTTP URL."""
assert url is not None, "Missing URL in PreparedRequest"

print(url, base_url)
if base_url is not None:
if parse_scheme(url, default="") == "":
if base_url.endswith("/"):
base_url = base_url[:-1]
if not url.startswith("/"):
url = f"/{url}"
url = base_url + url

#: Accept objects that have string representations.
#: We're unable to blindly call unicode/str functions
#: as this will include the bytestring indicator (b'')
Expand Down
8 changes: 8 additions & 0 deletions src/niquests/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ class Session:
"_happy_eyeballs",
"_keepalive_delay",
"_keepalive_idle_window",
"base_url",
]

def __init__(
Expand All @@ -252,6 +253,7 @@ def __init__(
happy_eyeballs: bool | int = False,
keepalive_delay: float | int | None = 300.0,
keepalive_idle_window: float | int | None = 60.0,
base_url: str | None = None,
):
"""
:param resolver: Specify a DNS resolver that should be used within this Session.
Expand All @@ -274,6 +276,7 @@ def __init__(
frame. This only applies to HTTP/2 onward.
:param keepalive_idle_window: Delay expressed in seconds, in which we should send a PING frame after the connection
being completely idle. This only applies to HTTP/2 onward.
:param base_url: Automatically set a URL prefix (or base url) on every request emitted if applicable.
"""
if [disable_ipv4, disable_ipv6].count(True) == 2:
raise RuntimeError("Cannot disable both IPv4 and IPv6")
Expand Down Expand Up @@ -364,6 +367,9 @@ def __init__(
#: authentication and similar.
self.trust_env: bool = True

#: Automatically set a URL prefix to every emitted request.
self.base_url: str | None = base_url

#: A CookieJar containing all currently outstanding cookies set on this
#: session. By default it is a
#: :class:`RequestsCookieJar <requests.cookies.RequestsCookieJar>`, but
Expand Down Expand Up @@ -469,6 +475,7 @@ def prepare_request(self, request: Request) -> PreparedRequest:
auth=merge_setting(auth, self.auth),
cookies=merged_cookies,
hooks=merge_hooks(request.hooks, self.hooks),
base_url=self.base_url,
)
return p

Expand Down Expand Up @@ -548,6 +555,7 @@ def request(
auth=auth,
cookies=cookies,
hooks=hooks,
base_url=self.base_url,
)

prep: PreparedRequest = dispatch_hook(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
@pytest.mark.asyncio
class TestAsyncWithoutMultiplex:
async def test_awaitable_get(self):
async with AsyncSession() as s:
resp = await s.get("https://httpbingo.org/get")
async with AsyncSession(base_url="https://httpbingo.org") as s:
resp = await s.get("/get")

assert resp.lazy is False
assert resp.status_code == 200
Expand Down
5 changes: 3 additions & 2 deletions tests/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ def test_ensure_ipv6(self) -> None:
assert is_ipv6_address(r.conn_info.destination_address[0])

def test_ensure_http2(self) -> None:
with Session(disable_http3=True) as s:
r = s.get("https://httpbingo.org/get")
with Session(disable_http3=True, base_url="https://httpbingo.org") as s:
r = s.get("/get")
assert r.conn_info.http_version is not None
assert r.conn_info.http_version == HttpVersion.h2
assert r.url == "https://httpbingo.org/get"

@pytest.mark.skipif(qh3 is None, reason="qh3 unavailable")
def test_ensure_http3_default(self) -> None:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,20 @@ def test_urllib3_retries(httpbin):
s.get(httpbin("status/500"))


def test_urllib3_retries_shortcut(httpbin):
from niquests._compat import HAS_LEGACY_URLLIB3

if not HAS_LEGACY_URLLIB3:
from urllib3.util import Retry
else:
from urllib3_future.util import Retry

s = niquests.Session(retries=Retry(total=2, status_forcelist=[500]))

with pytest.raises(RetryError):
s.get(httpbin("status/500"))


def test_urllib3_pool_connection_closed(httpbin):
s = niquests.Session()
s.mount("http://", HTTPAdapter(pool_connections=0, pool_maxsize=0))
Expand Down Expand Up @@ -2764,6 +2778,32 @@ def test_preparing_bad_url(self, url):
with pytest.raises(niquests.exceptions.InvalidURL):
r.prepare()

@pytest.mark.parametrize(
"base_url, url, join_expected",
[
("http://example.com/api/v1", "/token", "http://example.com/api/v1/token"),
("http://localhost/", "/hello", "http://localhost/hello"),
("http://httpbingo.org", "get", "http://httpbingo.org/get"),
("http://api.example.com", "/v1/users", "http://api.example.com/v1/users"),
("http://api.example.com", "v1/users", "http://api.example.com/v1/users"),
],
)
def test_base_url_prepare(self, base_url, url, join_expected):
r = niquests.Request("GET", url=url, base_url=base_url)

prepared = r.prepare()

assert prepared.url == join_expected

def test_base_url_skip(self):
r = niquests.Request(
"GET", url="https://google.com", base_url="http://example.com/api/v1"
)

prepared = r.prepare()

assert prepared.url == "https://google.com"

@pytest.mark.parametrize("url, exception", (("http://localhost:-1", InvalidURL),))
def test_redirecting_to_bad_url(self, httpbin, url, exception):
with pytest.raises(exception):
Expand Down

0 comments on commit 5d9b5e3

Please sign in to comment.