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.
- {dwdopen-0.2.2 → dwdopen-0.2.3}/CHANGELOG.md +8 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/PKG-INFO +10 -7
- {dwdopen-0.2.2 → dwdopen-0.2.3}/README.md +9 -6
- {dwdopen-0.2.2 → dwdopen-0.2.3}/pyproject.toml +1 -1
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/__init__.py +29 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/model.py +36 -3
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/request.py +6 -4
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_download.py +6 -1
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_download.py +66 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_levels.py +27 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_naming_plans.py +15 -11
- {dwdopen-0.2.2 → dwdopen-0.2.3}/.github/workflows/publish.yml +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/.gitignore +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/LICENSE +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/design/internal_api_mock.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/design/usage_examples.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/__init__.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/download.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/http.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/listing.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/naming.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/paths.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/_fileserver/traversal.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/client.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/exceptions.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/__init__.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/catalogue.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/durations.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/query.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/run.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/nwp/selectors.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/src/dwdopen/py.typed +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/testing.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_discovery.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/integration/test_live_listing.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_case_insensitivity.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_durations.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_listing.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_members.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_paths.py +0 -0
- {dwdopen-0.2.2 → dwdopen-0.2.3}/tests/unit/test_repr.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
|
@@ -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"{
|
|
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(
|
|
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-
|
|
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) <
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
85
|
+
assert "_soil0.18m_" in name
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def
|
|
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-
|
|
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
|
-
#
|
|
149
|
-
|
|
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) <
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|