anemoi-utils 0.3.13__tar.gz → 0.3.15__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.

Potentially problematic release.


This version of anemoi-utils might be problematic. Click here for more details.

Files changed (57) hide show
  1. anemoi_utils-0.3.15/.github/workflows/changelog-pr-update.yml +15 -0
  2. anemoi_utils-0.3.15/.github/workflows/ci.yml +40 -0
  3. anemoi_utils-0.3.15/.github/workflows/label-public-pr.yml +10 -0
  4. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/.github/workflows/python-publish.yml +3 -23
  5. anemoi_utils-0.3.15/.github/workflows/readthedocs-pr-update.yml +22 -0
  6. anemoi_utils-0.3.15/CHANGELOG.md +39 -0
  7. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/PKG-INFO +2 -1
  8. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/pyproject.toml +2 -0
  9. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/_version.py +2 -2
  10. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/cli.py +7 -0
  11. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/dates.py +144 -18
  12. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/humanize.py +59 -0
  13. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/s3.py +1 -0
  14. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/text.py +85 -11
  15. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/PKG-INFO +2 -1
  16. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/SOURCES.txt +6 -0
  17. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/requires.txt +1 -0
  18. anemoi_utils-0.3.15/tests/test_frequency.py +37 -0
  19. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/.gitignore +0 -0
  20. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/.pre-commit-config.yaml +0 -0
  21. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/.readthedocs.yaml +0 -0
  22. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/LICENSE +0 -0
  23. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/README.md +0 -0
  24. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/Makefile +0 -0
  25. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/_static/logo.png +0 -0
  26. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/_static/style.css +0 -0
  27. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/_templates/.gitkeep +0 -0
  28. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/conf.py +0 -0
  29. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/index.rst +0 -0
  30. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/installing.rst +0 -0
  31. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/checkpoints.rst +0 -0
  32. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/config.rst +0 -0
  33. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/dates.rst +0 -0
  34. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/grib.rst +0 -0
  35. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/humanize.rst +0 -0
  36. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/provenance.rst +0 -0
  37. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/s3.rst +0 -0
  38. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/docs/modules/text.rst +0 -0
  39. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/setup.cfg +0 -0
  40. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/__init__.py +0 -0
  41. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/__main__.py +0 -0
  42. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/caching.py +0 -0
  43. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/checkpoints.py +0 -0
  44. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/commands/__init__.py +0 -0
  45. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/commands/config.py +0 -0
  46. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/config.py +0 -0
  47. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/grib.py +0 -0
  48. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/hindcasts.py +0 -0
  49. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/mars/__init__.py +0 -0
  50. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/mars/mars.yaml +0 -0
  51. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/provenance.py +0 -0
  52. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi/utils/timer.py +0 -0
  53. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/dependency_links.txt +0 -0
  54. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/entry_points.txt +0 -0
  55. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/src/anemoi_utils.egg-info/top_level.txt +0 -0
  56. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/tests/test_dates.py +0 -0
  57. {anemoi_utils-0.3.13 → anemoi_utils-0.3.15}/tests/test_utils.py +0 -0
