Skip to content
Merged
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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Run each package's suite in a **separate** `pytest` invocation, as nox does —

### Type Checking
```bash
nox -s basedpyright(bfabric)
nox -s basedpyright(bfabric_scripts)
nox -s basedpyright(bfabric_app_runner)
nox -s "basedpyright(package='bfabric')"
nox -s "basedpyright(package='bfabric_scripts')"
nox -s "basedpyright(package='bfabric_app_runner')"
```

### Linting and formatting
Expand Down
18 changes: 18 additions & 0 deletions bfabric/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ Minor breaking changes are still possible in `1.X.Y` but we try to announce them

## \[Unreleased\]

## \[1.24.0\] - 2026-10-05

### Added

- `upload_files` accepts `on_hash_progress`, called while each file's MD5 is computed; `bfabric-cli workunit upload` shows it as a progress bar.
- `upload_files` remembers each file's MD5 by path, size and mtime (`hash_cache`, default `~/.bfabric/hashes.json`), so a retry or resumed upload skips re-hashing unchanged files. `hash_workers` hashes several entries concurrently. `on_hash_start` reports the total files and bytes before hashing, and a file served from the hash cache is reported as fully hashed.
- `upload_files` accepts `chunk_size` (bytes per tus request, default 32 MiB instead of the mover's 4 MiB) and `send_to_sink` forwards it to the tus mover.

### Changed

- `pkce_login` no longer tries to open a browser over SSH, without a display, or when only a text browser is available; it prints the login URL instead. `bfabric.oauth.graphical_browser_available` exposes that check.

### Fixed

- `upload_files` resuming an interrupted upload no longer fails with `importResourceIds count must match resourceIds count`. Interrupted uploads saved by an earlier version restart from byte 0 instead of resuming.
- `upload_files` resuming an interrupted run no longer fails with a 409 conflict: files that already finished are reported as uploaded, and files that never started reuse the resource created for them.
- OAuth requests to an instance recorded with an `http://` base URL are sent over `https://` instead of failing on the server's redirect (loopback hosts excepted). `bfabric.oauth.https_base_url` exposes the upgrade.

## \[1.23.0\] - 2026-09-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion bfabric/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "bfabric"
description = "Python client for the B-Fabric API"
readme = "README.md"
version = "1.23.0"
version = "1.24.0"
license = { text = "GPL-3.0" }
authors = [
{ name = "Christian Panse", email = "cp@fgcz.ethz.ch" },
Expand Down
6 changes: 4 additions & 2 deletions bfabric/src/bfabric/oauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# Importing this module pulls authlib, joserfc and httpx; ``import bfabric`` alone does not.
from bfabric.oauth._credential_provider import OAuthCredentialProvider
from bfabric.oauth._device_code import device_code_login
from bfabric.oauth._endpoints import token_url
from bfabric.oauth._pkce import AuthorizationRequest, exchange_code, pkce_login
from bfabric.oauth._endpoints import https_base_url, token_url
from bfabric.oauth._pkce import AuthorizationRequest, exchange_code, graphical_browser_available, pkce_login
from bfabric.oauth._registration import (
delete_client,
read_client,
Expand All @@ -26,6 +26,8 @@
"delete_client",
"device_code_login",
"exchange_code",
"graphical_browser_available",
"https_base_url",
"pkce_login",
"read_client",
"register_client",
Expand Down
4 changes: 2 additions & 2 deletions bfabric/src/bfabric/oauth/_device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from loguru import logger

from bfabric.errors import BfabricOAuthError, raise_if_unavailable
from bfabric.oauth._endpoints import token_url
from bfabric.oauth._endpoints import https_base_url, token_url

if TYPE_CHECKING:
from bfabric.config.base_url import BaseUrl
Expand All @@ -31,7 +31,7 @@ def _request_device_code(
logger.debug("Requesting device code from {}", base_url)
with raise_if_unavailable(base_url):
response = httpx.post(
f"{base_url}/rest/oauth/device_authorization",
f"{https_base_url(base_url)}/rest/oauth/device_authorization",
data={
"client_id": client_id,
"scope": scope,
Expand Down
22 changes: 19 additions & 3 deletions bfabric/src/bfabric/oauth/_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,27 @@

from __future__ import annotations

from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit, urlunsplit

from bfabric.config.base_url import BaseUrl


_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})


def https_base_url(base_url: str) -> str:
"""*base_url* with ``http`` upgraded to ``https``, except for a loopback host (local development).

OAuth requests carry codes, secrets and refresh tokens, so they must not go out in the clear. A
config that still records ``http://`` would otherwise send the first request unencrypted and then
fail, because the server answers with a redirect that ``httpx`` does not follow.
"""
parts = urlsplit(base_url)
if parts.scheme != "http" or parts.hostname in _LOOPBACK_HOSTS:
return base_url
return urlunsplit(parts._replace(scheme="https"))


def authorize_url(
base_url: str,
*,
Expand All @@ -36,9 +52,9 @@ def authorize_url(
"scope": scope,
}
)
return f"{BaseUrl(base_url)}/rest/oauth/authorize?{query}"
return f"{https_base_url(BaseUrl(base_url))}/rest/oauth/authorize?{query}"


def token_url(base_url: str) -> str:
"""The token endpoint that issues, exchanges and refreshes tokens."""
return f"{BaseUrl(base_url)}/rest/oauth/token"
return f"{https_base_url(BaseUrl(base_url))}/rest/oauth/token"
26 changes: 24 additions & 2 deletions bfabric/src/bfabric/oauth/_pkce.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
import base64
import hashlib
import html
import os
import secrets
import sys
import threading
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import parse_qs, urlparse
Expand Down Expand Up @@ -227,6 +229,25 @@ def exchange_code(
return result


_TEXT_BROWSERS = frozenset({"www-browser", "links", "links2", "elinks", "lynx", "w3m", "browsh"})


def graphical_browser_available() -> bool:
"""Whether opening a browser here would plausibly reach a GUI the user can see.

``webbrowser.open`` reports success for a text browser or a remote session, which only strands the user.
"""
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
return False
if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return False
try:
name = getattr(webbrowser.get(), "name", "")
except webbrowser.Error:
return False
return Path(name).name not in _TEXT_BROWSERS


def pkce_login(
base_url: BaseUrl,
*,
Expand All @@ -239,7 +260,8 @@ def pkce_login(
"""Perform an OAuth 2.0 Authorization Code flow with PKCE.

:param port: Local port for the callback server (``0`` = auto-assign)
:param open_browser: If ``False``, or if the browser fails to open, the URL is printed to stderr
:param open_browser: If ``False``, over SSH, without a display or with only a text browser, or if the browser
fails to open, the URL is printed to stderr instead
:param timeout: Seconds to wait for the user to complete login
:returns: Token dict with ``access_token``, ``refresh_token``, etc.
:raises BfabricOAuthError: On timeout, CSRF state mismatch, or authorization error
Expand All @@ -255,7 +277,7 @@ def pkce_login(
)

browser_opened = False
if open_browser:
if open_browser and graphical_browser_available():
browser_opened = webbrowser.open(request.url)
if not browser_opened:
print(
Expand Down
3 changes: 2 additions & 1 deletion bfabric/src/bfabric/oauth/_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from loguru import logger

from bfabric.errors import raise_if_unavailable
from bfabric.oauth._endpoints import https_base_url

if TYPE_CHECKING:
from bfabric.bfabric import Bfabric
Expand Down Expand Up @@ -60,7 +61,7 @@ def register_client(
:param grant_types: Explicit list of grant types to request (overrides the default)
:returns: Registration response containing ``client_id``, ``client_secret``, etc.
"""
url = f"{base_url}/rest/oauth/register"
url = f"{https_base_url(base_url)}/rest/oauth/register"
resolved_grant_types = grant_types if grant_types is not None else _default_grant_types(service_user)
body: dict[str, object] = {
"client_name": client_name,
Expand Down
4 changes: 2 additions & 2 deletions bfabric/src/bfabric/oauth/_token_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from bfabric.errors import raise_if_unavailable

from bfabric.oauth._endpoints import token_url
from bfabric.oauth._endpoints import https_base_url, token_url
from bfabric.oauth._url_token import UrlTokenContext

if TYPE_CHECKING:
Expand Down Expand Up @@ -66,7 +66,7 @@ def introspect_token(

:raises httpx.HTTPStatusError: On non-2xx responses
"""
url = f"{base_url}/rest/oauth/introspect"
url = f"{https_base_url(base_url)}/rest/oauth/introspect"
logger.debug("Introspecting token at {}", url)
with raise_if_unavailable(base_url):
response = httpx.post(
Expand Down
3 changes: 2 additions & 1 deletion bfabric/src/bfabric/oauth/_url_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from bfabric.config.base_url import BaseUrl
from bfabric.errors import raise_if_unavailable
from bfabric.oauth._endpoints import https_base_url
from pydantic import BaseModel, ConfigDict, Field


Expand Down Expand Up @@ -73,7 +74,7 @@ def _fetch_jwks(base_url: BaseUrl) -> dict[str, object]:
return jwks

logger.debug("Fetching JWKS from {}", base_url)
url = f"{base_url}/rest/oauth/jwks"
url = f"{https_base_url(base_url)}/rest/oauth/jwks"
with raise_if_unavailable(base_url):
response = httpx.get(url, timeout=30)
_ = response.raise_for_status()
Expand Down
Loading
Loading