Skip to content

Commit 7d6a3b5

Browse files
committed
1 parent 1c4f811 commit 7d6a3b5

14 files changed

Lines changed: 3129 additions & 0 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright 2016 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+
import warnings
15+
from os.path import exists, expanduser
16+
17+
from .config_exception import ConfigException
18+
from .incluster_config import load_incluster_config
19+
from .kube_config import (
20+
KUBE_CONFIG_DEFAULT_LOCATION, list_kube_config_contexts, load_kube_config,
21+
load_kube_config_from_dict, new_client_from_config,
22+
new_client_from_config_dict, refresh_token,
23+
)
24+
25+
26+
async def load_config(**kwargs):
27+
"""
28+
Wrapper function to load the kube_config.
29+
It will initially try to load_kube_config from provided path,
30+
then check if the KUBE_CONFIG_DEFAULT_LOCATION exists
31+
If neither exists, it will fall back to load_incluster_config
32+
and inform the user accordingly.
33+
34+
:param kwargs: A combination of all possible kwargs that
35+
can be passed to either load_kube_config or
36+
load_incluster_config functions.
37+
"""
38+
if "config_file" in kwargs.keys():
39+
await load_kube_config(**kwargs)
40+
elif "kube_config_path" in kwargs.keys():
41+
kwargs["config_file"] = kwargs.pop("kube_config_path", None)
42+
await load_kube_config(**kwargs)
43+
elif exists(expanduser(KUBE_CONFIG_DEFAULT_LOCATION)):
44+
await load_kube_config(**kwargs)
45+
else:
46+
warnings.warn(
47+
"kube_config_path not provided and "
48+
"default location ({0}) does not exist. "
49+
"Using inCluster Config. "
50+
"This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)
51+
)
52+
load_incluster_config(**kwargs)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright 2016 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+
15+
16+
class ConfigException(Exception):
17+
pass
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright 2017 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+
15+
import datetime
16+
import math
17+
import re
18+
19+
20+
class TimezoneInfo(datetime.tzinfo):
21+
def __init__(self, h, m):
22+
self._name = "UTC"
23+
if h != 0 and m != 0:
24+
self._name += "%+03d:%2d" % (h, m)
25+
self._delta = datetime.timedelta(hours=h, minutes=math.copysign(m, h))
26+
27+
def utcoffset(self, dt):
28+
return self._delta
29+
30+
def tzname(self, dt):
31+
return self._name
32+
33+
def dst(self, dt):
34+
return datetime.timedelta(0)
35+
36+
37+
UTC = TimezoneInfo(0, 0)
38+
39+
# ref https://www.ietf.org/rfc/rfc3339.txt
40+
_re_rfc3339 = re.compile(r"(\d\d\d\d)-(\d\d)-(\d\d)" # full-date
41+
r"[ Tt]" # Separator
42+
r"(\d\d):(\d\d):(\d\d)([.,]\d+)?" # partial-time
43+
r"([zZ ]|[-+]\d\d?:\d\d)?", # time-offset
44+
re.VERBOSE + re.IGNORECASE)
45+
_re_timezone = re.compile(r"([-+])(\d\d?):?(\d\d)?")
46+
47+
MICROSEC_PER_SEC = 1000000
48+
49+
50+
def parse_rfc3339(s):
51+
if isinstance(s, datetime.datetime):
52+
if not s.tzinfo:
53+
return s.replace(tzinfo=UTC)
54+
return s
55+
56+
m = _re_rfc3339.fullmatch(s.strip())
57+
if m is None:
58+
raise ValueError(
59+
f"Invalid RFC3339 datetime: {s!r} "
60+
"(expected YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM])"
61+
)
62+
63+
groups = m.groups()
64+
dt = [0] * 7
65+
for x in range(6):
66+
dt[x] = int(groups[x])
67+
68+
us = 0
69+
if groups[6] is not None:
70+
partial_sec = float(groups[6].replace(",", "."))
71+
us = int(MICROSEC_PER_SEC * partial_sec)
72+
73+
tz = UTC
74+
if groups[7] is not None and groups[7] not in ('Z', 'z', ' '):
75+
tz_match = _re_timezone.search(groups[7])
76+
if tz_match is None:
77+
raise ValueError(
78+
f"Invalid timezone format in RFC3339 string {s!r}: "
79+
f"timezone part {groups[7]!r} does not match expected "
80+
f"format (±HH:MM)"
81+
)
82+
tz_groups = tz_match.groups()
83+
hour = int(tz_groups[1])
84+
minute = 0
85+
if tz_groups[0] == "-":
86+
hour *= -1
87+
if tz_groups[2]:
88+
minute = int(tz_groups[2])
89+
tz = TimezoneInfo(hour, minute)
90+
91+
try:
92+
return datetime.datetime(
93+
year=dt[0], month=dt[1], day=dt[2],
94+
hour=dt[3], minute=dt[4], second=dt[5],
95+
microsecond=us, tzinfo=tz)
96+
except ValueError as e:
97+
raise ValueError(
98+
f"Invalid date/time values in RFC3339 string {s!r}: {e}"
99+
) from e
100+
101+
102+
103+
def format_rfc3339(date_time):
104+
if date_time.tzinfo is None:
105+
date_time = date_time.replace(tzinfo=UTC)
106+
date_time = date_time.astimezone(UTC)
107+
return date_time.strftime('%Y-%m-%dT%H:%M:%SZ')
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Copyright 2016 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+
15+
import unittest
16+
from datetime import datetime
17+
18+
from .dateutil import UTC, TimezoneInfo, format_rfc3339, parse_rfc3339
19+
20+
21+
class DateUtilTest(unittest.TestCase):
22+
23+
def _parse_rfc3339_test(self, st, y, m, d, h, mn, s, us):
24+
actual = parse_rfc3339(st)
25+
expected = datetime(y, m, d, h, mn, s, us, UTC)
26+
self.assertEqual(expected, actual)
27+
28+
def test_parse_rfc3339(self):
29+
self._parse_rfc3339_test("2017-07-25T04:44:21Z",
30+
2017, 7, 25, 4, 44, 21, 0)
31+
self._parse_rfc3339_test("2017-07-25 04:44:21Z",
32+
2017, 7, 25, 4, 44, 21, 0)
33+
self._parse_rfc3339_test("2017-07-25T04:44:21",
34+
2017, 7, 25, 4, 44, 21, 0)
35+
self._parse_rfc3339_test("2017-07-25T04:44:21z",
36+
2017, 7, 25, 4, 44, 21, 0)
37+
self._parse_rfc3339_test("2017-07-25T04:44:21+03:00",
38+
2017, 7, 25, 1, 44, 21, 0)
39+
self._parse_rfc3339_test("2017-07-25T04:44:21-03:00",
40+
2017, 7, 25, 7, 44, 21, 0)
41+
42+
self._parse_rfc3339_test("2017-07-25T04:44:21,005Z",
43+
2017, 7, 25, 4, 44, 21, 5000)
44+
self._parse_rfc3339_test("2017-07-25T04:44:21.005Z",
45+
2017, 7, 25, 4, 44, 21, 5000)
46+
self._parse_rfc3339_test("2017-07-25 04:44:21.0050Z",
47+
2017, 7, 25, 4, 44, 21, 5000)
48+
self._parse_rfc3339_test("2017-07-25T04:44:21.5",
49+
2017, 7, 25, 4, 44, 21, 500000)
50+
self._parse_rfc3339_test("2017-07-25T04:44:21.005z",
51+
2017, 7, 25, 4, 44, 21, 5000)
52+
self._parse_rfc3339_test("2017-07-25T04:44:21.005+03:00",
53+
2017, 7, 25, 1, 44, 21, 5000)
54+
self._parse_rfc3339_test("2017-07-25T04:44:21.005-03:00",
55+
2017, 7, 25, 7, 44, 21, 5000)
56+
57+
def test_format_rfc3339(self):
58+
self.assertEqual(
59+
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0, UTC)),
60+
"2017-07-25T04:44:21Z")
61+
self.assertEqual(
62+
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0,
63+
TimezoneInfo(2, 0))),
64+
"2017-07-25T02:44:21Z")
65+
self.assertEqual(
66+
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0,
67+
TimezoneInfo(-2, 30))),
68+
"2017-07-25T07:14:21Z")
69+
70+
def test_parse_rfc3339_invalid_formats(self):
71+
"""Test that invalid RFC3339 formats raise ValueError"""
72+
invalid_inputs = [
73+
"2025-13-02T13:37:00Z", # Invalid month
74+
"2025-12-32T13:37:00Z", # Invalid day
75+
"2025-12-02T25:00:00Z", # Invalid hour
76+
"2025-12-02T13:60:00Z", # Invalid minute
77+
"2025-12-02T13:37:60Z", # Invalid second
78+
"not-a-valid-date", # Completely invalid
79+
"", # Empty string
80+
"2025-12-02Z13:37:00", # Timezone before time
81+
]
82+
83+
for invalid_input in invalid_inputs:
84+
with self.assertRaises(ValueError):
85+
parse_rfc3339(invalid_input)
86+
87+
88+
89+
def test_parse_rfc3339_with_whitespace(self):
90+
"""Test that leading/trailing whitespace is handled"""
91+
actual = parse_rfc3339(" 2017-07-25T04:44:21Z ")
92+
expected = datetime(2017, 7, 25, 4, 44, 21, 0, UTC)
93+
self.assertEqual(expected, actual)
94+
95+
def test_parse_rfc3339_error_message_clarity(self):
96+
"""Test that error messages are clear and helpful"""
97+
try:
98+
parse_rfc3339("invalid-date-format")
99+
except ValueError as e:
100+
error_msg = str(e)
101+
# Verify error message contains helpful information
102+
self.assertIn("Invalid RFC3339", error_msg)
103+
self.assertIn("YYYY-MM-DD", error_msg)
104+
self.assertIn("expected", error_msg)
105+
106+
def test_parse_rfc3339_handles_none_from_timezone_regex(self):
107+
"""Test parse_rfc3339 handles timezone regex returning None.
108+
109+
This test addresses the GitHub issue where parse_rfc3339 was
110+
calling .groups() on None when the timezone regex failed to match,
111+
causing: 'NoneType' object has no attribute 'groups'
112+
113+
The fix adds a check to ensure _re_timezone.search() result is
114+
not None before calling .groups(), and provides a clear error
115+
message.
116+
"""
117+
# The main RFC3339 regex allows space in timezone position: [zZ ]
118+
# If a space ends up in groups[7], it should be handled gracefully
119+
# Since the current code uses strip(), trailing spaces are removed,
120+
# but the fix ensures robustness for any edge case
121+
122+
# Test that space in timezone is treated as UTC (like Z/z)
123+
actual = parse_rfc3339("2017-07-25 04:44:21")
124+
expected = datetime(2017, 7, 25, 4, 44, 21, 0, UTC)
125+
self.assertEqual(expected, actual)

0 commit comments

Comments
 (0)