diff --git a/kubernetes/test/test_quantity.py b/kubernetes/test/test_quantity.py new file mode 100644 index 0000000000..1d3b115392 --- /dev/null +++ b/kubernetes/test/test_quantity.py @@ -0,0 +1,100 @@ +# coding: utf-8 +# Copyright 2019 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. + +from __future__ import absolute_import + +import unittest +from kubernetes.utils import parse_quantity +from decimal import Decimal + + +class TestQuantity(unittest.TestCase): + def test_parse(self): + self.assertIsInstance(parse_quantity(2.2), Decimal) + tests = [ + (2, 2), + (2, Decimal("2")), + (2., 2), + (Decimal("2.2"), Decimal("2.2")), + (2., Decimal(2)), + (Decimal("2."), 2), + ("123", 123), + ("2", 2), + ("2m", Decimal("0.002")), + ("223k", 223000), + ("002M", 2 * 1000**2), + ("2M", 2 * 1000**2), + ("4123G", 4123 * 1000**3), + ("2T", 2 * 1000**4), + ("2P", 2 * 1000**5), + ("2E", 2 * 1000**6), + + ("223Ki", 223 * 1024), + ("002Mi", 2 * 1024**2), + ("2Mi", 2 * 1024**2), + ("2Gi", 2 * 1024**3), + ("4123Gi", 4123 * 1024**3), + ("2Ti", 2 * 1024**4), + ("2Pi", 2 * 1024**5), + ("2Ei", 2 * 1024**6), + + ("2.34Ki", Decimal("2.34") * 1024), + ("2.34", Decimal("2.34")), + (".34", Decimal("0.34")), + ("34.", 34), + (".34M", Decimal("0.34") * 1000**2), + + ("2e2K", Decimal("2e2") * 1000), + ("2e2Ki", Decimal("2e2") * 1024), + ("2e-2Ki", Decimal("2e-2") * 1024), + ("2.34E1", Decimal("2.34E1")), + (".34e-2", Decimal("0.34e-2")), + ] + + for inp, out in tests: + self.assertEqual(parse_quantity(inp), out) + if isinstance(inp, (int, float, Decimal)): + self.assertEqual(parse_quantity(-1 * inp), -out) + else: + self.assertEqual(parse_quantity("-" + inp), -out) + self.assertEqual(parse_quantity("+" + inp), out) + + def test_parse_invalid(self): + self.assertRaises(ValueError, parse_quantity, []) + self.assertRaises(ValueError, parse_quantity, "") + self.assertRaises(ValueError, parse_quantity, "-") + self.assertRaises(ValueError, parse_quantity, "i") + self.assertRaises(ValueError, parse_quantity, "2i") + self.assertRaises(ValueError, parse_quantity, "2e") + self.assertRaises(ValueError, parse_quantity, "2.2i") + self.assertRaises(ValueError, parse_quantity, "bla") + self.assertRaises(ValueError, parse_quantity, "Ki") + self.assertRaises(ValueError, parse_quantity, "M") + self.assertRaises(ValueError, parse_quantity, "2ki") + self.assertRaises(ValueError, parse_quantity, "2Ki ") + self.assertRaises(ValueError, parse_quantity, "20Ki ") + self.assertRaises(ValueError, parse_quantity, "20B") + self.assertRaises(ValueError, parse_quantity, "20Bi") + self.assertRaises(ValueError, parse_quantity, "20.2Bi") + self.assertRaises(ValueError, parse_quantity, "2MiKi") + self.assertRaises(ValueError, parse_quantity, "2MK") + self.assertRaises(ValueError, parse_quantity, "2MKi") + self.assertRaises(ValueError, parse_quantity, "234df") + self.assertRaises(ValueError, parse_quantity, "df234") + self.assertRaises(ValueError, parse_quantity, tuple()) + + +if __name__ == '__main__': + unittest.main() diff --git a/kubernetes/utils/__init__.py b/kubernetes/utils/__init__.py index 72f55c7511..8add80bcfe 100644 --- a/kubernetes/utils/__init__.py +++ b/kubernetes/utils/__init__.py @@ -16,3 +16,4 @@ from .create_from_yaml import (FailToCreateError, create_from_dict, create_from_yaml) +from .quantity import parse_quantity diff --git a/kubernetes/utils/quantity.py b/kubernetes/utils/quantity.py new file mode 100644 index 0000000000..77d54a280d --- /dev/null +++ b/kubernetes/utils/quantity.py @@ -0,0 +1,73 @@ +# Copyright 2019 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. +from decimal import Decimal, InvalidOperation + + +def parse_quantity(quantity): + """ + Parse kubernetes canonical form quantity like 200Mi to a decimal number. + Supported SI suffixes: + base1024: Ki | Mi | Gi | Ti | Pi | Ei + base1000: m | "" | k | M | G | T | P | E + + Input: + quanity: string. kubernetes canonical form quantity + + Returns: + Decimal + + Raises: + ValueError on invalid or unknown input + """ + if isinstance(quantity, (int, float, Decimal)): + return Decimal(quantity) + + exponents = {"m": -1, "K": 1, "k": 1, "M": 2, + "G": 3, "T": 4, "P": 5, "E": 6} + + quantity = str(quantity) + number = quantity + suffix = None + if len(quantity) >= 2 and quantity[-1] == "i": + if quantity[-2] in exponents: + number = quantity[:-2] + suffix = quantity[-2:] + elif len(quantity) >= 1 and quantity[-1] in exponents: + number = quantity[:-1] + suffix = quantity[-1:] + + try: + number = Decimal(number) + except InvalidOperation: + raise ValueError("Invalid number format: {}".format(number)) + + if suffix is None: + return number + + if suffix.endswith("i"): + base = 1024 + elif len(suffix) == 1: + base = 1000 + else: + raise ValueError("{} has unknown suffix".format(quantity)) + + # handly SI inconsistency + if suffix == "ki": + raise ValueError("{} has unknown suffix".format(quantity)) + + if suffix[0] not in exponents: + raise ValueError("{} has unknown suffix".format(quantity)) + + exponent = Decimal(exponents[suffix[0]]) + return number * (base ** exponent)