xpk 0.14.3__py3-none-any.whl → 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. integration/gcluster_a3mega_test.py +11 -0
  2. integration/gcluster_a3ultra_test.py +11 -0
  3. integration/gcluster_a4_test.py +11 -0
  4. xpk/commands/cluster.py +57 -21
  5. xpk/commands/cluster_gcluster.py +25 -5
  6. xpk/commands/cluster_gcluster_test.py +11 -2
  7. xpk/commands/cluster_test.py +233 -12
  8. xpk/commands/config.py +3 -5
  9. xpk/commands/kind.py +1 -1
  10. xpk/commands/storage.py +8 -10
  11. xpk/commands/workload.py +28 -11
  12. xpk/commands/workload_test.py +3 -3
  13. xpk/core/blueprint/blueprint_generator.py +70 -33
  14. xpk/core/blueprint/blueprint_test.py +9 -0
  15. xpk/core/capacity.py +46 -8
  16. xpk/core/capacity_test.py +32 -1
  17. xpk/core/cluster.py +37 -57
  18. xpk/core/cluster_test.py +95 -0
  19. xpk/core/commands.py +4 -10
  20. xpk/core/config.py +9 -2
  21. xpk/core/gcloud_context.py +18 -12
  22. xpk/core/gcloud_context_test.py +111 -1
  23. xpk/core/kjob.py +6 -9
  24. xpk/core/kueue_manager.py +192 -32
  25. xpk/core/kueue_manager_test.py +132 -4
  26. xpk/core/nodepool.py +21 -29
  27. xpk/core/nodepool_test.py +17 -15
  28. xpk/core/scheduling.py +16 -1
  29. xpk/core/scheduling_test.py +85 -6
  30. xpk/core/system_characteristics.py +77 -19
  31. xpk/core/system_characteristics_test.py +80 -5
  32. xpk/core/telemetry.py +263 -0
  33. xpk/core/telemetry_test.py +211 -0
  34. xpk/main.py +31 -13
  35. xpk/parser/cluster.py +48 -9
  36. xpk/parser/cluster_test.py +42 -3
  37. xpk/parser/workload.py +12 -0
  38. xpk/parser/workload_test.py +4 -4
  39. xpk/telemetry_uploader.py +29 -0
  40. xpk/templates/kueue_gke_default_topology.yaml.j2 +1 -1
  41. xpk/templates/kueue_sub_slicing_topology.yaml.j2 +3 -8
  42. xpk/utils/console.py +41 -10
  43. xpk/utils/console_test.py +106 -0
  44. xpk/utils/feature_flags.py +7 -1
  45. xpk/utils/file.py +4 -1
  46. xpk/utils/topology.py +4 -0
  47. xpk/utils/user_agent.py +35 -0
  48. xpk/utils/user_agent_test.py +44 -0
  49. xpk/utils/user_input.py +48 -0
  50. xpk/utils/user_input_test.py +92 -0
  51. xpk/utils/validation.py +0 -11
  52. xpk/utils/versions.py +31 -0
  53. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/METADATA +113 -92
  54. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/RECORD +58 -48
  55. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/WHEEL +0 -0
  56. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/entry_points.txt +0 -0
  57. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/licenses/LICENSE +0 -0
  58. {xpk-0.14.3.dist-info → xpk-0.15.0.dist-info}/top_level.txt +0 -0
xpk/utils/console.py CHANGED
@@ -16,6 +16,9 @@ limitations under the License.
16
16
 
17
17
  import sys
18
18
  from typing import NoReturn
19
+ from typing import Literal
20
+
21
+ from .execution_context import is_quiet
19
22
 
20
23
 
21
24
  def xpk_print(*args, **kwargs):
@@ -25,7 +28,7 @@ def xpk_print(*args, **kwargs):
25
28
  *args: user provided print args.
26
29
  **kwargs: user provided print args.
