cloudnet-api-client 0.12.3__tar.gz → 0.12.5__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 (31) hide show
  1. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/CHANGELOG.md +12 -0
  2. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/PKG-INFO +9 -5
  3. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/README.md +8 -4
  4. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/client.py +32 -20
  5. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/containers.py +25 -4
  6. cloudnet_api_client-0.12.5/cloudnet_api_client/version.py +1 -0
  7. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/test_client.py +51 -3
  8. cloudnet_api_client-0.12.3/cloudnet_api_client/version.py +0 -1
  9. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/dataportal.env +0 -0
  10. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/db.env +0 -0
  11. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/docker-compose.yml +0 -0
  12. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/initdb.d/init-dbs.sh +0 -0
  13. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/ss.env +0 -0
  14. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/workflows/publish.yml +0 -0
  15. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.github/workflows/test.yml +0 -0
  16. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.gitignore +0 -0
  17. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/.pre-commit-config.yaml +0 -0
  18. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/LICENSE +0 -0
  19. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/__init__.py +0 -0
  20. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/dl.py +0 -0
  21. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/py.typed +0 -0
  22. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/cloudnet_api_client/utils.py +0 -0
  23. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/pyproject.toml +0 -0
  24. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20140205_hyytiala_classification.nc +0 -0
  25. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250801_Magurele_CHM170137_000.nc +0 -0
  26. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250803_JOYCE_WST_01m.dat +0 -0
  27. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250808_Granada_CHM170119_0045_000.nc +0 -0
  28. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250808_hyytiala_iwc-Z-T-method.nc +0 -0
  29. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250814_bucharest_classification.nc +0 -0
  30. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250821_limassol_parsivel_41582c49.nc +0 -0
  31. {cloudnet_api_client-0.12.3 → cloudnet_api_client-0.12.5}/tests/data/20250822_leipzig-lim_ecmwf-open.nc +0 -0
@@ -5,6 +5,18 @@ 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.5 – 2025-09-03
9
+
10
+ - Add "mobile" site type
11
+ - Split `Location` to `MeanLocation` and `RawLocation`
12
+
13
+ ## 0.12.4 – 2025-08-26
14
+
15
+ - Add `software` attribute to `ProductMetadata`
16
+ - Fix hashability of `Product` in `ProductMetadata`
17
+ - Add possible timeliness values
18
+ - Use aware datetimes
19
+
8
20
  ## 0.12.3 – 2025-08-26
9
21
 
10
22
  - Fix owners type
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnet-api-client
3
- Version: 0.12.3
3
+ Version: 0.12.5
4
4
  Summary: Cloudnet API client
5
5
  Author-email: Simo Tukiainen <simo.tukiainen@fmi.fi>
6
6
  License-File: LICENSE
@@ -195,21 +195,25 @@ Parameters:
195
195
 
196
196
  Fetch all instruments.
197
197
 
198
+ ### `APIClient().instrument_ids()` &rarr; `frozenset[str]`
199
+
200
+ Fetch all instrument identifiers.
201
+
198
202
  ### `APIClient().instrument()` &rarr; `ExtendedInstrument`
199
203
 
200
204
  Fetch a single instruments.
201
205
 
202
206
  Parameters:
203
207
 
204
- | name | type |
205
- | ---- | ----- |
206
- | uuid | `str` |
208
+ | name | type |
209
+ | ---- | -------------------- |
210
+ | uuid | `str` or `uuid.UUID` |
207
211
 
208
212
  ### `APIClient().models()` &rarr; `list[Model]`
209
213
 
210
214
  Fetch all models.
211
215
 
212
- ### `APIClient().instrument()` &rarr; `Model`
216
+ ### `APIClient().model()` &rarr; `Model`
213
217
 
214
218
  Fetch a single model.
215
219
 
@@ -166,21 +166,25 @@ Parameters:
166
166
 
167
167
  Fetch all instruments.
168
168
 
169
+ ### `APIClient().instrument_ids()` &rarr; `frozenset[str]`
170
+
171
+ Fetch all instrument identifiers.
172
+
169
173
  ### `APIClient().instrument()` &rarr; `ExtendedInstrument`
170
174
 
171
175
  Fetch a single instruments.
172
176
 
173
177
  Parameters:
174
178
 
175
- | name | type |
176
- | ---- | ----- |
177
- | uuid | `str` |
179
+ | name | type |
180
+ | ---- | -------------------- |
181
+ | uuid | `str` or `uuid.UUID` |
178
182
 
179
183
  ### `APIClient().models()` &rarr; `list[Model]`
180
184
 
181
185
  Fetch all models.
182
186
 
