cloudnet-api-client 0.10.0__tar.gz → 0.11.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.
Files changed (30) hide show
  1. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/workflows/test.yml +1 -1
  2. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/CHANGELOG.md +5 -0
  3. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/PKG-INFO +25 -3
  4. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/README.md +22 -2
  5. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/client.py +41 -8
  6. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/containers.py +21 -1
  7. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/utils.py +8 -3
  8. cloudnet_api_client-0.11.0/cloudnet_api_client/version.py +1 -0
  9. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/pyproject.toml +4 -1
  10. cloudnet_api_client-0.11.0/tests/data/20140205_hyytiala_classification.nc +0 -0
  11. cloudnet_api_client-0.11.0/tests/data/20250808_hyytiala_iwc-Z-T-method.nc +0 -0
  12. cloudnet_api_client-0.11.0/tests/data/20250814_bucharest_classification.nc +0 -0
  13. cloudnet_api_client-0.11.0/tests/test_client.py +359 -0
  14. cloudnet_api_client-0.10.0/cloudnet_api_client/version.py +0 -1
  15. cloudnet_api_client-0.10.0/tests/test_client.py +0 -139
  16. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/dataportal.env +0 -0
  17. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/db.env +0 -0
  18. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/docker-compose.yml +0 -0
  19. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/initdb.d/init-dbs.sh +0 -0
  20. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/ss.env +0 -0
  21. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.github/workflows/publish.yml +0 -0
  22. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.gitignore +0 -0
  23. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/.pre-commit-config.yaml +0 -0
  24. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/LICENSE +0 -0
  25. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/__init__.py +0 -0
  26. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/dl.py +0 -0
  27. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/cloudnet_api_client/py.typed +0 -0
  28. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/tests/data/20250801_Magurele_CHM170137_000.nc +0 -0
  29. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/tests/data/20250803_JOYCE_WST_01m.dat +0 -0
  30. {cloudnet_api_client-0.10.0 → cloudnet_api_client-0.11.0}/tests/data/20250808_Granada_CHM170119_0045_000.nc +0 -0
@@ -1,4 +1,4 @@
1
- name: Test against dataportal backend
1
+ name: Test and lint
2
2
 
3
3
  on: [push, pull_request]
4
4
 
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.11.0 – 2025-08-16
9
+
10
+ - Adjust routes and responses
11
+ - Improve tests
12
+
8
13
  ## 0.10.0 – 2025-08-13
9
14
 
10
15
  - Add `volatile` to metadata response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnet-api-client
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Cloudnet API client
5
5
  Author-email: Simo Tukiainen <simo.tukiainen@fmi.fi>
6
6
  License-File: LICENSE
@@ -22,7 +22,9 @@ Requires-Dist: types-requests; extra == 'dev'
22
22
  Requires-Dist: types-tqdm; extra == 'dev'
23
23
  Provides-Extra: test
24
24
  Requires-Dist: mypy; extra == 'test'
25
+ Requires-Dist: netcdf4; extra == 'test'
25
26
  Requires-Dist: pytest; extra == 'test'
27
+ Requires-Dist: pytest-asyncio; extra == 'test'
26
28
  Description-Content-Type: text/markdown
27
29
 
