Add utility functions to parse and format GEP-2257 Duration strings for Gateway API

Signed-off-by: Flynn <emissary@flynn.kodachi.com>
This commit is contained in:
Flynn 2024-08-09 23:35:23 -04:00
parent 7a278c794a
commit 9d620ad4cb
5 changed files with 250 additions and 77 deletions

View File

@ -2,6 +2,10 @@
Kubernetes API Version: v1.30.1
**New Feature:**
- Add utility functions to parse and format [GEP-2257] Duration strings for Gateway API
[GEP-2257]: https://gateway-api.sigs.k8s.io/geps/gep-2257/
# v30.1.0b1

View File

@ -0,0 +1,70 @@
import unittest
import datetime
import kubernetes
from kubernetes.utils.duration import parse_duration, format_duration
class TestDuration(unittest.TestCase):
def test_parse_duration(self):
# Valid durations
self.assertEqual(parse_duration("0h"), datetime.timedelta(hours=0))
self.assertEqual(parse_duration("0s"), datetime.timedelta(hours=0))
self.assertEqual(parse_duration("0h0m0s"), datetime.timedelta(hours=0))
self.assertEqual(parse_duration("1h"), datetime.timedelta(hours=1))
self.assertEqual(parse_duration("30m"), datetime.timedelta(minutes=30))
self.assertEqual(parse_duration("10s"), datetime.timedelta(seconds=10))
self.assertEqual(parse_duration("500ms"), datetime.timedelta(milliseconds=500))
self.assertEqual(parse_duration("2h30m"), datetime.timedelta(hours=2, minutes=30))
self.assertEqual(parse_duration("150m"), datetime.timedelta(hours=2, minutes=30))
self.assertEqual(parse_duration("7230s"), datetime.timedelta(hours=2, seconds=30))
self.assertEqual(parse_duration("1h30m10s"), datetime.timedelta(hours=1, minutes=30, seconds=10))
self.assertEqual(parse_duration("10s30m1h"), datetime.timedelta(hours=1, minutes=30, seconds=10))
self.assertEqual(parse_duration("100ms200ms300ms"), datetime.timedelta(milliseconds=600))
self.assertEqual(parse_duration("100ms200ms300ms"), datetime.timedelta(milliseconds=600))
# Invalid durations
with self.assertRaises(ValueError):
parse_duration("1d") # Invalid unit 'd'
with self.assertRaises(ValueError):
parse_duration("1") # Missing unit
with self.assertRaises(ValueError):
parse_duration("1m1") # Missing unit
with self.assertRaises(ValueError):
parse_duration("1h30m10s20ms50h") # Too many units
with self.assertRaises(ValueError):
parse_duration("999999h") # Too many digits
with self.assertRaises(ValueError):
parse_duration("1.5h") # Floating point is not supported
with self.assertRaises(ValueError):
parse_duration("-15m") # Negative durations are not supported
def test_format_duration(self):
# Valid durations
self.assertEqual(format_duration(datetime.timedelta(0)), "0s")
self.assertEqual(format_duration(datetime.timedelta(hours=1)), "1h")
self.assertEqual(format_duration(datetime.timedelta(minutes=30)), "30m")
self.assertEqual(format_duration(datetime.timedelta(seconds=10)), "10s")
self.assertEqual(format_duration(datetime.timedelta(milliseconds=500)), "500ms")
self.assertEqual(format_duration(datetime.timedelta(hours=2, minutes=30)), "2h30m")
self.assertEqual(format_duration(datetime.timedelta(hours=1, minutes=30, seconds=10)), "1h30m10s")
self.assertEqual(format_duration(datetime.timedelta(milliseconds=600)), "600ms")
self.assertEqual(format_duration(datetime.timedelta(hours=2, milliseconds=600)), "2h600ms")
self.assertEqual(format_duration(datetime.timedelta(hours=2, minutes=30, milliseconds=600)), "2h30m600ms")
self.assertEqual(format_duration(datetime.timedelta(hours=2, minutes=30, seconds=10, milliseconds=600)), "2h30m10s600ms")
self.assertEqual(format_duration(datetime.timedelta(minutes=0.5)), "30s")
self.assertEqual(format_duration(datetime.timedelta(seconds=0.5)), "500ms")
self.assertEqual(format_duration(datetime.timedelta(days=10)), "240h") # 10 days = 240 hours
# Invalid durations
with self.assertRaises(ValueError):
format_duration(datetime.timedelta(microseconds=100)) # Sub-millisecond precision
with self.assertRaises(ValueError):
format_duration(datetime.timedelta(milliseconds=0.5)) # Sub-millisecond precision
with self.assertRaises(ValueError):
format_duration(datetime.timedelta(days=10000)) # Out of range (more than 99999 hours)
with self.assertRaises(ValueError):
format_duration(datetime.timedelta(minutes=-15)) # Negative durations are not supported
if __name__ == '__main__':
unittest.main()

