cloudnet-api-client 0.5.1__tar.gz → 0.7.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.
@@ -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.7.0 – 2025-05-06
9
+
10
+ - Validate checksum optionally
11
+ - Make site optional
12
+
13
+ ## 0.6.0 – 2025-04-22
14
+
15
+ - Fix datetime parsing for Python 3.10
16
+ - Add Instrument and Model objects to metadata
17
+
8
18
  ## 0.5.1 – 2025-04-04
9
19
 
10
20
  - Raise if downloading failed after retries
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnet-api-client
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Cloudnet API client
5
5
  Author-email: Simo Tukiainen <simo.tukiainen@fmi.fi>
6
6
  License-File: LICENSE
@@ -65,7 +65,7 @@ Parameters:
65
65
 
66
66
  | name | type | default | example |
67
67
  | ------------------- | --------------------------- | ------- | ---------------------------------------------------- |
68
- | site_id | `str` | | "hyytiala" |
68
+ | site_id | `str` or `list[str]` | `None` | "hyytiala" |
69
69
  | date | `str` or `date` | `None` | "2024-01-01" |
70
70
  | date_from | `str` or `date` | `None` | "2025-01-01" |
71
71
  | date_to | `str` or `date` | `None` | "2025-01-01" |
@@ -158,6 +158,7 @@ Parameters:
158
158
  | output_directory | `PathLike` or `str` | |
159
159
  | concurrency_limit | `int` | 5 |
160
160
  | progress | `bool` or `None` | `None` |
161
+ | validate_checksum | `bool` | `False` |
161
162
 
162
163
  There's also an asynchronous version of this function:
163
164
  `cloudnet_api_client.adownload`. It's useful for usage inside Jupyter notebook.
@@ -38,7 +38,7 @@ Parameters:
38
38
 
39
39
  | name | type | default | example |
40
40
  | ------------------- | --------------------------- | ------- | ---------------------------------------------------- |
41
- | site_id | `str` | | "hyytiala" |
41
+ | site_id | `str` or `list[str]` | `None` | "hyytiala" |
42
42
  | date | `str` or `date` | `None` | "2024-01-01" |
43
43
  | date_from | `str` or `date` | `None` | "2025-01-01" |
44
44
  | date_to | `str` or `date` | `None` | "2025-01-01" |
@@ -131,6 +131,7 @@ Parameters:
131
131
  | output_directory | `PathLike` or `str` | |
132
132
  | concurrency_limit | `int` | 5 |
133
133
  | progress | `bool` or `None` | `None` |
134
+ | validate_checksum | `bool` | `False` |
134
135
 
135
136
  There's also an asynchronous version of this function:
136
137
  `cloudnet_api_client.adownload`. It's useful for usage inside Jupyter notebook.
@@ -85,7 +85,7 @@ class APIClient:
85
85
 
86
86
  def metadata(
87
87
  self,
88
- site_id: str,
88
+ site_id: QueryParam = None,
89
89
  date: DateParam = None,
90
90
  date_from: DateParam = None,
91
91
  date_to: DateParam = None,
@@ -108,6 +108,9 @@ class APIClient:
108
108
  _add_date_params(
109
109
  params, date, date_from, date_to, updated_at, updated_at_from, updated_at_to
110
110
  )
111
+
112
+ _check_params(params, ("showLegacy",))
113
+
111
114
  no_instrument = not instrument_id or instrument_pid
112
115
  if no_instrument and (not product and model_id):
113
116
  files_res = []
@@ -129,7 +132,7 @@ class APIClient:
129
132
 
130
133
  def raw_metadata(
131
134
  self,
132
- site_id: str,
135
+ site_id: QueryParam = None,
133
136
  date: DateParam = None,
134
137
  date_from: DateParam = None,
135
138
  date_to: DateParam = None,
@@ -181,6 +184,9 @@ class APIClient:
181
184
  _add_date_params(
182
185
  params, date, date_from, date_to, updated_at, updated_at_from, updated_at_to
183
186
  )
187
+
188
+ _check_params(params)
189
+
184
190
  res = self._get_response("raw-model-files", params)
185
191
  return _build_raw_model_meta_objects(res)
186
192
 
@@ -190,9 +196,16 @@ class APIClient:
190
196
  output_directory: str | PathLike,
191
197
  concurrency_limit: int = 5,
192
198
  progress: bool | None = None,
199
+ validate_checksum: bool = False,
193
200
  ) -> list[Path]:
194
201
  return asyncio.run(
195
- self.adownload(metadata, output_directory, concurrency_limit, progress)
202
+ self.adownload(
203
+ metadata,
204
+ output_directory,
205
+ concurrency_limit,
206
+ progress,
207
+ validate_checksum,
208
+ )
196
209
  )
197
210
 
198
211
  async def adownload(
@@ -201,6 +214,7 @@ class APIClient:
201
214
  output_directory: str | PathLike,
202
215
  concurrency_limit: int = 5,
203
216
  progress: bool | None = None,
217
+ validate_checksum: bool = False,
204
218
  ) -> list[Path]:
205
219
  disable_progress = not progress if progress is not None else None
206
220
  output_directory = Path(output_directory).resolve()
@@ -211,6 +225,7 @@ class APIClient:
211
225
  output_directory,
212
226
  concurrency_limit,
213
227
  disable_progress,
228
+ validate_checksum,
214
229
  )
