cloudnet-api-client 0.12.6__tar.gz → 0.12.8__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 (32) hide show
  1. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/dataportal.env +3 -3
  2. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/docker-compose.yml +1 -1
  3. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/workflows/test.yml +4 -0
  4. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/CHANGELOG.md +10 -0
  5. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/PKG-INFO +9 -9
  6. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/README.md +8 -8
  7. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/cloudnet_api_client/client.py +2 -2
  8. cloudnet_api_client-0.12.8/cloudnet_api_client/dl.py +175 -0
  9. cloudnet_api_client-0.12.8/cloudnet_api_client/version.py +1 -0
  10. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/test_client.py +15 -2
  11. cloudnet_api_client-0.12.6/cloudnet_api_client/dl.py +0 -108
  12. cloudnet_api_client-0.12.6/cloudnet_api_client/version.py +0 -1
  13. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/db.env +0 -0
  14. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/initdb.d/init-dbs.sh +0 -0
  15. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/ss.env +0 -0
  16. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.github/workflows/publish.yml +0 -0
  17. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.gitignore +0 -0
  18. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/.pre-commit-config.yaml +0 -0
  19. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/LICENSE +0 -0
  20. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/cloudnet_api_client/__init__.py +0 -0
  21. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/cloudnet_api_client/containers.py +0 -0
  22. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/cloudnet_api_client/py.typed +0 -0
  23. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/cloudnet_api_client/utils.py +0 -0
  24. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/pyproject.toml +0 -0
  25. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20140205_hyytiala_classification.nc +0 -0
  26. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250801_Magurele_CHM170137_000.nc +0 -0
  27. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250803_JOYCE_WST_01m.dat +0 -0
  28. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250808_Granada_CHM170119_0045_000.nc +0 -0
  29. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250808_hyytiala_iwc-Z-T-method.nc +0 -0
  30. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250814_bucharest_classification.nc +0 -0
  31. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250821_limassol_parsivel_41582c49.nc +0 -0
  32. {cloudnet_api_client-0.12.6 → cloudnet_api_client-0.12.8}/tests/data/20250822_leipzig-lim_ecmwf-open.nc +0 -0
@@ -8,8 +8,8 @@ TYPEORM_PORT=5432
8
8
  TYPEORM_SYNCHRONIZE=false
9
9
  TYPEORM_MIGRATIONS_RUN=true
10
10
  TYPEORM_LOGGING=false
