Source code for httk.core.docs.inventories
#
# The high-throughput toolkit (httk)
# Copyright (C) 2012-2024 the httk AUTHORS
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Read and validate Sphinx intersphinx v2 inventory headers."""
import os
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
__all__ = ["InventoryError", "fetch_inventory", "read_inventory_header"]
[docs]
class InventoryError(RuntimeError):
"""Raised when an inventory cannot be fetched or has unexpected metadata."""
_ALLOWED_SCHEMES = frozenset({"http", "https", "file"})
_MAX_INVENTORY_BYTES = 64 * 1024 * 1024
_READ_CHUNK_SIZE = 1024 * 1024
def _validate_url(url: str) -> None:
scheme = urlsplit(url).scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
raise InventoryError(f"inventory URL has unsupported scheme {scheme!r}: {url}")
class _AllowedRedirectHandler(HTTPRedirectHandler):
def redirect_request(
self, req: object, fp: object, code: int, msg: str, headers: object, newurl: str
) -> Request | None:
_validate_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl) # type: ignore[arg-type]
def _inventory_bytes(path_or_bytes: str | Path | bytes) -> bytes:
if isinstance(path_or_bytes, bytes):
return path_or_bytes
return Path(path_or_bytes).read_bytes()
[docs]
def fetch_inventory(
url: str,
dest: str | Path,
*,
expected_project: str | None = None,
expected_version: str | None = None,
) -> tuple[str, str]:
"""Fetch an inventory from HTTP(S) or ``file://``, validate it, and save it."""
_validate_url(url)
try:
opener = build_opener(_AllowedRedirectHandler)
with opener.open(url, timeout=30) as response:
content_length = response.headers.get("Content-Length")
if content_length is not None and int(content_length) > _MAX_INVENTORY_BYTES:
raise InventoryError(f"inventory {url} exceeds the 64 MiB size limit")
chunks: list[bytes] = []
total = 0
while True:
chunk = response.read(_READ_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > _MAX_INVENTORY_BYTES:
raise InventoryError(f"inventory {url} exceeds the 64 MiB size limit")
chunks.append(chunk)
final_url = response.geturl()
_validate_url(final_url)
data = b"".join(chunks)
except OSError as exc:
raise InventoryError(f"cannot fetch inventory {url}: {exc}") from exc
except ValueError as exc:
raise InventoryError(f"invalid inventory response from {url}: {exc}") from exc
try:
project, version = read_inventory_header(data)
except InventoryError as exc:
raise InventoryError(f"inventory {url}: {exc}") from exc
if expected_project is not None and project != expected_project:
raise InventoryError(f"inventory {url}: expected project {expected_project!r}, found {project!r}")
if expected_version is not None and version != expected_version:
raise InventoryError(f"inventory {url}: expected version {expected_version!r}, found {version!r}")
destination = Path(dest)
if destination.is_symlink():
raise InventoryError(f"refusing symlink inventory destination: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{destination.name}.staging-", dir=destination.parent)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
os.replace(temporary_path, destination)
finally:
temporary_path.unlink(missing_ok=True)
return project, version