Skip to content

Commit 04f951a

Browse files
authored
Merge pull request #275 from SciCatProject/profiles
Implement profiles to specify scicat instances more easily
2 parents 414ec2b + d3d30cd commit 04f951a

File tree

8 files changed

+287
-15
lines changed

8 files changed

+287
-15
lines changed

docs/reference/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Auxiliary classes
4545
datablock.OrigDatablock
4646
dataset.DatablockUploadModels
4747
PID
48+
Profile
4849
RemotePath
4950
Thumbnail
5051
DatasetType

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"httpx >= 0.24",
3434
"pydantic >= 2",
3535
"python-dateutil >= 2.8",
36+
"tomli >= 2.2.0", # TODO remove when we drop py3.10
3637
]
3738
dynamic = ["version"]
3839

requirements/base.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ httpx >= 0.24
33
paramiko >= 3
44
pydantic >= 2
55
python-dateutil >= 2.8
6+
tomli >= 2.2.0 # TODO remove when we drop py3.10

src/scitacean/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
except importlib.metadata.PackageNotFoundError:
1111
__version__ = "0.0.0"
1212

13+
from ._profile import Profile
1314
from .client import Client
1415
from .datablock import OrigDatablock
1516
from .dataset import Dataset
@@ -38,6 +39,7 @@
3839
"FileUploadError",
3940
"IntegrityError",
4041
"OrigDatablock",
42+
"Profile",
4143
"RemotePath",
4244
"Sample",
4345
"ScicatCommError",