28
30
  [![CI](https://github.com/actris-cloudnet/cloudnet-api-client/actions/workflows/test.yml/badge.svg)](https://github.com/actris-cloudnet/cloudnet-api-client/actions/workflows/test.yml)
@@ -128,13 +130,33 @@ Parameters:
128
130
 
129
131
  \* = only with `RawMetadata`
130
132
 
133
+ ### `APIClient().file()` &rarr; `ProductMetadata`
134
+
135
+ Fetch metadata of a single file.
136
+
137
+ Parameters:
138
+
139
+ | name | type |
140
+ | ---- | -------------------- |
141
+ | uuid | `str` or `uuid.UUID` |
142
+
143
+ ### `APIClient().versions()` &rarr; `list[VersionMetadata]`
144
+
145
+ Fetch information of all versions of a file.
146
+
147
+ Parameters:
148
+
149
+ | name | type |
150
+ | ---- | -------------------- |
151
+ | uuid | `str` or `uuid.UUID` |
152
+
131
153
  ### `APIClient().sites()` &rarr; `list[Site]`
132
154
 
133
155
  Fetch cloudnet sites.
134
156
 
135
157
  Parameters:
136
158
 
137
- | name | type | Choices | default |
159
+ | name | type | choices | default |
138
160
  | ------- | -------------------- | ----------------------------------------- | ------- |
139
161
  | site_id | `str` | | `None` |
140
162
  | type | `str` or `list[str]` | "cloudnet", "campaign", "model", "hidden" | `None` |
@@ -145,7 +167,7 @@ Fetch cloudnet products.
145
167
 
146
168
  Parameters:
147
169
 
148
- | name | type | Choices | default |
170
+ | name | type | choices | default |
149
171
  | ---- | -------------------- | ----------------------------------------- | ------- |
150
172
  | type | `str` or `list[str]` | "instrument", "geophysical", "evaluation" | `None` |
151
173
 
@@ -101,13 +101,33 @@ Parameters:
101
101
 
102
102
  \* = only with `RawMetadata`
103
103
 
104
+ ### `APIClient().file()` &rarr; `ProductMetadata`
105
+
106
+ Fetch metadata of a single file.
107
+
108
+ Parameters:
109
+
110
+ | name | type |
111
+ | ---- | -------------------- |
112
+ | uuid | `str` or `uuid.UUID` |
113
+
114
+ ### `APIClient().versions()` &rarr; `list[VersionMetadata]`
115
+
116
+ Fetch information of all versions of a file.
117
+
118
+ Parameters:
119
+
120
+ | name | type |
121
+ | ---- | -------------------- |
122
+ | uuid | `str` or `uuid.UUID` |
123
+
104
124
  ### `APIClient().sites()` &rarr; `list[Site]`
105
125
 
106
126
  Fetch cloudnet sites.
107
127
 
108
128
  Parameters:
109
129
 
110
- | name | type | Choices | default |
130
+ | name | type | choices | default |
111
131
  | ------- | -------------------- | ----------------------------------------- | ------- |
112
132
  | site_id | `str` | | `None` |
113
133
  | type | `str` or `list[str]` | "cloudnet", "campaign", "model", "hidden" | `None` |
@@ -118,7 +138,7 @@ Fetch cloudnet products.
118
138
 
119
139
  Parameters:
120
140
 
121
- | name | type | Choices | default |
141
+ | name | type | choices | default |
122
142
  | ---- | -------------------- | ----------------------------------------- | ------- |
123
143
  | type | `str` or `list[str]` | "instrument", "geophysical", "evaluation" | `None` |
124
144
 
@@ -3,12 +3,12 @@ import calendar
3
3
  import datetime
4
4
  import os
5
5
  import re
6
- import uuid
7
6
  from dataclasses import fields, is_dataclass
8
7
  from os import PathLike
9
8
  from pathlib import Path
10
9
  from typing import TypeVar, cast
11
10
  from urllib.parse import urljoin
11
+ from uuid import UUID
12
12
 
13
13
  import requests
14
14
  from requests.adapters import HTTPAdapter
@@ -25,6 +25,7 @@ from cloudnet_api_client.containers import (
25
25
  RawMetadata,
26
26
  RawModelMetadata,
27
27
  Site,
28
+ VersionMetadata,
28
29
  )
29
30
  from cloudnet_api_client.dl import download_files
30
31
 
@@ -42,6 +43,8 @@ class APIClient:
42
43
  base_url: str = "https://cloudnet.fmi.fi/api/",
43
44
  session: requests.Session | None = None,
44
45
  ) -> None:
46
+ if not base_url.endswith("/"):
47
+ base_url += "/"
45
48
  self.base_url = base_url
46
49
  self.session = session or _make_session()
47
50
 
@@ -74,7 +77,7 @@ class APIClient:
74
77
  instrument_id=obj["instrument"]["id"],
75
78
  model=obj["model"],
76
79
  type=obj["type"],
77
- uuid=uuid.UUID(obj["uuid"]),
80
+ uuid=UUID(obj["uuid"]),
78
81
  pid=obj["pid"],
79
82
  owners=obj["owners"],
80
83
  serial_number=obj["serialNumber"],
@@ -83,6 +86,31 @@ class APIClient:
83
86
  for obj in res
84
87
  ]
85
88
 
89
+ def file(
90
+ self,
91
+ uuid: str | UUID,
92
+ ) -> ProductMetadata:
93
+ res = self._get_response(f"files/{uuid}")
94
+ return _build_meta_objects(res)[0]
95
+
96
+ def versions(self, uuid: str | UUID) -> list[VersionMetadata]:
97
+ res = self._get_response(
98
+ f"files/{uuid}/versions",
99
+ {"properties": ["pid", "dvasId", "legacy", "size", "checksum"]},
100
+ )
101
+ return [
102
+ VersionMetadata(
103
+ uuid=UUID(obj["uuid"]),
104
+ created_at=_parse_datetime(obj["createdAt"]),
105
+ pid=obj["pid"],
106
+ dvas_id=obj["dvasId"],
107
+ legacy=obj["legacy"],
108
+ size=int(obj["size"]),
109
+ checksum=obj["checksum"],
110
+ )
111
+ for obj in res
112
+ ]
113
+
86
114
  def metadata(
87
115
  self,
88
116
  site_id: QueryParam = None,
@@ -105,6 +133,10 @@ class APIClient:
105
133
  "product": product,
106
134
  "showLegacy": show_legacy,
107
135
  }