View File

@ -17,3 +17,4 @@ from __future__ import absolute_import
from .create_from_yaml import (FailToCreateError, create_from_dict,
create_from_yaml, create_from_directory)
from .quantity import parse_quantity
from. duration import parse_duration

View File

@ -0,0 +1,97 @@
# Copyright 2024 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import re
import durationpy
# Initialize our RE statically, rather than compiling for every call. This has
# the downside that it'll get compiled at import time but that shouldn't
# really be a big deal.
reDuration = re.compile(r'^([0-9]{1,5}(h|m|s|ms)){1,4}$')
def parse_duration(duration) -> datetime.timedelta:
"""
Parse GEP-2257 Duration format to a datetime.timedelta object.
The GEP-2257 Duration format is a restricted form of the input to the Go
time.ParseDuration function; specifically, it must match the regex
"^([0-9]{1,5}(h|m|s|ms)){1,4}$".
See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details.
Input: duration: string
Returns: datetime.timedelta
Raises: ValueError on invalid or unknown input
"""
if not reDuration.match(duration):
raise ValueError("Invalid duration format: {}".format(duration))
return durationpy.from_str(duration)
def format_duration(delta: datetime.timedelta) -> str:
"""
Format a datetime.timedelta object to GEP-2257 Duration format.
The GEP-2257 Duration format is a restricted form of the input to the Go
time.ParseDuration function; specifically, it must match the regex
"^([0-9]{1,5}(h|m|s|ms)){1,4}$".
See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details.
Input: duration: datetime.timedelta
Returns: string
Raises: ValueError if the timedelta given cannot be expressed as a
GEP-2257 Duration.
"""
# Short-circuit if we have a zero delta.
if delta == datetime.timedelta(0):
return "0s"
# durationpy.to_str() is happy to use floating-point seconds, which
# GEP-2257 is _not_ happy with. So start by peeling off any microseconds
# from our delta.
delta_us = delta.microseconds
if (delta_us % 1000) != 0:
raise ValueError(
"Cannot express sub-millisecond precision in GEP-2257: {}"
.format(delta)
)
# Second short-circuit.
delta -= datetime.timedelta(microseconds=delta_us)
delta_ms = delta_us // 1000
delta_str = durationpy.to_str(delta)
if delta_ms > 0:
# We have milliseconds to add back in. Make sure to not have a leading
# "0" if we have no other duration components.
if delta == datetime.timedelta(0):
delta_str = ""
delta_str += f"{delta_ms}ms"
if not reDuration.match(delta_str):
raise ValueError("Invalid duration format: {}".format(durationpy.to_str(delta)))
return delta_str

View File

@ -9,3 +9,4 @@ requests # Apache-2.0
requests-oauthlib # ISC
oauthlib>=3.2.2 # BSD
urllib3>=1.24.2 # MIT
durationpy>=0.7 # MIT