215
230
 
216
231
  @staticmethod
@@ -270,13 +285,13 @@ def _add_date_params(
270
285
  msg = "Cannot use 'date' with 'date_from' and 'date_to'"
271
286
  raise ValueError(msg)
272
287
  if date is not None:
273
- start, stop = _parse_date(date)
288
+ start, stop = _parse_date_param(date)
274
289
  params["dateFrom"] = start.isoformat()
275
290
  params["dateTo"] = stop.isoformat()
276
291
  if date_from is not None:
277
- params["dateFrom"] = _parse_date(date_from)[0].isoformat()
292
+ params["dateFrom"] = _parse_date_param(date_from)[0].isoformat()
278
293
  if date_to is not None:
279
- params["dateTo"] = _parse_date(date_to)[1].isoformat()
294
+ params["dateTo"] = _parse_date_param(date_to)[1].isoformat()
280
295
 
281
296
  if updated_at is not None and (
282
297
  updated_at_from is not None or updated_at_to is not None
@@ -284,16 +299,16 @@ def _add_date_params(
284
299
  msg = "Cannot use 'updated_at' with 'updated_at_from' and 'updated_at_to'"
285
300
  raise ValueError(msg)
286
301
  if updated_at is not None:
287
- start, stop = _parse_datetime(updated_at)
302
+ start, stop = _parse_datetime_param(updated_at)
288
303
  params["updatedAtFrom"] = start.isoformat()
289
304
  params["updatedAtTo"] = stop.isoformat()
290
305
  if updated_at_from is not None:
291
- params["updatedAtFrom"] = _parse_datetime(updated_at_from)[0].isoformat()
306
+ params["updatedAtFrom"] = _parse_datetime_param(updated_at_from)[0].isoformat()
292
307
  if updated_at_to is not None:
293
- params["updatedAtTo"] = _parse_datetime(updated_at_to)[1].isoformat()
308
+ params["updatedAtTo"] = _parse_datetime_param(updated_at_to)[1].isoformat()
294
309
 
295
310
 
296
- def _parse_date(date: DateParam) -> tuple[datetime.date, datetime.date]:
311
+ def _parse_date_param(date: DateParam) -> tuple[datetime.date, datetime.date]:
297
312
  if isinstance(date, datetime.date):
298
313
  return date, date
299
314
  error = ValueError(f"Invalid date format: {date}")
@@ -316,7 +331,9 @@ def _parse_date(date: DateParam) -> tuple[datetime.date, datetime.date]:
316
331
  raise error
317
332
 
318
333
 
319
- def _parse_datetime(dt: DateTimeParam) -> tuple[datetime.datetime, datetime.datetime]:
334
+ def _parse_datetime_param(
335
+ dt: DateTimeParam,
336
+ ) -> tuple[datetime.datetime, datetime.datetime]:
320
337
  if isinstance(dt, datetime.datetime):
321
338
  return dt, dt
322
339
  if isinstance(dt, datetime.date):
@@ -376,19 +393,49 @@ CONVERTED = {"measurement_date", "created_at", "updated_at", "size", "uuid"}
376
393
 
377
394
 
378
395
  def _build_meta_objects(res: list[dict]) -> list[ProductMetadata]:
379
- field_names = {f.name for f in fields(ProductMetadata)} - CONVERTED - {"product"}
396
+ field_names = (
397
+ {f.name for f in fields(ProductMetadata)}
398
+ - CONVERTED
399
+ - {"product", "instrument", "model"}
400
+ )
380
401
  return [
381
402
  ProductMetadata(
382
403
  **{_to_snake(k): v for k, v in obj.items() if _to_snake(k) in field_names},
383
404
  product=Product(
384
405
  id=obj["product"]["id"],
385
406
  human_readable_name=obj["product"]["humanReadableName"],
386
- type=[obj["product"]["type"][1:-1]],
407
+ type=obj["product"]["type"],
387
408
  experimental=obj["product"]["experimental"],
388
409
  ),
410
+ instrument=Instrument(
411
+ instrument_id=obj["instrument"]["instrumentId"],
412
+ model=obj["instrument"]["model"],
413
+ type=obj["instrument"]["type"],
414
+ uuid=uuid.UUID(obj["instrument"]["uuid"]),
415
+ pid=obj["instrument"]["pid"],
416
+ owners=obj["instrument"]["owners"],
417
+ serial_number=obj["instrument"]["serialNumber"],
418
+ name=obj["instrument"]["name"],
419
+ )
420
+ if "instrument" in obj and obj["instrument"] is not None
421
+ else None,
422
+ model=Model(
423
+ model_id=obj["model"]["id"],
424
+ name=obj["model"]["humanReadableName"],
425
+ optimum_order=obj["model"]["optimumOrder"],
426
+ source_model_id=obj["model"]["sourceModelId"],
427
+ forecast_start=obj["model"]["forecastStart"]
428
+ if obj["model"]["forecastStart"] is not None
429
+ else None,
430
+ forecast_end=obj["model"]["forecastEnd"]
431
+ if obj["model"]["forecastEnd"] is not None
432
+ else None,
433
+ )
434
+ if "model" in obj and obj["model"] is not None
435
+ else None,
389
436
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
390
- created_at=datetime.datetime.fromisoformat(obj["createdAt"]),
391
- updated_at=datetime.datetime.fromisoformat(obj["updatedAt"]),
437
+ created_at=_parse_datetime(obj["createdAt"]),
438
+ updated_at=_parse_datetime(obj["updatedAt"]),
392
439
  size=int(obj["size"]),
393
440
  uuid=uuid.UUID(obj["uuid"]),
394
441
  )
@@ -412,8 +459,8 @@ def _build_raw_meta_objects(res: list[dict]) -> list[RawMetadata]:
412
459
  name=obj["instrumentInfo"]["name"],
413
460
  ),
414
461
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
415
- created_at=datetime.datetime.fromisoformat(obj["createdAt"]),
416
- updated_at=datetime.datetime.fromisoformat(obj["updatedAt"]),
462
+ created_at=_parse_datetime(obj["createdAt"]),
463
+ updated_at=_parse_datetime(obj["updatedAt"]),
417
464
  size=int(obj["size"]),
418
465
  uuid=uuid.UUID(obj["uuid"]),
419
466
  )
@@ -439,8 +486,8 @@ def _build_raw_model_meta_objects(res: list[dict]) -> list[RawModelMetadata]:
439
486
  else None,
440
487
  ),
441
488
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
442
- created_at=datetime.datetime.fromisoformat(obj["createdAt"]),
443
- updated_at=datetime.datetime.fromisoformat(obj["updatedAt"]),
489
+ created_at=_parse_datetime(obj["createdAt"]),
490
+ updated_at=_parse_datetime(obj["updatedAt"]),
444
491
  size=int(obj["size"]),
445
492
  uuid=uuid.UUID(obj["uuid"]),
446
493
  )