183
- ### `APIClient().instrument()` &rarr; `Model`
187
+ ### `APIClient().model()` &rarr; `Model`
184
188
 
185
189
  Fetch a single model.
186
190
 
@@ -22,14 +22,17 @@ from cloudnet_api_client.containers import (
22
22
  STATUS,
23
23
  ExtendedInstrument,
24
24
  ExtendedProduct,
25
+ ExtendedProductMetadata,
25
26
  Instrument,
26
- Location,
27
+ MeanLocation,
27
28
  Model,
28
29
  Product,
29
30
  ProductMetadata,
31
+ RawLocation,
30
32
  RawMetadata,
31
33
  RawModelMetadata,
32
34
  Site,
35
+ Software,
33
36
  VersionMetadata,
34
37
  )
35
38
  from cloudnet_api_client.dl import download_files
@@ -60,7 +63,7 @@ class APIClient:
60
63
  self,
61
64
  type: SITE_TYPE | list[SITE_TYPE] | None = None,
62
65
  ) -> list[Site]:
63
- type = validate_type(type, SITE_TYPE)
66
+ type = _validate_type(type, SITE_TYPE)
64
67
  res = self._get("sites", {"type": type})
65
68
  return _build_objects(res, Site)
66
69
 
@@ -71,7 +74,7 @@ class APIClient:
71
74
  def products(
72
75
  self, type: PRODUCT_TYPE | list[PRODUCT_TYPE] | None = None
73
76
  ) -> list[Product]:
74
- type = validate_type(type, PRODUCT_TYPE)
77
+ type = _validate_type(type, PRODUCT_TYPE)
75
78
  data = self._get("products")
76
79
  if type is not None:
77
80
  data = [obj for obj in data if any(t in obj["type"] for t in type)]
@@ -121,14 +124,18 @@ class APIClient:
121
124
  def file(
122
125
  self,
123
126
  uuid: str | UUID,
124
- ) -> ProductMetadata:
127
+ ) -> ExtendedProductMetadata:
125
128
  file_res = self._get(f"files/{uuid}")[0]
126
129
  if file_res.get("instrument") is not None:
127
130
  instrument_uuid = file_res["instrument"]["uuid"]
128
131
  instrument_res = self._get(f"instrument-pids/{instrument_uuid}")[0]
129
132
  else:
130
133
  instrument_res = None
131
- return _build_meta_objects([file_res], instrument_res)[0]
134
+ obj = _build_meta_objects([file_res], instrument_res)[0]
135
+ return ExtendedProductMetadata(
136
+ **_asdict_shallow(obj),
137
+ software=tuple(_build_objects(file_res["software"], Software)),
138
+ )
132
139
 
133
140
  def versions(self, uuid: str | UUID) -> list[VersionMetadata]:
134
141
  payload = {"properties": ["pid", "dvasId", "legacy", "size", "checksum"]}
@@ -271,12 +278,12 @@ class APIClient:
271
278
 
272
279
  def moving_site_mean_location(
273
280
  self, site_id: str, date: datetime.date | str
274
- ) -> Location:
281
+ ) -> MeanLocation:
275
282
  if not isinstance(date, datetime.date):
276
283
  date = datetime.date.fromisoformat(date)
277
284
  payload = {"date": date}
278
285
  res = self._get(f"sites/{site_id}/locations", params=payload)[0]
