Skip to content

Commit 46e1a16

Browse files
committed
Add duration and quantity utilities
Add duration and quantity utilities, sourced from kubernetes-client/python. Signed-off-by: Jacob Henner <code@ventricle.us>
1 parent 33c014b commit 46e1a16

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Copyright 2024 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from typing import List
15+
16+
import datetime
17+
import re
18+
19+
import durationpy
20+
21+
# Initialize our RE statically, rather than compiling for every call. This has
22+
# the downside that it'll get compiled at import time but that shouldn't
23+
# really be a big deal.
24+
reDuration = re.compile(r"^([0-9]{1,5}(h|m|s|ms)){1,4}$")
25+
26+
# maxDuration_ms is the maximum duration that GEP-2257 can support, in
27+
# milliseconds.
28+
maxDuration_ms = (((99999 * 3600) + (59 * 60) + 59) * 1_000) + 999
29+
30+
31+
def parse_duration(duration) -> datetime.timedelta:
32+
"""
33+
Parse GEP-2257 Duration format to a datetime.timedelta object.
34+
35+
The GEP-2257 Duration format is a restricted form of the input to the Go
36+
time.ParseDuration function; specifically, it must match the regex
37+
"^([0-9]{1,5}(h|m|s|ms)){1,4}$".
38+
39+
See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details.
40+
41+
Input: duration: string
42+
Returns: datetime.timedelta
43+
44+
Raises: ValueError on invalid or unknown input
45+
46+
Examples:
47+
>>> parse_duration("1h")
48+
datetime.timedelta(seconds=3600)
49+
>>> parse_duration("1m")
50+
datetime.timedelta(seconds=60)
51+
>>> parse_duration("1s")
52+
datetime.timedelta(seconds=1)
53+
>>> parse_duration("1ms")
54+
datetime.timedelta(microseconds=1000)
55+
>>> parse_duration("1h1m1s")
56+
datetime.timedelta(seconds=3661)
57+
>>> parse_duration("10s30m1h")
58+
datetime.timedelta(seconds=5410)
59+
60+
Units are always required.
61+
>>> parse_duration("1")
62+
Traceback (most recent call last):
63+
...
64+
ValueError: Invalid duration format: 1
65+
66+
Floating-point and negative durations are not valid.
67+
>>> parse_duration("1.5m")
68+
Traceback (most recent call last):
69+
...
70+
ValueError: Invalid duration format: 1.5m
71+
>>> parse_duration("-1m")
72+
Traceback (most recent call last):
73+
...
74+
ValueError: Invalid duration format: -1m
75+
"""
76+
77+
if not reDuration.match(duration):
78+
raise ValueError("Invalid duration format: {}".format(duration))
79+
80+
return durationpy.from_str(duration)
81+
82+
83+
def format_duration(delta: datetime.timedelta) -> str:
84+
"""
85+
Format a datetime.timedelta object to GEP-2257 Duration format.
86+
87+
The GEP-2257 Duration format is a restricted form of the input to the Go
88+
time.ParseDuration function; specifically, it must match the regex
89+
"^([0-9]{1,5}(h|m|s|ms)){1,4}$".
90+
91+
See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details.
92+
93+
Input: duration: datetime.timedelta
94+
95+
Returns: string
96+
97+
Raises: ValueError if the timedelta given cannot be expressed as a
98+
GEP-2257 Duration.
99+
100+
Examples:
101+
>>> format_duration(datetime.timedelta(seconds=3600))
102+
'1h'
103+
>>> format_duration(datetime.timedelta(seconds=60))
104+
'1m'
105+
>>> format_duration(datetime.timedelta(seconds=1))
106+
'1s'
107+
>>> format_duration(datetime.timedelta(microseconds=1000))
108+
'1ms'
109+
>>> format_duration(datetime.timedelta(seconds=5410))
110+
'1h30m10s'
111+
112+
The zero duration is always "0s".
113+
>>> format_duration(datetime.timedelta(0))
114+
'0s'
115+
116+
Sub-millisecond precision is not allowed.
117+
>>> format_duration(datetime.timedelta(microseconds=100))
118+
Traceback (most recent call last):
119+
...
120+
ValueError: Cannot express sub-millisecond precision in GEP-2257: 0:00:00.000100
121+
122+
Negative durations are not allowed.
123+
>>> format_duration(datetime.timedelta(seconds=-1))
124+
Traceback (most recent call last):
125+
...
126+
ValueError: Cannot express negative durations in GEP-2257: -1 day, 23:59:59
127+
"""
128+
129+
# Short-circuit if we have a zero delta.
130+
if delta == datetime.timedelta(0):
131+
return "0s"
132+
133+
# Check range early.
134+
if delta < datetime.timedelta(0):
135+
raise ValueError(
136+
"Cannot express negative durations in GEP-2257: {}".format(delta)
137+
)
138+
139+
if delta > datetime.timedelta(milliseconds=maxDuration_ms):
140+
raise ValueError(
141+
"Cannot express durations longer than 99999h59m59s999ms in GEP-2257: {}".format(
142+
delta
143+
)
144+
)
145+
146+
# durationpy.to_str() is happy to use floating-point seconds, which
147+
# GEP-2257 is _not_ happy with. So start by peeling off any microseconds
148+
# from our delta.
149+
delta_us = delta.microseconds
150+
151+
if (delta_us % 1000) != 0:
152+
raise ValueError(
153+
"Cannot express sub-millisecond precision in GEP-2257: {}".format(delta)
154+
)
155+
156+
# After that, do the usual div & mod tree to take seconds and get hours,
157+
# minutes, and seconds from it.
158+
secs = int(delta.total_seconds())
159+
160+
output: List[str] = []
161+
162+
hours = secs // 3600
163+
if hours > 0:
164+
output.append(f"{hours}h")
165+
secs -= hours * 3600
166+
167+
minutes = secs // 60
168+
if minutes > 0:
169+
output.append(f"{minutes}m")
170+
secs -= minutes * 60
171+
172+
if secs > 0:
173+
output.append(f"{secs}s")
174+
175+
if delta_us > 0:
176+
output.append(f"{delta_us // 1000}ms")
177+
178+
return "".join(output)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2019 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from decimal import Decimal, InvalidOperation
15+
16+
17+
def parse_quantity(quantity):
18+
"""
19+
Parse kubernetes canonical form quantity like 200Mi to a decimal number.
20+
Supported SI suffixes:
21+
base1024: Ki | Mi | Gi | Ti | Pi | Ei
22+
base1000: n | u | m | "" | k | M | G | T | P | E
23+
24+
See https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go
25+
26+
Input:
27+
quantity: string. kubernetes canonical form quantity
28+
29+
Returns:
30+
Decimal
31+
32+
Raises:
33+
ValueError on invalid or unknown input
34+
"""
35+
if isinstance(quantity, (int, float, Decimal)):
36+
return Decimal(quantity)
37+
38+
exponents = {
39+
"n": -3,
40+
"u": -2,
41+
"m": -1,
42+
"K": 1,
43+
"k": 1,
44+
"M": 2,
45+
"G": 3,
46+
"T": 4,
47+
"P": 5,
48+
"E": 6,
49+
}
50+
51+
quantity = str(quantity)
52+
number = quantity
53+
suffix = None
54+
if len(quantity) >= 2 and quantity[-1] == "i":
55+
if quantity[-2] in exponents:
56+
number = quantity[:-2]
57+
suffix = quantity[-2:]
58+
elif len(quantity) >= 1 and quantity[-1] in exponents:
59+
number = quantity[:-1]
60+
suffix = quantity[-1:]
61+
62+
try:
63+
number = Decimal(number)
64+
except InvalidOperation:
65+
raise ValueError("Invalid number format: {}".format(number))
66+
67+
if suffix is None:
68+
return number
69+
70+
if suffix.endswith("i"):
71+
base = 1024
72+
elif len(suffix) == 1:
73+
base = 1000
74+
else:
75+
raise ValueError("{} has unknown suffix".format(quantity))
76+
77+
# handle SI inconsistency
78+
if suffix == "ki":
79+
raise ValueError("{} has unknown suffix".format(quantity))
80+
81+
if suffix[0] not in exponents:
82+
raise ValueError("{} has unknown suffix".format(quantity))
83+
84+
exponent = Decimal(exponents[suffix[0]])
85+
return number * (base**exponent)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"urllib3>=1.24.2",
3535
"pyyaml>=3.12",
3636
"aiohttp>=3.9.0,<4.0.0",
37+
"durationpy>=0.7",
3738
]
3839

3940
[dependency-groups]

0 commit comments

Comments
 (0)