Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement profiles to specify scicat instances more easily #275

Merged
merged 4 commits into from
Mar 26, 2025
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
1 change: 1 addition & 0 deletions docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Auxiliary classes
datablock.OrigDatablock
dataset.DatablockUploadModels
PID
Profile
RemotePath
Thumbnail
DatasetType
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"httpx >= 0.24",
"pydantic >= 2",
"python-dateutil >= 2.8",
"tomli >= 2.2.0", # TODO remove when we drop py3.10
]
dynamic = ["version"]

Expand Down
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ httpx >= 0.24
paramiko >= 3
pydantic >= 2
python-dateutil >= 2.8
tomli >= 2.2.0 # TODO remove when we drop py3.10
2 changes: 2 additions & 0 deletions src/scitacean/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"

from ._profile import Profile
from .client import Client
from .datablock import OrigDatablock
from .dataset import Dataset
Expand Down Expand Up @@ -38,6 +39,7 @@
"FileUploadError",
"IntegrityError",
"OrigDatablock",
"Profile",
"RemotePath",
"Sample",
"ScicatCommError",
Expand Down
146 changes: 146 additions & 0 deletions src/scitacean/_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 SciCat Project (https://github.com/SciCatProject/scitacean)
"""Profiles for connecting to SciCat."""

from dataclasses import dataclass
from pathlib import Path

import tomli

from .transfer.copy import CopyFileTransfer
from .transfer.link import LinkFileTransfer
from .transfer.select import SelectFileTransfer
from .transfer.sftp import SFTPFileTransfer
from .typing import FileTransfer


@dataclass(frozen=True, slots=True)
class Profile:
"""Parameters for connecting to a specific instance of SciCat.

The fields of a profile correspond to the arguments of the constructors
of :class:`Client`.
They can be overridden by the explicit constructor arguments.

When constructing a client from a profile, the ``profile`` argument may be one of:

- If ``profile`` is a :class:`Profile`, it is returned as is.
- If ``profile`` is a :class:`pathlib.Path`, a profile is loaded from
the file at that path.
- If ``profile`` is a :class:`str`
* and ``profile`` matches the name of a builtin profile,
that profile is returned.
* Otherwise, a profile is loaded from a file with this path, potentially
by appending ``".profile.toml"`` to the name.
"""

url: str
"""URL of the SciCat API.

Note that this is the URL to the API, *not* the web interface.
For example, at ESS, the web interface URL is ``"https://scicat.ess.eu"``.
But the API URL is ``"https://scicat.ess.eu/api/v3"`` (at the time of writing).
"""
file_transfer: FileTransfer | None
"""A file transfer object for uploading and downloading files.

See the `File transfer <../../reference/index.rst#file-transfer>`_. section for
implementations provided by Scitacean.
"""


def locate_profile(spec: str | Path | Profile) -> Profile:
"""Find and return a specified profile."""
if isinstance(spec, Profile):
return spec

if isinstance(spec, Path):
return _load_profile_from_file(spec)

try:
return _builtin_profile(spec)
except KeyError:
pass

try:
return _load_profile_from_file(spec)
except FileNotFoundError:
if spec.endswith(".profile.toml"):
raise ValueError(f"Unknown profile: {spec}") from None

try:
return _load_profile_from_file(f"{spec}.profile.toml")
except FileNotFoundError:
raise ValueError(f"Unknown profile: {spec}") from None


def _builtin_profile(name: str) -> Profile:
match name:
case "ess":
return Profile(
url="https://scicat.ess.eu/api/v3", file_transfer=_ess_file_transfer()
)
case "staging.ess":
return Profile(
url="https://staging.scicat.ess.eu/api/v3",
file_transfer=_ess_file_transfer(),
)
raise KeyError(f"Unknown builtin profile: {name}")


def _ess_file_transfer() -> FileTransfer:
return SelectFileTransfer(
[
LinkFileTransfer(),
CopyFileTransfer(),
SFTPFileTransfer(host="login.esss.dk"),
]
)


def _load_profile_from_file(name: str | Path) -> Profile:
with open(name, "rb") as file:
contents = tomli.load(file)
return Profile(url=contents["url"], file_transfer=None)


@dataclass(frozen=True, slots=True)
class ClientParams:
"""Parameters for creating a client."""

url: str
file_transfer: FileTransfer | None


def make_client_params(
*,
profile: str | Path | Profile | None,
url: str | None,
file_transfer: FileTransfer | None,
) -> ClientParams:
"""Return parameters for creating a client."""
p = locate_profile(profile) if profile is not None else None
url = _get_url(p, url)
file_transfer = _get_file_transfer(p, file_transfer)
return ClientParams(url=url, file_transfer=file_transfer)


def _get_url(profile: Profile | None, url: str | None) -> str:
match (profile, url):
case (None, None):
raise TypeError("Either `profile` or `url` must be provided")
case (p, None):
return p.url # type: ignore[union-attr]
case _:
return url # type: ignore[return-value]


def _get_file_transfer(
profile: Profile | None,
file_transfer: FileTransfer | None,
) -> FileTransfer | None:
if profile is None:
return file_transfer
if file_transfer is None:
return profile.file_transfer
return file_transfer
48 changes: 39 additions & 9 deletions src/scitacean/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from . import model
from ._base_model import convert_download_to_user_model
from ._profile import Profile, make_client_params
from .dataset import Dataset
from .error import ScicatCommError, ScicatLoginError
from .file import File
Expand Down Expand Up @@ -62,15 +63,20 @@ def __init__(
@classmethod
def from_token(
cls,
profile: str | Path | Profile | None = None,
*,
url: str,
url: str | None = None,
token: str | StrStorage,
file_transfer: FileTransfer | None = None,
) -> Client:
"""Create a new client and authenticate with a token.

Parameters
----------
profile:
Encodes how to connect to SciCat.
Elements are overridden by the other arguments if provided.
The behaviour is described in :class:`Profile`.
url:
URL of the SciCat api.
token:
Expand All @@ -83,17 +89,21 @@ def from_token(
:
A new client.
"""
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return Client(
client=ScicatClient.from_token(url=url, token=token),
file_transfer=file_transfer,
client=ScicatClient.from_token(url=params.url, token=token),
file_transfer=params.file_transfer,
)

# TODO rename to login? and provide logout?
@classmethod
def from_credentials(
cls,
profile: str | Path | Profile | None = None,
*,
url: str,
url: str | None = None,
username: str | StrStorage,
password: str | StrStorage,
file_transfer: FileTransfer | None = None,
Expand All @@ -102,6 +112,10 @@ def from_credentials(

Parameters
----------
profile:
Encodes how to connect to SciCat.
Elements are overridden by the other arguments if provided.
The behaviour is described in :class:`Profile`.
url:
URL of the SciCat api.
It should include the suffix `api/vn` where `n` is a number.
Expand All @@ -117,26 +131,38 @@ def from_credentials(
:
A new client.
"""
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return Client(
client=ScicatClient.from_credentials(
url=url, username=username, password=password
url=params.url, username=username, password=password
),
file_transfer=file_transfer,
file_transfer=params.file_transfer,
)

@classmethod
def without_login(
cls, *, url: str, file_transfer: FileTransfer | None = None
cls,
profile: str | Path | Profile | None = None,
*,
url: str | None = None,
file_transfer: FileTransfer | None = None,
) -> Client:
"""Create a new client without authentication.

The client can only download public datasets and not upload at all.

Parameters
----------
profile:
Encodes how to connect to SciCat.
Elements are overridden by the other arguments if provided.
The behaviour is described in :class:`Profile`.
url:
URL of the SciCat api.
It should include the suffix `api/vn` where `n` is a number.
It typically should include the suffix `api/vn` where `n` is a number
Must be provided is ``profile is None``.
file_transfer:
Handler for down-/uploads of files.

Expand All @@ -145,8 +171,12 @@ def without_login(
:
A new client.
"""
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return Client(
client=ScicatClient.without_login(url=url), file_transfer=file_transfer
client=ScicatClient.without_login(url=params.url),
file_transfer=params.file_transfer,
)

@property
Expand Down
29 changes: 23 additions & 6 deletions src/scitacean/testing/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import uuid
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from typing import Any

from .. import model
from .._profile import Profile, make_client_params
from ..client import Client, ScicatClient
from ..error import ScicatCommError
from ..pid import PID
Expand Down Expand Up @@ -132,22 +134,27 @@ def __init__(
@classmethod
def from_token(
cls,
profile: str | Path | Profile | None = None,
*,
url: str,
url: str | None = None,
token: str | StrStorage,
file_transfer: FileTransfer | None = None,
) -> FakeClient:
"""Create a new fake client.

All arguments except ``file_Transfer`` are ignored.
"""
return FakeClient(file_transfer=file_transfer)
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return FakeClient(file_transfer=params.file_transfer)

@classmethod
def from_credentials(
cls,
profile: str | Path | Profile | None = None,
*,
url: str,
url: str | None = None,
username: str | StrStorage,
password: str | StrStorage,
file_transfer: FileTransfer | None = None,
Expand All @@ -156,17 +163,27 @@ def from_credentials(

All arguments except ``file_Transfer`` are ignored.
"""
return FakeClient(file_transfer=file_transfer)
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return FakeClient(file_transfer=params.file_transfer)

@classmethod
def without_login(
cls, *, url: str, file_transfer: FileTransfer | None = None
cls,
profile: str | Path | Profile | None = None,
*,
url: str | None = None,
file_transfer: FileTransfer | None = None,
) -> FakeClient:
"""Create a new fake client.

All arguments except ``file_Transfer`` are ignored.
"""
return FakeClient(file_transfer=file_transfer)
params = make_client_params(
profile=profile, url=url, file_transfer=file_transfer
)
return FakeClient(file_transfer=params.file_transfer)


class FakeScicatClient(ScicatClient):
Expand Down
Loading