66"failed to validate project: invalid_argument: id is required". That surfaced as a
77RuntimeSystemError and was reported to Sentry as an SDK crash rather than as the user's
88missing configuration.
9+
10+ The normalization lives on the click option, not in `CLIConfig.init`, because only a value
11+ typed on the command line is ambiguous. `flyte get/create/delete secret` call `init` with a
12+ literal `""` to select the org-level scope, and that has to survive -- the second class below
13+ pins it.
914"""
1015
1116from __future__ import annotations
1520import click
1621import pytest
1722
18- from flyte .cli ._common import CLIConfig
23+ from flyte .cli ._common import CLIConfig , CommandBase , blank_option_to_none
1924from flyte .config ._config import Config , TaskConfig
2025
2126
@@ -27,46 +32,98 @@ def _make_cli_config(config: Config | None = None) -> CLIConfig:
2732CONFIGURED = Config (task = TaskConfig (project = "from-config" , domain = "from-config-domain" ))
2833
2934
30- class TestBlankProjectDomain :
35+ def _invoke (args : list [str ], config : Config ) -> Config :
36+ """Run a `CommandBase` command with `args` and return the Config handed to init_from_config.
37+
38+ Goes through click so the option callback runs, which is where the blank is normalized.
39+ """
40+ captured = {}
41+
42+ @click .command (cls = CommandBase )
43+ @click .pass_context
44+ def cmd (ctx , project = None , domain = None ):
45+ CLIConfig (config = config , ctx = ctx ).init (project = project , domain = domain )
46+
47+ with patch ("flyte.cli._common.flyte" ) as mock_flyte :
48+ cmd .main (args = args , standalone_mode = False )
49+ captured ["cfg" ] = mock_flyte .init_from_config .call_args [0 ][0 ]
50+ return captured ["cfg" ]
51+
52+
53+ class TestBlankProjectDomainOnTheCommandLine :
3154 @pytest .mark .parametrize ("blank" , ["" , " " , "\t " ])
55+ def test_blank_cli_values_fall_back_to_config (self , blank ):
56+ cfg = _invoke (["--project" , blank , "--domain" , blank ], CONFIGURED )
57+ assert cfg .task .project == "from-config"
58+ assert cfg .task .domain == "from-config-domain"
59+
60+ def test_explicit_values_still_override_config (self ):
61+ cfg = _invoke (["--project" , "explicit" , "--domain" , "explicit-domain" ], CONFIGURED )
62+ assert cfg .task .project == "explicit"
63+ assert cfg .task .domain == "explicit-domain"
64+
65+ def test_padded_values_are_stripped (self ):
66+ cfg = _invoke (["--project" , " explicit " , "--domain" , " explicit-domain " ], CONFIGURED )
67+ assert cfg .task .project == "explicit"
68+ assert cfg .task .domain == "explicit-domain"
69+
70+ @pytest .mark .parametrize ("blank" , ["" , " " ])
71+ def test_blank_on_both_sides_leaves_project_unset (self , blank ):
72+ """With nothing configured either, project stays None so the guard can raise.
73+
74+ None is what `require_project_and_domain` reports as "Project must be provided",
75+ a user-kind InitializationError that is filtered out of Sentry.
76+ """
77+ cfg = _invoke (["--project" , blank , "--domain" , blank ], Config ())
78+ assert cfg .task .project is None
79+ assert cfg .task .domain is None
80+
81+ def test_omitting_the_flags_entirely_falls_back_to_config (self ):
82+ cfg = _invoke ([], CONFIGURED )
83+ assert cfg .task .project == "from-config"
84+ assert cfg .task .domain == "from-config-domain"
85+
86+ @pytest .mark .parametrize (
87+ "value, expected" ,
88+ [(None , None ), ("" , None ), (" " , None ), ("\t " , None ), ("proj" , "proj" ), (" proj " , "proj" )],
89+ )
90+ def test_callback_normalization (self , value , expected ):
91+ assert blank_option_to_none (MagicMock (spec = click .Context ), MagicMock (spec = click .Parameter ), value ) == expected
92+
93+
94+ class TestOrgLevelSecretScopeIsPreserved :
95+ """`flyte get/create/delete secret` pass project="" to mean "org level", not "unset".
96+
97+ Normalizing that blank away inside `CLIConfig.init` would make those three commands fall
98+ back to the config file's project/domain: the listing would silently change scope, and
99+ `--cluster-pool` would start failing outright, since `Secret._resolve_scope` rejects a
100+ request that carries both a cluster pool and a project/domain.
101+ """
102+
32103 @patch ("flyte.cli._common.flyte" )
33- def test_blank_cli_values_fall_back_to_config (self , mock_flyte , blank ):
104+ def test_programmatic_blank_stays_blank (self , mock_flyte ):
34105 cli = _make_cli_config (config = CONFIGURED )
35- cli .init (project = blank , domain = blank )
106+ cli .init (project = "" , domain = "" )
36107
37108 call_cfg = mock_flyte .init_from_config .call_args [0 ][0 ]
38- assert call_cfg .task .project == "from-config "
39- assert call_cfg .task .domain == "from-config-domain "
109+ assert call_cfg .task .project == ""
110+ assert call_cfg .task .domain == ""
40111
41112 @patch ("flyte.cli._common.flyte" )
42- def test_explicit_values_still_override_config (self , mock_flyte ):
113+ def test_cluster_pool_scope_check_still_sees_an_empty_scope (self , mock_flyte ):
114+ """The empty scope is what keeps the `--cluster-pool` guard from rejecting the request."""
43115 cli = _make_cli_config (config = CONFIGURED )
44- cli .init (project = "explicit " , domain = "explicit-domain " )
116+ cli .init (project = "" , domain = "" )
45117
46118 call_cfg = mock_flyte .init_from_config .call_args [0 ][0 ]
47- assert call_cfg .task .project == "explicit"
48- assert call_cfg .task .domain == "explicit-domain"
119+ assert not call_cfg .task .project
120+ assert not call_cfg .task .domain
49121
50122 @patch ("flyte.cli._common.flyte" )
51- def test_padded_values_are_stripped (self , mock_flyte ):
123+ def test_programmatic_real_values_still_pass_through (self , mock_flyte ):
52124 cli = _make_cli_config (config = CONFIGURED )
53- cli .init (project = " explicit " , domain = " explicit-domain " )
125+ cli .init (project = "explicit" , domain = "explicit-domain" )
54126
55127 call_cfg = mock_flyte .init_from_config .call_args [0 ][0 ]
56128 assert call_cfg .task .project == "explicit"
57129 assert call_cfg .task .domain == "explicit-domain"
58-
59- @pytest .mark .parametrize ("blank" , ["" , " " ])
60- @patch ("flyte.cli._common.flyte" )
61- def test_blank_on_both_sides_leaves_project_unset (self , mock_flyte , blank ):
62- """With nothing configured either, project stays None so the guard can raise.
63-
64- None is what `require_project_and_domain` reports as "Project must be provided",
65- a user-kind InitializationError that is filtered out of Sentry.
66- """
67- cli = _make_cli_config ()
68- cli .init (project = blank , domain = blank )
69-
70- call_cfg = mock_flyte .init_from_config .call_args [0 ][0 ]
71- assert call_cfg .task .project is None
72- assert call_cfg .task .domain is None
0 commit comments