27
30
  """
28
- sys.stdout.write('[XPK] ')
31
+ sys.stdout.write("[XPK] ")
29
32
  print(*args, **kwargs)
30
33
  sys.stdout.flush()
31
34
 
@@ -37,20 +40,48 @@ def xpk_exit(error_code) -> NoReturn:
37
40
  error_code: If the code provided is zero, then no issues occurred.
38
41
  """
39
42
  if error_code == 0:
40
- xpk_print('Exiting XPK cleanly')
43
+ xpk_print("Exiting XPK cleanly")
41
44
  sys.exit(0)
42
45
  else:
43
- xpk_print(f'XPK failed, error code {error_code}')
46
+ xpk_print(f"XPK failed, error code {error_code}")
44
47
  sys.exit(error_code)
45
48
 
46
49
 
47
- def get_user_input(input_msg):
48
- """Function to get the user input for a prompt.
50
+ def ask_for_user_consent(
51
+ question: str, default_option: Literal["Y", "N"] = "N"
52
+ ) -> bool:
53
+ """Prompts user with the given question, asking for a yes/no answer and returns a relevant boolean.
54
+ Important: immediatelly returns `True` in quiet mode!
55
+
56
+ Example prompt for `question='Continue?'`: `[XPK] Continue? (y/N): `.
49
57
 
50
58
  Args:
51
- input_msg: message to be displayed by the prompt.
52
- Returns:
53
- True if user enter y or yes at the prompt, False otherwise.
59
+ question: The question to ask the user.
60
+ default_option: Option to use when user response is empty.
61
+ """
62
+ if is_quiet():
63
+ return True
64
+
65
+ options = "y/N" if default_option == "N" else "Y/n"
66
+ prompt = f"[XPK] {question} ({options}): "
67
+
68
+ while True:
69
+ user_input = input(prompt) or default_option
70
+ if user_input.lower() in ["yes", "y"]:
71
+ return True
72
+ elif user_input.lower() in ["no", "n"]:
73
+ return False
74
+ else:
75
+ xpk_print("Invalid input. Please enter: yes/no/y/n.")
76
+
77
+
78
+ def exit_code_to_int(exit_code: str | int | None) -> int:
79
+ """
80
+ Converts sys._ExitCode to an int value that is used to exit the program.
81
+ See more: https://github.com/python/typeshed/issues/8513#issue-1333671093
54
82
  """
55
- user_input = input(input_msg)
56
- return user_input in ('y', 'yes')
83
+ if isinstance(exit_code, int):
84
+ return int(exit_code)
85
+ if exit_code is None:
86
+ return 0
87
+ return 1
@@ -0,0 +1,106 @@
1
+ """
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from unittest.mock import MagicMock, patch
18
+ import pytest
19
+ from pytest_mock import MockerFixture
20
+
21
+ from .console import exit_code_to_int, ask_for_user_consent
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def mock_is_quiet(mocker: MockerFixture):
26
+ return mocker.patch("xpk.utils.console.is_quiet", return_value=False)
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ "user_input,expected",
31
+ [
32
+ ("yes", True),
33
+ ("y", True),
34
+ ("Y", True),
35
+ ("Yes", True),
36
+ ("YES", True),
37
+ ("no", False),
38
+ ("n", False),
39
+ ("N", False),
40
+ ("No", False),
41
+ ("NO", False),
42
+ ],
43
+ )
44
+ @patch("xpk.utils.console.input")
45
+ def test_ask_for_user_consent(mock_input: MagicMock, user_input, expected):
46
+ mock_input.return_value = user_input
47
+
48
+ assert ask_for_user_consent("Test question?") is expected
49
+
50
+
51
+ def fake_input_factory(user_inputs: list[str]):
52
+ def fake_input(prompt: str) -> str:
53
+ return user_inputs.pop(0)
54
+
55
+ return fake_input
56
+
57
+
58
+ @patch("xpk.utils.console.input", wraps=fake_input_factory(["invalid", "y"]))
59
+ def test_ask_for_user_consent_invalid_input(mock_input: MagicMock):
60
+ agreed = ask_for_user_consent("Test question?")
61
+
62
+ assert agreed is True
63
+ assert mock_input.call_count == 2
64
+
65
+
66
+ @patch("xpk.utils.console.input", return_value="")
67
+ def test_ask_for_user_consent_default_No(mock_input: MagicMock):
68
+ agreed = ask_for_user_consent("Test question?", default_option="N")
69
+
70
+ assert agreed is False
71
+ mock_input.assert_called_once_with("[XPK] Test question? (y/N): ")
72
+
73
+
74
+ @patch("xpk.utils.console.input", return_value="")
75
+ def test_ask_for_user_consent_default_Yes(mock_input: MagicMock):
76
+ agreed = ask_for_user_consent("Test question?", default_option="Y")
77
+
78
+ assert agreed is True
79
+ mock_input.assert_called_once_with("[XPK] Test question? (Y/n): ")
80
+
81
+
82
+ @patch("xpk.utils.console.input")
83
+ def test_ask_for_user_consent_with_quiet_mode_always_agrees(
84
+ mock_input: MagicMock,
85
+ mock_is_quiet: MagicMock,
86
+ ):
87
+ mock_is_quiet.return_value = True
88
+
89
+ agreed = ask_for_user_consent("Test question?", default_option="N")
90
+
91
+ assert agreed is True
92
+ mock_input.assert_not_called()
93
+
94
+
95
+ @pytest.mark.parametrize(
96
+ "exit_code,expected",
97
+ [
98
+ (0, 0),
99
+ (1, 1),
100
+ ("Error", 1),
101
+ ({"foo": "bar"}, 1),
102
+ (None, 0),
103
+ ],
104
+ )
105
+ def test_exit_code_to_int_returns_correct_value(exit_code, expected):
106
+ assert exit_code_to_int(exit_code) == expected
@@ -18,11 +18,17 @@ import os
18
18
 