136
+ if show_legacy is not True:
137
+ # API shows legacy files with any value (even <False>)
138
+ del params["showLegacy"]
139
+
108
140
  _add_date_params(
109
141
  params, date, date_from, date_to, updated_at, updated_at_from, updated_at_to
110
142
  )
@@ -125,7 +157,8 @@ class APIClient:
125
157
  or (model_id is not None and (product is None or "model" in product))
126
158
  ):
127
159
  for key in ("showLegacy", "product", "instrument", "instrumentPid"):
128
- del params[key]
160
+ if key in params:
161
+ del params[key]
129
162
  params["model"] = model_id
130
163
  files_res += self._get_response("model-files", params)
131
164
 
@@ -418,7 +451,7 @@ def _build_meta_objects(res: list[dict]) -> list[ProductMetadata]:
418
451
  created_at=_parse_datetime(obj["createdAt"]),
419
452
  updated_at=_parse_datetime(obj["updatedAt"]),
420
453
  size=int(obj["size"]),
421
- uuid=uuid.UUID(obj["uuid"]),
454
+ uuid=UUID(obj["uuid"]),
422
455
  site=_create_site_object(obj["site"]),
423
456
  )
424
457
  for obj in res
@@ -437,7 +470,7 @@ def _build_raw_meta_objects(res: list[dict]) -> list[RawMetadata]:
437
470
  created_at=_parse_datetime(obj["createdAt"]),
438
471
  updated_at=_parse_datetime(obj["updatedAt"]),
439
472
  size=int(obj["size"]),
440
- uuid=uuid.UUID(obj["uuid"]),
473
+ uuid=UUID(obj["uuid"]),
441
474
  site=_create_site_object(obj["site"]),
442
475
  )
443
476
  for obj in res
@@ -456,7 +489,7 @@ def _build_raw_model_meta_objects(res: list[dict]) -> list[RawModelMetadata]:
456
489
  created_at=_parse_datetime(obj["createdAt"]),
457
490
  updated_at=_parse_datetime(obj["updatedAt"]),
458
491
  size=int(obj["size"]),
459
- uuid=uuid.UUID(obj["uuid"]),
492
+ uuid=UUID(obj["uuid"]),
460
493
  site=_create_site_object(obj["site"]),
461
494
  )
462
495
  for obj in res
@@ -498,10 +531,10 @@ def _create_site_object(metadata: dict) -> Site:
498
531
 
499
532
  def _create_instrument_object(metadata: dict) -> Instrument:
