Skip to content

Commit 9f03be9

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 9f03be9

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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("Cannot express negative durations in GEP-2257: {}".format(delta))
136+
137+
if delta > datetime.timedelta(milliseconds=maxDuration_ms):
138+
raise ValueError(
139+
"Cannot express durations longer than 99999h59m59s999ms in GEP-2257: {}".format(delta))
140+
141+
# durationpy.to_str() is happy to use floating-point seconds, which
142+
# GEP-2257 is _not_ happy with. So start by peeling off any microseconds
143+
# from our delta.
144+
delta_us = delta.microseconds
145+
146+
if (delta_us % 1000) != 0:
147+
raise ValueError(
148+
"Cannot express sub-millisecond precision in GEP-2257: {}"
149+
.format(delta)
150+
)
151+
152+
# After that, do the usual div & mod tree to take seconds and get hours,
153+
# minutes, and seconds from it.
154+
secs = int(delta.total_seconds())
155+
156+
output: List[str] = []
157+
158+
hours = secs // 3600
159+
if hours > 0:
160+
output.append(f"{hours}h")
161+
secs -= hours * 3600
162+
163+
minutes = secs // 60
164+
if minutes > 0:
165+
output.append(f"{minutes}m")
166+
secs -= minutes * 60
167+
168+
if secs > 0:
169+
output.append(f"{secs}s")
170+
171+
if delta_us > 0:
172+
output.append(f"{delta_us // 1000}ms")
173+
174+
return "".join(output)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 = {"n": -3, "u": -2, "m": -1, "K": 1, "k": 1, "M": 2,
39+
"G": 3, "T": 4, "P": 5, "E": 6}
40+
41+
quantity = str(quantity)
42+
number = quantity
43+
suffix = None
44+
if len(quantity) >= 2 and quantity[-1] == "i":
45+
if quantity[-2] in exponents:
46+
number = quantity[:-2]
47+
suffix = quantity[-2:]
48+
elif len(quantity) >= 1 and quantity[-1] in exponents:
49+
number = quantity[:-1]
50+
suffix = quantity[-1:]
51+
52+
try:
53+
number = Decimal(number)
54+
except InvalidOperation:
55+
raise ValueError("Invalid number format: {}".format(number))
56+
57+
if suffix is None:
58+
return number
59+
60+
if suffix.endswith("i"):
61+
base = 1024
62+
elif len(suffix) == 1:
63+
base = 1000
64+
else:
65+
raise ValueError("{} has unknown suffix".format(quantity))
66+
67+
# handle SI inconsistency
68+
if suffix == "ki":
69+
raise ValueError("{} has unknown suffix".format(quantity))
70+
71+
if suffix[0] not in exponents:
72+
raise ValueError("{} has unknown suffix".format(quantity))
73+
74+
exponent = Decimal(exponents[suffix[0]])
75+
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)