Skip to content
Draft
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
16 changes: 0 additions & 16 deletions .basedpyright/baseline.bfabric.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,6 @@
"lineCount": 1
}
},
{
"code": "reportCallIssue",
"range": {
"startColumn": 17,
"endColumn": 55,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
Expand Down Expand Up @@ -1703,14 +1695,6 @@
}
],
"./bfabric/src/bfabric/entities/core/uri.py": [
{
"code": "reportArgumentType",
"range": {
"startColumn": 29,
"endColumn": 45,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
Expand Down
6 changes: 3 additions & 3 deletions bfabric/docs/api_reference/entity_uri/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ from bfabric.entities.core.uri import EntityUri
uri = EntityUri("https://fgcz-bfabric.uzh.ch/bfabric/sample/show.html?id=123")

# Access components
print(uri.components.bfabric_instance) # "https://fgcz-bfabric.uzh.ch/bfabric/"
print(uri.components.bfabric_instance) # "https://fgcz-bfabric.uzh.ch/bfabric"
print(uri.components.entity_type) # "sample"
print(uri.components.entity_id) # 123
```
Expand Down Expand Up @@ -94,7 +94,7 @@ from bfabric.entities.core.uri import EntityUri

# Build URI from parts
uri = EntityUri.from_components(
bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
entity_type="sample",
entity_id=123,
)
Expand Down Expand Up @@ -133,7 +133,7 @@ entities = reader.read_uris(uris)

| Component | Description | Example |
|-----------|-------------|---------|
| `bfabric_instance` | Base URL of B-Fabric instance | `https://fgcz-bfabric.uzh.ch/bfabric/` |
| `bfabric_instance` | Base URL of B-Fabric instance | `https://fgcz-bfabric.uzh.ch/bfabric` |
| `entity_type` | Entity name (lowercase) | `sample`, `project`, `workunit` |
| `entity_id` | Numeric entity ID | `123` |

Expand Down
4 changes: 2 additions & 2 deletions bfabric/docs/api_reference/token_data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ from bfabric import Bfabric
from bfabric.experimental.webapp_integration_settings import TokenValidationSettings

settings = TokenValidationSettings(
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
supported_bfabric_instances=["https://fgcz-bfabric.uzh.ch/bfabric/"],
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
supported_bfabric_instances=["https://fgcz-bfabric.uzh.ch/bfabric"],
)

client, token_data = Bfabric.connect_token(token=token, settings=settings)
Expand Down
8 changes: 8 additions & 0 deletions bfabric/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ Minor breaking changes are still possible in `1.X.Y` but we try to announce them
- PKCE's printed-URL fallback and timeout error now name the loopback redirect target and point at the device-code flow.
- `use_client` logs the reported error's traceback at DEBUG; the `Error: <message>` line and exit code 1 are unchanged.
- `create_workunit` accepts a plain mapping for `params`, validated internally so an invalid mapping raises `ValidationError` before any write.
- **Breaking: `BfabricClientConfig.base_url` is canonicalised *without* a trailing slash**, reversing the 1.15.0 "always ends with exactly one `/`". Config files and `connect_*` arguments still accept one.
- `Entity.bfabric_instance` and `EntityUri.components.bfabric_instance` follow the same form; `EntityUri` strings are unchanged.
- `connect_oauth` / `connect_pkce` / `connect_device_code` / `connect_pat` and `WebappClient.create` canonicalise `base_url` through the new `BaseUrl`, so a host with mixed case or a default port is normalised too, and a non-HTTP URL is rejected up front with a plain `ValueError`.
- **Breaking: new `bfabric.BaseUrl`**, a `str` subclass holding a validated slash-free instance URL. `BfabricClientConfig.base_url`, `Entity.bfabric_instance` and `EntityUriComponents.bfabric_instance` (was a pydantic `HttpUrl`) are now this type, and `EntityReader`'s `bfabric_instance` arguments require it.
- It behaves as a `str` everywhere, so interpolation, comparison and dict keys are unaffected. Constructing `BfabricClientConfig` directly now needs `BaseUrl(...)`; `model_validate` still accepts a plain string.
- `bfabric-cli auth login` / `auth pat` write the config with `yaml.safe_dump`, matching the `safe_load` used to read it back.

### Fixed

- The SUDS and Zeep WSDL URLs no longer contain a doubled slash (`…/bfabric//workunit?wsdl`), and neither do the `show.html` links printed by `bfabric_read` and `bfabric-cli api read`.
- `validate_token` canonicalises both the token's `caller` and the configured `supported_bfabric_instances` before comparing, so a trailing slash on either side no longer rejects a valid token.
- `ResultContainer.assert_success` raises `BfabricRequestError` instead of a bare `RuntimeError`; it remains a `RuntimeError` subclass, so existing `except RuntimeError` handlers keep working.
- The "could not find the config file" and "empty list provided for deletion" diagnostics go through loguru at WARNING instead of `print()`, so they honour the configured log level and sink.
- `setup_script_logging` no longer skips setup in subprocesses, which fell back to loguru's verbose DEBUG default; its repeat guard is now process-local.
Expand Down
11 changes: 7 additions & 4 deletions bfabric/docs/getting_started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ GENERAL:
PRODUCTION:
login: yourBfabricLogin
password: yourBfabricWebServicePassword # Get from B-Fabric profile
base_url: https://fgcz-bfabric.uzh.ch/bfabric/
base_url: https://fgcz-bfabric.uzh.ch/bfabric

TEST:
login: yourBfabricLogin
password: yourBfabricWebServicePassword
base_url: https://fgcz-bfabric-test.uzh.ch/bfabric/
base_url: https://fgcz-bfabric-test.uzh.ch/bfabric
```

A trailing slash on `base_url` is accepted and dropped, so `client.config.base_url` always reads back
without one.

### Web Service Password

The password in your config file is **NOT** your login password. Find your web service password:
Expand Down Expand Up @@ -80,7 +83,7 @@ python script.py # Will use TEST environment
Complete configuration override (highest priority). Used primarily for integration tests, where it needs to be prevented that the regular config file leads to the wrong B-Fabric instance being modified.

```bash
export BFABRICPY_CONFIG_OVERRIDE='{"client": {"base_url": "https://fgcz-bfabric.uzh.ch/bfabric/"}, "auth": {"login": "myuser", "password": "mypass"}}'
export BFABRICPY_CONFIG_OVERRIDE='{"client": {"base_url": "https://fgcz-bfabric.uzh.ch/bfabric"}, "auth": {"login": "myuser", "password": "mypass"}}'
python script.py # Uses this config, ignoring ~/.bfabricpy.yml
```

Expand All @@ -103,7 +106,7 @@ from bfabric.config import BfabricAuth, BfabricClientConfig

# Create config programmatically
client_config = BfabricClientConfig(
base_url="https://fgcz-bfabric.uzh.ch/bfabric/",
base_url="https://fgcz-bfabric.uzh.ch/bfabric",
engine="SUDS", # default; "ZEEP" is also available but requires `pip install bfabric[zeep]`
)

Expand Down
6 changes: 3 additions & 3 deletions bfabric/docs/getting_started/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ GENERAL:
PRODUCTION:
login: yourBfabricLogin
password: yourBfabricWebServicePassword
base_url: https://fgcz-bfabric.uzh.ch/bfabric/
base_url: https://fgcz-bfabric.uzh.ch/bfabric
```

## Query Issues
Expand Down Expand Up @@ -148,8 +148,8 @@ print(f"User: {client.auth.login}")

Common URLs:

- **Production**: `https://fgcz-bfabric.uzh.ch/bfabric/`
- **Test**: `https://fgcz-bfabric-test.uzh.ch/bfabric/`
- **Production**: `https://fgcz-bfabric.uzh.ch/bfabric`
- **Test**: `https://fgcz-bfabric-test.uzh.ch/bfabric`

If you're connecting to the wrong instance, update your config file or use a different environment:

Expand Down
28 changes: 14 additions & 14 deletions bfabric/docs/user_guides/creating_a_client/server_webapp_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ from bfabric.experimental.webapp_integration_settings import TokenValidationSett

# Configure which B-Fabric instances are allowed
settings = TokenValidationSettings(
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
supported_bfabric_instances=[
"https://fgcz-bfabric.uzh.ch/bfabric/",
"https://fgcz-bfabric-test.uzh.ch/bfabric/",
"https://fgcz-bfabric.uzh.ch/bfabric",
"https://fgcz-bfabric-test.uzh.ch/bfabric",
],
)

Expand Down Expand Up @@ -61,12 +61,12 @@ from bfabric import Bfabric
from bfabric.rest.token_data import get_token_data

# Validate token first
base_url = "https://fgcz-bfabric.uzh.ch/bfabric/"
base_url = "https://fgcz-bfabric.uzh.ch/bfabric"
token = "your_token_here"
token_data = get_token_data(base_url=base_url, token=token)

# Check if the token is from an allowed instance
allowed_instances = ["https://fgcz-bfabric.uzh.ch/bfabric/"]
allowed_instances = ["https://fgcz-bfabric.uzh.ch/bfabric"]
if token_data.caller not in allowed_instances:
raise ValueError(f"Token from {token_data.caller} is not allowed")

Expand All @@ -84,10 +84,10 @@ The `TokenValidationSettings` class configures which B-Fabric instances are allo
from bfabric.experimental.webapp_integration_settings import TokenValidationSettings

settings = TokenValidationSettings(
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
supported_bfabric_instances=[
"https://fgcz-bfabric.uzh.ch/bfabric/",
"https://fgcz-bfabric-test.uzh.ch/bfabric/",
"https://fgcz-bfabric.uzh.ch/bfabric",
"https://fgcz-bfabric-test.uzh.ch/bfabric",
],
)
```
Expand All @@ -110,10 +110,10 @@ from bfabric.experimental.webapp_integration_settings import WebappIntegrationSe
from bfabric.config import BfabricAuth

settings = WebappIntegrationSettings(
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
supported_bfabric_instances=["https://fgcz-bfabric.uzh.ch/bfabric/"],
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
supported_bfabric_instances=["https://fgcz-bfabric.uzh.ch/bfabric"],
feeder_user_credentials={
"https://fgcz-bfabric.uzh.ch/bfabric/": BfabricAuth(
"https://fgcz-bfabric.uzh.ch/bfabric": BfabricAuth(
login="feeder_user", password="feeder_user_password"
),
},
Expand All @@ -137,10 +137,10 @@ Always restrict `supported_bfabric_instances` to only the instances you trust:

```python
settings = TokenValidationSettings(
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric/",
validation_bfabric_instance="https://fgcz-bfabric.uzh.ch/bfabric",
supported_bfabric_instances=[
"https://fgcz-bfabric.uzh.ch/bfabric/", # Only allow production
# "https://fgcz-bfabric-test.uzh.ch/bfabric/", # Commented out to prevent test tokens
"https://fgcz-bfabric.uzh.ch/bfabric", # Only allow production
# "https://fgcz-bfabric-test.uzh.ch/bfabric", # Commented out to prevent test tokens
],
)
```
Expand Down
2 changes: 1 addition & 1 deletion bfabric/docs/user_guides/reading_data/entity_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ sample = reader.read_id(entity_type="sample", entity_id=123)
# Entity identifier
print(sample.id) # 123
print(sample.classname) # "sample"
print(sample.bfabric_instance) # "https://fgcz-bfabric.uzh.ch/bfabric/"
print(sample.bfabric_instance) # "https://fgcz-bfabric.uzh.ch/bfabric"

# Entity URI
uri = sample.uri
Expand Down
2 changes: 2 additions & 0 deletions bfabric/src/bfabric/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import importlib.metadata

from bfabric.bfabric import Bfabric
from bfabric.config.base_url import BaseUrl
from bfabric.config.bfabric_auth import BfabricAuth
from bfabric.config.bfabric_client_config import BfabricAPIEngineType, BfabricClientConfig

__all__ = [
"BaseUrl",
"Bfabric",
"BfabricAPIEngineType",
"BfabricAuth",
Expand Down
3 changes: 2 additions & 1 deletion bfabric/src/bfabric/_oauth/credential_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path

if TYPE_CHECKING:
from bfabric.config.base_url import BaseUrl
from pathlib import Path


Expand Down Expand Up @@ -120,7 +121,7 @@ def __init__(
self._persist()

@classmethod
def cache_login_token(cls, base_url: str, *, client_id: str, token: dict[str, object], env_name: str) -> Path:
def cache_login_token(cls, base_url: BaseUrl, *, client_id: str, token: dict[str, object], env_name: str) -> Path:
"""Normalize and cache a freshly obtained login *token*, returning its cache path.

Ingesting the token derives its absolute ``expires_at`` (from ``expires_in``) and writes the
Expand Down
12 changes: 7 additions & 5 deletions bfabric/src/bfabric/_oauth/device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@

import sys
import time
from typing import TYPE_CHECKING

import httpx
from loguru import logger

from bfabric.errors import BfabricOAuthError

if TYPE_CHECKING:
from bfabric.config.base_url import BaseUrl


def _request_device_code(
base_url: str,
base_url: BaseUrl,
*,
client_id: str,
scope: str,
Expand Down Expand Up @@ -51,7 +55,7 @@ def _request_device_code(


def _poll_for_token(
base_url: str,
base_url: BaseUrl,
*,
device_code: str,
client_id: str,
Expand Down Expand Up @@ -136,7 +140,7 @@ def _poll_for_token(


def device_code_login(
base_url: str,
base_url: BaseUrl,
*,
client_id: str,
scope: str,
Expand All @@ -147,14 +151,12 @@ def device_code_login(
Requests a device code, displays the user code and verification URI,
then polls until the user authorizes or the request times out.

:param base_url: B-Fabric instance URL (e.g. ``https://bfabric.example.com/bfabric``)
:param client_id: OAuth client ID
:param scope: OAuth scope
:param timeout: Seconds to wait for the user to authorize
:returns: Token dict with ``access_token``, ``refresh_token``, etc.
:raises RuntimeError: On timeout, expired token, or access denied
"""
base_url = base_url.rstrip("/")
logger.debug("Starting device code flow for {}", base_url)
device_response = _request_device_code(base_url, client_id=client_id, scope=scope)

Expand Down
7 changes: 4 additions & 3 deletions bfabric/src/bfabric/_oauth/pkce.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING
from urllib.parse import parse_qs, urlencode, urlparse

import httpx
from loguru import logger

from bfabric.errors import BfabricOAuthError

if TYPE_CHECKING:
from bfabric.config.base_url import BaseUrl

_REMOTE_HOST_CAVEAT = "On a remote host, use 'bfabric-cli auth device-code' instead."

Expand Down Expand Up @@ -166,7 +169,7 @@ def _exchange_code(


def pkce_login(
base_url: str,
base_url: BaseUrl,
*,
client_id: str,
scope: str,
Expand All @@ -176,14 +179,12 @@ def pkce_login(
) -> dict[str, object]:
"""Perform an OAuth 2.0 Authorization Code flow with PKCE.

:param base_url: B-Fabric instance URL (e.g. ``https://bfabric.example.com/bfabric``)
: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 timeout: Seconds to wait for the user to complete login
:returns: Token dict with ``access_token``, ``refresh_token``, etc.
:raises RuntimeError: On timeout, CSRF state mismatch, or authorization error
"""
base_url = base_url.rstrip("/")
logger.debug("Starting PKCE login flow for {}", base_url)
verifier = _generate_verifier()
challenge = _generate_challenge(verifier)
Expand Down
10 changes: 4 additions & 6 deletions bfabric/src/bfabric/_oauth/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

if TYPE_CHECKING:
from bfabric.bfabric import Bfabric
from bfabric.config.base_url import BaseUrl
from bfabric.results.result_container import ResultContainer
from bfabric.typing import ApiRequestDataType

Expand All @@ -32,7 +33,7 @@ def _default_grant_types(service_user: str | None) -> list[str]:


def register_client(
base_url: str,
base_url: BaseUrl,
token: str,
client_name: str,
redirect_uri: str,
Expand All @@ -49,7 +50,6 @@ def register_client(
``token-exchange``, ``refresh_token`` and ``authorization_code`` always, plus
``client_credentials`` when *service_user* is provided. Pass *grant_types* to override.

:param base_url: B-Fabric instance URL (e.g. ``https://bfabric.example.com/bfabric``)
:param token: Employee Bearer token for authorization
:param client_name: Human-readable name for the client
:param redirect_uri: OAuth redirect URI for the client
Expand All @@ -58,7 +58,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.rstrip('/')}/rest/oauth/register"
url = f"{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 Expand Up @@ -115,10 +115,8 @@ def register_webapp(
:returns: Dict with ``"oauth"`` (registration response) and ``"application"``
(save response) keys
"""
base_url = client.config.base_url.rstrip("/")

oauth_result = register_client(
base_url=base_url,
base_url=client.config.base_url,
token=token,
client_name=app_name,
redirect_uri=web_url,
Expand Down
Loading