500
533
  return Instrument(
501
- instrument_id=metadata["instrumentId"],
534
+ instrument_id=metadata.get("instrumentId"), # not in api/files/:uuid
502
535
  model=metadata["model"],
503
536
  type=metadata["type"],
504
- uuid=uuid.UUID(metadata["uuid"]),
537
+ uuid=UUID(metadata["uuid"]),
505
538
  pid=metadata["pid"],
506
539
  owners=metadata["owners"],
507
540
  serial_number=metadata["serialNumber"],
@@ -35,7 +35,7 @@ class Product:
35
35
 
36
36
  @dataclass(frozen=True, slots=True)
37
37
  class Instrument:
38
- instrument_id: str # CLU internal identifier, e.g. "rpg-fmcw-94"
38
+ instrument_id: str | None # CLU internal identifier, e.g. "rpg-fmcw-94"
39
39
  model: str # From ACTRIS Vocabulary, e.g. "RPG-FMCW-94 DP"
40
40
  type: str # From ACTRIS Vocabulary, e.g. "Doppler non-scanning cloud radar"
41
41
  name: str # e.g. "FMI RPG-FMCW-94 (Pallas)"
@@ -87,3 +87,23 @@ class ProductMetadata(Metadata):
87
87
  instrument: Instrument | None
88
88
  model: Model | None
89
89
  volatile: bool
90
+ legacy: bool
91
+ pid: str
92
+ dvas_id: str | None
93
+ error_level: str | None
94
+ coverage: float
95
+ timeliness: str
96
+ format: str
97
+ start_time: datetime.datetime | None
98
+ stop_time: datetime.datetime | None
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class VersionMetadata:
103
+ uuid: uuid.UUID
104
+ created_at: datetime.datetime
105
+ pid: str
106
+ checksum: str
107
+ legacy: bool
108
+ size: int
109
+ dvas_id: str | None
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import hashlib
2
3
  from os import PathLike
3
4
  from typing import Literal
@@ -7,13 +8,17 @@ def sha256sum(filename: str | PathLike) -> str:
7
8
  return _calc_hash_sum(filename, "sha256")
8
9
 
9
10
 
10
- def md5sum(filename: str | PathLike) -> str:
11
- return _calc_hash_sum(filename, "md5")
11
+ def md5sum(filename: str | PathLike, is_base64: bool = False) -> str:
12
+ return _calc_hash_sum(filename, "md5", is_base64)
12
13
 
13
14
 
14
- def _calc_hash_sum(filename: str | PathLike, method: Literal["sha256", "md5"]) -> str:
15
+ def _calc_hash_sum(
16
+ filename: str | PathLike, method: Literal["sha256", "md5"], is_base64: bool = False
17
+ ) -> str:
15
18
  hash_sum = getattr(hashlib, method)()
16
19
  with open(filename, "rb") as f:
17
20
  for byte_block in iter(lambda: f.read(4096), b""):
18
21
  hash_sum.update(byte_block)
22
+ if is_base64:
23
+ return base64.b64encode(hash_sum.digest()).decode("utf-8")
19
24
  return hash_sum.hexdigest()
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -22,12 +22,15 @@ dependencies = ["aiohttp", "numpy", "requests", "tqdm"]
22
22
  dynamic = ["version"]
23
23
 
24
24
  [project.optional-dependencies]
25
- test = ["mypy", "pytest"]
25
+ test = ["mypy", "netCDF4", "pytest", "pytest-asyncio"]
26
26
  dev = ["pre-commit", "release-version", "types-requests", "types-tqdm"]
27
27
 
28
28
  [tool.hatch.version]
29
29
  path = "cloudnet_api_client/version.py"
30
30
 
31
+ [tool.pytest.ini_options]
32
+ asyncio_mode = "auto"
33
+
31
34
  [tool.release-version]
32
35
  filename = "cloudnet_api_client/version.py"
33
36
  pattern = ["__version__ = \"(?P<major>\\d+).(?P<minor>\\d+).(?P<patch>\\d+)\""]
@@ -0,0 +1,359 @@
1
+ import datetime
2
+ import os
3
+ from pathlib import Path
4
+ from typing import NamedTuple
5
+ from uuid import UUID
6
+
7
+ import netCDF4
8
+ import pytest
9
+ import requests
10
+
11
+ from cloudnet_api_client import APIClient
12
+ from cloudnet_api_client.containers import (
13
+ Instrument,
14
+ Product,
15
+ ProductMetadata,
16
+ RawMetadata,
17
+ Site,
18
+ VersionMetadata,
19
+ )
20
+ from cloudnet_api_client.utils import md5sum, sha256sum
21
+
22
+
23
+ class RawFile(NamedTuple):
24
+ filename: str
25
+ site: str
26
+ instrument: str
27
+ date: str
28
+ pid: str
29
+
30
+
31
+ class File(NamedTuple):
32
+ filename: str
33
+ legacy: bool
34
+ volatile: bool
35
+
36
+
37
+ @pytest.fixture(scope="session")
38
+ def backend_url() -> str:
39
+ return os.getenv("BACKEND_URL", "http://localhost:3000")
40
+
41
+
42
+ @pytest.fixture(scope="session")
43
+ def client(backend_url) -> APIClient:
44
+ return APIClient(base_url=f"{backend_url}/api/")
45
+
46
+
47
+ @pytest.fixture(scope="session")
48
+ def data_path() -> Path:
49
+ return Path(__file__).parent / "data"
50
+
51
+
52
+ @pytest.fixture(scope="session")
53
+ def files_raw() -> list[RawFile]:
54
+ return [
55
+ RawFile(
56
+ filename="20250801_Magurele_CHM170137_000.nc",
57
+ site="bucharest",
58
+ instrument="chm15k",
59
+ date="2025-08-01",
60
+ pid="https://hdl.handle.net/21.12132/3.c60c931fac9d43f0",
61
+ ),
62
+ RawFile(
63
+ filename="20250808_Granada_CHM170119_0045_000.nc",
64
+ site="granada",
65
+ instrument="chm15k",
66
+ date="2025-08-08",
67
+ pid="https://hdl.handle.net/21.12132/3.77a75f3b32294855",
68
+ ),
69
+ RawFile(
70
+ filename="20250803_JOYCE_WST_01m.dat",
71
+ site="juelich",
72
+ instrument="weather-station",
73
+ date="2025-08-01",
74
+ pid="https://hdl.handle.net/21.12132/3.726b3b29de1949cc",
75
+ ),
76
+ ]
77
+
78
+
79
+ @pytest.fixture(scope="session")
80
+ def files_product() -> list[File]:
81
+ return [
82
+ File("20250814_bucharest_classification.nc", legacy=False, volatile=True),
83
+ File("20250808_hyytiala_iwc-Z-T-method.nc", legacy=False, volatile=False),
84
+ File("20140205_hyytiala_classification.nc", legacy=True, volatile=False),
85
+ ]
86
+
87
+
88
+ @pytest.fixture(scope="session", autouse=True)
89
+ def submit_raw_files(backend_url, data_path, files_raw):
90
+ for file_meta in files_raw:
91
+ _submit_raw_file(backend_url, data_path, file_meta)
92
+
93
+
94
+ @pytest.fixture(scope="session", autouse=True)
95
+ def submit_product_files(backend_url: str, data_path: Path, files_product: list[File]):
96
+ for file_meta in files_product:
97
+ _submit_product_file(backend_url, data_path, file_meta)
98
+
99
+
100
+ class TestBasicMetadata:
101
+ def test_sites(self, client: APIClient):
102
+ sites = client.sites()
103
+ assert sites
104
+ assert isinstance(sites[0], Site)
105
+
106
+ def test_site_filter_cloudnet(self, client: APIClient):
107
+ sites = client.sites(type="cloudnet")
108
+ assert all("cloudnet" in site.type for site in sites)
109
+
110
+ def test_site_filter_hidden(self, client: APIClient):
111
+ sites = client.sites(type="hidden")
112
+ assert all("hidden" in site.type for site in sites)
113
+ assert all("cloudnet" not in site.type for site in sites)
114
+
115
+ def test_products(self, client: APIClient):
116
+ products = client.products()
117
+ assert products
118
+ assert isinstance(products[0], Product)
119
+
120
+ def test_product_type_filter(self, client: APIClient):
121
+ products = client.products("instrument")
122
+ assert all(product.type == ["instrument"] for product in products)
123
+
124
+ def test_product_type_filter_combo(self, client: APIClient):
125
+ products = client.products(["instrument", "geophysical"])
126
+ assert all(
127
+ product.type in [["instrument"], ["geophysical"]] for product in products
128
+ )
129
+
130
+ def test_instruments(self, client: APIClient):
131
+ instruments = client.instruments()
132
+ assert instruments
133
+ assert isinstance(instruments[0], Instrument)
134
+
135
+
136
+ class TestDateParameterHandling:
137
+ """Test various date parameter formats and edge cases."""
138
+
139
+ def test_date_string_formats(self, client: APIClient):
140
+ meta1 = client.raw_metadata(date="2025-08-01")
141
+ meta2 = client.raw_metadata(date="2025-8-1")
142
+ assert len(meta1) == len(meta2)
143
+
144
+ def test_date_object_parameter(self, client: APIClient):
145
+ date_obj = datetime.date(2025, 8, 1)
146
+ meta = client.raw_metadata(date=date_obj)
147
+ assert len(meta) >= 0
148
+
149
+ def test_datetime_parameter(self, client: APIClient):
150
+ datetime_obj = datetime.datetime(2025, 8, 1, 12, 30)
151
+ meta = client.raw_metadata(updated_at=datetime_obj)
152
+ assert len(meta) >= 0
153
+
154
+ def test_invalid_date_format(self, client: APIClient):
155
+ with pytest.raises(ValueError):
156
+ client.raw_metadata(date="invalid-date")
157
+
158
+ def test_date_range_validation(self, client: APIClient):
159
+ # date_from > date_to should return empty results
160
+ meta = client.raw_metadata(date_from="2025-08-10", date_to="2025-08-01")
161
+ assert len(meta) == 0
162
+
163
+
164
+ class TestRawMetadata:
165
+ def test_filter_by_site_and_date(self, client: APIClient):
166
+ meta = client.raw_metadata(site_id="bucharest", date="2025-08-01")
167
+ assert len(meta) == 1
168
+ assert isinstance(meta[0], RawMetadata)
169
+
170
+ def test_filter_by_date_only(self, client: APIClient):
171
+ meta = client.raw_metadata(date="2025-08-08")
172
+ assert len(meta) == 1
173
+
174
+ def test_filter_by_instrument_pid(self, client: APIClient):
175
+ pid = "https://hdl.handle.net/21.12132/3.77a75f3b32294855"
176
+ meta = client.raw_metadata(instrument_pid=pid)
177
+ assert len(meta) == 1
178
+
179
+ def test_filter_by_instrument_pid_no_match(self, client: APIClient):
180
+ pid = "https://hdl.handle.net/21.12132/3.77a75f3b32294855"
181
+ meta = client.raw_metadata(instrument_pid=pid, date="2022-01-01")
182
+ assert len(meta) == 0
183
+
184
+ def test_filter_by_date_range_from(self, client: APIClient):
185
+ meta = client.raw_metadata(date_from="2025-08-01")
186
+ assert len(meta) == 3
187
+
188
+ def test_filter_by_date_range_inclusive(self, client: APIClient):
189
+ meta = client.raw_metadata(date_from="2025-08-01", date_to="2025-08-08")
190
+ assert len(meta) == 3
191
+
192
+ def test_filter_by_date_range_exclusive(self, client: APIClient):
193
+ meta = client.raw_metadata(date_from="2025-08-01", date_to="2025-08-07")
194
+ assert len(meta) == 2
195
+
196
+ def test_filter_by_filename_prefix(self, client: APIClient):
197
+ meta = client.raw_metadata(filename_prefix="20250801")
198
+ assert len(meta) == 1
199
+
200
+ def test_filter_by_filename_suffix(self, client: APIClient):
201
+ meta = client.raw_metadata(filename_suffix="000.nc")
202
+ assert len(meta) == 2
203
+
204
+ def test_filter_by_instrument_id(self, client: APIClient):
205
+ meta = client.raw_metadata(instrument_id="weather-station")
206
+ assert len(meta) == 1
207
+
208
+ def test_instrument_id_vs_pid_exclusivity(self, client: APIClient):
209
+ meta1 = client.raw_metadata(instrument_id="chm15k")
210
+ pid = "https://hdl.handle.net/21.12132/3.c60c931fac9d43f0"
211
+ meta2 = client.raw_metadata(instrument_pid=pid)
212
+ assert len(meta1) > 1 # Multiple chm15k files
213
+ assert len(meta2) == 1 # Specific PID
214
+
215
+ def test_malformed_pid(self, client: APIClient):
216
+ meta = client.raw_metadata(instrument_pid="not-a-valid-pid")
217
+ assert len(meta) == 0
218
+
219
+
220
+ class TestDownloadingFunctionality:
221
+ def test_downloading(self, client: APIClient, tmp_path: Path):
222
+ meta = client.raw_metadata(date_from="2025-08-01")
223
+ assert len(meta) == 3
224
+ paths = client.download(meta, output_directory=tmp_path, progress=False)
225
+ assert len(paths) == 3
226
+ for path in paths:
227
+ assert path.exists()
228
+
229
+ def test_download_with_custom_directory(self, client: APIClient, tmp_path: Path):
230
+ meta = client.raw_metadata(date="2025-08-01", site_id="bucharest")
231
+ custom_dir = tmp_path / "custom" / "nested"
232
+ paths = client.download(meta, output_directory=custom_dir, progress=False)
233
+ assert len(paths) == 1
234
+ assert paths[0].parent == custom_dir
235
+ assert paths[0].exists()
236
+
237
+ def test_download_existing_file_skip(self, client: APIClient, tmp_path: Path):
238
+ meta = client.raw_metadata(date="2025-08-01", site_id="bucharest")
239
+ paths1 = client.download(meta, output_directory=tmp_path, progress=False)
240
+ original_size = paths1[0].stat().st_size
241
+ paths2 = client.download(meta, output_directory=tmp_path, progress=False)
242
+ assert paths1 == paths2
243
+ assert paths2[0].stat().st_size == original_size
244
+
245
+ async def test_async_download(self, client: APIClient, tmp_path: Path):
246
+ meta = client.raw_metadata(date_from="2025-08-01")
247
+ assert len(meta) == 3
248
+ paths = await client.adownload(meta, output_directory=tmp_path, progress=False)
249
+ assert len(paths) == 3
250
+ for path in paths:
251
+ assert path.exists()
252
+
253
+
254
+ class TestProductMeta:
255
+ def test_file_route(self, client: APIClient):
256
+ uuid = "8dcc865c-6920-49ce-a627-de045ec896e8"
257
+ meta = client.file(uuid)
258
+ assert isinstance(meta, ProductMetadata)
259
+ assert str(meta.uuid) == uuid
260
+
261
+ def test_versions_route(self, client: APIClient):
262
+ uuid = "8dcc865c-6920-49ce-a627-de045ec896e8"
263
+ meta = client.versions(uuid)
264
+ assert len(meta) == 1
265
+ assert isinstance(meta[0], VersionMetadata)
266
+ assert str(meta[0].uuid) == uuid
267
+
268
+ def test_product_option(self, client: APIClient):
269
+ meta = client.metadata(site_id="hyytiala", product="iwc")
270
+ assert len(meta) == 1
271
+
272
+ def test_show_legacy_option(self, client: APIClient):
273
+ meta = client.metadata(site_id="hyytiala", date="2014-02-05")
274
+ assert len(meta) == 0
275
+ meta = client.metadata(site_id="hyytiala", date="2014-02-05", show_legacy=True)
276
+ assert len(meta) == 1
277
+
278
+ def test_downloading(self, client: APIClient, tmp_path: Path):
279
+ meta = client.metadata(date_from="2025-08-01")
280
+ assert len(meta) == 2
281
+ paths = client.download(meta, output_directory=tmp_path, progress=False)
282
+ assert len(paths) == 2
283
+ for path in paths:
284
+ assert path.exists()
285
+
286
+
287
+ class TestFilterCombinations:
288
+ def test_multiple_filters_combined(self, client: APIClient):
289
+ meta = client.raw_metadata(
290
+ site_id="bucharest",
291
+ instrument_id="chm15k",
292
+ date_from="2025-08-01",
293
+ date_to="2025-08-01",
294
+ )
295
+ assert len(meta) == 1
296
+ assert meta[0].site.id == "bucharest"
297
+
298
+ def test_contradictory_filters(self, client: APIClient):
299
+ meta = client.raw_metadata(site_id="bucharest", instrument_id="weather-station")
300
+ assert len(meta) == 0
301
+
302
+ def test_partial_filename_matches(self, client: APIClient):
303
+ meta = client.raw_metadata(filename_prefix="2025", filename_suffix=".nc")
304
+ assert len(meta) == 2
305
+
306
+
307
+ def _submit_product_file(backend_url: str, data_path: Path, meta: File):
308
+ _date, site_id, product = meta.filename.removesuffix(".nc").split("_")
309
+ full_path = data_path / meta.filename
310
+ headers = {"content-md5": md5sum(full_path, is_base64=True)}
311
+ bucket = f"cloudnet-product{'-volatile' if meta.volatile else ''}"
312
+ url = f"http://localhost:5900/{bucket}/{meta.filename}"
313
+ with open(full_path, "rb") as f:
314
+ res = requests.put(url, data=f, auth=("test", "test"), headers=headers)
315
+ res.raise_for_status()
316
+ file_info = {
317
+ "version": res.json().get("version", ""),
318
+ "size": int(res.json()["size"]),
319
+ }
320
+ with netCDF4.Dataset(full_path, "r") as nc:
321
+ year, month, day = str(nc.year), str(nc.month).zfill(2), str(nc.day).zfill(2)
322
+ payload = {
323
+ "product": getattr(nc, "cloudnet_file_type", product),
324
+ "site": site_id,
325
+ "measurementDate": f"{year}-{month}-{day}",
326
+ "format": "HDF5 (NetCDF4)",
327
+ "checksum": sha256sum(full_path),
328
+ "volatile": meta.volatile,
329
+ "legacy": meta.legacy,
330
+ "uuid": str(UUID(nc.file_uuid)),
331
+ "pid": nc.pid,
332
+ **file_info,
333
+ }
334
+ url = f"{backend_url}/files/{meta.filename}"
335
+ res = requests.put(url, json=payload)
336
+ if res.status_code == 403:
337
+ return
338
+ res.raise_for_status()
339
+
340
+
341
+ def _submit_raw_file(backend_url: str, data_path: Path, meta: RawFile):
342
+ auth = ("admin", "admin")
343
+ file_path = data_path / meta.filename
344
+ checksum = md5sum(file_path)
345
+ metadata = {
346
+ "filename": meta.filename,
347
+ "checksum": checksum,
348
+ "site": meta.site,
349
+ "instrument": meta.instrument,
350
+ "measurementDate": meta.date,
351
+ "instrumentPid": meta.pid,
352
+ }
353
+ res = requests.post(f"{backend_url}/upload/metadata/", json=metadata, auth=auth)
354
+ if res.status_code == 409:
355
+ return
356
+ res.raise_for_status()
357
+ with open(file_path, "rb") as f:
358
+ res = requests.put(f"{backend_url}/upload/data/{checksum}", data=f, auth=auth)
359
+ res.raise_for_status()
@@ -1 +0,0 @@
1
- __version__ = "0.10.0"
@@ -1,139 +0,0 @@
1
- import hashlib
2
- from pathlib import Path
3
-
4
- import requests
5
-
6
- from cloudnet_api_client import APIClient
7
- from cloudnet_api_client.containers import Instrument, Product, RawMetadata, Site
8
-
9
- BACKEND_URL = "http://localhost:3000"
10
- DATA_PATH = Path(__file__).parent / "data"
11
-
12
-
13
- class TestFixtures:
14
- def setup_method(self):
15
- self.client = APIClient(base_url=f"{BACKEND_URL}/api/")
16
-
17
- def test_sites(self):
18
- sites = self.client.sites()
19
- assert isinstance(sites[0], Site)
20
-
21
- def test_site_filter_cloudnet(self):
22
- sites = self.client.sites(type="cloudnet")
23
- assert all("cloudnet" in site.type for site in sites)
24
-
25
- def test_site_filter_hidden(self):
26
- sites = self.client.sites(type="hidden")
27
- assert all("hidden" in site.type for site in sites)
28
- assert all("cloudnet" not in site.type for site in sites)
29
-
30
- def test_products(self):
31
- products = self.client.products()
32
- assert isinstance(products[0], Product)
33
-
34
- def test_instruments(self):
35
- instruments = self.client.instruments()
36
- assert isinstance(instruments[0], Instrument)
37
-
38
-
39
- class TestWithRawFiles:
40
- def setup_method(self):
41
- self.client = APIClient(base_url=f"{BACKEND_URL}/api/")
42
- metadata_list = [
43
- (
44
- "20250801_Magurele_CHM170137_000.nc",
45
- "bucharest",
46
- "chm15k",
47
- "2025-08-01",
48
- "https://hdl.handle.net/21.12132/3.c60c931fac9d43f0",
49
- ),
50
- (
51
- "20250808_Granada_CHM170119_0045_000.nc",
52
- "granada",
53
- "chm15k",
54
- "2025-08-08",
55
- "https://hdl.handle.net/21.12132/3.77a75f3b32294855",
56
- ),
57
- (
58
- "20250803_JOYCE_WST_01m.dat",
59
- "juelich",
60
- "weather-station",
61
- "2025-08-01",
62
- "https://hdl.handle.net/21.12132/3.726b3b29de1949cc",
63
- ),
64
- ]
65
-
66
- for item in metadata_list:
67
- _submit_file(*item)
68
-
69
- def test_raw_metadata_1(self):
70
- meta = self.client.raw_metadata(site_id="bucharest", date="2025-08-01")
71
- assert isinstance(meta, list)
72
- assert len(meta) == 1
73
- assert isinstance(meta[0], RawMetadata)
74
-
75
- def test_raw_metadata_2(self):
76
- meta = self.client.raw_metadata(date="2025-08-08")
77
- assert len(meta) == 1
78
-
79
- def test_raw_metadata_3(self):
80
- pid = "https://hdl.handle.net/21.12132/3.77a75f3b32294855"
81
- meta = self.client.raw_metadata(instrument_pid=pid)
82
- assert len(meta) == 1
83
-
84
- def test_raw_metadata_4(self):
85
- pid = "https://hdl.handle.net/21.12132/3.77a75f3b32294855"
86
- meta = self.client.raw_metadata(instrument_pid=pid, date="2022-01-01")
87
- assert len(meta) == 0
88
-
89
- def test_raw_metadata_5(self):
90
- meta = self.client.raw_metadata(date_from="2025-08-01")
91
- assert len(meta) == 3
92
-
93
- def test_raw_metadata_6(self):
94
- meta = self.client.raw_metadata(date_from="2025-08-01", date_to="2025-08-08")
95
- assert len(meta) == 3
96
-
97
- def test_raw_metadata_7(self):
98
- meta = self.client.raw_metadata(date_from="2025-08-01", date_to="2025-08-07")
99
- assert len(meta) == 2
100
-
101
- def test_raw_metadata_8(self):
102
- meta = self.client.raw_metadata(filename_prefix="20250801")
103
- assert len(meta) == 1
104
-
105
- def test_raw_metadata_9(self):
106
- meta = self.client.raw_metadata(filename_suffix="000.nc")
107
- assert len(meta) == 2
108
-
109
- def test_raw_metadata_10(self):
110
- meta = self.client.raw_metadata(instrument_id="weather-station")
111
- assert len(meta) == 1
112
-
113
-
114
- def _submit_file(filename: str, site: str, instrument: str, date: str, pid: str):
115
- auth = ("admin", "admin")
116
- file_path = DATA_PATH / filename
117
-
118
- with open(file_path, "rb") as f:
119
- checksum = hashlib.md5(f.read()).hexdigest()
120
-
121
- metadata = {
122
- "filename": filename,
123
- "checksum": checksum,
124
- "site": site,
125
- "instrument": instrument,
126
- "measurementDate": date,
127
- "instrumentPid": pid,
128
- }
129
-
130
- res = requests.post(f"{BACKEND_URL}/upload/metadata/", json=metadata, auth=auth)
131
- if res.status_code not in (200, 409):
132
- res.raise_for_status()
133
-
134
- if res.status_code == 200:
135
- with open(file_path, "rb") as f:
136
- res = requests.put(
137
- f"{BACKEND_URL}/upload/data/{checksum}", data=f, auth=auth
138
- )
139
- res.raise_for_status()