nomad-fsspec 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomad_fsspec-0.1.0/.gitignore +7 -0
- nomad_fsspec-0.1.0/LICENSE +21 -0
- nomad_fsspec-0.1.0/PKG-INFO +62 -0
- nomad_fsspec-0.1.0/README.md +38 -0
- nomad_fsspec-0.1.0/pyproject.toml +70 -0
- nomad_fsspec-0.1.0/src/nomad_fsspec/__init__.py +5 -0
- nomad_fsspec-0.1.0/src/nomad_fsspec/client.py +95 -0
- nomad_fsspec-0.1.0/src/nomad_fsspec/errors.py +6 -0
- nomad_fsspec-0.1.0/src/nomad_fsspec/filesystem.py +217 -0
- nomad_fsspec-0.1.0/src/nomad_fsspec/py.typed +0 -0
- nomad_fsspec-0.1.0/tests/test_client.py +58 -0
- nomad_fsspec-0.1.0/tests/test_filesystem.py +246 -0
- nomad_fsspec-0.1.0/tests/test_live.py +37 -0
- nomad_fsspec-0.1.0/tests/test_package.py +7 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nomad-fsspec contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nomad-fsspec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An fsspec filesystem for the public raw data of NOMAD.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arash77/nomad-fsspec
|
|
6
|
+
Project-URL: Issues, https://github.com/arash77/nomad-fsspec/issues
|
|
7
|
+
Author: nomad-fsspec contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: filesystem,fsspec,materials science,nomad
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: fsspec>=2024.1.0
|
|
21
|
+
Requires-Dist: requests>=2.31
|
|
22
|
+
Requires-Dist: urllib3>=1.26
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# nomad-fsspec
|
|
26
|
+
|
|
27
|
+
An [fsspec](https://filesystem-spec.readthedocs.io) filesystem for the public raw data of
|
|
28
|
+
[NOMAD](https://nomad-lab.eu), the materials science repository. It is read-only.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import fsspec
|
|
32
|
+
|
|
33
|
+
fs = fsspec.filesystem("nomad") # https://nomad-lab.eu/prod/v1 by default
|
|
34
|
+
fs.ls("/") # public datasets
|
|
35
|
+
fs.ls("/wWgAnNZNQxOHLf97H62dgw") # the uploads of one dataset
|
|
36
|
+
fs.get_file(
|
|
37
|
+
"/wWgAnNZNQxOHLf97H62dgw/Xa2LB__ESiKu-odRBPJhjA/nomad-demo-data/BrK_svSe/TBCC006.ABC/vasprun.xml.relax2",
|
|
38
|
+
"vasprun.xml",
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Paths are `/<dataset id>/<upload id>/<path inside the upload>`. Datasets and uploads have their
|
|
43
|
+
names in `display_name`. To use a NOMAD Oasis, pass `base_url`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
fs = fsspec.filesystem("nomad", base_url="https://my-oasis.example.org/nomad-oasis")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Only public data is listed. NOMAD's rate limits (HTTP 429 and 503) are retried.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
just install
|
|
55
|
+
just test # unit tests
|
|
56
|
+
just test-live # a few calls to nomad-lab.eu
|
|
57
|
+
just lint
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# nomad-fsspec
|
|
2
|
+
|
|
3
|
+
An [fsspec](https://filesystem-spec.readthedocs.io) filesystem for the public raw data of
|
|
4
|
+
[NOMAD](https://nomad-lab.eu), the materials science repository. It is read-only.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import fsspec
|
|
8
|
+
|
|
9
|
+
fs = fsspec.filesystem("nomad") # https://nomad-lab.eu/prod/v1 by default
|
|
10
|
+
fs.ls("/") # public datasets
|
|
11
|
+
fs.ls("/wWgAnNZNQxOHLf97H62dgw") # the uploads of one dataset
|
|
12
|
+
fs.get_file(
|
|
13
|
+
"/wWgAnNZNQxOHLf97H62dgw/Xa2LB__ESiKu-odRBPJhjA/nomad-demo-data/BrK_svSe/TBCC006.ABC/vasprun.xml.relax2",
|
|
14
|
+
"vasprun.xml",
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Paths are `/<dataset id>/<upload id>/<path inside the upload>`. Datasets and uploads have their
|
|
19
|
+
names in `display_name`. To use a NOMAD Oasis, pass `base_url`:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
fs = fsspec.filesystem("nomad", base_url="https://my-oasis.example.org/nomad-oasis")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Only public data is listed. NOMAD's rate limits (HTTP 429 and 503) are retried.
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
just install
|
|
31
|
+
just test # unit tests
|
|
32
|
+
just test-live # a few calls to nomad-lab.eu
|
|
33
|
+
just lint
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nomad-fsspec"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An fsspec filesystem for the public raw data of NOMAD."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "nomad-fsspec contributors" }]
|
|
13
|
+
keywords = ["nomad", "fsspec", "materials science", "filesystem"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"fsspec>=2024.1.0",
|
|
26
|
+
"requests>=2.31",
|
|
27
|
+
"urllib3>=1.26",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.entry-points."fsspec.specs"]
|
|
31
|
+
nomad = "nomad_fsspec:NomadFileSystem"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/arash77/nomad-fsspec"
|
|
35
|
+
Issues = "https://github.com/arash77/nomad-fsspec/issues"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8.0",
|
|
40
|
+
"responses>=0.25",
|
|
41
|
+
"ruff>=0.6",
|
|
42
|
+
"mypy>=1.11",
|
|
43
|
+
"types-requests",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/nomad_fsspec"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
src = ["src", "tests"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.10"
|
|
62
|
+
warn_unused_ignores = true
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
files = ["src/nomad_fsspec"]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
minversion = "8.0"
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
addopts = "-ra --strict-markers"
|
|
70
|
+
markers = ["live: tests that call the public NOMAD at nomad-lab.eu"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote, urlparse
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from requests.adapters import HTTPAdapter
|
|
8
|
+
from urllib3.util.retry import Retry
|
|
9
|
+
|
|
10
|
+
from .errors import NomadError
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://nomad-lab.eu/prod/v1"
|
|
13
|
+
API_PATH = "/api/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NomadClient:
|
|
17
|
+
"""A small client for NOMAD's REST API."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, base_url: str = DEFAULT_BASE_URL, timeout: tuple[float, float] = (10, 120)
|
|
21
|
+
) -> None:
|
|
22
|
+
base = base_url.strip().rstrip("/")
|
|
23
|
+
if base.endswith(API_PATH):
|
|
24
|
+
base = base[: -len(API_PATH)]
|
|
25
|
+
self.api_url = base + API_PATH
|
|
26
|
+
self.host = urlparse(base).netloc or base
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
# NOMAD refuses bursts with 429 or 503, usually with Retry-After
|
|
29
|
+
retry = Retry(
|
|
30
|
+
total=5,
|
|
31
|
+
backoff_factor=1,
|
|
32
|
+
status_forcelist=[429, 503],
|
|
33
|
+
allowed_methods={"GET", "POST"},
|
|
34
|
+
respect_retry_after_header=True,
|
|
35
|
+
raise_on_status=False,
|
|
36
|
+
)
|
|
37
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
38
|
+
self.session = requests.Session()
|
|
39
|
+
self.session.mount("https://", adapter)
|
|
40
|
+
self.session.mount("http://", adapter)
|
|
41
|
+
|
|
42
|
+
def url(self, parts: list[str]) -> str:
|
|
43
|
+
return "/".join([self.api_url, *(quote(part, safe="") for part in parts)])
|
|
44
|
+
|
|
45
|
+
def get_json(self, parts: list[str], params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
46
|
+
with self.request("GET", parts, params=params) as response:
|
|
47
|
+
return self._json(response)
|
|
48
|
+
|
|
49
|
+
def post_json(self, parts: list[str], body: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
with self.request("POST", parts, json=body) as response:
|
|
51
|
+
return self._json(response)
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def request(self, method: str, parts: list[str], **kwargs: Any) -> Iterator[requests.Response]:
|
|
55
|
+
label = "/" + "/".join(parts)
|
|
56
|
+
try:
|
|
57
|
+
with self.session.request(
|
|
58
|
+
method, self.url(parts), timeout=self.timeout, **kwargs
|
|
59
|
+
) as response:
|
|
60
|
+
self._raise_for_status(response, label)
|
|
61
|
+
yield response
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
raise NomadError(f"Could not reach NOMAD at {self.host}: {e}") from e
|
|
64
|
+
|
|
65
|
+
def _raise_for_status(self, response: requests.Response, label: str) -> None:
|
|
66
|
+
if response.ok:
|
|
67
|
+
return
|
|
68
|
+
detail = _detail(response)
|
|
69
|
+
if response.status_code == 404:
|
|
70
|
+
raise FileNotFoundError(f"{label}: {detail}")
|
|
71
|
+
if response.status_code in (401, 403):
|
|
72
|
+
raise PermissionError(f"NOMAD at {self.host} refused access to {label}: {detail}")
|
|
73
|
+
raise NomadError(
|
|
74
|
+
f"NOMAD at {self.host} answered HTTP {response.status_code} for {label}: {detail}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _json(self, response: requests.Response) -> dict[str, Any]:
|
|
78
|
+
message = f"{self.host} did not answer like a NOMAD API; check the base URL"
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
raise NomadError(message) from e
|
|
83
|
+
if not isinstance(body, dict):
|
|
84
|
+
raise NomadError(message)
|
|
85
|
+
return body
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _detail(response: requests.Response) -> str:
|
|
89
|
+
try:
|
|
90
|
+
detail = response.json().get("detail")
|
|
91
|
+
except (ValueError, AttributeError):
|
|
92
|
+
detail = None
|
|
93
|
+
if isinstance(detail, str):
|
|
94
|
+
return detail
|
|
95
|
+
return response.reason or f"HTTP {response.status_code}"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from typing import IO, Any
|
|
2
|
+
|
|
3
|
+
from fsspec import AbstractFileSystem
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, NomadClient
|
|
6
|
+
|
|
7
|
+
# NOMAD reads a whole directory for every page, so ask for all of it at once
|
|
8
|
+
PAGE_SIZE = 10000
|
|
9
|
+
AGGREGATION_PAGE_SIZE = 2500
|
|
10
|
+
CHUNK_SIZE = 2**20
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NomadFileSystem(AbstractFileSystem):
|
|
14
|
+
"""Read-only access to the public datasets of a NOMAD deployment.
|
|
15
|
+
|
|
16
|
+
Paths are ``/<dataset_id>/<upload_id>/<path inside the upload>``. Datasets and uploads carry
|
|
17
|
+
their names in ``display_name``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
protocol = "nomad"
|
|
21
|
+
root_marker = "/"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
26
|
+
timeout: tuple[float, float] = (10, 120),
|
|
27
|
+
**storage_options: Any,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(**storage_options)
|
|
30
|
+
self.client = NomadClient(base_url, timeout)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _strip_protocol(cls, path: Any) -> Any:
|
|
34
|
+
if isinstance(path, list):
|
|
35
|
+
return [cls._strip_protocol(p) for p in path]
|
|
36
|
+
return "/" + super()._strip_protocol(path).strip("/")
|
|
37
|
+
|
|
38
|
+
def ls(self, path: str, detail: bool = True, **kwargs: Any) -> list[Any]:
|
|
39
|
+
path = self._strip_protocol(path)
|
|
40
|
+
# not _ls_from_cache: it answers a folder with its own entry once the parent is cached
|
|
41
|
+
key = path.rstrip("/")
|
|
42
|
+
try:
|
|
43
|
+
entries = self.dircache[key]
|
|
44
|
+
except KeyError:
|
|
45
|
+
entries = self._fetch(path)
|
|
46
|
+
self.dircache[key] = entries
|
|
47
|
+
return entries if detail else [entry["name"] for entry in entries]
|
|
48
|
+
|
|
49
|
+
def get_file(
|
|
50
|
+
self,
|
|
51
|
+
rpath: str,
|
|
52
|
+
lpath: Any = None,
|
|
53
|
+
callback: Any = None,
|
|
54
|
+
outfile: IO[bytes] | None = None,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
parts = self._file_parts(rpath)
|
|
58
|
+
params = {"ignore_mime_type": "true"}
|
|
59
|
+
with self.client.request("GET", _raw(parts), params=params, stream=True) as response:
|
|
60
|
+
if outfile is not None:
|
|
61
|
+
_copy(response.iter_content(CHUNK_SIZE), outfile)
|
|
62
|
+
else:
|
|
63
|
+
with open(lpath, "wb") as target:
|
|
64
|
+
_copy(response.iter_content(CHUNK_SIZE), target)
|
|
65
|
+
|
|
66
|
+
def cat_file(
|
|
67
|
+
self, path: str, start: int | None = None, end: int | None = None, **kwargs: Any
|
|
68
|
+
) -> bytes:
|
|
69
|
+
parts = self._file_parts(path)
|
|
70
|
+
start = start or 0
|
|
71
|
+
if start < 0 or (end is not None and end < 0):
|
|
72
|
+
size = int(self.info(path)["size"])
|
|
73
|
+
start = max(size + start, 0) if start < 0 else start
|
|
74
|
+
end = size + end if end is not None and end < 0 else end
|
|
75
|
+
if end is not None and end <= start:
|
|
76
|
+
return b""
|
|
77
|
+
# NOMAD ignores HTTP Range headers; it takes offset and length instead
|
|
78
|
+
params: dict[str, Any] = {"ignore_mime_type": "true"}
|
|
79
|
+
if start:
|
|
80
|
+
params["offset"] = start
|
|
81
|
+
if end is not None:
|
|
82
|
+
params["length"] = end - start
|
|
83
|
+
with self.client.request("GET", _raw(parts), params=params) as response:
|
|
84
|
+
return response.content
|
|
85
|
+
|
|
86
|
+
def _open(self, path: str, mode: str = "rb", **kwargs: Any) -> Any:
|
|
87
|
+
if mode != "rb":
|
|
88
|
+
raise PermissionError(f"NOMAD is read-only; cannot open {path} with mode {mode!r}")
|
|
89
|
+
return super()._open(path, mode=mode, **kwargs)
|
|
90
|
+
|
|
91
|
+
def _file_parts(self, path: str) -> list[str]:
|
|
92
|
+
parts = _split(self._strip_protocol(path))
|
|
93
|
+
if len(parts) < 3:
|
|
94
|
+
raise IsADirectoryError(path)
|
|
95
|
+
return parts
|
|
96
|
+
|
|
97
|
+
def _fetch(self, path: str) -> list[dict[str, Any]]:
|
|
98
|
+
parts = _split(path)
|
|
99
|
+
if not parts:
|
|
100
|
+
return self._list_datasets()
|
|
101
|
+
if len(parts) == 1:
|
|
102
|
+
return self._list_uploads(parts[0])
|
|
103
|
+
return self._list_upload_directory(parts)
|
|
104
|
+
|
|
105
|
+
def _list_datasets(self) -> list[dict[str, Any]]:
|
|
106
|
+
public = {bucket["value"] for bucket in self._aggregate("datasets.dataset_id", {})}
|
|
107
|
+
entries: list[dict[str, Any]] = []
|
|
108
|
+
offset = 0
|
|
109
|
+
while True:
|
|
110
|
+
params = {
|
|
111
|
+
"page_size": PAGE_SIZE,
|
|
112
|
+
"page_offset": offset,
|
|
113
|
+
"order_by": "dataset_name",
|
|
114
|
+
"order": "asc",
|
|
115
|
+
}
|
|
116
|
+
body = self.client.get_json(["datasets", ""], params)
|
|
117
|
+
page = body["data"]
|
|
118
|
+
entries.extend(
|
|
119
|
+
_directory(f"/{dataset['dataset_id']}", dataset.get("dataset_name"))
|
|
120
|
+
for dataset in page
|
|
121
|
+
if dataset["dataset_id"] in public
|
|
122
|
+
)
|
|
123
|
+
offset += len(page)
|
|
124
|
+
if not page or offset >= body["pagination"]["total"]:
|
|
125
|
+
return entries
|
|
126
|
+
|
|
127
|
+
def _list_uploads(self, dataset_id: str) -> list[dict[str, Any]]:
|
|
128
|
+
query = {"datasets.dataset_id": dataset_id}
|
|
129
|
+
buckets = self._aggregate("upload_id", query, include=["upload_name"])
|
|
130
|
+
if not buckets:
|
|
131
|
+
raise FileNotFoundError(f"/{dataset_id}")
|
|
132
|
+
uploads = [
|
|
133
|
+
_directory(
|
|
134
|
+
f"/{dataset_id}/{bucket['value']}",
|
|
135
|
+
(bucket.get("entries") or [{}])[0].get("upload_name"),
|
|
136
|
+
)
|
|
137
|
+
for bucket in buckets
|
|
138
|
+
]
|
|
139
|
+
return sorted(
|
|
140
|
+
uploads, key=lambda upload: (upload["display_name"].casefold(), upload["name"])
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _list_upload_directory(self, parts: list[str]) -> list[dict[str, Any]]:
|
|
144
|
+
path = "/" + "/".join(parts)
|
|
145
|
+
entries: list[dict[str, Any]] = []
|
|
146
|
+
offset = 0
|
|
147
|
+
while True:
|
|
148
|
+
params = {"page_size": PAGE_SIZE, "page_offset": offset}
|
|
149
|
+
body = self.client.get_json(["uploads", parts[1], "rawdir", *parts[2:], ""], params)
|
|
150
|
+
if "directory_metadata" not in body:
|
|
151
|
+
size = (body.get("file_metadata") or {}).get("size")
|
|
152
|
+
return [{"name": path, "size": int(size or 0), "type": "file"}]
|
|
153
|
+
content = body["directory_metadata"]["content"]
|
|
154
|
+
entries.extend(
|
|
155
|
+
{
|
|
156
|
+
"name": f"{path}/{item['name']}",
|
|
157
|
+
"size": int(item.get("size") or 0),
|
|
158
|
+
"type": "file" if item.get("is_file") else "directory",
|
|
159
|
+
}
|
|
160
|
+
for item in content
|
|
161
|
+
if item.get("name")
|
|
162
|
+
)
|
|
163
|
+
offset += len(content)
|
|
164
|
+
# an empty page or the total ends the listing; NOMAD answers 400 past the last page
|
|
165
|
+
if not content or offset >= body["pagination"]["total"]:
|
|
166
|
+
return entries
|
|
167
|
+
|
|
168
|
+
def _aggregate(
|
|
169
|
+
self, quantity: str, query: dict[str, Any], include: list[str] | None = None
|
|
170
|
+
) -> list[dict[str, Any]]:
|
|
171
|
+
buckets: list[dict[str, Any]] = []
|
|
172
|
+
after = None
|
|
173
|
+
while True:
|
|
174
|
+
pagination: dict[str, Any] = {"page_size": AGGREGATION_PAGE_SIZE}
|
|
175
|
+
if after is not None:
|
|
176
|
+
pagination["page_after_value"] = after
|
|
177
|
+
terms: dict[str, Any] = {"quantity": quantity, "pagination": pagination}
|
|
178
|
+
if include:
|
|
179
|
+
terms["entries"] = {"size": 1, "required": {"include": include}}
|
|
180
|
+
body = self.client.post_json(
|
|
181
|
+
["entries", "query"],
|
|
182
|
+
{
|
|
183
|
+
"owner": "public",
|
|
184
|
+
"query": query,
|
|
185
|
+
"pagination": {"page_size": 0},
|
|
186
|
+
"aggregations": {"buckets": {"terms": terms}},
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
result = body["aggregations"]["buckets"]["terms"]
|
|
190
|
+
buckets.extend(result["data"])
|
|
191
|
+
# NOMAD sets a cursor even on the last page, so a short page is the end
|
|
192
|
+
if len(result["data"]) < AGGREGATION_PAGE_SIZE:
|
|
193
|
+
return buckets
|
|
194
|
+
after = (result.get("pagination") or {}).get("next_page_after_value")
|
|
195
|
+
if after is None:
|
|
196
|
+
return buckets
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _split(path: str) -> list[str]:
|
|
200
|
+
parts = [part for part in path.split("/") if part]
|
|
201
|
+
if any(part in (".", "..") for part in parts):
|
|
202
|
+
raise ValueError(f"Invalid NOMAD path: {path}")
|
|
203
|
+
return parts
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _directory(path: str, display_name: str | None) -> dict[str, Any]:
|
|
207
|
+
name = display_name or path.rsplit("/", 1)[-1]
|
|
208
|
+
return {"name": path, "size": 0, "type": "directory", "display_name": name}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _raw(parts: list[str]) -> list[str]:
|
|
212
|
+
return ["uploads", parts[1], "raw", *parts[2:]]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _copy(chunks: Any, target: IO[bytes]) -> None:
|
|
216
|
+
for chunk in chunks:
|
|
217
|
+
target.write(chunk)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import requests
|
|
3
|
+
import responses
|
|
4
|
+
from responses import registries
|
|
5
|
+
|
|
6
|
+
from nomad_fsspec.client import NomadClient
|
|
7
|
+
from nomad_fsspec.errors import NomadError
|
|
8
|
+
|
|
9
|
+
API = "https://nomad.example.org/oasis/api/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
"base_url",
|
|
14
|
+
[
|
|
15
|
+
"https://nomad.example.org/oasis",
|
|
16
|
+
"https://nomad.example.org/oasis/",
|
|
17
|
+
"https://nomad.example.org/oasis/api/v1",
|
|
18
|
+
" https://nomad.example.org/oasis/api/v1/ ",
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
def test_base_url_spellings_reach_one_api_root(base_url):
|
|
22
|
+
client = NomadClient(base_url)
|
|
23
|
+
assert client.api_url == API
|
|
24
|
+
assert client.host == "nomad.example.org"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_path_segments_are_encoded_one_at_a_time():
|
|
28
|
+
url = NomadClient(API).url(["uploads", "u1", "raw", "run #1 100%.csv", ""])
|
|
29
|
+
assert url == f"{API}/uploads/u1/raw/run%20%231%20100%25.csv/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@responses.activate
|
|
33
|
+
def test_errors_are_mapped():
|
|
34
|
+
client = NomadClient(API)
|
|
35
|
+
responses.get(f"{API}/a", status=404, json={"detail": "The specified upload_id was not found."})
|
|
36
|
+
responses.get(f"{API}/b", status=403, json={"detail": "Forbidden"})
|
|
37
|
+
responses.get(f"{API}/c", status=500, body="<html>proxy error</html>")
|
|
38
|
+
responses.get(f"{API}/d", body="<html>the NOMAD GUI</html>")
|
|
39
|
+
responses.get(f"{API}/e", body=requests.ConnectionError("refused"))
|
|
40
|
+
with pytest.raises(FileNotFoundError, match="upload_id was not found"):
|
|
41
|
+
client.get_json(["a"])
|
|
42
|
+
with pytest.raises(PermissionError):
|
|
43
|
+
client.get_json(["b"])
|
|
44
|
+
with pytest.raises(NomadError, match="HTTP 500"):
|
|
45
|
+
client.get_json(["c"])
|
|
46
|
+
with pytest.raises(NomadError, match="did not answer like a NOMAD API"):
|
|
47
|
+
client.get_json(["d"])
|
|
48
|
+
with pytest.raises(NomadError, match="Could not reach NOMAD"):
|
|
49
|
+
client.get_json(["e"])
|
|
50
|
+
assert not issubclass(NomadError, OSError)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@responses.activate(registry=registries.OrderedRegistry)
|
|
54
|
+
def test_rate_limited_and_unavailable_answers_are_retried():
|
|
55
|
+
responses.get(f"{API}/x", status=503)
|
|
56
|
+
responses.get(f"{API}/x", status=429, headers={"Retry-After": "0"})
|
|
57
|
+
responses.get(f"{API}/x", json={"ok": True})
|
|
58
|
+
assert NomadClient(API).get_json(["x"]) == {"ok": True}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import responses
|
|
3
|
+
from responses import matchers, registries
|
|
4
|
+
|
|
5
|
+
from nomad_fsspec import NomadError, NomadFileSystem, filesystem
|
|
6
|
+
|
|
7
|
+
BASE = "https://nomad.example.org/oasis"
|
|
8
|
+
API = f"{BASE}/api/v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_fs() -> NomadFileSystem:
|
|
12
|
+
return NomadFileSystem(base_url=BASE, skip_instance_cache=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def aggregation_body(quantity, query, page_size, after=None, include=None):
|
|
16
|
+
pagination = {"page_size": page_size}
|
|
17
|
+
if after is not None:
|
|
18
|
+
pagination["page_after_value"] = after
|
|
19
|
+
terms = {"quantity": quantity, "pagination": pagination}
|
|
20
|
+
if include:
|
|
21
|
+
terms["entries"] = {"size": 1, "required": {"include": include}}
|
|
22
|
+
return {
|
|
23
|
+
"owner": "public",
|
|
24
|
+
"query": query,
|
|
25
|
+
"pagination": {"page_size": 0},
|
|
26
|
+
"aggregations": {"buckets": {"terms": terms}},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def aggregation(*values, names=None, after=None):
|
|
31
|
+
data = []
|
|
32
|
+
for value in values:
|
|
33
|
+
bucket = {"value": value, "count": 1}
|
|
34
|
+
if names and value in names:
|
|
35
|
+
bucket["entries"] = [{"upload_name": names[value]}]
|
|
36
|
+
data.append(bucket)
|
|
37
|
+
terms = {"data": data, "pagination": {"next_page_after_value": after}}
|
|
38
|
+
return {"aggregations": {"buckets": {"terms": terms}}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@responses.activate
|
|
42
|
+
def test_root_lists_public_datasets_by_name_and_caches():
|
|
43
|
+
# strict matchers matter: NOMAD ignores a parameter it does not know and answers with everything
|
|
44
|
+
responses.post(
|
|
45
|
+
f"{API}/entries/query",
|
|
46
|
+
match=[matchers.json_params_matcher(aggregation_body("datasets.dataset_id", {}, 2500))],
|
|
47
|
+
json=aggregation("ds1", "ds2"),
|
|
48
|
+
)
|
|
49
|
+
responses.get(
|
|
50
|
+
f"{API}/datasets/",
|
|
51
|
+
match=[
|
|
52
|
+
matchers.query_param_matcher(
|
|
53
|
+
{
|
|
54
|
+
"page_size": "10000",
|
|
55
|
+
"page_offset": "0",
|
|
56
|
+
"order_by": "dataset_name",
|
|
57
|
+
"order": "asc",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
],
|
|
61
|
+
json={
|
|
62
|
+
"pagination": {"total": 3},
|
|
63
|
+
"data": [
|
|
64
|
+
{"dataset_id": "ds1", "dataset_name": "demo data"},
|
|
65
|
+
{"dataset_id": "ds2"},
|
|
66
|
+
{"dataset_id": "ds3", "dataset_name": "no public entries"},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
fs = make_fs()
|
|
71
|
+
entries = fs.ls("nomad://")
|
|
72
|
+
assert [(e["name"], e["display_name"], e["type"]) for e in entries] == [
|
|
73
|
+
("/ds1", "demo data", "directory"),
|
|
74
|
+
("/ds2", "ds2", "directory"),
|
|
75
|
+
]
|
|
76
|
+
assert fs.ls("/", detail=False) == ["/ds1", "/ds2"]
|
|
77
|
+
assert len(responses.calls) == 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@responses.activate(registry=registries.OrderedRegistry)
|
|
81
|
+
def test_a_dataset_lists_its_uploads_across_pages(monkeypatch):
|
|
82
|
+
monkeypatch.setattr(filesystem, "AGGREGATION_PAGE_SIZE", 2)
|
|
83
|
+
query = {"datasets.dataset_id": "ds1"}
|
|
84
|
+
include = ["upload_name"]
|
|
85
|
+
responses.post(
|
|
86
|
+
f"{API}/entries/query",
|
|
87
|
+
match=[
|
|
88
|
+
matchers.json_params_matcher(aggregation_body("upload_id", query, 2, include=include))
|
|
89
|
+
],
|
|
90
|
+
json=aggregation("up1", "up2", names={"up1": "b.zip", "up2": "a.zip"}, after="up2"),
|
|
91
|
+
)
|
|
92
|
+
responses.post(
|
|
93
|
+
f"{API}/entries/query",
|
|
94
|
+
match=[
|
|
95
|
+
matchers.json_params_matcher(
|
|
96
|
+
aggregation_body("upload_id", query, 2, after="up2", include=include)
|
|
97
|
+
)
|
|
98
|
+
],
|
|
99
|
+
json=aggregation("up3", after="up3"),
|
|
100
|
+
)
|
|
101
|
+
entries = make_fs().ls("/ds1")
|
|
102
|
+
assert [(e["name"], e["display_name"]) for e in entries] == [
|
|
103
|
+
("/ds1/up2", "a.zip"),
|
|
104
|
+
("/ds1/up1", "b.zip"),
|
|
105
|
+
("/ds1/up3", "up3"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@responses.activate
|
|
110
|
+
def test_a_dataset_without_public_uploads_is_not_found():
|
|
111
|
+
responses.post(f"{API}/entries/query", json=aggregation())
|
|
112
|
+
with pytest.raises(FileNotFoundError):
|
|
113
|
+
make_fs().ls("/no-such-dataset")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_nomad_error_is_exported():
|
|
117
|
+
assert issubclass(NomadError, Exception)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rawdir(*content, total=None):
|
|
121
|
+
return {
|
|
122
|
+
"directory_metadata": {"content": list(content)},
|
|
123
|
+
"pagination": {"total": len(content) if total is None else total},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def page(size, offset):
|
|
128
|
+
return matchers.query_param_matcher({"page_size": str(size), "page_offset": str(offset)})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@responses.activate
|
|
132
|
+
def test_upload_directory_pages_until_the_total(monkeypatch):
|
|
133
|
+
monkeypatch.setattr(filesystem, "PAGE_SIZE", 2)
|
|
134
|
+
url = f"{API}/uploads/up1/rawdir/data/"
|
|
135
|
+
first = rawdir(
|
|
136
|
+
{"name": "a.txt", "size": 5, "is_file": True},
|
|
137
|
+
{"name": "sub", "size": 9, "is_file": False},
|
|
138
|
+
total=4,
|
|
139
|
+
)
|
|
140
|
+
second = rawdir({"name": "", "is_file": False}, {"name": "z.txt", "is_file": True}, total=4)
|
|
141
|
+
responses.get(url, match=[page(2, 0)], json=first)
|
|
142
|
+
responses.get(url, match=[page(2, 2)], json=second)
|
|
143
|
+
fs = make_fs()
|
|
144
|
+
entries = fs.ls("/ds1/up1/data")
|
|
145
|
+
assert [(e["name"], e["type"], e["size"]) for e in entries] == [
|
|
146
|
+
("/ds1/up1/data/a.txt", "file", 5),
|
|
147
|
+
("/ds1/up1/data/sub", "directory", 9),
|
|
148
|
+
("/ds1/up1/data/z.txt", "file", 0),
|
|
149
|
+
]
|
|
150
|
+
assert fs.info("/ds1/up1/data/a.txt")["size"] == 5
|
|
151
|
+
assert len(responses.calls) == 2
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@responses.activate
|
|
155
|
+
def test_an_empty_page_ends_the_listing(monkeypatch):
|
|
156
|
+
monkeypatch.setattr(filesystem, "PAGE_SIZE", 1)
|
|
157
|
+
url = f"{API}/uploads/up1/rawdir/"
|
|
158
|
+
responses.get(url, match=[page(1, 0)], json=rawdir({"name": "a", "is_file": True}, total=5))
|
|
159
|
+
responses.get(url, match=[page(1, 1)], json=rawdir(total=5))
|
|
160
|
+
assert [e["name"] for e in make_fs().ls("/ds1/up1")] == ["/ds1/up1/a"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@responses.activate
|
|
164
|
+
def test_listing_a_file_returns_the_file():
|
|
165
|
+
responses.get(
|
|
166
|
+
f"{API}/uploads/up1/rawdir/a.txt/", json={"file_metadata": {"name": "", "size": 7}}
|
|
167
|
+
)
|
|
168
|
+
assert make_fs().ls("/ds1/up1/a.txt") == [{"name": "/ds1/up1/a.txt", "size": 7, "type": "file"}]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@responses.activate
|
|
172
|
+
def test_dot_segments_are_refused():
|
|
173
|
+
with pytest.raises(ValueError):
|
|
174
|
+
make_fs().ls("/ds1/up1/../up2")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@responses.activate
|
|
178
|
+
def test_a_failing_folder_is_not_hidden_by_walk():
|
|
179
|
+
responses.get(f"{API}/uploads/up1/rawdir/", json=rawdir({"name": "sub", "is_file": False}))
|
|
180
|
+
responses.get(f"{API}/uploads/up1/rawdir/sub/", status=500, json={"detail": "boom"})
|
|
181
|
+
with pytest.raises(NomadError, match="boom"):
|
|
182
|
+
list(make_fs().walk("/ds1/up1"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@responses.activate
|
|
186
|
+
def test_a_folder_lists_its_contents_after_its_parent_was_listed():
|
|
187
|
+
responses.get(f"{API}/uploads/up1/rawdir/", json=rawdir({"name": "sub", "is_file": False}))
|
|
188
|
+
responses.get(f"{API}/uploads/up1/rawdir/sub/", json=rawdir({"name": "b.txt", "is_file": True}))
|
|
189
|
+
fs = make_fs()
|
|
190
|
+
fs.ls("/ds1/up1")
|
|
191
|
+
assert fs.ls("/ds1/up1/sub", detail=False) == ["/ds1/up1/sub/b.txt"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def raw_params(**extra):
|
|
195
|
+
return matchers.query_param_matcher({"ignore_mime_type": "true", **extra})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@responses.activate
|
|
199
|
+
def test_get_file_streams_one_request(tmp_path):
|
|
200
|
+
responses.get(
|
|
201
|
+
f"{API}/uploads/up1/raw/data/run%20%231%20100%25.csv",
|
|
202
|
+
match=[raw_params()],
|
|
203
|
+
body=b"a,b\n1,2\n",
|
|
204
|
+
)
|
|
205
|
+
target = tmp_path / "out.csv"
|
|
206
|
+
make_fs().get_file("/ds1/up1/data/run #1 100%.csv", str(target))
|
|
207
|
+
assert target.read_bytes() == b"a,b\n1,2\n"
|
|
208
|
+
assert len(responses.calls) == 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@responses.activate
|
|
212
|
+
def test_get_file_refuses_a_dataset_or_an_upload(tmp_path):
|
|
213
|
+
with pytest.raises(IsADirectoryError):
|
|
214
|
+
make_fs().get_file("/ds1/up1", str(tmp_path / "x"))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@responses.activate
|
|
218
|
+
def test_partial_reads_use_offset_and_length():
|
|
219
|
+
url = f"{API}/uploads/up1/raw/a.bin"
|
|
220
|
+
responses.get(
|
|
221
|
+
f"{API}/uploads/up1/rawdir/", json=rawdir({"name": "a.bin", "size": 10, "is_file": True})
|
|
222
|
+
)
|
|
223
|
+
responses.get(url, match=[raw_params(offset="2", length="3")], body=b"cde")
|
|
224
|
+
responses.get(url, match=[raw_params(length="2")], body=b"ab")
|
|
225
|
+
responses.get(url, match=[raw_params(offset="8")], body=b"ij")
|
|
226
|
+
responses.get(url, match=[raw_params(length="10")], body=b"abcdefghij")
|
|
227
|
+
fs = make_fs()
|
|
228
|
+
assert fs.cat_file("/ds1/up1/a.bin", start=2, end=5) == b"cde"
|
|
229
|
+
assert fs.cat_file("/ds1/up1/a.bin", end=2) == b"ab"
|
|
230
|
+
assert fs.cat_file("/ds1/up1/a.bin", start=3, end=3) == b""
|
|
231
|
+
assert fs.cat_file("/ds1/up1/a.bin", start=-2) == b"ij"
|
|
232
|
+
with fs.open("/ds1/up1/a.bin", "rb") as handle:
|
|
233
|
+
assert handle.read() == b"abcdefghij"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@responses.activate
|
|
237
|
+
def test_writing_is_refused():
|
|
238
|
+
with pytest.raises(PermissionError):
|
|
239
|
+
make_fs().open("/ds1/up1/new.txt", "wb")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@responses.activate(registry=registries.OrderedRegistry)
|
|
243
|
+
def test_a_full_aggregation_page_without_a_cursor_ends_the_listing(monkeypatch):
|
|
244
|
+
monkeypatch.setattr(filesystem, "AGGREGATION_PAGE_SIZE", 2)
|
|
245
|
+
responses.post(f"{API}/entries/query", json=aggregation("up1", "up2"))
|
|
246
|
+
assert make_fs().ls("/ds1", detail=False) == ["/ds1/up1", "/ds1/up2"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from nomad_fsspec import NomadFileSystem
|
|
5
|
+
|
|
6
|
+
pytestmark = pytest.mark.live
|
|
7
|
+
|
|
8
|
+
DEMO_DATASET = "/wWgAnNZNQxOHLf97H62dgw"
|
|
9
|
+
DEMO_FILE = (
|
|
10
|
+
f"{DEMO_DATASET}/Xa2LB__ESiKu-odRBPJhjA/nomad-demo-data/BrK_svSe/TBCC006.ABC/vasprun.xml.relax2"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="module")
|
|
15
|
+
def fs():
|
|
16
|
+
try:
|
|
17
|
+
requests.get("https://nomad-lab.eu/prod/v1/api/v1/info", timeout=20).raise_for_status()
|
|
18
|
+
except requests.RequestException as e:
|
|
19
|
+
pytest.skip(f"nomad-lab.eu is not reachable: {e}")
|
|
20
|
+
return NomadFileSystem(skip_instance_cache=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_root_lists_public_datasets_by_name(fs):
|
|
24
|
+
root = fs.ls("/")
|
|
25
|
+
assert len(root) > 1000
|
|
26
|
+
assert any(entry["display_name"] == "demo example data" for entry in root)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_demo_file_downloads_and_reads_in_parts(fs, tmp_path):
|
|
30
|
+
uploads = fs.ls(DEMO_DATASET)
|
|
31
|
+
assert [upload["display_name"] for upload in uploads] == ["nomad-demo-data.zip"]
|
|
32
|
+
assert fs.info(DEMO_FILE)["size"] == 424235
|
|
33
|
+
target = tmp_path / "vasprun.xml"
|
|
34
|
+
fs.get_file(DEMO_FILE, str(target))
|
|
35
|
+
data = target.read_bytes()
|
|
36
|
+
assert len(data) == 424235
|
|
37
|
+
assert fs.cat_file(DEMO_FILE, start=100, end=150) == data[100:150]
|