279
- return Location(
286
+ return MeanLocation(
280
287
  time=date,
281
288
  latitude=res["latitude"],
282
289
  longitude=res["longitude"],
@@ -284,13 +291,13 @@ class APIClient:
284
291
 
285
292
  def moving_site_locations(
286
293
  self, site_id: str, date: datetime.date | str
287
- ) -> list[Location]:
294
+ ) -> list[RawLocation]:
288
295
  if not isinstance(date, datetime.date):
289
296
  date = datetime.date.fromisoformat(date)
290
297
  payload = {"date": date, "raw": "1"}
291
298
  locations = self._get(f"sites/{site_id}/locations", params=payload)
292
299
  return [
293
- Location(
300
+ RawLocation(
294
301
  time=_parse_datetime(location["date"]),
295
302
  latitude=location["latitude"],
296
303
  longitude=location["longitude"],
@@ -482,7 +489,9 @@ def _parse_datetime_param(
482
489
  }
483
490
  for fmt, unit in patterns:
484
491
  try:
485
- start_date = datetime.datetime.strptime(dt, fmt)
492
+ start_date = datetime.datetime.strptime(dt, fmt).replace(
493
+ tzinfo=datetime.timezone.utc
494
+ )
486
495
  except ValueError:
487
496
  continue
488
497
  if unit == "years":
@@ -524,17 +533,12 @@ def _build_meta_objects(
524
533
  field_names = (
525
534
  {f.name for f in fields(ProductMetadata)}
526
535
  - CONVERTED
527
- - {"product", "instrument", "model", "site"}
536
+ - {"product", "instrument", "model", "site", "software"}
528
537
  )
529
538
  return [
530
539
  ProductMetadata(
531
540
  **{_to_snake(k): v for k, v in obj.items() if _to_snake(k) in field_names},
532
- product=Product(
533
- id=obj["product"]["id"],
534
- human_readable_name=obj["product"]["humanReadableName"],
535
- type=obj["product"]["type"],
536
- experimental=obj["product"]["experimental"],
537
- ),
541
+ product=_build_object(obj["product"], Product),
538
542
  instrument=_create_instrument_object(instrument_meta or obj["instrument"])
539
543
  if instrument_meta or "instrument" in obj and obj["instrument"]
540
544
  else None,
@@ -664,9 +668,13 @@ def _make_session() -> requests.Session:
664
668
 
665
669
  def _parse_datetime(dt: str) -> datetime.datetime:
666
670
  try:
667
- return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%fZ")
671
+ return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%fZ").replace(
672
+ tzinfo=datetime.timezone.utc
673
+ )
668
674
  except ValueError:
669
- return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%SZ")
675
+ return datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%SZ").replace(
676
+ tzinfo=datetime.timezone.utc
677
+ )
670
678
 
671
679
 
672
680
  def _check_params(params: dict, ignore: tuple = ()) -> None:
@@ -674,7 +682,11 @@ def _check_params(params: dict, ignore: tuple = ()) -> None:
674
682
  raise TypeError("At least one of the parameters must be set.")
675
683
 
676
684
 
677
- def validate_type(type, values) -> list | None:
685
+ def _asdict_shallow(obj) -> dict:
686
+ return dict((field.name, getattr(obj, field.name)) for field in fields(obj))
687
+
688
+
689
+ def _validate_type(type, values) -> list | None:
678
690
  if type is not None:
679
691
  if not isinstance(type, str | list):
680
692
  raise ValueError(f"Invalid type: {type}")
@@ -3,14 +3,22 @@ import uuid
3
3
  from dataclasses import dataclass
4
4
  from typing import Literal
5
5
 
6
- SITE_TYPE = Literal["cloudnet", "model", "hidden", "campaign"]
6
+ SITE_TYPE = Literal["cloudnet", "model", "hidden", "campaign", "mobile"]
7
7
  PRODUCT_TYPE = Literal["instrument", "geophysical", "evaluation", "model"]
8
8
  STATUS = Literal["created", "uploaded", "processed", "invalid"]
9
+ TIMELINESS = Literal["rrt", "nrt", "scheduled"]
9
10
 
10
11
 
11
12
  @dataclass(frozen=True, slots=True)
12
- class Location:
13
- time: datetime.datetime | datetime.date
13
+ class MeanLocation:
14
+ time: datetime.date
15
+ latitude: float
16
+ longitude: float
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class RawLocation:
21
+ time: datetime.datetime
14
22
  latitude: float
15
23
  longitude: float
16
24
 
@@ -74,6 +82,14 @@ class Model:
74
82
  forecast_end: int | None
75
83
 
76
84
 
85
+ @dataclass(frozen=True, slots=True)
86
+ class Software:
87
+ id: str
88
+ version: str
89
+ title: str
90
+ url: str
91
+
92
+
77
93
  @dataclass(frozen=True, slots=True)
78
94
  class Metadata:
79
95
  uuid: uuid.UUID
@@ -111,12 +127,17 @@ class ProductMetadata(Metadata):
111
127
  dvas_id: str | None
112
128
  error_level: str | None
113
129
  coverage: float
114
- timeliness: str
130
+ timeliness: TIMELINESS
115
131
  format: str
116
132
  start_time: datetime.datetime | None
117
133
  stop_time: datetime.datetime | None
118
134
 
119
135
 
136
+ @dataclass(frozen=True, slots=True)
137
+ class ExtendedProductMetadata(ProductMetadata):
138
+ software: tuple[Software, ...]
139
+
140
+
120
141
  @dataclass(frozen=True, slots=True)
121
142
  class VersionMetadata:
122
143
  uuid: uuid.UUID
@@ -0,0 +1 @@
1
+ __version__ = "0.12.5"
@@ -13,10 +13,11 @@ from cloudnet_api_client.containers import (
13
13
  ExtendedInstrument,
14
14
  ExtendedProduct,
15
15
  Instrument,
16
- Location,
16
+ MeanLocation,
17
17
  Model,
18
18
  Product,
19
19
  ProductMetadata,
20
+ RawLocation,
20
21
  RawMetadata,
21
22
  Site,
22
23
  VersionMetadata,
@@ -128,7 +129,7 @@ class TestSites:
128
129
 
129
130
  def test_moving_site_mean_location(self, client: APIClient):
130
131
  location = client.moving_site_mean_location("boaty", "2022-01-01")
131
- assert isinstance(location, Location)
132
+ assert isinstance(location, MeanLocation)
132
133
  assert location.time == datetime.date(2022, 1, 1)
133
134
  assert round(location.latitude) == 60
134
135
  assert round(location.longitude) == 25
@@ -136,12 +137,22 @@ class TestSites:
136
137
  def test_moving_site_locations(self, client: APIClient):
137
138
  locations = client.moving_site_locations("boaty", "2022-01-01")
138
139
  assert isinstance(locations, list)
139
- assert all(isinstance(loc, Location) for loc in locations)
140
+ assert all(isinstance(loc, RawLocation) for loc in locations)
141
+ assert all(loc.time.date().isoformat() == "2022-01-01" for loc in locations)
140
142
 
141
143
  def test_invalid_site_id(self, client: APIClient):
142
144
  with pytest.raises(CloudnetAPIError):
143
145
  client.site("invalid-site-id")
144
146
 
147
+ def test_sites_are_hashable(self, client: APIClient):
148
+ sites = client.sites()
149
+ for site in sites:
150
+ hash(site)
151
+
152
+ def test_site_is_hashable(self, client: APIClient):
153
+ site = client.site("bucharest")
154
+ hash(site)
155
+
145
156
 
146
157
  class TestProducts:
147
158
  def test_products_route(self, client: APIClient):
@@ -200,6 +211,15 @@ class TestProducts:
200
211
  with pytest.raises(CloudnetAPIError):
201
212
  client.product("invalid-product-id")
202
213
 
214
+ def test_products_are_hashable(self, client: APIClient):
215
+ products = client.products()
216
+ for product in products:
217
+ hash(product)
218
+
219
+ def test_product_is_hashable(self, client: APIClient):
220
+ product = client.product("categorize")
221
+ hash(product)
222
+
203
223
 
204
224
  class TestModels:
205
225
  def test_models_route(self, client: APIClient):
@@ -216,6 +236,15 @@ class TestModels:
216
236
  with pytest.raises(CloudnetAPIError):
217
237
  client.model("invalid-model-id")
218
238
 
239
+ def test_models_are_hashable(self, client: APIClient):
240
+ models = client.models()
241
+ for model in models:
242
+ hash(model)
243
+
244
+ def test_model_is_hashable(self, client: APIClient):
245
+ model = client.model("arpege-12-23")
246
+ hash(model)
247
+
219
248
 
220
249
  class TestInstruments:
221
250
  def test_instruments_route(self, client: APIClient):
@@ -241,6 +270,15 @@ class TestInstruments:
241
270
  with pytest.raises(CloudnetAPIError):
242
271
  client.instrument("invalid-instrument-id")
243
272
 
273
+ def test_instruments_are_hashable(self, client: APIClient):
274
+ instruments = client.instruments()
275
+ for instrument in instruments:
276
+ hash(instrument)
277
+
278
+ def test_instrument_is_hashable(self, client: APIClient):
279
+ instrument = client.instrument("12da536f-0d07-41ea-9ced-f6cdeb97198b")
280
+ hash(instrument)
281
+
244
282
 
245
283
  class TestProductFiles:
246
284
  def test_file_route_with_geophysical_product(self, client: APIClient):
@@ -295,6 +333,11 @@ class TestProductFiles:
295
333
  with pytest.raises(CloudnetAPIError):
296
334
  client.files(site_id="invalid-site")
297
335
 
336
+ def test_file_is_hashable(self, client: APIClient):
337
+ uuid = "8dcc865c-6920-49ce-a627-de045ec896e8"
338
+ meta = client.file(uuid)
339
+ hash(meta)
340
+
298
341
 
299
342
  class TestRawFiles:
300
343
  def test_filter_by_site_and_date(self, client: APIClient):
@@ -356,6 +399,11 @@ class TestRawFiles:
356
399
  with pytest.raises(CloudnetAPIError):
357
400
  client.raw_files(site_id="invalid-site")
358
401
 
402
+ def test_raw_files_are_hashable(self, client: APIClient):
403
+ meta = client.raw_files(date_from="2025-08-01", date_to="2025-08-08")
404
+ for m in meta:
405
+ hash(m)
406
+
359
407
 
360
408
  class TestDateParameterHandling:
361
409
  """Test various date parameter formats and edge cases."""
@@ -1 +0,0 @@
1
- __version__ = "0.12.3"