dwdopen 0.2.2__tar.gz → 0.2.3__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 (42) hide show
  1. {dwdopen-0.2.2 → dwdopen-0.2.3}/CHANGELOG.md +8 -0
  2. {dwdopen-0.2.2 → dwdopen-0.2.3}/PKG-INFO +10 -7
  3. {dwdopen-0.2.2 → dwdopen-0.2.3}/README.md +9 -6
  4. {dwdopen-0.2.2 → dwdopen-0.2.3}/pyproject.toml +1 -1
  5. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/__init__.py +29 -0
  6. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/model.py +36 -3
  7. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/request.py +6 -4
  8. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_download.py +6 -1
  9. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_download.py +66 -0
  10. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_levels.py +27 -0
  11. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_naming_plans.py +15 -11
  12. {dwdopen-0.2.2 → dwdopen-0.2.3}/.github/workflows/publish.yml +0 -0
  13. {dwdopen-0.2.2 → dwdopen-0.2.3}/.gitignore +0 -0
  14. {dwdopen-0.2.2 → dwdopen-0.2.3}/LICENSE +0 -0
  15. {dwdopen-0.2.2 → dwdopen-0.2.3}/design/internal_api_mock.py +0 -0
  16. {dwdopen-0.2.2 → dwdopen-0.2.3}/design/usage_examples.py +0 -0
  17. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/__init__.py +0 -0
  18. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/download.py +0 -0
  19. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/http.py +0 -0
  20. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/listing.py +0 -0
  21. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/naming.py +0 -0
  22. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/paths.py +0 -0
  23. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/traversal.py +0 -0
  24. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/client.py +0 -0
  25. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/exceptions.py +0 -0
  26. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/__init__.py +0 -0
  27. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/catalogue.py +0 -0
  28. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/durations.py +0 -0
  29. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/query.py +0 -0
  30. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/run.py +0 -0
  31. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/selectors.py +0 -0
  32. {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/py.typed +0 -0
  33. {dwdopen-0.2.2 → dwdopen-0.2.3}/testing.py +0 -0
  34. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_discovery.py +0 -0
  35. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_listing.py +0 -0
  36. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_case_insensitivity.py +0 -0
  37. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_durations.py +0 -0
  38. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_listing.py +0 -0
  39. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_members.py +0 -0
  40. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_paths.py +0 -0
  41. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_repr.py +0 -0
  42. {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_traversal.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  Versions follow [semantic versioning](https://semver.org/).
4
4
 
5
+ ## 0.2.3
6
+
7
+ - Added proper logging, and print some download info by default, so users do not think
8
+ that the application hangs.
9
+ - Generated file names now contain the level type.
10
+ - Asking for a parameter on a level type it is not available on is now refused at
11
+ the `select()` stage.
12
+
5
13
  ## 0.2.2
6
14
 
7
15
  - README updated.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dwdopen
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Typed Python access to DWD Open Data numerical weather prediction (ICON).
5
5
  Project-URL: Homepage, https://github.com/MuffinCompiler/dwdopen
6
6
  Project-URL: Repository, https://github.com/MuffinCompiler/dwdopen
@@ -173,16 +173,19 @@ checking in a scheduled job.
173
173
 
174
174
  ## Logging
175
175
 
176
- The library logs through the standard `dwdopen` logger. It never prompts or reads from
177
- stdin as the library is meant to run unattended.
176
+ The library logs through the `dwdopen` logger. You can change the logging level via::
178
177
 
179
178
  ```python
180
- import logging
181
- logging.basicConfig(level=logging.INFO)
179
+ import logging, dwdopen
180
+
181
+ dwdopen.set_log_level(logging.DEBUG) # a line per file to download
182
+ dwdopen.set_log_level(logging.WARNING) # only retries and warnings
183
+ dwdopen.set_log_level(None) # disable the dwdopen logging
182
184
  ```
183
185
 
184
- `INFO` reports what is about to be downloaded and how large it is. `WARNING` covers
185
- retries and mixed level types.
186
+ The handler is connected to the `dwdopen` logger. It will not override
187
+ the logging your application sets up for itself. If you do configure your own logger
188
+ and want to use that logger, call `set_log_level(None)`. That will uninstall the handler.
186
189
 
187
190
  ## Not yet implemented
188
191
 
@@ -146,16 +146,19 @@ checking in a scheduled job.
146
146
 
147
147
  ## Logging
148
148
 
149
- The library logs through the standard `dwdopen` logger. It never prompts or reads from
150
- stdin as the library is meant to run unattended.
149
+ The library logs through the `dwdopen` logger. You can change the logging level via::
151
150
 
152
151
  ```python
153
- import logging
154
- logging.basicConfig(level=logging.INFO)
152
+ import logging, dwdopen
153
+
154
+ dwdopen.set_log_level(logging.DEBUG) # a line per file to download
155
+ dwdopen.set_log_level(logging.WARNING) # only retries and warnings
156
+ dwdopen.set_log_level(None) # disable the dwdopen logging
155
157
  ```
156
158
 
157
- `INFO` reports what is about to be downloaded and how large it is. `WARNING` covers
158
- retries and mixed level types.
159
+ The handler is connected to the `dwdopen` logger. It will not override
160
+ the logging your application sets up for itself. If you do configure your own logger
161
+ and want to use that logger, call `set_log_level(None)`. That will uninstall the handler.
159
162
 
160
163
  ## Not yet implemented
161
164
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dwdopen"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Typed Python access to DWD Open Data numerical weather prediction (ICON)."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from importlib.metadata import PackageNotFoundError, version
6
7
 
7
8
  from dwdopen.client import DWD, NWP
@@ -27,6 +28,33 @@ from dwdopen.nwp.request import DownloadResult
27
28
  from dwdopen.nwp.run import Run
28
29
  from dwdopen.nwp.selectors import Between, Every, LevelType
29
30
 
31
+ _handler = logging.StreamHandler()
32
+ _handler.setFormatter(
33
+ logging.Formatter("%(asctime)s %(levelname)-7s %(message)s", "%H:%M:%S")
34
+ )
35
+
36
+
37
+ def set_log_level(level: int | None = logging.INFO) -> None:
38
+ """Sets the log level to use when printing logs.
39
+ Set to None to disable logging.
40
+ """
41
+ logger = logging.getLogger("dwdopen")
42
+
43
+ if level is None:
44
+ logger.removeHandler(_handler)
45
+ logger.setLevel(logging.NOTSET)
46
+ return
47
+
48
+ # Add the handler to the dwdopen logger only.
49
+ if _handler not in logger.handlers:
50
+ logger.addHandler(_handler)
51
+
52
+ logger.setLevel(level)
53
+
54
+ # Setup default logging.
55
+ set_log_level()
56
+
57
+
30
58
  __all__ = [
31
59
  "DWD",
32
60
  "NWP",
@@ -37,6 +65,7 @@ __all__ = [
37
65
  "DWDOpenError",
38
66
  "DownloadError",
39
67
  "DownloadResult",
68
+ "set_log_level",
40
69
  "Every",
41
70
  "IncompleteRunError",
42
71
  "InvalidSelectorError",
@@ -211,12 +211,15 @@ class Model:
211
211
  the level type is unambiguous. Every selected parameter must be available
212
212
  on the same level type, or on none at all.
213
213
  """
214
- if level_type is not None:
215
- return LevelType.coerce(level_type)
216
-
217
214
  per_parameter = {
218
215
  name: self._catalogue.level_types(self._name, name) for name in parameters
219
216
  }
217
+
218
+ if level_type is not None:
219
+ chosen = LevelType.coerce(level_type)
220
+ self._require_published_on(chosen, per_parameter)
221
+ return chosen
222
+
220
223
  candidates = {lt for types in per_parameter.values() for lt in types}
221
224
 
222
225
  if not candidates:
@@ -242,3 +245,33 @@ class Model:
242
245
  )
243
246
 
244
247
  return candidates.pop()
248
+
249
+ def _require_published_on(
250
+ self,
251
+ chosen: LevelType,
252
+ per_parameter: dict[str, list[LevelType]],
253
+ ) -> None:
254
+ """Raise InvalidSelectorError if the chosen level type is not
255
+ available for all the parameters.
256
+ The usual case is a parameter that only exists on one of the two
257
+ vertical coordinates. W is on model levels alone, and OMEGA is the
258
+ pressure-level vertical velocity.
259
+ """
260
+ # Get all parameters that are not available on the chosen level type.
261
+ wrong = {
262
+ name: level_types
263
+ for name, level_types in per_parameter.items()
264
+ if chosen not in level_types
265
+ }
266
+ if not wrong:
267
+ return
268
+
269
+ details = "; ".join(
270
+ f"{name} is on {', '.join(str(t) for t in types) or 'a single level'}"
271
+ for name, types in sorted(wrong.items())
272
+ )
273
+ raise InvalidSelectorError(
274
+ f"{', '.join(sorted(wrong))} in {self._name!r} "
275
+ f"{'is' if len(wrong) == 1 else 'are'} not published on {chosen}. "
276
+ f"{details}"
277
+ )
@@ -395,16 +395,18 @@ class ResolvedRequest:
395
395
  levels = {a.level for a in self.assets if a.level is not None}
396
396
  kinds = {a.level_type for a in self.assets if a.level_type is not None}
397
397
  if not kinds:
398
- return ""
398
+ return "single"
399
399
  if len(kinds) > 1:
400
- # Put number of level types
401
400
  return f"{len(kinds)}lvt"
401
+
402
402
  kind = kinds.pop()
403
+ # The readable level type name where there is one, otherwise the code.
404
+ name = kind.alias or f"lvt{kind.code}"
403
405
  if len(levels) == 1:
404
406
  unit = kind.user_unit or kind.unit or ""
405
407
  value = _trim(kind.to_user(levels.pop()))
406
- return f"{value}{unit}" if unit != "index" else f"lv{value}"
407
- return f"{len(levels)}lv"
408
+ return f"{name}{value}" if unit == "index" else f"{name}{value}{unit}"
409
+ return f"{name}{len(levels)}lv"
408
410
 
409
411
  def _describe_members(self) -> str:
410
412
  """The ensemble member, empty for a deterministic plan.
@@ -161,7 +161,12 @@ def test_combine_member_writes_one_file_per_member(tmp_path):
161
161
  assert len(result.files) == 2
162
162
  # The member is in the name, not only in the hash, or the files would be
163
163
  # impossible to tell apart.
164
- assert sorted(p.name.split("_T_2M_")[1][:3] for p in result.files) == ["e01", "e02"]
164
+ assert sorted(
165
+ part
166
+ for p in result.files
167
+ for part in p.name.split("_")
168
+ if part.startswith("e0")
169
+ ) == ["e01", "e02"]
165
170
  for path in result.files:
166
171
  assert grib_messages(path) == 1
167
172
 
@@ -377,3 +377,69 @@ def test_a_generated_combined_name_is_skipped_on_a_second_run(tmp_path):
377
377
  assert second.assets_downloaded == 0
378
378
  assert second.files == first.files
379
379
  assert len(http.requested) == before # nothing fetched
380
+
381
+
382
+ # --- log level ------------------------------------------------------------
383
+
384
+ def test_progress_is_visible_without_any_setup():
385
+ """A download that prints nothing looks like one that hung."""
386
+ import logging
387
+
388
+ logger = logging.getLogger("dwdopen")
389
+ assert logger.handlers, "dwdopen should attach its own handler on import"
390
+ assert logger.getEffectiveLevel() <= logging.INFO
391
+
392
+
393
+ def test_the_handler_is_never_put_on_the_root_logger():
394
+ """basicConfig() takes effect once.
395
+
396
+ A library that configured the root logger would silently override wherever
397
+ an application meant its logs to go.
398
+ """
399
+ import logging
400
+
401
+ import dwdopen
402
+
403
+ root_before = list(logging.getLogger().handlers)
404
+ dwdopen.set_log_level(logging.DEBUG)
405
+ assert logging.getLogger().handlers == root_before
406
+ dwdopen.set_log_level()
407
+
408
+
409
+ def test_the_level_can_be_turned_up_and_down():
410
+ import logging
411
+
412
+ import dwdopen
413
+
414
+ dwdopen.set_log_level(logging.DEBUG)
415
+ assert logging.getLogger("dwdopen").level == logging.DEBUG
416
+ dwdopen.set_log_level(logging.WARNING)
417
+ assert logging.getLogger("dwdopen").level == logging.WARNING
418
+ dwdopen.set_log_level()
419
+ assert logging.getLogger("dwdopen").level == logging.INFO
420
+
421
+
422
+ def test_none_hands_logging_back_to_the_application(caplog):
423
+ import logging
424
+
425
+ import dwdopen
426
+
427
+ dwdopen.set_log_level(None)
428
+ try:
429
+ assert dwdopen._handler not in logging.getLogger("dwdopen").handlers
430
+ # Records still reach an application that configured its own logging.
431
+ with caplog.at_level(logging.INFO, logger="dwdopen"):
432
+ logging.getLogger("dwdopen").info("a message")
433
+ assert "a message" in caplog.text
434
+ finally:
435
+ dwdopen.set_log_level()
436
+
437
+
438
+ def test_setting_the_level_twice_does_not_duplicate_the_handler():
439
+ import logging
440
+
441
+ import dwdopen
442
+
443
+ dwdopen.set_log_level()
444
+ dwdopen.set_log_level()
445
+ assert logging.getLogger("dwdopen").handlers.count(dwdopen._handler) == 1
@@ -216,3 +216,30 @@ def test_an_invariant_field_is_selected_without_a_level_type():
216
216
  # HSURF, CLAT, FR_LAND and friends are plain 2-D parameters in v1.
217
217
  model = model_with({"HSURF": []})
218
218
  assert model.select(parameters="HSURF", steps="0h")._level_type is None
219
+
220
+
221
+ def test_a_parameter_not_on_the_requested_level_type_is_refused():
222
+ """W exists on model levels only; OMEGA is the pressure-level equivalent.
223
+
224
+ Caught at select(), so the error names the call the caller wrote instead
225
+ of surfacing as a 404 on an lv1 listing partway through resolve().
226
+ """
227
+ model = model_with({"U": [PRESSURE, MODEL], "W": [MODEL]})
228
+ with pytest.raises(InvalidSelectorError) as caught:
229
+ model.select(parameters=["U", "W"], level_type="pressure", levels=[850])
230
+ message = str(caught.value)
231
+ assert "W" in message
232
+ assert "not published on pressure" in message
233
+ assert "model (150)" in message # says what it does have
234
+
235
+
236
+ def test_a_surface_parameter_asked_for_on_levels_is_refused():
237
+ model = model_with({"T_2M": []})
238
+ with pytest.raises(InvalidSelectorError, match="single level"):
239
+ model.select(parameters="T_2M", level_type="pressure", levels=[850])
240
+
241
+
242
+ def test_a_parameter_that_does_have_the_level_type_is_accepted():
243
+ model = model_with({"U": [PRESSURE, MODEL], "V": [PRESSURE, MODEL]})
244
+ query = model.select(parameters=["U", "V"], level_type="pressure", levels=[850])
245
+ assert query._level_type is PRESSURE
@@ -37,7 +37,7 @@ def plan(assets):
37
37
  def test_one_parameter_names_the_model_run_parameter_and_steps():
38
38
  made = plan([asset("T_2M", 0), asset("T_2M", 12)])
39
39
  assert made.suggested_name() == (
40
- f"icon-eu_2026-09-24T0600_T_2M_0h-12h_{made.fingerprint()}.grib2"
40
+ f"icon-eu_2026-09-24T0600_T_2M_single_0h-12h_{made.fingerprint()}.grib2"
41
41
  )
42
42
 
43
43
 
@@ -60,35 +60,37 @@ def test_more_parameters_are_counted_so_the_name_stays_short():
60
60
  many = [asset(f"P{i:02d}") for i in range(21)]
61
61
  name = plan(many).suggested_name()
62
62
  assert "21params" in name
63
- assert len(name) < 60
63
+ assert len(name) < 70
64
64
 
65
65
 
66
66
  def test_a_single_pressure_level_is_named_in_hpa():
67
67
  name = plan([asset("T", level_type=PRESSURE, level=85000)]).suggested_name()
68
- assert "_850hPa_" in name
68
+ assert "_pressure850hPa_" in name
69
69
  assert "85000" not in name
70
70
 
71
71
 
72
72
  def test_several_levels_are_counted():
73
73
  assets = [asset("T", level_type=PRESSURE, level=lv) for lv in (85000, 50000)]
74
- assert "_2lv_" in plan(assets).suggested_name()
74
+ assert "_pressure2lv_" in plan(assets).suggested_name()
75
75
 
76
76
 
77
77
  def test_a_model_level_is_labelled_rather_than_given_a_unit():
78
78
  # The unit is "index", which reads badly as a suffix.
79
79
  name = plan([asset("T", level_type=MODEL, level=60)]).suggested_name()
80
- assert "_lv60_" in name
80
+ assert "_model60_" in name
81
81
 
82
82
 
83
83
  def test_a_soil_level_keeps_its_metres():
84
84
  name = plan([asset("T_SO", level_type=SOIL, level="0.18")]).suggested_name()
85
- assert "_0.18m_" in name
85
+ assert "_soil0.18m_" in name
86
86
 
87
87
 
88
- def test_a_surface_selection_has_no_level_part():
88
+ def test_a_surface_selection_is_labelled_single():
89
+ # Named rather than left blank, so a surface file is recognisable as one
90
+ # instead of merely lacking a field.
89
91
  made = plan([asset("T_2M")])
90
92
  assert made.suggested_name() == (
91
- f"icon-eu_2026-09-24T0600_T_2M_0h_{made.fingerprint()}.grib2"
93
+ f"icon-eu_2026-09-24T0600_T_2M_single_0h_{made.fingerprint()}.grib2"
92
94
  )
93
95
 
94
96
 
@@ -145,8 +147,10 @@ def test_the_realistic_worst_case_fits():
145
147
  ]
146
148
  name = plan(assets).suggested_name()
147
149
  assert len(name) <= MAX_NAME_LENGTH
148
- # Short enough that the names are still listed, not counted.
149
- assert "DUST_MAX_TOTAL_MC_LAYER" in name
150
+ # Three of these no longer fit alongside the level field and the hash, so
151
+ # the count takes over. Degrading is the point; overflowing is not.
152
+ assert "3params" in name
153
+ assert name.endswith(f"_{plan(assets).fingerprint()}.grib2")
150
154
 
151
155
 
152
156
  def test_long_names_fall_back_to_counting_rather_than_overflowing():
@@ -160,7 +164,7 @@ def test_forty_surface_fields_stay_short():
160
164
  assets = [asset(f"PARAM_{i:02d}", hours=h) for i in range(40) for h in (0, 120)]
161
165
  name = plan(assets).suggested_name()
162
166
  assert "40params" in name
163
- assert len(name) < 60
167
+ assert len(name) < 70
164
168
 
165
169
 
166
170
  def test_the_suffix_survives_even_a_pathological_name():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes