cloudnet-api-client 0.6.0__tar.gz → 0.8.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,15 @@ 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.8.0 – 2025-05-14
9
+
10
+ - Add site to metadata responses
11
+
12
+ ## 0.7.0 – 2025-05-06
13
+
14
+ - Validate checksum optionally
15
+ - Make site optional
16
+
8
17
  ## 0.6.0 – 2025-04-22
9
18
 
10
19
  - Fix datetime parsing for Python 3.10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnet-api-client
3
- Version: 0.6.0
3
+ Version: 0.8.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,
@@ -158,7 +161,7 @@ class APIClient:
158
161
 
159
162
  def raw_model_metadata(
160
163
  self,
161
- site_id: str,
164
+ site_id: QueryParam = None,
162
165
  model_id: QueryParam = None,
163
166
  date: DateParam = None,
164
167
  date_from: 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
@@ -381,7 +396,7 @@ def _build_meta_objects(res: list[dict]) -> list[ProductMetadata]:
381
396
  field_names = (
382
397
  {f.name for f in fields(ProductMetadata)}
383
398
  - CONVERTED
384
- - {"product", "instrument", "model"}
399
+ - {"product", "instrument", "model", "site"}
385
400
  )
386
401
  return [
387
402
  ProductMetadata(
@@ -392,30 +407,10 @@ def _build_meta_objects(res: list[dict]) -> list[ProductMetadata]:
392
407
  type=obj["product"]["type"],
393
408
  experimental=obj["product"]["experimental"],
394
409
  ),
395
- instrument=Instrument(
396
- instrument_id=obj["instrument"]["instrumentId"],
397
- model=obj["instrument"]["model"],
398
- type=obj["instrument"]["type"],
399
- uuid=uuid.UUID(obj["instrument"]["uuid"]),
400
- pid=obj["instrument"]["pid"],
401
- owners=obj["instrument"]["owners"],
402
- serial_number=obj["instrument"]["serialNumber"],
403
- name=obj["instrument"]["name"],
404
- )
410
+ instrument=_create_instrument_object(obj["instrument"])
405
411
  if "instrument" in obj and obj["instrument"] is not None
406
412
  else None,
407
- model=Model(
408
- model_id=obj["model"]["id"],
409
- name=obj["model"]["humanReadableName"],
410
- optimum_order=obj["model"]["optimumOrder"],
411
- source_model_id=obj["model"]["sourceModelId"],
412
- forecast_start=obj["model"]["forecastStart"]
413
- if obj["model"]["forecastStart"] is not None
414
- else None,
415
- forecast_end=obj["model"]["forecastEnd"]
416
- if obj["model"]["forecastEnd"] is not None
417
- else None,
418
- )
413
+ model=_create_model_object(obj["model"])
419
414
  if "model" in obj and obj["model"] is not None
420
415
  else None,
421
416
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
@@ -423,63 +418,96 @@ def _build_meta_objects(res: list[dict]) -> list[ProductMetadata]:
423
418
  updated_at=_parse_datetime(obj["updatedAt"]),
424
419
  size=int(obj["size"]),
425
420
  uuid=uuid.UUID(obj["uuid"]),
421
+ site=_create_site_object(obj["site"]),
426
422
  )
427
423
  for obj in res
428
424
  ]
429
425
 
430
426
 
431
427
  def _build_raw_meta_objects(res: list[dict]) -> list[RawMetadata]:
432
- field_names = {f.name for f in fields(RawMetadata)} - CONVERTED - {"instrument"}
428
+ field_names = (
429
+ {f.name for f in fields(RawMetadata)} - CONVERTED - {"instrument", "site"}
430
+ )
433
431
  return [
434
432
  RawMetadata(
435
433
  **{_to_snake(k): v for k, v in obj.items() if _to_snake(k) in field_names},
436
- instrument=Instrument(
437
- instrument_id=obj["instrumentInfo"]["instrumentId"],
438
- model=obj["instrumentInfo"]["model"],
439
- type=obj["instrumentInfo"]["type"],
440
- uuid=uuid.UUID(obj["instrumentInfo"]["uuid"]),
441
- pid=obj["instrumentInfo"]["pid"],
442
- owners=obj["instrumentInfo"]["owners"],
443
- serial_number=obj["instrumentInfo"]["serialNumber"],
444
- name=obj["instrumentInfo"]["name"],
445
- ),
434
+ instrument=_create_instrument_object(obj["instrumentInfo"]),
446
435
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
447
436
  created_at=_parse_datetime(obj["createdAt"]),
448
437
  updated_at=_parse_datetime(obj["updatedAt"]),
449
438
  size=int(obj["size"]),
450
439
  uuid=uuid.UUID(obj["uuid"]),
440
+ site=_create_site_object(obj["site"]),
451
441
  )
452
442
  for obj in res
453
443
  ]
