Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/config/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ The following values have special meanings:
| --- | --- |
| `isolated` (default) | `<DATA_DIR>/pythons` |

## Network

### Timeout

Requests that Hatch itself makes over the network, such as downloading Python distributions or publishing to a package index, time out after 10 seconds by default.

You can change this by setting the `HATCH_NETWORK_TIMEOUT` environment variable to the desired number of seconds, which is useful on slow or heavily proxied connections:

```console
$ HATCH_NETWORK_TIMEOUT=60 hatch python install 3.12
```

The value must be a positive number, and may be fractional e.g. `2.5`.

!!! note
This does not affect the tools that Hatch invokes to install dependencies. Configure those directly, such as with pip's [`--timeout`](https://pip.pypa.io/en/stable/cli/pip/#cmdoption-timeout) option or uv's [`UV_HTTP_TIMEOUT`](https://docs.astral.sh/uv/reference/environment/#uv_http_timeout) environment variable.

## Terminal

You can configure how all output is displayed using the `terminal.styles` table. These settings are also applied to all plugins.
Expand Down
3 changes: 3 additions & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## Unreleased

***Added:***

- The default timeout for Hatch's own network requests can now be overridden with the `HATCH_NETWORK_TIMEOUT` environment variable.

***Fixed:***

Expand Down
1 change: 1 addition & 0 deletions src/hatch/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class AppEnvVars:
NO_COLOR = "NO_COLOR"
FORCE_COLOR = "FORCE_COLOR"
KEEP_ENV = "HATCH_KEEP_ENV"
NETWORK_TIMEOUT = "HATCH_NETWORK_TIMEOUT"


class ConfigEnvVars:
Expand Down
4 changes: 2 additions & 2 deletions src/hatch/index/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ def client(self) -> httpx2.Client:
import httpx2

from hatch.utils.linehaul import get_linehaul_component
from hatch.utils.network import DEFAULT_TIMEOUT
from hatch.utils.network import get_timeout

user_agent = f"Hatch/{__version__} {get_linehaul_component()} HTTPX2/{httpx2.__version__}"
return httpx2.Client(
headers={"User-Agent": user_agent},
transport=httpx2.HTTPTransport(retries=3, verify=self.__verify, cert=self.__cert),
timeout=DEFAULT_TIMEOUT,
timeout=get_timeout(),
)

def upload_artifact(self, artifact: Path, data: dict):
Expand Down
28 changes: 27 additions & 1 deletion src/hatch/utils/network.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import math
import os
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any

from hatch.config.constants import AppEnvVars

if TYPE_CHECKING:
from collections.abc import Generator

Expand All @@ -19,6 +23,28 @@
DEFAULT_TIMEOUT = 10


def get_timeout() -> float:
"""
The number of seconds to wait for network requests, defaulting to `DEFAULT_TIMEOUT`. This may be
overridden by the `HATCH_NETWORK_TIMEOUT` environment variable, which must be a positive number.
"""
timeout = os.environ.get(AppEnvVars.NETWORK_TIMEOUT, "").strip()
if not timeout:
return float(DEFAULT_TIMEOUT)

try:
value = float(timeout)
except ValueError:
message = f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be a number, not: {timeout}"
raise ValueError(message) from None

if not math.isfinite(value) or value <= 0:
message = f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be positive, not: {timeout}"
raise ValueError(message)

return value


@contextmanager
def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx2.Response, None, None]:
from secrets import choice
Expand All @@ -43,7 +69,7 @@ def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx2.Response,


def download_file(path: Path, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
kwargs.setdefault("timeout", get_timeout())

with path.open(mode="wb", buffering=0) as f, streaming_response("GET", *args, **kwargs) as response:
for chunk in response.iter_bytes(16384):
Expand Down
72 changes: 72 additions & 0 deletions tests/utils/test_network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import re

import pytest

from hatch.config.constants import AppEnvVars
from hatch.utils.network import DEFAULT_TIMEOUT, download_file, get_timeout
from hatch.utils.structures import EnvVars


class TestGetTimeout:
def test_default(self):
with EnvVars(exclude=[AppEnvVars.NETWORK_TIMEOUT]):
assert get_timeout() == DEFAULT_TIMEOUT

@pytest.mark.parametrize("value", ["", " "])
def test_unset_by_empty_value(self, value):
with EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}):
assert get_timeout() == DEFAULT_TIMEOUT

@pytest.mark.parametrize(("value", "expected"), [("30", 30.0), ("2.5", 2.5), (" 45 ", 45.0)])
def test_override(self, value, expected):
with EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}):
assert get_timeout() == expected

@pytest.mark.parametrize("value", ["foo", "10s", "1,5"])
def test_not_a_number(self, value):
with (
EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}),
pytest.raises(
ValueError, match=re.escape(f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be a number")
),
):
get_timeout()

@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"])
def test_not_positive(self, value):
with (
EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}),
pytest.raises(
ValueError, match=re.escape(f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be positive")
),
):
get_timeout()


class TestDownloadFile:
def test_default_timeout(self, mocker, temp_dir):
streaming_response = mocker.patch("hatch.utils.network.streaming_response")
streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"]

with EnvVars(exclude=[AppEnvVars.NETWORK_TIMEOUT]):
download_file(temp_dir / "file.txt", "https://example.com")

assert streaming_response.call_args.kwargs["timeout"] == DEFAULT_TIMEOUT

def test_timeout_from_env_var(self, mocker, temp_dir):
streaming_response = mocker.patch("hatch.utils.network.streaming_response")
streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"]

with EnvVars({AppEnvVars.NETWORK_TIMEOUT: "45"}):
download_file(temp_dir / "file.txt", "https://example.com")

assert streaming_response.call_args.kwargs["timeout"] == 45.0

def test_explicit_timeout_takes_precedence(self, mocker, temp_dir):
streaming_response = mocker.patch("hatch.utils.network.streaming_response")
streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"]

with EnvVars({AppEnvVars.NETWORK_TIMEOUT: "45"}):
download_file(temp_dir / "file.txt", "https://example.com", timeout=5)

assert streaming_response.call_args.kwargs["timeout"] == 5