11
- TYPEORM_ENTITIES=build/entity/*.js
12
- TYPEORM_MIGRATIONS=build/migration/*.js
11
+ TYPEORM_ENTITIES=build/app/src/entity/*.js
12
+ TYPEORM_MIGRATIONS=build/app/src/migration/*.js
13
13
  DP_SS_URL=http://storage-service:5900
14
14
  DP_SS_USER=test
15
15
  DP_SS_PASSWORD=test
@@ -24,6 +24,6 @@ DATACITE_API_TIMEOUT_MS=2000
24
24
  DATACITE_DOI_SERVER=http://handle.datacite.test
25
25
  DATACITE_DOI_PREFIX=XXX
26
26
  LABELLING_URL=http://localhost:5803
27
- HANDLE_API_URL=http://localhost:5804
27
+ INSTRUMENTDB_URL=http://localhost:5805
28
28
  DVAS_URL=https://dvas.test
29
29
  DC_URL=https://dc.test
@@ -15,7 +15,7 @@ services:
15
15
  [
16
16
  "sh",
17
17
  "-c",
18
- "node build/fixtures.js /backend-fixtures/backend/fixtures TRUNCATE && node build/fixtures.js /dataportal-fixtures APPEND && npm run start",
18
+ "node build/app/src/fixtures.js /backend-fixtures/backend/fixtures TRUNCATE && node build/app/src/fixtures.js /dataportal-fixtures APPEND && npm run start",
19
19
  ]
20
20
  db:
21
21
  image: "postgres:16"
@@ -62,3 +62,7 @@ jobs:
62
62
  - name: Shutdown backend
63
63
  if: always()
64
64
  run: docker compose -f .github/docker-compose.yml down
65
+
66
+ - name: Print logs
67
+ if: always()
68
+ run: docker compose -f .github/docker-compose.yml logs
@@ -5,6 +5,16 @@ 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.12.8 – 2026-03-24
9
+
10
+ - Support downloading of single metadata objects
11
+ - Hide total progress bar when downloading a single file
12
+
13
+ ## 0.12.7 – 2025-12-18
14
+
15
+ - Adjust progress bars
16
+ - Write partial file
17
+
8
18
  ## 0.12.6 – 2025-09-17
9
19
 
10
20
  - Allow other iterables besides `list` as argument
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnet-api-client
3
- Version: 0.12.6
3
+ Version: 0.12.8
4
4
  Summary: Cloudnet API client
5
5
  Author-email: Simo Tukiainen <simo.tukiainen@fmi.fi>
6
6
  License-File: LICENSE
@@ -51,7 +51,7 @@ sites = client.sites()
51
51
  site = client.site("hyytiala")
52
52
 
53
53
  products = client.products()
54
- product = client.products("classification")
54
+ product = client.product("classification")
55
55
 
56
56
  models = client.models()
57
57
  model = client.model("ecmwf-open")
@@ -239,13 +239,13 @@ Download files from the fetched metadata.
239
239
 
240
240
  Parameters:
241
241
 
242
- | name | type | default |
243
- | ----------------- | ---------------------------------------------- | ----------------- |
244
- | metadata | `list[RawMetadata]` or `list[ProductMetadata]` | |
245
- | output_directory | `PathLike` or `str` | Current directory |
246
- | concurrency_limit | `int` | 5 |
247
- | progress | `bool` or `None` | `None` |
248
- | validate_checksum | `bool` | `False` |
242
+ | name | type | default |
243
+ | ----------------- | ----------------------------------------------- | ----------------- |
244
+ | metadata | `RawMetadata`, `ProductMetadata` or `list[...]` | |
245
+ | output_directory | `PathLike` or `str` | Current directory |
246
+ | concurrency_limit | `int` | 5 |
247
+ | progress | `bool` or `None` | `None` |
248
+ | validate_checksum | `bool` | `False` |
249
249
 
250
250
  There's also an asynchronous version of this function:
251
251
  `cloudnet_api_client.adownload`. It's useful for usage inside Jupyter notebook.
@@ -22,7 +22,7 @@ sites = client.sites()
22
22
  site = client.site("hyytiala")
23
23
 
24
24
  products = client.products()
25
- product = client.products("classification")
25
+ product = client.product("classification")
26
26
 
27
27
  models = client.models()
28
28
  model = client.model("ecmwf-open")
@@ -210,13 +210,13 @@ Download files from the fetched metadata.
210
210
 
211
211
  Parameters:
212
212
 
213
- | name | type | default |
214
- | ----------------- | ---------------------------------------------- | ----------------- |
215
- | metadata | `list[RawMetadata]` or `list[ProductMetadata]` | |
216
- | output_directory | `PathLike` or `str` | Current directory |
217
- | concurrency_limit | `int` | 5 |
218
- | progress | `bool` or `None` | `None` |
219
- | validate_checksum | `bool` | `False` |
213
+ | name | type | default |
214
+ | ----------------- | ----------------------------------------------- | ----------------- |
215
+ | metadata | `RawMetadata`, `ProductMetadata` or `list[...]` | |
216
+ | output_directory | `PathLike` or `str` | Current directory |
217
+ | concurrency_limit | `int` | 5 |
218
+ | progress | `bool` or `None` | `None` |
219
+ | validate_checksum | `bool` | `False` |
220
220
 
221
221
  There's also an asynchronous version of this function:
222
222
  `cloudnet_api_client.adownload`. It's useful for usage inside Jupyter notebook.
@@ -322,7 +322,7 @@ class APIClient:
322
322
 
323
323
  def download(
324
324
  self,
325
- metadata: MetadataList,
325
+ metadata: MetadataList | TMetadata,
326
326
  output_directory: str | PathLike = ".",
327
327
  concurrency_limit: int = 5,
328
328
  progress: bool | None = None,
@@ -340,7 +340,7 @@ class APIClient:
340
340
 
341
341
  async def adownload(
342
342
  self,
343
- metadata: MetadataList,
343
+ metadata: MetadataList | TMetadata,
344
344
  output_directory: str | PathLike = ".",
345
345
  concurrency_limit: int = 5,
346
346
  progress: bool | None = None,
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import logging
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import aiohttp
8
+ from tqdm import tqdm
9
+
10
+ from cloudnet_api_client import utils
11
+ from cloudnet_api_client.containers import (
12
+ Metadata,
13
+ ProductMetadata,
14
+ )
15
+
16
+
17
+ class BarConfig:
18
+ def __init__(
19
+ self,
20
+ disable: bool | None,
21
+ max_workers: int,
22
+ total_bytes: int,
23
+ n_files: int,
24
+ ) -> None:
25
+ self.disable = disable
26
+ self.single_file = n_files <= 1
27
+ self.position_queue = self._init_position_queue(max_workers)
28
+ self.total_amount = tqdm(
29
+ total=total_bytes,
30
+ desc="Progress",
31
+ unit="iB",
32
+ unit_scale=True,
33
+ unit_divisor=1024,
34
+ disable=self.disable if not self.single_file else True,
35
+ position=0,
36
+ leave=False,
37
+ colour="green",
38
+ )
39
+ self.lock = asyncio.Lock()
40
+
41
+ def _init_position_queue(self, max_workers: int) -> asyncio.Queue:
42
+ queue: asyncio.Queue = asyncio.Queue()
43
+ start = 0 if self.single_file else 1
44
+ for i in range(start, start + max_workers):
45
+ queue.put_nowait(i)
46
+ return queue
47
+
48
+
49
+ @dataclass
50
+ class DlParams:
51
+ url: str
52
+ destination: Path
53
+ session: aiohttp.ClientSession
54
+ semaphore: asyncio.Semaphore
55
+ bar_config: BarConfig
56
+ disable: bool | None
57
+
58
+
59
+ async def download_files(
60
+ base_url: str,
61
+ metadata: Iterable[Metadata] | Metadata,
62
+ output_path: Path,
63
+ concurrency_limit: int,
64
+ disable_progress: bool | None,
65
+ validate_checksum: bool = False,
66
+ ) -> list[Path]:
67
+ metas = list(metadata) if isinstance(metadata, Iterable) else [metadata]
68
+ file_exists = _checksum_matches if validate_checksum else _size_and_name_matches
69
+ semaphore = asyncio.Semaphore(concurrency_limit)
70
+ total_bytes = sum(meta.size for meta in metas)
71
+ bar_config = BarConfig(disable_progress, concurrency_limit, total_bytes, len(metas))
72
+ full_paths = []
73
+ async with aiohttp.ClientSession() as session:
74
+ tasks = []
75
+ for meta in metas:
76
+ download_url = f"{base_url}{meta.download_url.split('/api/')[-1]}"
77
+ destination = output_path / meta.download_url.split("/")[-1]
78
+ full_paths.append(destination)
79
+ if destination.exists() and file_exists(meta, destination):
80
+ logging.debug(f"Already downloaded: {destination}")
81
+ continue
82
+ dl_params = DlParams(
83
+ url=download_url,
84
+ destination=destination,
85
+ session=session,
86
+ semaphore=semaphore,
87
+ bar_config=bar_config,
88
+ disable=disable_progress,
89
+ )
90
+ task = asyncio.create_task(_download_file_with_retries(dl_params))
91
+ tasks.append(task)
92
+ if disable_progress is True:
93
+ print(f"Downloading {len(metas)} files...", end="", flush=True)
94
+ await asyncio.gather(*tasks)
95
+ bar_config.total_amount.close()
96
+ bar_config.total_amount.clear()
97
+ if disable_progress is True:
98
+ print(" done.", flush=True)
99
+ return full_paths
100
+
101
+
102
+ async def _download_file_with_retries(
103
+ params: DlParams,
104
+ max_retries: int = 3,
105
+ ) -> None:
106
+ """Attempt to download a file, retrying up to max_retries times if needed."""
107
+ position = await params.bar_config.position_queue.get()
108
+ try:
109
+ for attempt in range(1, max_retries + 1):
110
+ try:
111
+ await _download_file(params, position)
112
+ return
113
+ except aiohttp.ClientError as e:
114
+ logging.warning(f"Attempt {attempt} failed for {params.url}: {e}")
115
+ if attempt == max_retries:
116
+ logging.error(
117
+ f"Giving up on {params.url} after {max_retries} attempts."
118
+ )
119
+ raise e
120
+ else:
121
+ # Exponential backoff before retrying
122
+ await asyncio.sleep(2**attempt)
123
+ finally:
124
+ params.bar_config.position_queue.put_nowait(position)
125
+ raise RuntimeError("Unreachable code reached.")
126
+
127
+
128
+ async def _download_file(
129
+ params: DlParams,
130
+ position: int,
131
+ ) -> None:
132
+ tmp_path = params.destination.with_suffix(f"{params.destination.suffix}.part")
133
+ async with params.semaphore, params.session.get(params.url) as response:
134
+ response.raise_for_status()
135
+ bar = tqdm(
136
+ desc=params.destination.name,
137
+ total=response.content_length,
138
+ unit="iB",
139
+ unit_scale=True,
140
+ unit_divisor=1024,
141
+ disable=params.bar_config.disable,
142
+ position=position,
143
+ leave=False,
144
+ colour="cyan",
145
+ )
146
+ try:
147
+ tmp_path.parent.mkdir(parents=True, exist_ok=True)
148
+ with tmp_path.open("wb") as f:
149
+ async for chunk in response.content.iter_chunked(8192):
150
+ f.write(chunk)
151
+ bar.update(len(chunk))
152
+ params.bar_config.total_amount.update(len(chunk))
153
+ tmp_path.replace(params.destination)
154
+ except Exception:
155
+ try:
156
+ if tmp_path.exists():
157
+ tmp_path.unlink()
158
+ except OSError:
159
+ pass
160
+ raise
161
+ finally:
162
+ bar.close()
163
+ bar.clear()
164
+
165
+
166
+ def _checksum_matches(meta: Metadata, destination: Path) -> bool:
167
+ fun = utils.sha256sum if isinstance(meta, ProductMetadata) else utils.md5sum
168
+ return fun(destination) == meta.checksum
169
+
170
+
171
+ def _size_and_name_matches(meta: Metadata, destination: Path) -> bool:
172
+ return (
173
+ destination.stat().st_size == meta.size
174
+ and destination.name == meta.download_url.split("/")[-1]
175
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.12.8"
@@ -382,7 +382,7 @@ class TestRawFiles:
382
382
 
383
383
  def test_filter_by_filename_suffix(self, client: APIClient):
384
384
  meta = client.raw_files(filename_suffix="000.nc")
385
- assert len(meta) == 2
385
+ assert len(meta) == 3
386
386
 
387
387
  def test_filter_by_instrument_id(self, client: APIClient):
388
388
  meta = client.raw_files(instrument_id="weather-station")
@@ -462,6 +462,19 @@ class TestDownloadingFunctionality:
462
462
  assert paths1 == paths2
463
463
  assert paths2[0].stat().st_size == original_size
464
464
 
465
+ def test_downloading_single_metadata(self, client: APIClient, tmp_path: Path):
466
+ uuid = "ab872770-9136-4e61-8958-31e62abdfb1b"
467
+ meta = client.file(uuid)
468
+ paths = client.download(meta, output_directory=tmp_path, progress=False)
469
+ assert len(paths) == 1
470
+ assert paths[0].exists()
471
+
472
+ def test_downloading_single_metadata_II(self, client: APIClient, tmp_path: Path):
473
+ meta = client.raw_files(date_from="2025-08-01")
474
+ assert len(meta) == 3
475
+ paths = client.download(meta[0], output_directory=tmp_path, progress=False)
476
+ assert len(paths) == 1
477
+
465
478
  async def test_async_download(self, client: APIClient, tmp_path: Path):
466
479
  meta = client.raw_files(date_from="2025-08-01")
467
480
  assert len(meta) == 3
@@ -487,7 +500,7 @@ class TestFilterCombinations:
487
500
  assert len(meta) == 0
488
501
 
489
502
  def test_partial_filename_matches(self, client: APIClient):
490
- meta = client.raw_files(filename_prefix="2025", filename_suffix=".nc")
503
+ meta = client.raw_files(filename_prefix="202508", filename_suffix=".nc")
491
504
  assert len(meta) == 2
492
505
 
493
506
 
@@ -1,108 +0,0 @@
1
- import asyncio
2
- import logging
3
- from collections.abc import Iterable
4
- from pathlib import Path
5
-
6
- import aiohttp
7
- from tqdm import tqdm
8
- from tqdm.asyncio import tqdm_asyncio
9
-
10
- from cloudnet_api_client import utils
11
- from cloudnet_api_client.containers import Metadata, ProductMetadata
12
-
13
-
14
- async def download_files(
15
- base_url: str,
16
- metadata: Iterable[Metadata],
17
- output_path: Path,
18
- concurrency_limit: int,
19
- disable_progress: bool | None,
20
- validate_checksum: bool = False,
21
- ) -> list[Path]:
22
- file_exists = _checksum_matches if validate_checksum else _size_and_name_matches
23
- semaphore = asyncio.Semaphore(concurrency_limit)
24
- full_paths = []
25
- async with aiohttp.ClientSession() as session:
26
- tasks = []
27
- for meta in metadata:
28
- download_url = f"{base_url}{meta.download_url.split('/api/')[-1]}"
29
- destination = output_path / meta.download_url.split("/")[-1]
30
- full_paths.append(destination)
31
- if destination.exists() and file_exists(meta, destination):
32
- logging.debug(f"Already downloaded: {destination}")
33
- continue
34
- task = asyncio.create_task(
35
- _download_file_with_retries(
36
- session, download_url, destination, semaphore, disable_progress
37
- )
38
- )
39
- tasks.append(task)
40
- await tqdm_asyncio.gather(
41
- *tasks, desc="Completed files", disable=disable_progress
42
- )
43
- return full_paths
44
-
45
-
46
- async def _download_file_with_retries(
47
- session: aiohttp.ClientSession,
48
- url: str,
49
- destination: Path,
50
- semaphore: asyncio.Semaphore,
51
- disable_progress: bool | None,
52
- max_retries: int = 3,
53
- ) -> None:
54
- """Attempt to download a file, retrying up to max_retries times if needed."""
55
- for attempt in range(1, max_retries + 1):
56
- try:
57
- await _download_file(session, url, destination, semaphore, disable_progress)
58
- return
59
- except aiohttp.ClientError as e:
60
- logging.warning(f"Attempt {attempt} failed for {url}: {e}")
61
- if attempt == max_retries:
62
- logging.error(f"Giving up on {url} after {max_retries} attempts.")
63
- raise e
64
- else:
65
- # Exponential backoff before retrying
66
- await asyncio.sleep(2**attempt)
67
-
68
-
69
- async def _download_file(
70
- session: aiohttp.ClientSession,
71
- url: str,
72
- destination: Path,
73
- semaphore: asyncio.Semaphore,
74
- disable_progress: bool | None,
75
- ) -> None:
76
- async with semaphore:
77
- async with session.get(url) as response:
78
- response.raise_for_status()
79
- with (
80
- destination.open("wb") as file_out,
81
- tqdm(
82
- desc=destination.name,
83
- total=response.content_length,
84
- unit="iB",
85
- unit_scale=True,
86
- unit_divisor=1024,
87
- disable=disable_progress,
88
- ) as bar,
89
- ):
90
- while True:
91
- chunk = await response.content.read(8192)
92
- if not chunk:
93
- break
94
- file_out.write(chunk)
95
- bar.update(len(chunk))
96
- logging.debug(f"Downloaded: {destination}")
97
-
98
-
99
- def _checksum_matches(meta: Metadata, destination: Path) -> bool:
100
- fun = utils.sha256sum if isinstance(meta, ProductMetadata) else utils.md5sum
101
- return fun(destination) == meta.checksum
102
-
103
-
104
- def _size_and_name_matches(meta: Metadata, destination: Path) -> bool:
105
- return (
106
- destination.stat().st_size == meta.size
107
- and destination.name == meta.download_url.split("/")[-1]
108
- )
@@ -1 +0,0 @@
1
- __version__ = "0.12.6"