19
19
 
20
20
  def _get_boolean_flag(flag: str, default: bool) -> bool:
21
- return os.getenv(flag, str(default)).lower() == "true"
21
+ experiment_value = os.getenv(flag, "").lower()
22
+ if experiment_value in ["true", "false"]:
23
+ return experiment_value == "true"
24
+
25
+ xpk_tester = os.getenv("XPK_TESTER", "").lower() == "true"
26
+ return xpk_tester or default
22
27
 
23
28
 
24
29
  class _FeatureFlags:
25
30
  SUB_SLICING_ENABLED = _get_boolean_flag("SUB_SLICING_ENABLED", default=False)
31
+ TELEMETRY_ENABLED = _get_boolean_flag("TELEMETRY_ENABLED", default=False)
26
32
 
27
33
 
28
34
  FeatureFlags = _FeatureFlags()
xpk/utils/file.py CHANGED
@@ -18,6 +18,7 @@ import tempfile
18
18
  import os
19
19
  import hashlib
20
20
  from .execution_context import is_dry_run
21
+ from .console import xpk_print
21
22
 
22
23
 
23
24
  def make_tmp_files(per_command_name: list[str]) -> list[str]:
@@ -51,7 +52,9 @@ def write_tmp_file(payload: str) -> str:
51
52
  A file object that was written to.