454
444
 
455
445
 
456
446
  def _build_raw_model_meta_objects(res: list[dict]) -> list[RawModelMetadata]:
457
- field_names = {f.name for f in fields(RawModelMetadata)} - CONVERTED - {"model"}
447
+ field_names = (
448
+ {f.name for f in fields(RawModelMetadata)} - CONVERTED - {"model", "site"}
449
+ )
458
450
  return [
459
451
  RawModelMetadata(
460
452
  **{_to_snake(k): v for k, v in obj.items() if _to_snake(k) in field_names},
461
- model=Model(
462
- model_id=obj["model"]["id"],
463
- name=obj["model"]["humanReadableName"],
464
- optimum_order=int(obj["model"]["optimumOrder"]),
465
- source_model_id=obj["model"]["sourceModelId"],
466
- forecast_start=int(obj["model"]["forecastStart"])
467
- if obj["model"]["forecastStart"] is not None
468
- else None,
469
- forecast_end=int(obj["model"]["forecastEnd"])
470
- if obj["model"]["forecastEnd"] is not None
471
- else None,
472
- ),
453
+ model=_create_model_object(obj["model"]),
473
454
  measurement_date=datetime.date.fromisoformat(obj["measurementDate"]),
474
455
  created_at=_parse_datetime(obj["createdAt"]),
475
456
  updated_at=_parse_datetime(obj["updatedAt"]),
476
457
  size=int(obj["size"]),
477
458
  uuid=uuid.UUID(obj["uuid"]),
459
+ site=_create_site_object(obj["site"]),
478
460
  )
479
461
  for obj in res
480
462
  ]
481
463
 
482
464
 
465
+ def _create_model_object(metadata: dict) -> Model:
466
+ return Model(
467
+ model_id=metadata["id"],
468
+ name=metadata["humanReadableName"],
469
+ optimum_order=int(metadata["optimumOrder"]),
470
+ source_model_id=metadata["sourceModelId"],
471
+ forecast_start=int(metadata["forecastStart"])
472
+ if metadata["forecastStart"] is not None
473
+ else None,
474
+ forecast_end=int(metadata["forecastEnd"])
475
+ if metadata["forecastEnd"] is not None
476
+ else None,
477
+ )
478
+
479
+
480
+ def _create_site_object(metadata: dict) -> Site:
481
+ return Site(
482
+ id=metadata["id"],
483
+ human_readable_name=metadata["humanReadableName"],
484
+ station_name=metadata["stationName"],
485
+ latitude=float(metadata["latitude"]),
486
+ longitude=float(metadata["longitude"]),
487
+ altitude=float(metadata["altitude"]),
488
+ dvas_id=metadata["dvasId"],
489
+ actris_id=metadata["actrisId"],
490
+ country=metadata["country"],
491
+ country_code=metadata["countryCode"],
492
+ country_subdivision_code=metadata["countrySubdivisionCode"],
493
+ type=metadata["type"],
494
+ gaw=metadata["gaw"],
495
+ )
496
+
497
+
498
+ def _create_instrument_object(metadata: dict) -> Instrument:
499
+ return Instrument(
500
+ instrument_id=metadata["instrumentId"],
501
+ model=metadata["model"],
502
+ type=metadata["type"],
503
+ uuid=uuid.UUID(metadata["uuid"]),
504
+ pid=metadata["pid"],
505
+ owners=metadata["owners"],
506
+ serial_number=metadata["serialNumber"],
507
+ name=metadata["name"],
508
+ )
509
+
510
+
483
511
  def _to_snake(name: str) -> str:
484
512
  return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
485
513
 
@@ -495,3 +523,8 @@ def _make_session() -> requests.Session:
495
523
 
496
524
  def _parse_datetime(dt: str) -> datetime.datetime:
497
525
  return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%fZ")
526
+
527
+
528
+ def _check_params(params: dict, ignore: tuple = ()) -> None:
529
+ if sum(1 for key, value in params.items() if key not in ignore and value) == 0:
530
+ raise TypeError("At least one of the parameters must be set.")
@@ -65,6 +65,7 @@ class Metadata:
65
65
  measurement_date: datetime.date
66
66
  created_at: datetime.datetime
67
67
  updated_at: datetime.datetime
68
+ site: Site
68
69
 
69
70
 
70
71
  @dataclass(frozen=True, slots=True)
@@ -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.8.0"
@@ -1 +0,0 @@
1
- __version__ = "0.6.0"