@@ -0,0 +1,15 @@
1
+ name: Check Changelog Update on PR
2
+ on:
3
+ pull_request:
4
+ types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
5
+ branches:
6
+ - main
7
+ - develop
8
+ jobs:
9
+ Check-Changelog:
10
+ name: Check Changelog Action
11
+ runs-on: ubuntu-20.04
12
+ steps:
13
+ - uses: tarides/changelog-check-action@v2
14
+ with:
15
+ changelog: CHANGELOG.md
@@ -0,0 +1,40 @@
1
+ name: ci
2
+
3
+ on:
4
+ # Trigger the workflow on push to master or develop, except tag creation
5
+ push:
6
+ branches:
7
+ - 'main'
8
+ - 'develop'
9
+ tags-ignore:
10
+ - '**'
11
+
12
+ # Trigger the workflow on pull request
13
+ pull_request: ~
14
+
15
+ # Trigger the workflow manually installs
16
+ workflow_dispatch: ~
17
+
18
+ # Trigger after public PR approved for CI
19
+ pull_request_target:
20
+ types: [labeled]
21
+
22
+ jobs:
23
+ # Run CI including downstream packages on self-hosted runners
24
+ downstream-ci:
25
+ name: downstream-ci
26
+ if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }}
27
+ uses: ecmwf-actions/downstream-ci/.github/workflows/downstream-ci.yml@main
28
+ with:
29
+ anemoi-utils: ecmwf/anemoi-utils@${{ github.event.pull_request.head.sha || github.sha }}
30
+ codecov_upload: true
31
+ secrets: inherit
32
+
33
+ # Build downstream packages on HPC
34
+ downstream-ci-hpc:
35
+ name: downstream-ci-hpc
36
+ if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }}
37
+ uses: ecmwf-actions/downstream-ci/.github/workflows/downstream-ci.yml@main
38
+ with:
39
+ anemoi-utils: ecmwf/anemoi-utils@${{ github.event.pull_request.head.sha || github.sha }}
40
+ secrets: inherit
@@ -0,0 +1,10 @@
1
+ # Manage labels of pull requests that originate from forks
2
+ name: label-public-pr
3
+
4
+ on:
5
+ pull_request_target:
6
+ types: [opened, synchronize]
7
+
8
+ jobs:
9
+ label:
10
+ uses: ecmwf-actions/reusable-workflows/.github/workflows/label-pr.yml@v2
@@ -35,7 +35,7 @@ jobs:
35
35
  steps:
36
36
  - uses: actions/checkout@v4
37
37
 
38
- - uses: actions/setup-python@v2
38
+ - uses: actions/setup-python@v5
39
39
  with:
40
40
  python-version: ${{ matrix.python-version }}
41
41
 
@@ -49,26 +49,6 @@ jobs:
49
49
 
50
50
  deploy:
51
51
 
52
- if: ${{ github.event_name == 'release' }}
53
- runs-on: ubuntu-latest
54
52
  needs: [checks, quality]