52
53
  """
53
54
  if is_dry_run():
54
- return _hash_filename(payload)
55
+ name = _hash_filename(payload)
56
+ xpk_print(f'Temp file ({name}) content: \n{payload}')
57
+ return name
55
58
 
56
59
  with tempfile.NamedTemporaryFile(delete=False) as tmp:
57
60
  with open(file=tmp.name, mode='w', encoding='utf=8') as f:
xpk/utils/topology.py CHANGED
@@ -44,3 +44,7 @@ def is_topology_contained(contained: str, container: str) -> bool:
44
44
  contained <= container
45
45
  for contained, container in zip(contained_parsed, container_parsed)
46
46
  )
47
+
48
+
49
+ def get_slice_topology_level(topology: str) -> str:
50
+ return f"cloud.google.com/gke-tpu-slice-{topology}-id"
@@ -0,0 +1,35 @@
1
+ """
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ import platform
18
+ from ..core.config import __version__ as xpk_version
19
+
20
+
21
+ def get_user_agent() -> str:
22
+ return f'XPK/{xpk_version} ({_get_user_agent_platform()})'
23
+
24
+
25
+ def _get_user_agent_platform() -> str:
26
+ system = platform.system().lower()
27
+ if system == 'windows':
28
+ return f'Windows NT {platform.version()}'
29
+ elif system == 'linux':
30
+ return f'Linux; {platform.machine()}'
31
+ elif system == 'darwin':
32
+ version, _, arch = platform.mac_ver()
33
+ return f'Macintosh; {arch} Mac OS X {version}'
34
+ else:
35
+ return ''
@@ -0,0 +1,44 @@
1
+ """
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from .user_agent import get_user_agent
18
+
19
+
20
+ def test_get_user_agent_returns_correct_value_for_windows(mocker):
21
+ mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
22
+ mocker.patch('platform.system', return_value='Windows')
23
+ mocker.patch('platform.version', return_value='10.0')
24
+ assert get_user_agent() == 'XPK/v1.0.0 (Windows NT 10.0)'
25
+
26
+
27
+ def test_get_user_agent_returns_correct_value_for_linux(mocker):
28
+ mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
29
+ mocker.patch('platform.system', return_value='Linux')
30
+ mocker.patch('platform.machine', return_value='x86_64')
31
+ assert get_user_agent() == 'XPK/v1.0.0 (Linux; x86_64)'
32
+
33
+
34
+ def test_get_user_agent_returns_correct_value_for_darwin(mocker):
35
+ mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
36
+ mocker.patch('platform.system', return_value='Darwin')
37
+ mocker.patch('platform.mac_ver', return_value=('10.15', '', 'x86_64'))
38
+ assert get_user_agent() == 'XPK/v1.0.0 (Macintosh; x86_64 Mac OS X 10.15)'
39
+
40
+
41
+ def test_get_user_agent_returns_correct_value_for_unknown(mocker):
42
+ mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
43
+ mocker.patch('platform.system', return_value='Unknown')
44
+ assert get_user_agent() == 'XPK/v1.0.0 ()'
@@ -0,0 +1,48 @@
1
+ """
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from typing import Literal
18
+
19
+ from .console import xpk_print
20
+ from .execution_context import is_quiet
21
+
22
+
23
+ def ask_for_user_consent(
24
+ question: str, default_option: Literal["Y", "N"] = "N"
25
+ ) -> bool:
26
+ """Prompts user with the given question, asking for a yes/no answer and returns a relevant boolean.
27
+ Important: immediatelly returns `True` in quiet mode!
28
+
29
+ Example prompt for `question='Continue?'`: `[XPK] Continue? (y/N): `.
30
+
31
+ Args:
32
+ question: The question to ask the user.
33
+ default_option: Option to use when user response is empty.
34
+ """
35
+ if is_quiet():
36
+ return True
37
+
38
+ options = "y/N" if default_option == "N" else "Y/n"
39
+ prompt = f"[XPK] {question} ({options}): "
40
+
41
+ while True:
42
+ user_input = input(prompt) or default_option
43
+ if user_input.lower() in ["yes", "y"]:
44
+ return True
45
+ elif user_input.lower() in ["no", "n"]:
46
+ return False
47
+ else:
48
+ xpk_print("Invalid input. Please enter: yes/no/y/n.")
@@ -0,0 +1,92 @@
1
+ """
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from unittest.mock import MagicMock, patch
18
+ import pytest
19
+ from pytest_mock import MockerFixture
20
+
21
+ from xpk.utils.user_input import ask_for_user_consent
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def mock_is_quiet(mocker: MockerFixture):
26
+ return mocker.patch("xpk.utils.user_input.is_quiet", return_value=False)
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ "user_input,expected",
31
+ [
32
+ ("yes", True),
33
+ ("y", True),
34
+ ("Y", True),
35
+ ("Yes", True),
36
+ ("YES", True),
37
+ ("no", False),
38
+ ("n", False),
39
+ ("N", False),
40
+ ("No", False),
41
+ ("NO", False),
42
+ ],
43
+ )
44
+ @patch("xpk.utils.user_input.input")
45
+ def test_ask_for_user_consent(mock_input: MagicMock, user_input, expected):
46
+ mock_input.return_value = user_input
47
+
48
+ assert ask_for_user_consent("Test question?") is expected
49
+
50
+
51
+ def fake_input_factory(user_inputs: list[str]):
52
+ def fake_input(prompt: str) -> str:
53
+ return user_inputs.pop(0)
54
+
55
+ return fake_input
56
+
57
+
58
+ @patch("xpk.utils.user_input.input", wraps=fake_input_factory(["invalid", "y"]))
59
+ def test_ask_for_user_consent_invalid_input(mock_input: MagicMock):
60
+ agreed = ask_for_user_consent("Test question?")
61
+
62
+ assert agreed is True
63
+ assert mock_input.call_count == 2
64
+
65
+
66
+ @patch("xpk.utils.user_input.input", return_value="")
67
+ def test_ask_for_user_consent_default_No(mock_input: MagicMock):
68
+ agreed = ask_for_user_consent("Test question?", default_option="N")
69
+
70
+ assert agreed is False
71
+ mock_input.assert_called_once_with("[XPK] Test question? (y/N): ")
72
+
73
+
74
+ @patch("xpk.utils.user_input.input", return_value="")
75
+ def test_ask_for_user_consent_default_Yes(mock_input: MagicMock):
76
+ agreed = ask_for_user_consent("Test question?", default_option="Y")
77
+
78
+ assert agreed is True
79
+ mock_input.assert_called_once_with("[XPK] Test question? (Y/n): ")
80
+
81
+
82
+ @patch("xpk.utils.user_input.input")
83
+ def test_ask_for_user_consent_with_quiet_mode_always_agrees(
84
+ mock_input: MagicMock,
85
+ mock_is_quiet: MagicMock,
86
+ ):
87
+ mock_is_quiet.return_value = True
88
+
89
+ agreed = ask_for_user_consent("Test question?", default_option="N")
90
+
91
+ assert agreed is True
92
+ mock_input.assert_not_called()
xpk/utils/validation.py CHANGED
@@ -15,10 +15,7 @@ limitations under the License.
15
15
  """