src/scitacean/_profile.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2025 SciCat Project (https://github.com/SciCatProject/scitacean)
3+
"""Profiles for connecting to SciCat."""
4+
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
import tomli
9+
10+
from .transfer.copy import CopyFileTransfer
11+
from .transfer.link import LinkFileTransfer
12+
from .transfer.select import SelectFileTransfer
13+
from .transfer.sftp import SFTPFileTransfer
14+
from .typing import FileTransfer
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class Profile:
19+
"""Parameters for connecting to a specific instance of SciCat.
20+
21+
The fields of a profile correspond to the arguments of the constructors
22+
of :class:`Client`.
23+
They can be overridden by the explicit constructor arguments.
24+
25+
When constructing a client from a profile, the ``profile`` argument may be one of:
26+
27+
- If ``profile`` is a :class:`Profile`, it is returned as is.
28+
- If ``profile`` is a :class:`pathlib.Path`, a profile is loaded from
29+
the file at that path.
30+
- If ``profile`` is a :class:`str`
31+
* and ``profile`` matches the name of a builtin profile,
32+
that profile is returned.
33+
* Otherwise, a profile is loaded from a file with this path, potentially
34+
by appending ``".profile.toml"`` to the name.
35+
"""
36+
37+
url: str
38+
"""URL of the SciCat API.
39+
40+
Note that this is the URL to the API, *not* the web interface.
41+
For example, at ESS, the web interface URL is ``"https://scicat.ess.eu"``.
42+
But the API URL is ``"https://scicat.ess.eu/api/v3"`` (at the time of writing).
43+
"""
44+
file_transfer: FileTransfer | None
45+
"""A file transfer object for uploading and downloading files.
46+
47+
See the `File transfer <../../reference/index.rst#file-transfer>`_. section for
48+
implementations provided by Scitacean.
49+
"""
50+
51+
52+
def locate_profile(spec: str | Path | Profile) -> Profile:
53+
"""Find and return a specified profile."""
54+
if isinstance(spec, Profile):
55+
return spec
56+
57+
if isinstance(spec, Path):
58+
return _load_profile_from_file(spec)
59+
60+
try:
61+
return _builtin_profile(spec)
62+
except KeyError:
63+
pass
64+
65+
try:
66+
return _load_profile_from_file(spec)
67+
except FileNotFoundError:
68+
if spec.endswith(".profile.toml"):
69+
raise ValueError(f"Unknown profile: {spec}") from None
70+
71+
try:
72+
return _load_profile_from_file(f"{spec}.profile.toml")
73+
except FileNotFoundError:
74+
raise ValueError(f"Unknown profile: {spec}") from None
75+
76+
77+
def _builtin_profile(name: str) -> Profile:
78+
match name:
79+
case "ess":
80+
return Profile(
81+
url="https://scicat.ess.eu/api/v3", file_transfer=_ess_file_transfer()
82+
)
83+
case "staging.ess":
84+
return Profile(
85+
url="https://staging.scicat.ess.eu/api/v3",
86+
file_transfer=_ess_file_transfer(),
87+
)
88+
raise KeyError(f"Unknown builtin profile: {name}")
89+
90+
91+
def _ess_file_transfer() -> FileTransfer:
92+
return SelectFileTransfer(
93+
[
94+
LinkFileTransfer(),
95+
CopyFileTransfer(),
96+
SFTPFileTransfer(host="login.esss.dk"),
97+
]
98+
)
99+
100+
101+
def _load_profile_from_file(name: str | Path) -> Profile:
102+
with open(name, "rb") as file:
103+
contents = tomli.load(file)
104+
return Profile(url=contents["url"], file_transfer=None)
105+
106+
107+
@dataclass(frozen=True, slots=True)
108+
class ClientParams:
109+
"""Parameters for creating a client."""
110+
111+
url: str
112+
file_transfer: FileTransfer | None
113+
114+
115+
def make_client_params(
116+
*,
117+
profile: str | Path | Profile | None,
118+
url: str | None,
119+
file_transfer: FileTransfer | None,
120+
) -> ClientParams:
121+
"""Return parameters for creating a client."""
122+
p = locate_profile(profile) if profile is not None else None
123+
url = _get_url(p, url)
124+
file_transfer = _get_file_transfer(p, file_transfer)
125+
return ClientParams(url=url, file_transfer=file_transfer)
126+
127+
128+
def _get_url(profile: Profile | None, url: str | None) -> str:
129+
match (profile, url):
130+
case (None, None):
131+
raise TypeError("Either `profile` or `url` must be provided")
132+
case (p, None):
133+
return p.url # type: ignore[union-attr]
134+
case _:
135+
return url # type: ignore[return-value]
136+
137+
138+
def _get_file_transfer(
139+
profile: Profile | None,
140+
file_transfer: FileTransfer | None,
141+
) -> FileTransfer | None:
142+
if profile is None:
143+
return file_transfer
144+
if file_transfer is None:
145+
return profile.file_transfer
146+
return file_transfer

src/scitacean/client.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from . import model
2323
from ._base_model import convert_download_to_user_model
24+
from ._profile import Profile, make_client_params
2425
from .dataset import Dataset
2526
from .error import ScicatCommError, ScicatLoginError
2627
from .file import File
@@ -62,15 +63,20 @@ def __init__(
6263
@classmethod
6364
def from_token(
6465
cls,
66+
profile: str | Path | Profile | None = None,
6567
*,
66-
url: str,
68+
url: str | None = None,
6769
token: str | StrStorage,
6870
file_transfer: FileTransfer | None = None,
6971
) -> Client:
7072
"""Create a new client and authenticate with a token.
7173
7274
Parameters
7375
----------
76+
profile:
77+
Encodes how to connect to SciCat.
78+
Elements are overridden by the other arguments if provided.
79+
The behaviour is described in :class:`Profile`.
7480
url:
7581
URL of the SciCat api.
7682
token:
@@ -83,17 +89,21 @@ def from_token(
8389
:
8490
A new client.
8591
"""
92+
params = make_client_params(
93+
profile=profile, url=url, file_transfer=file_transfer
94+
)
8695
return Client(
87-
client=ScicatClient.from_token(url=url, token=token),
88-
file_transfer=file_transfer,
96+
client=ScicatClient.from_token(url=params.url, token=token),
97+
file_transfer=params.file_transfer,
8998
)
9099

91100
# TODO rename to login? and provide logout?
92101
@classmethod
93102
def from_credentials(
94103
cls,
104+
profile: str | Path | Profile | None = None,
95105
*,
96-
url: str,
106+
url: str | None = None,
97107
username: str | StrStorage,
98108
password: str | StrStorage,
99109
file_transfer: FileTransfer | None = None,
@@ -102,6 +112,10 @@ def from_credentials(
102112
103113
Parameters
104114
----------
115+
profile:
116+
Encodes how to connect to SciCat.
117+
Elements are overridden by the other arguments if provided.
118+
The behaviour is described in :class:`Profile`.
105119
url:
106120
URL of the SciCat api.
107121
It should include the suffix `api/vn` where `n` is a number.
@@ -117,26 +131,38 @@ def from_credentials(
117131
:
118132
A new client.
119133
"""
134+
params = make_client_params(
135+
profile=profile, url=url, file_transfer=file_transfer
136+
)
120137
return Client(
121138
client=ScicatClient.from_credentials(
122-
url=url, username=username, password=password
139+
url=params.url, username=username, password=password
123140
),
124-
file_transfer=file_transfer,
141+
file_transfer=params.file_transfer,
125142
)
126143

127144
@classmethod
128145
def without_login(
129-
cls, *, url: str, file_transfer: FileTransfer | None = None
146+
cls,
147+
profile: str | Path | Profile | None = None,
148+
*,
149+
url: str | None = None,
150+
file_transfer: FileTransfer | None = None,
130151
) -> Client:
131152
"""Create a new client without authentication.
132153
133154
The client can only download public datasets and not upload at all.
134155
135156
Parameters
136157
----------
158+
profile:
159+
Encodes how to connect to SciCat.
160+
Elements are overridden by the other arguments if provided.
161+
The behaviour is described in :class:`Profile`.
137162
url:
138163
URL of the SciCat api.
139-
It should include the suffix `api/vn` where `n` is a number.
164+
It typically should include the suffix `api/vn` where `n` is a number
165+
Must be provided is ``profile is None``.
140166
file_transfer:
141167
Handler for down-/uploads of files.
142168
@@ -145,8 +171,12 @@ def without_login(
145171
:
146172
A new client.
147173
"""
174+
params = make_client_params(
175+
profile=profile, url=url, file_transfer=file_transfer
176+
)
148177
return Client(
149-
client=ScicatClient.without_login(url=url), file_transfer=file_transfer
178+
client=ScicatClient.without_login(url=params.url),
179+
file_transfer=params.file_transfer,
150180
)
151181

152182
@property

src/scitacean/testing/client.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import uuid
1010
from collections.abc import Callable
1111
from copy import deepcopy
12+
from pathlib import Path
1213
from typing import Any
1314

1415
from .. import model
16+
from .._profile import Profile, make_client_params
1517
from ..client import Client, ScicatClient
1618
from ..error import ScicatCommError
1719
from ..pid import PID
@@ -132,22 +134,27 @@ def __init__(
132134
@classmethod
133135
def from_token(
134136
cls,
137+
profile: str | Path | Profile | None = None,
135138
*,
136-
url: str,
139+
url: str | None = None,
137140
token: str | StrStorage,
138141
file_transfer: FileTransfer | None = None,
139142
) -> FakeClient:
140143
"""Create a new fake client.
141144
142145
All arguments except ``file_Transfer`` are ignored.
143146
"""
144-
return FakeClient(file_transfer=file_transfer)
147+
params = make_client_params(
148+
profile=profile, url=url, file_transfer=file_transfer
149+
)
150+
return FakeClient(file_transfer=params.file_transfer)
145151

146152
@classmethod
147153
def from_credentials(
148154
cls,
155+
profile: str | Path | Profile | None = None,
149156
*,
150-
url: str,
157+
url: str | None = None,
151158
username: str | StrStorage,
152159
password: str | StrStorage,
153160
file_transfer: FileTransfer | None = None,
@@ -156,17 +163,27 @@ def from_credentials(
156163
157164
All arguments except ``file_Transfer`` are ignored.
158165
"""
159-
return FakeClient(file_transfer=file_transfer)
166+
params = make_client_params(
167+
profile=profile, url=url, file_transfer=file_transfer
168+
)
169+
return FakeClient(file_transfer=params.file_transfer)
160170

161171
@classmethod
162172
def without_login(
163-
cls, *, url: str, file_transfer: FileTransfer | None = None
173+
cls,
174+
profile: str | Path | Profile | None = None,
175+
*,
176+
url: str | None = None,
177+
file_transfer: FileTransfer | None = None,
164178
) -> FakeClient:
165179
"""Create a new fake client.
166180
167181
All arguments except ``file_Transfer`` are ignored.
168182
"""
169-
return FakeClient(file_transfer=file_transfer)
183+
params = make_client_params(
184+
profile=profile, url=url, file_transfer=file_transfer
185+
)
186+
return FakeClient(file_transfer=params.file_transfer)
170187

171188

172189
class FakeScicatClient(ScicatClient):

0 commit comments

Comments
 (0)