Skip to content

Commit

Permalink
Add support for fetching file:// URLs.
Browse files Browse the repository at this point in the history
  • Loading branch information
jsirois committed Jan 12, 2025
1 parent 45d5f96 commit de4bd0e
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 29 deletions.
2 changes: 1 addition & 1 deletion science/commands/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def launch(
| subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
)
}
if Platform.current() in (Platform.Windows_aarch64, Platform.Windows_x86_64)
if Platform.current().is_windows
else {
# The os.setsid function is not available on Windows.
"preexec_fn": os.setsid # type: ignore[attr-defined]
Expand Down
123 changes: 119 additions & 4 deletions science/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@
from __future__ import annotations

import hashlib
import io
import json
import logging
import os
import urllib.parse
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import timedelta
from json import JSONDecodeError
from netrc import NetrcParseError
from pathlib import Path
from typing import Any, ClassVar, Mapping
from types import TracebackType
from typing import Any, BinaryIO, ClassVar, Iterator, Mapping, Protocol

import click
import httpx
from httpx import HTTPStatusError, TimeoutException
from httpx import HTTPStatusError, Request, Response, SyncByteStream, TimeoutException, codes
from tenacity import (
before_sleep_log,
retry,
Expand All @@ -31,6 +35,7 @@
from science.errors import InputError
from science.hashing import Digest, ExpectedDigest, Fingerprint
from science.model import Url
from science.platform import Platform

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -122,7 +127,115 @@ def require_password(auth_type: str) -> str:
return None


def configured_client(url: Url, headers: Mapping[str, str] | None = None) -> httpx.Client:
class Client(Protocol):
def __enter__(self) -> Client: ...

def __exit__(
self,
exc_type: type[BaseException] | None = None,
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None: ...

def get(self, url: Url) -> Response: ...

def head(self, url: Url) -> Response: ...

@contextmanager
def stream(self, method: str, url: Url) -> Iterator[Response]: ...


class FileClient:
def __enter__(self) -> Client:
return self

def __exit__(
self,
exc_type: type[BaseException] | None = None,
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None:
return None

@staticmethod
def _vet_request(url: Url, method: str = "GET") -> tuple[Request, Path] | Response:
request = Request(method=method, url=url)
if request.method not in ("GET", "HEAD"):
return Response(status_code=codes.METHOD_NOT_ALLOWED, request=request)

raw_path = urllib.parse.unquote_plus(url.info.path)
if Platform.current().is_windows:
# Handle `file:///C:/a/path` -> `C:/a/path`.
parts = raw_path.split("/")
if ":" == parts[1][-1]:
parts.pop(0)
path = Path("/".join(parts))
else:
path = Path(raw_path)

if not path.exists():
return Response(status_code=codes.NOT_FOUND, request=request)
if not path.is_file():
return Response(status_code=codes.BAD_REQUEST, request=request)
if not os.access(path, os.R_OK):
return Response(status_code=codes.FORBIDDEN, request=request)

return request, path

def head(self, url: Url) -> Response:
result = self._vet_request(url)
if isinstance(result, Response):
return result

request, path = result
return httpx.Response(
status_code=codes.OK,
headers={"Content-Length": str(path.stat().st_size)},
request=request,
)

def get(self, url: Url) -> Response:
result = self._vet_request(url)
if isinstance(result, Response):
return result

request, path = result
content = path.read_bytes()
return httpx.Response(
status_code=codes.OK,
headers={"Content-Length": str(len(content))},
content=content,
request=request,
)

@dataclass(frozen=True)
class FileByteStream(SyncByteStream):
stream: BinaryIO

def __iter__(self) -> Iterator[bytes]:
return iter(lambda: self.stream.read(io.DEFAULT_BUFFER_SIZE), b"")

def close(self) -> None:
self.stream.close()

@contextmanager
def stream(self, method: str, url: Url) -> Iterator[httpx.Response]:
result = self._vet_request(url, method=method)
if isinstance(result, Response):
yield result
return

request, path = result
with path.open("rb") as fp:
yield httpx.Response(
status_code=codes.OK,
headers={"Content-Length": str(path.stat().st_size)},
stream=self.FileByteStream(fp),
request=request,
)


def configured_client(url: Url, headers: Mapping[str, str] | None = None) -> Client:
headers = dict(headers) if headers else {}
auth = _configure_auth(url) if "Authorization" not in headers else None
return httpx.Client(follow_redirects=True, headers=headers, auth=auth)
Expand Down Expand Up @@ -190,7 +303,9 @@ def _expected_digest(

with configured_client(url, headers) as client:
return ExpectedDigest(
fingerprint=Fingerprint(client.get(f"{url}.{algorithm}").text.split(" ", 1)[0].strip()),
fingerprint=Fingerprint(
client.get(Url(f"{url}.{algorithm}")).text.split(" ", 1)[0].strip()
),
algorithm=algorithm,
)

Expand Down
6 changes: 5 additions & 1 deletion science/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ def current(cls) -> Platform:
f"{system} / {machine}"
)

@property
def is_windows(self) -> bool:
return self in (self.Windows_aarch64, self.Windows_x86_64)

@property
def extension(self):
return ".exe" if self in (self.Windows_aarch64, self.Windows_x86_64) else ""
return ".exe" if self.is_windows else ""

def binary_name(self, binary_name: str) -> str:
return f"{binary_name}{self.extension}"
Expand Down
17 changes: 8 additions & 9 deletions science/providers/pypy.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,13 @@ def distribution(self, platform: Platform) -> Distribution | None:
# We correct for that discrepency here:
top_level_archive_dir = re.sub(r"-portable$", "", selected_asset.file_stem())

match platform:
case Platform.Windows_aarch64 | Platform.Windows_x86_64:
pypy_binary = f"{top_level_archive_dir}\\{pypy}.exe"
placeholders[Identifier("pypy")] = pypy_binary
placeholders[Identifier("python")] = pypy_binary
case _:
pypy_binary = f"{top_level_archive_dir}/bin/{pypy}"
placeholders[Identifier("pypy")] = pypy_binary
placeholders[Identifier("python")] = pypy_binary
if platform.is_windows:
pypy_binary = f"{top_level_archive_dir}\\{pypy}.exe"
placeholders[Identifier("pypy")] = pypy_binary
placeholders[Identifier("python")] = pypy_binary
else:
pypy_binary = f"{top_level_archive_dir}/bin/{pypy}"
placeholders[Identifier("pypy")] = pypy_binary
placeholders[Identifier("python")] = pypy_binary

return Distribution(id=self.id, file=file, placeholders=FrozenDict(placeholders))
13 changes: 6 additions & 7 deletions science/providers/python_build_standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,13 +435,12 @@ def distribution(self, platform: Platform) -> Distribution | None:
placeholders = {}
match self._distributions.flavor:
case "install_only" | "install_only_stripped":
match platform:
case Platform.Windows_aarch64 | Platform.Windows_x86_64:
placeholders[Identifier("python")] = "python\\python.exe"
case _:
version = f"{selected_asset.version.major}.{selected_asset.version.minor}"
placeholders[Identifier("python")] = f"python/bin/python{version}"
placeholders[Identifier("pip")] = f"python/bin/pip{version}"
if platform.is_windows:
placeholders[Identifier("python")] = "python\\python.exe"
else:
version = f"{selected_asset.version.major}.{selected_asset.version.minor}"
placeholders[Identifier("python")] = f"python/bin/python{version}"
placeholders[Identifier("pip")] = f"python/bin/pip{version}"
case flavor:
raise InputError(
"PythonBuildStandalone currently only understands the 'install_only' flavor of "
Expand Down
13 changes: 6 additions & 7 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,12 @@ def test_interpreter_groups(tmp_path: Path, science_pyz: Path) -> None:

def test_scie_base(tmp_path: Path, science_pyz: Path) -> None:
current_platform = Platform.current()
match current_platform:
case Platform.Windows_aarch64 | Platform.Windows_x86_64:
config_name = "scie-base.windows.toml"
expected_base = "~\\AppData\\Local\\Temp\\custom-base"
case _:
config_name = "scie-base.unix.toml"
expected_base = "/tmp/custom-base"
if current_platform.is_windows:
config_name = "scie-base.windows.toml"
expected_base = "~\\AppData\\Local\\Temp\\custom-base"
else:
config_name = "scie-base.unix.toml"
expected_base = "/tmp/custom-base"

with resources.as_file(resources.files("data") / config_name) as config:
subprocess.run(
Expand Down

0 comments on commit de4bd0e

Please sign in to comment.