16
16
 
17
17
  from ..core.commands import run_command_for_value
18
- from ..core.config import __version__ as xpk_version
19
18
  from .console import xpk_exit, xpk_print
20
- from ..commands.config import xpk_cfg
21
- from ..core.config import DEPENDENCIES_KEY
22
19
  from enum import Enum
23
20
  from dataclasses import dataclass
24
21
 
@@ -80,14 +77,6 @@ def should_validate_dependencies(args):
80
77
  return not skip_validation and not dry_run
81
78
 
82
79
 
83
- def validate_dependencies():
84
- """Validates all system dependencies if validation has not been done with current XPK version."""
85
- deps_version = xpk_cfg.get(DEPENDENCIES_KEY)
86
- if deps_version is None or deps_version != xpk_version:
87
- validate_dependencies_list(list(SystemDependency))
88
- xpk_cfg.set(DEPENDENCIES_KEY, xpk_version)
89
-
90
-
91
80
  def validate_dependencies_list(dependencies: list[SystemDependency]):
92
81
  """Validates a list of system dependencies and returns none or exits with error."""
93
82
  for dependency in dependencies:
xpk/utils/versions.py ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ Copyright 2024 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ import enum
18
+
19
+
20
+ class ReleaseChannel(enum.Enum):
21
+ """
22
+ Represents the GKE cluster release channels.
23
+
24
+ See: https://cloud.google.com/kubernetes-engine/docs/concepts/release-channels
25
+ """
26
+
27
+ RELEASE_CHANNEL_UNSPECIFIED = "RELEASE_CHANNEL_UNSPECIFIED"
28
+ RAPID = "RAPID"
29
+ REGULAR = "REGULAR"
30
+ STABLE = "STABLE"
31
+ EXTENDED = "EXTENDED"