@@ -459,3 +506,12 @@ def _make_session() -> requests.Session:
459
506
  session.mount("https://", adapter)
460
507
  session.mount("http://", adapter)
461
508
  return session
509
+
510
+
511
+ def _parse_datetime(dt: str) -> datetime.datetime:
512
+ return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%fZ")
513
+
514
+
515
+ def _check_params(params: dict, ignore: tuple = ()) -> None:
516
+ if sum(1 for key, value in params.items() if key not in ignore and value) == 0:
517
+ raise TypeError("At least one of the parameters must be set.")
@@ -83,3 +83,5 @@ class RawModelMetadata(Metadata):
83
83
  @dataclass(frozen=True, slots=True)
84
84
  class ProductMetadata(Metadata):
85
85
  product: Product
86
+ instrument: Instrument | None
87
+ model: Model | None
@@ -20,7 +20,9 @@ async def download_files(
20
20
  output_path: Path,
21
21
  concurrency_limit: int,
22
22
  disable_progress: bool | None,
23
+ validate_checksum: bool = False,
23
24
  ) -> list[Path]:
25
+ file_exists = _checksum_matches if validate_checksum else _size_and_name_matches
24
26
  semaphore = asyncio.Semaphore(concurrency_limit)
25
27
  full_paths = []
26
28
  async with aiohttp.ClientSession() as session:
@@ -29,7 +31,7 @@ async def download_files(
29
31
  download_url = f"{base_url}{meta.download_url.split('/api/')[-1]}"
30
32
  destination = output_path / meta.download_url.split("/")[-1]
31
33
  full_paths.append(destination)
32
- if destination.exists() and _file_checksum_matches(meta, destination):
34
+ if destination.exists() and file_exists(meta, destination):
33
35
  logging.debug(f"Already downloaded: {destination}")
34
36
  continue
35
37
  task = asyncio.create_task(
@@ -97,8 +99,17 @@ async def _download_file(
97
99
  logging.debug(f"Downloaded: {destination}")
98
100
 
99
101
 
100
- def _file_checksum_matches(
102
+ def _checksum_matches(
101
103
  meta: ProductMetadata | RawMetadata | RawModelMetadata, destination: Path
102
104
  ) -> bool:
103
105
  fun = utils.sha256sum if isinstance(meta, ProductMetadata) else utils.md5sum
104
106
  return fun(destination) == meta.checksum
107
+
108
+
109
+ def _size_and_name_matches(
110
+ meta: ProductMetadata | RawMetadata | RawModelMetadata, destination: Path
111
+ ) -> bool:
112
+ return (
113
+ destination.stat().st_size == meta.size
114
+ and destination.name == meta.download_url.split("/")[-1]
115
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -1 +0,0 @@
1
- __version__ = "0.5.1"