55
-
56
- steps:
57
- - uses: actions/checkout@v4
58
-
59
- - name: Set up Python
60
- uses: actions/setup-python@v2
61
- with:
62
- python-version: 3.x
63
-
64
- - name: Install dependencies
65
- run: |
66
- python -m pip install --upgrade pip
67
- pip install build wheel twine
68
- - name: Build and publish
69
- env:
70
- TWINE_USERNAME: __token__
71
- TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
72
- run: |
73
- python -m build
74
- twine upload dist/*
53
+ uses: ecmwf-actions/reusable-workflows/.github/workflows/cd-pypi.yml@v2
54
+ secrets: inherit
@@ -0,0 +1,22 @@
1
+ name: Read the Docs PR Preview
2
+ on:
3
+ pull_request_target:
4
+ types:
5
+ - opened
6
+ - synchronize
7
+ - reopened
8
+ # Execute this action only on PRs that touch
9
+ # documentation files.
10
+ paths:
11
+ - "docs/**"
12
+
13
+ permissions:
14
+ pull-requests: write
15
+
16
+ jobs:
17
+ documentation-links:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: readthedocs/actions/preview@v1
21
+ with:
22
+ project-slug: "anemoi-utils"
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Please add your functional changes to the appropriate section in the PR.
9
+ Keep it human-readable, your future self will thank you!
10
+
11
+ ## [Unreleased]
12
+
13
+ ### Added
14
+
15
+ ### Changed
16
+
17
+ ### Removed
18
+
19
+ ## [0.3.0] - Initial Release, utility functions
20
+
21
+ ### Added
22
+ - Command line interface utility
23
+
24
+ ## [0.2.0] - Initial Release, utility functions
25
+
26
+ ### Changed
27
+ - updated documentation
28
+
29
+ ## [0.1.0] - Initial Release, utility functions
30
+
31
+ ### Added
32
+ - Documentation
33
+ - Initial implementation for a series of utility functions for used by the rest of the Anemoi packages
34
+
35
+ <!-- Add Git Diffs for Links above -->
36
+ [unreleased]: https://github.com/ecmwf/anemoi-utils/compare/0.3.13...HEAD
37
+ https://github.com/ecmwf/anemoi-utils/compare/0.2.0...0.3.0
38
+ https://github.com/ecmwf/anemoi-utils/compare/0.1.0...0.2.0
39
+ [0.1.0]: https://github.com/ecmwf/anemoi-utils/releases/tag/0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: anemoi-utils
3
- Version: 0.3.13
3
+ Version: 0.3.15
4
4
  Summary: A package to hold various functions to support training of ML models on ECMWF data.
5
5
  Author-email: "European Centre for Medium-Range Weather Forecasts (ECMWF)" <software.support@ecmwf.int>
6
6
  License: Apache License
@@ -223,6 +223,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
223
223
  Classifier: Programming Language :: Python :: Implementation :: PyPy
224
224
  Requires-Python: >=3.9
225
225
  License-File: LICENSE
226
+ Requires-Dist: isodate
226
227
  Requires-Dist: pyyaml
227
228
  Requires-Dist: tomli
228
229
  Requires-Dist: tqdm
@@ -49,6 +49,8 @@ dynamic = [
49
49
  "version",
50
50
  ]
51
51
  dependencies = [
52
+ "isodate",
53
+
52
54
  "pyyaml",
53
55
  "tomli", # Only needed before 3.11
54
56
  "tqdm",
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.3.13'
16
- __version_tuple__ = version_tuple = (0, 3, 13)
15
+ __version__ = version = '0.3.15'
16
+ __version_tuple__ = version_tuple = (0, 3, 15)
@@ -12,6 +12,11 @@ import os
12
12
  import sys
13
13
  import traceback
14
14
 
15
+ try:
16
+ import argcomplete
17
+ except ImportError:
18
+ argcomplete = None
19
+
15
20
  LOG = logging.getLogger(__name__)
16
21
 
17
22
 
@@ -100,6 +105,8 @@ def register_commands(here, package, select, fail=None):
100
105
  def cli_main(version, description, commands):
101
106
  parser = make_parser(description, commands)
102
107
  args, unknown = parser.parse_known_args()
108
+ if argcomplete:
109
+ argcomplete.autocomplete(parser)
103
110
 
104
111
  if args.version:
105
112
  print(version)
@@ -8,21 +8,14 @@
8
8
 
9
9
  import calendar
10
10
  import datetime
11
+ import re
11
12
 
12
- from .hindcasts import HindcastDatesTimes
13
-
14
-
15
- def normalise_frequency(frequency):
16
- if isinstance(frequency, int):
17
- return frequency
18
- assert isinstance(frequency, str), (type(frequency), frequency)
13
+ import isodate
19
14
 
20
- unit = frequency[-1].lower()
21
- v = int(frequency[:-1])
22
- return {"h": v, "d": v * 24}[unit]
15
+ from .hindcasts import HindcastDatesTimes
23
16
 
24
17
 
25
- def no_time_zone(date):
18
+ def _no_time_zone(date):
26
19
  """Remove time zone information from a date.
27
20
 
28
21
  Parameters
@@ -40,13 +33,15 @@ def no_time_zone(date):
40
33
 
41
34
 
42
35
  # this function is use in anemoi-datasets
43
- def as_datetime(date):
36
+ def as_datetime(date, keep_time_zone=False):
44
37
  """Convert a date to a datetime object, removing any time zone information.
45
38
 
46
39
  Parameters
47
40
  ----------
48
41
  date : datetime.date or datetime.datetime or str
49
42
  The date to convert.
43
+ keep_time_zone : bool, optional
44
+ If True, the time zone information is kept, by default False.
50
45
 
51
46
  Returns
52
47
  -------
@@ -54,18 +49,150 @@ def as_datetime(date):
54
49
  The datetime object.
55
50
  """
56
51
 
52
+ tidy = _no_time_zone if not keep_time_zone else lambda x: x
53
+
57
54
  if isinstance(date, datetime.datetime):
58
- return no_time_zone(date)
55
+ return tidy(date)
59
56
 
60
57
  if isinstance(date, datetime.date):
61
- return no_time_zone(datetime.datetime(date.year, date.month, date.day))
58
+ return tidy(datetime.datetime(date.year, date.month, date.day))
62
59
 
63
60
  if isinstance(date, str):
64
- return no_time_zone(datetime.datetime.fromisoformat(date))
61
+ return tidy(datetime.datetime.fromisoformat(date))
65
62
 
66
63
  raise ValueError(f"Invalid date type: {type(date)}")
67
64
 
68
65
 
66
+ def frequency_to_timedelta(frequency):
67
+ """Convert a frequency to a timedelta object.
68
+
69
+ Parameters
70
+ ----------
71
+ frequency : int or str or datetime.timedelta
72
+ The frequency to convert. If an integer, it is assumed to be in hours. If a string, it can be in the format:
73
+
74
+ - "1h" for 1 hour
75
+ - "1d" for 1 day
76
+ - "1m" for 1 minute
77
+ - "1s" for 1 second
78
+ - "1:30" for 1 hour and 30 minutes
79
+ - "1:30:10" for 1 hour, 30 minutes and 10 seconds
80
+ - "PT10M" for 10 minutes (ISO8601)
81
+
82
+ If a timedelta object is provided, it is returned as is.
83
+
84
+ Returns
85
+ -------
86
+ datetime.timedelta
87
+ The timedelta object.
88
+
89
+ Raises
90
+ ------
91
+ ValueError
92
+ Exception raised if the frequency cannot be converted to a timedelta.
93
+ """
94
+
95
+ if isinstance(frequency, datetime.timedelta):
96
+ return frequency
97
+
98
+ if isinstance(frequency, int):
99
+ return datetime.timedelta(hours=frequency)
100
+
101
+ assert isinstance(frequency, str), (type(frequency), frequency)
102
+
103
+ try:
104
+ return frequency_to_timedelta(int(frequency))
105
+ except ValueError:
106
+ pass
107
+
108
+ if re.match(r"^\d+[hdms]$", frequency, re.IGNORECASE):
109
+ unit = frequency[-1].lower()
110
+ v = int(frequency[:-1])
111
+ unit = {"h": "hours", "d": "days", "s": "seconds", "m": "minutes"}[unit]
112
+ return datetime.timedelta(**{unit: v})
113
+
114
+ m = frequency.split(":")
115
+ if len(m) == 2:
116
+ return datetime.timedelta(hours=int(m[0]), minutes=int(m[1]))
117
+
118
+ if len(m) == 3:
119
+ return datetime.timedelta(hours=int(m[0]), minutes=int(m[1]), seconds=int(m[2]))
120
+
121
+ # ISO8601
122
+ try:
123
+ return isodate.parse_duration(frequency)
124
+ except isodate.isoerror.ISO8601Error:
125
+ pass
126
+
127
+ raise ValueError(f"Cannot convert frequency {frequency} to timedelta")
128
+
129
+
130
+ def frequency_to_string(frequency):
131
+ """Convert a frequency (i.e. a datetime.timedelta) to a string.
132
+
133
+ Parameters
134
+ ----------
135
+ frequency : datetime.timedelta
136
+ The frequency to convert.
137
+
138
+ Returns
139
+ -------
140
+ str
141
+ A string representation of the frequency.
142
+ """
143
+
144
+ frequency = frequency_to_timedelta(frequency)
145
+
146
+ total_seconds = frequency.total_seconds()
147
+ assert int(total_seconds) == total_seconds, total_seconds
148
+ total_seconds = int(total_seconds)
149
+
150
+ seconds = total_seconds
151
+
152
+ days = seconds // (24 * 3600)
153
+ seconds %= 24 * 3600
154
+ hours = seconds // 3600
155
+ seconds %= 3600
156
+ minutes = seconds // 60
157
+ seconds %= 60
158
+
159
+ if days > 0 and hours == 0 and minutes == 0 and seconds == 0:
160
+ return f"{days}d"
161
+
162
+ if days == 0 and hours > 0 and minutes == 0 and seconds == 0:
163
+ return f"{hours}h"
164
+
165
+ if days == 0 and hours == 0 and minutes > 0 and seconds == 0:
166
+ return f"{minutes}m"
167
+
168
+ if days == 0 and hours == 0 and minutes == 0 and seconds > 0:
169
+ return f"{seconds}s"
170
+
171
+ if days > 0:
172
+ return f"{total_seconds}s"
173
+
174
+ return str(frequency)
175
+
176
+
177
+ def frequency_to_seconds(frequency):
178
+ """Convert a frequency to seconds.
179
+
180
+ Parameters
181
+ ----------
182
+ frequency : _type_
183
+ _description_
184
+
185
+ Returns
186
+ -------
187
+ int
188
+ Number of seconds.
189
+ """
190
+
191
+ result = frequency_to_timedelta(frequency).total_seconds()
192
+ assert int(result) == result, result
193
+ return int(result)
194
+
195
+
69
196
  DOW = {
70
197
  "monday": 0,
71
198
  "tuesday": 1,
@@ -142,7 +269,7 @@ class DateTimes:
142
269
  """
143
270
  self.start = as_datetime(start)
144
271
  self.end = as_datetime(end)
145
- self.increment = datetime.timedelta(hours=increment)
272
+ self.increment = frequency_to_timedelta(increment)
146
273
  self.day_of_month = _make_day(day_of_month)
147
274
  self.day_of_week = _make_week(day_of_week)
148
275
  self.calendar_months = _make_months(calendar_months)
@@ -273,8 +400,7 @@ def datetimes_factory(*args, **kwargs):
273
400
 
274
401
  kwargs = kwargs.copy()
275
402
  if "frequency" in kwargs:
276
- freq = kwargs.pop("frequency")
277
- kwargs["increment"] = normalise_frequency(freq)
403
+ kwargs["increment"] = kwargs.pop("frequency")
278
404
  return DateTimes(**kwargs)
279
405
 
280
406
  if not any((isinstance(x, dict) or isinstance(x, list)) for x in args):
@@ -15,6 +15,8 @@ import re
15
15
  import warnings
16
16
  from collections import defaultdict
17
17
 
18
+ from anemoi.utils.dates import as_datetime
19
+
18
20
 
19
21
  def bytes_to_human(n: float) -> str:
20
22
  """Convert a number of bytes to a human readable string
@@ -625,3 +627,60 @@ def shorten_list(lst, max_length=5):
625
627
  if isinstance(lst, tuple):
626
628
  return tuple(result)
627
629
  return result
630
+
631
+
632
+ def _compress_dates(dates):
633
+ dates = sorted(dates)
634
+ if len(dates) < 3:
635
+ yield dates
636
+ return
637
+
638
+ prev = first = dates.pop(0)
639
+ curr = dates.pop(0)
640
+ delta = curr - prev
641
+ while curr - prev == delta:
642
+ prev = curr
643
+ if not dates:
644
+ break
645
+ curr = dates.pop(0)
646
+
647
+ yield (first, prev, delta)
648
+ if dates:
649
+ yield from _compress_dates([curr] + dates)
650
+
651
+
652
+ def compress_dates(dates):
653
+ """Compress a list of dates into a human-readable format.
654
+
655
+ Parameters
656
+ ----------
657
+ dates : list
658
+ A list of dates, as datetime objects or strings.
659
+
660
+ Returns
661
+ -------
662
+ str
663
+ A human-readable string representing the compressed dates.
664
+ """
665
+
666
+ dates = [as_datetime(_) for _ in dates]
667
+ result = []
668
+
669
+ for n in _compress_dates(dates):
670
+ if isinstance(n, list):
671
+ result.extend([str(_) for _ in n])
672
+ else:
673
+ result.append(" ".join([str(n[0]), "to", str(n[1]), "by", str(n[2])]))
674
+
675
+ return result
676
+
677
+
678
+ def print_dates(dates):
679
+ """Print a list of dates in a human-readable format.
680
+
681
+ Parameters
682
+ ----------
683
+ dates : list
684
+ A list of dates, as datetime objects or strings.
685
+ """
686
+ print(compress_dates(dates))
@@ -343,6 +343,7 @@ def upload(source, target, *, overwrite=False, resume=False, verbosity=1, progre
343
343
  """
344
344
 
345
345
  uploader = Upload()
346
+
346
347
  if os.path.isdir(source):
347
348
  uploader.transfer_folder(
348
349
  source=source,
@@ -7,6 +7,7 @@
7
7
 
8
8
  """Text utilities"""
9
9
 
10
+ import re
10
11
  from collections import defaultdict
11
12
 
12
13
  # https://en.wikipedia.org/wiki/Box-drawing_character
@@ -32,6 +33,62 @@ def dotted_line(width=84) -> str:
32
33
  return "┈" * width
33
34
 
34
35
 
36
+ # Regular expression to match ANSI escape codes
37
+ _ansi_escape = re.compile(r"\x1b\[([0-9;]*[mGKH])")
38
+
39
+
40
+ def _has_ansi_escape(s):
41
+ return _ansi_escape.search(s) is not None
42
+
43
+
44
+ def _split_tokens(s):
45
+ """Split a string into a list of visual characters with their lenghts."""
46
+ from wcwidth import wcswidth
47
+
48
+ initial = s
49
+ out = []
50
+
51
+ # Function to probe the number of bytes needed to encode the first character
52
+ def probe_utf8(s):
53
+ for i in range(1, 5):
54
+ try:
55
+ s[:i].encode("utf-8")
56
+ except UnicodeEncodeError:
57
+ return i - 1
58
+ return 1
59
+
60
+ while s:
61
+ match = _ansi_escape.match(s)
62
+ if match:
63
+ token = match.group(0)
64
+ s = s[len(token) :]
65
+ out.append((token, 0))
66
+ else:
67
+ i = probe_utf8(s)
68
+ token = s[:i]
69
+ s = s[i:]
70
+ out.append((token, wcswidth(token)))
71
+
72
+ assert "".join(token for (token, _) in out) == initial, (out, initial)
73
+ return out
74
+
75
+
76
+ def visual_len(s):
77
+ """Compute the length of a string as it appears on the terminal."""
78
+ if isinstance(s, str):
79
+ s = _split_tokens(s)
80
+ assert isinstance(s, (tuple, list)), (type(s), s)
81
+ if len(s) == 0:
82
+ return 0
83
+ for _ in s:
84
+ assert isinstance(_, tuple), s
85
+ assert len(_) == 2, s
86
+ n = 0
87
+ for _, width in s:
88
+ n += width
89
+ return n
90
+
91
+
35
92
  def boxed(text, min_width=80, max_width=80) -> str:
36
93
  """Put a box around a text
37
94
 
@@ -57,27 +114,44 @@ def boxed(text, min_width=80, max_width=80) -> str:
57
114
 
58
115
  """
59
116
 
60
- lines = text.split("\n")
61
- width = max(len(_) for _ in lines)
117
+ lines = []
118
+ for line in text.split("\n"):
119
+ line = line.strip()
120
+ line = _split_tokens(line)
121
+ lines.append(line)
122
+
123
+ width = max(visual_len(_) for _ in lines)
62
124
 
63
125
  if min_width is not None:
64
126
  width = max(width, min_width)
65
127
 
66
128
  if max_width is not None:
129
+
130
+ def shorten_line(line, max_width):
131
+ if visual_len(line) > max_width:
132
+ while visual_len(line) >= max_width:
133
+ line = line[:-1]
134
+ line.append(("…", 1))
135
+ return line
136
+
67
137
  width = min(width, max_width)
68
- lines = []
69
- for line in text.split("\n"):
70
- if len(line) > max_width:
71
- line = line[: max_width - 1] + "…"
72
- lines.append(line)
73
- text = "\n".join(lines)
138
+ lines = [shorten_line(line, max_width) for line in lines]
139
+
140
+ def pad_line(line, width):
141
+ line = line + [" "] * (width - visual_len(line))
142
+ return line
143
+
144
+ lines = [pad_line(line, width) for line in lines]
74
145
 
75
146
  box = []
76
147
  box.append("┌" + "─" * (width + 2) + "┐")
77
148
  for line in lines:
78
- box.append(f"│ {line:{width}} │")
79
-
149
+ s = "".join(_[0] for _ in line)
150
+ if _has_ansi_escape(s):
151
+ s += "\x1b[0m"
152
+ box.append(f"│ {s} │")
80
153
  box.append("└" + "─" * (width + 2) + "┘")
154
+
81
155
  return "\n".join(box)
82
156
 
83
157
 
@@ -241,7 +315,7 @@ def table(rows, header, align, margin=0):
241
315
  ['B', 120, 1],
242
316
  ['C', 9, 123]],
243
317
  ['C1', 'C2', 'C3'],
244
- ['<', '>', '>']))
318
+ ['<', '>', '>'])
245
319
  C1 │ C2 │ C3
246
320
  ───┼─────┼────
247
321
  Aa │ 12 │ 5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: anemoi-utils
3
- Version: 0.3.13
3
+ Version: 0.3.15
4
4
  Summary: A package to hold various functions to support training of ML models on ECMWF data.
5
5
  Author-email: "European Centre for Medium-Range Weather Forecasts (ECMWF)" <software.support@ecmwf.int>
6
6
  License: Apache License
@@ -223,6 +223,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
223
223
  Classifier: Programming Language :: Python :: Implementation :: PyPy
224
224
  Requires-Python: >=3.9
225
225
  License-File: LICENSE
226
+ Requires-Dist: isodate
226
227
  Requires-Dist: pyyaml
227
228
  Requires-Dist: tomli
228
229
  Requires-Dist: tqdm
@@ -1,10 +1,15 @@
1
1
  .gitignore
2
2
  .pre-commit-config.yaml
3
3
  .readthedocs.yaml
4
+ CHANGELOG.md
4
5
  LICENSE
5
6
  README.md
6
7
  pyproject.toml
8
+ .github/workflows/changelog-pr-update.yml
9
+ .github/workflows/ci.yml
10
+ .github/workflows/label-public-pr.yml
7
11
  .github/workflows/python-publish.yml
12
+ .github/workflows/readthedocs-pr-update.yml
8
13
  docs/Makefile
9
14
  docs/conf.py
10
15
  docs/index.rst
@@ -46,4 +51,5 @@ src/anemoi_utils.egg-info/entry_points.txt
46
51
  src/anemoi_utils.egg-info/requires.txt
47
52
  src/anemoi_utils.egg-info/top_level.txt
48
53
  tests/test_dates.py
54
+ tests/test_frequency.py
49
55
  tests/test_utils.py
@@ -0,0 +1,37 @@
1
+ # (C) Copyright 2023 European Centre for Medium-Range Weather Forecasts.
2
+ # This software is licensed under the terms of the Apache Licence Version 2.0
3
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
4
+ # In applying this licence, ECMWF does not waive the privileges and immunities
5
+ # granted to it by virtue of its status as an intergovernmental organisation
6
+ # nor does it submit to any jurisdiction.
7
+
8
+ import datetime
9
+
10
+ from anemoi.utils.dates import frequency_to_string
11
+ from anemoi.utils.dates import frequency_to_timedelta
12
+
13
+
14
+ def test_frequency_to_string():
15
+ assert frequency_to_string(datetime.timedelta(hours=1)) == "1h"
16
+ assert frequency_to_string(datetime.timedelta(hours=1, minutes=30)) == "1:30:00"
17
+ assert frequency_to_string(datetime.timedelta(days=10)) == "10d"
18
+ assert frequency_to_string(datetime.timedelta(minutes=10)) == "10m"
19
+ assert frequency_to_string(datetime.timedelta(minutes=90)) == "1:30:00"
20
+
21
+
22
+ def test_frequency_to_timedelta():
23
+ assert frequency_to_timedelta("1s") == datetime.timedelta(seconds=1)
24
+ assert frequency_to_timedelta("3m") == datetime.timedelta(minutes=3)
25
+ assert frequency_to_timedelta("1h") == datetime.timedelta(hours=1)
26
+ assert frequency_to_timedelta("3d") == datetime.timedelta(days=3)
27
+ assert frequency_to_timedelta("90m") == datetime.timedelta(hours=1, minutes=30)
28
+ assert frequency_to_timedelta("0:30") == datetime.timedelta(minutes=30)
29
+ assert frequency_to_timedelta("0:30:10") == datetime.timedelta(minutes=30, seconds=10)
30
+ assert frequency_to_timedelta("1:30:10") == datetime.timedelta(hours=1, minutes=30, seconds=10)
31
+
32
+ assert frequency_to_timedelta("PT10M") == datetime.timedelta(minutes=10)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ test_frequency_to_string()
37
+ test_frequency_to_timedelta()
File without changes
File without changes
File without changes
File without changes