Skip to content

Commit 07bb579

Browse files
committed
fix: normalize a blank --project/--domain at the option, not in init
Follow-up to the previous commit, which normalized the blank inside `CLIConfig.init`. That was too low in the stack: `flyte get secret`, `flyte create secret` and `flyte delete secret` all call `init` with a literal empty project/domain to select the *org-level* scope, and the normalization rewrote it to None, which then fell back to the config file. The listing would have silently changed scope, and `--cluster-pool` would have started failing outright, since `Secret._resolve_scope` rejects a request that carries both a cluster pool and a project/domain. Only a value typed on the command line is ambiguous, so the normalization moves to a click callback on the shared PROJECT/DOMAIN options (and on the two `flyte rerun` declares itself, which bypass those). A caller that passes project="" in Python now means it. fixes FLYTE-SDK-3A Signed-off-by: Haytham Abuelfutuh <haytham@afutuh.com>
1 parent 8294b8b commit 07bb579

3 files changed

Lines changed: 132 additions & 36 deletions

File tree

src/flyte/cli/_common.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,39 @@
3737
PREFERRED_ACCENT_COLOR = "bold #FFD700"
3838
HEADER_STYLE = f"{PREFERRED_ACCENT_COLOR} on black"
3939

40+
41+
def blank_option_to_none(_ctx: click.Context, _param: click.Parameter, value: str | None) -> str | None:
42+
"""
43+
Normalize a blank command-line value to None, i.e. "the flag was not given".
44+
45+
`flyte run --project "$PROJECT"` with `PROJECT` unset hands click an empty string. Without
46+
this the blank counts as an explicit value, overrides the config file, and travels to the
47+
backend as `id is required` (FLYTE-SDK-3A).
48+
49+
This deliberately lives on the click option rather than in `CLIConfig.init`: only a value
50+
typed on the command line is ambiguous. A caller that passes `project=""` in Python means it
51+
-- `flyte get/create/delete secret` use the empty string as the org-level scope sentinel --
52+
and must not have it rewritten.
53+
54+
Args:
55+
_ctx: The click context, unused.
56+
_param: The parameter being processed, unused.
57+
value: The raw command-line value.
58+
59+
Returns:
60+
The stripped value, or None if it was absent or blank.
61+
"""
62+
from flyte._initialize import blank_to_none
63+
64+
return blank_to_none(value)
65+
66+
4067
PROJECT_OPTION = click.Option(
4168
param_decls=["-p", "--project"],
4269
required=False,
4370
type=str,
4471
default=None,
72+
callback=blank_option_to_none,
4573
help="Project to which this command applies.",
4674
show_default=True,
4775
)
@@ -51,6 +79,7 @@
5179
required=False,
5280
type=str,
5381
default=None,
82+
callback=blank_option_to_none,
5483
help="Domain to which this command applies.",
5584
show_default=True,
5685
)
@@ -134,17 +163,15 @@ def init(
134163
images: tuple[str, ...] | None = None,
135164
sync_local_sys_paths: bool = True,
136165
):
137-
from flyte._initialize import blank_to_none
138166
from flyte.config._config import TaskConfig
139167

140168
api_key = os.getenv("FLYTE_API_KEY")
141169

142-
# A blank --project/--domain is what the shell substitutes for an unset variable
143-
# (`flyte run --project "$PROJECT"`), so treat it as "not provided" and fall back to
144-
# the config file, matching what init_from_config already does with `project or ...`.
145-
project = blank_to_none(project)
146-
domain = blank_to_none(domain)
147-
170+
# NOTE: a blank project/domain is *not* normalized here. A blank typed on the command line
171+
# is already turned into None by `blank_option_to_none`; what reaches this point as ""
172+
# came from a caller that meant it -- `flyte get/create/delete secret` pass the empty
173+
# string to select the org-level scope -- so rewriting it here would silently rescope
174+
# those commands to the config file's project/domain.
148175
task_cfg = TaskConfig(
149176
org=self.org or self.config.task.org,
150177
project=project if project is not None else self.config.task.project,

src/flyte/cli/_rerun.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,20 @@ def _parse_kv(items: Tuple[str, ...], flag: str) -> Optional[Dict[str, str]]:
3737

3838
@click.command("rerun", cls=click.RichCommand)
3939
@click.argument("run_name", required=True)
40-
@click.option("-p", "--project", default=None, help="Project for the new run (defaults to config).")
41-
@click.option("-d", "--domain", default=None, help="Domain for the new run (defaults to config).")
40+
@click.option(
41+
"-p",
42+
"--project",
43+
default=None,
44+
callback=common.blank_option_to_none,
45+
help="Project for the new run (defaults to config).",
46+
)
47+
@click.option(
48+
"-d",
49+
"--domain",
50+
default=None,
51+
callback=common.blank_option_to_none,
52+
help="Domain for the new run (defaults to config).",
53+
)
4254
@click.option("--name", default=None, help="Name for the new run (a random name is generated if unset).")
4355
@click.option("-e", "--env", "env", multiple=True, help="Env var KEY=VALUE for the new run. Repeatable.")
4456
@click.option("--label", "label", multiple=True, help="Label KEY=VALUE for the new run. Repeatable.")

tests/cli/test_blank_project_domain.py

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
"failed to validate project: invalid_argument: id is required". That surfaced as a
77
RuntimeSystemError and was reported to Sentry as an SDK crash rather than as the user's
88
missing 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

1116
from __future__ import annotations
@@ -15,7 +20,7 @@
1520
import click
1621
import pytest
1722

18-
from flyte.cli._common import CLIConfig
23+
from flyte.cli._common import CLIConfig, CommandBase, blank_option_to_none
1924
from flyte.config._config import Config, TaskConfig
2025

2126

@@ -27,46 +32,98 @@ def _make_cli_config(config: Config | None = None) -> CLIConfig:
2732
CONFIGURED = 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

Comments
 (0)