Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,12 @@ def _register_job_subgroup(
cli: NemoCLI | None = None,
) -> None:
"""Register a ``<job-name>`` sub-group with run / submit / explain verbs."""
if not job_cls.generate_legacy_verbs:
_add_submit_command(cli_app, job_cls, scheduler, cli=cli, command_name=job_cls.name, rich_help_panel="Jobs")
if cli is not None:
cli.update_job_cli(job_cls, cli_app)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.

job_group = typer.Typer(
name=job_cls.name,
help=job_cls.description or f"Manage the {job_cls.name} job.",
Expand Down Expand Up @@ -441,7 +447,7 @@ def _do_run() -> Any:
renderer.on_complete(ctx=rctx)

help_text = "Run locally, in-process."
_run.__signature__ = _build_job_run_signature(leaves) # type: ignore[attr-defined]
setattr(_run, "__signature__", _build_job_run_signature(leaves))
group.command(name="run", help=help_text)(_run)


Expand Down Expand Up @@ -530,6 +536,8 @@ def _add_submit_command(
scheduler: NemoJobScheduler,
*,
cli: NemoCLI | None = None,
command_name: str = "submit",
rich_help_panel: str | None = None,
) -> None:
"""Register the ``submit`` verb. Generates per-field flags + static submit flags.

Expand Down Expand Up @@ -618,8 +626,8 @@ def _do_submit() -> Any:
renderer.on_complete(ctx=rctx)

help_text = "Submit to a cluster."
_submit.__signature__ = _build_job_submit_signature(leaves) # type: ignore[attr-defined]
group.command(name="submit", help=help_text)(_submit)
setattr(_submit, "__signature__", _build_job_submit_signature(leaves))
group.command(name=command_name, help=help_text, rich_help_panel=rich_help_panel)(_submit)


def _build_job_submit_signature(leaves: list[SpecLeafField]) -> inspect.Signature:
Expand Down Expand Up @@ -866,6 +874,12 @@ def _register_function_subgroup(
cli: NemoCLI | None = None,
) -> None:
"""Register a ``<fn-name>`` sub-group with run / submit verbs."""
if not fn_cls.generate_legacy_verbs:
_add_function_submit_command(cli_app, fn_cls, cli=cli, command_name=fn_cls.name, rich_help_panel="Functions")
if cli is not None:
cli.update_function_cli(fn_cls, cli_app)
return

fn_group = typer.Typer(
name=fn_cls.name,
help=fn_cls.description or f"Manage the {fn_cls.name} function.",
Expand Down Expand Up @@ -960,7 +974,7 @@ def _run(typer_ctx: typer.Context, **kwargs: object) -> None:

help_text = f"Run {fn_cls.name} locally, in-process."
epilog = build_epilog(schema=fn_cls.spec_schema, leaves=leaves, kind="Function")
_run.__signature__ = _build_function_run_signature(leaves) # type: ignore[attr-defined]
setattr(_run, "__signature__", _build_function_run_signature(leaves))
group.command(name="run", help=help_text, epilog=epilog)(_run)


Expand Down Expand Up @@ -1102,6 +1116,8 @@ def _add_function_submit_command(
fn_cls: type[NemoFunction],
*,
cli: NemoCLI | None = None,
command_name: str = "submit",
rich_help_panel: str | None = None,
) -> None:
"""Register the ``submit`` verb. Generates per-field flags + static submit flags.

Expand All @@ -1113,12 +1129,12 @@ def _add_function_submit_command(

def _submit(typer_ctx: typer.Context, **kwargs: object) -> None:
original_kwargs = dict(kwargs)
spec_str: str = kwargs.pop("spec", "{}") # type: ignore[assignment]
spec_file: Path | None = kwargs.pop("spec_file", None) # type: ignore[assignment]
cluster: str | None = kwargs.pop("cluster", None) # type: ignore[assignment]
base_url: str | None = kwargs.pop("base_url", None) # type: ignore[assignment]
workspace: str = kwargs.pop("workspace", "default") # type: ignore[assignment]
request_id: str | None = kwargs.pop("request_id", None) # type: ignore[assignment]
spec_str: str = cast(str, kwargs.pop("spec", "{}"))
spec_file: Path | None = cast("Path | None", kwargs.pop("spec_file", None))
cluster: str | None = cast("str | None", kwargs.pop("cluster", None))
base_url: str | None = cast("str | None", kwargs.pop("base_url", None))
workspace: str = cast(str, kwargs.pop("workspace", "default"))
request_id: str | None = cast("str | None", kwargs.pop("request_id", None))

base = _load_spec(spec_str, spec_file)
overlay = build_overlay(leaves, kwargs, unset_sentinel=UNSET)
Expand Down Expand Up @@ -1164,8 +1180,8 @@ def _submit(typer_ctx: typer.Context, **kwargs: object) -> None:

help_text = f"Submit {fn_cls.name} over HTTP."
epilog = build_epilog(schema=fn_cls.spec_schema, leaves=leaves, kind="Function")
_submit.__signature__ = _build_function_submit_signature(leaves) # type: ignore[attr-defined]
group.command(name="submit", help=help_text, epilog=epilog)(_submit)
setattr(_submit, "__signature__", _build_function_submit_signature(leaves))
group.command(name=command_name, help=help_text, epilog=epilog, rich_help_panel=rich_help_panel)(_submit)


def _build_function_submit_signature(leaves: list[SpecLeafField]) -> inspect.Signature:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ class NemoFunction(_NamedPlugin, Generic[SpecT]):
relocate itself outside its plugin's URL namespace. Leave
``None`` to use the default.

.. attribute:: generate_legacy_verbs
:type: bool

Temporary CLI compatibility knob. ``True`` keeps the generated
``<function> run|submit`` command group. ``False`` registers
``<function>`` itself as the remote submit command and omits the
local ``run`` and legacy ``submit`` verbs.

Stream response start:

.. attribute:: send_headers_before_first_frame
Expand Down Expand Up @@ -188,6 +196,8 @@ class NemoFunction(_NamedPlugin, Generic[SpecT]):

endpoint: ClassVar[str | None] = None

generate_legacy_verbs: ClassVar[bool] = True

send_headers_before_first_frame: ClassVar[bool] = False

# ------------------------------------------------------------------ #
Expand Down
14 changes: 14 additions & 0 deletions packages/nemo_platform_plugin/src/nemo_platform_plugin/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ class NemoJob(_NamedPlugin):
when ``name = "train"``; ``"/metric-jobs"`` for a legacy flat
collection path.

.. attribute:: generate_legacy_verbs
:type: bool

Temporary CLI compatibility knob. ``True`` keeps the generated
``<job> run|submit|explain`` command group. ``False`` registers
``<job>`` itself as the remote submit command and omits the legacy
``run``, ``submit``, and ``explain`` verbs.

Plugin-owned options:

.. attribute:: backend_options_schemas
Expand Down Expand Up @@ -188,6 +196,12 @@ class NemoJob(_NamedPlugin):

job_collection_path: ClassVar[str | None] = None

# ------------------------------------------------------------------ #
# Temporary CLI compatibility #
# ------------------------------------------------------------------ #

generate_legacy_verbs: ClassVar[bool] = True

# ------------------------------------------------------------------ #
# Plugin-owned options (inert; see class docstring) #
# ------------------------------------------------------------------ #
Expand Down
52 changes: 52 additions & 0 deletions packages/nemo_platform_plugin/tests/test_cli_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ def run(self, config: dict) -> dict:
return {"message": f"Bye, {config.get('name', 'world')}!"}


class _FlatGreetJob(_GreetJob):
generate_legacy_verbs: ClassVar[bool] = False


class _GreetSpec(BaseModel):
name: str

Expand All @@ -75,6 +79,10 @@ async def run(self, spec: _GreetSpec) -> _GreetResponse:
return _GreetResponse(message=f"Bye, {spec.name}!")


class _FlatGreetFunction(_GreetFunction):
generate_legacy_verbs: ClassVar[bool] = False


class _NoOpCLI(NemoCLI):
"""Minimal NemoCLI subclass with no hook overrides."""

Expand Down Expand Up @@ -212,6 +220,28 @@ def custom() -> None:
bye_help = runner.invoke(app, ["bye", "--help"])
assert "custom" not in bye_help.output

def test_direct_mode_hook_can_replace_flat_job_command(self) -> None:
class _CLI(_NoOpCLI):
def update_job_cli(self, job_cls, group) -> None:
if job_cls is not _FlatGreetJob:
return
original = next(c for c in group.registered_commands if c.name == "greet").callback
assert original is not None

@group.command("greet")
def greet() -> None:
typer.echo("flat-job-replaced")

app = _app_with_jobs(_FlatGreetJob, cli=_CLI())

help_result = runner.invoke(app, ["--help"])
assert help_result.exit_code == 0
assert "greet" in help_result.output

result = runner.invoke(app, ["greet"])
assert result.exit_code == 0
assert result.output.strip() == "flat-job-replaced"


# ---------------------------------------------------------------------------
# update_function_cli
Expand Down Expand Up @@ -309,3 +339,25 @@ def custom() -> None:

bye_help = runner.invoke(app, ["bye", "--help"])
assert "custom" not in bye_help.output

def test_direct_mode_hook_can_replace_flat_function_command(self) -> None:
class _CLI(_NoOpCLI):
def update_function_cli(self, fn_cls, group) -> None:
if fn_cls is not _FlatGreetFunction:
return
original = next(c for c in group.registered_commands if c.name == "greet").callback
assert original is not None

@group.command("greet")
def greet() -> None:
typer.echo("flat-function-replaced")

app = _app_with_functions(_FlatGreetFunction, cli=_CLI())

help_result = runner.invoke(app, ["--help"])
assert help_result.exit_code == 0
assert "greet" in help_result.output

result = runner.invoke(app, ["greet"])
assert result.exit_code == 0
assert result.output.strip() == "flat-function-replaced"
98 changes: 98 additions & 0 deletions packages/nemo_platform_plugin/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ def run(self, config: dict) -> dict:
return config


class _FlatGreetJob(_GreetJob):
generate_legacy_verbs: ClassVar[bool] = False


runner = CliRunner()


Expand Down Expand Up @@ -163,6 +167,20 @@ def test_job_verb_help_text_does_not_repeat_job_name(self) -> None:
assert "Run run locally" not in result.output
assert "schemas for run" not in result.output

def test_non_legacy_job_registers_flat_submit_command(self) -> None:
app = _app_with_jobs(_FlatGreetJob)
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "greet" in result.output

help_result = runner.invoke(app, ["greet", "--help"])
assert help_result.exit_code == 0
output = _plain(help_result.output)
assert "COMMAND" not in output
assert "--base-url" in output
assert "--profile" in output
assert "explain" not in output


# ---------------------------------------------------------------------------
# Bare form — usage + non-zero exit
Expand Down Expand Up @@ -367,6 +385,40 @@ def get_sdk_context(self) -> SimpleNamespace:
assert result.exit_code == 0, result.output
assert captured["headers"] == {"Authorization": "Bearer test-token"}

def test_non_legacy_job_name_submits_remotely(self, monkeypatch) -> None:
captured: dict[str, object] = {}

def _capture(_self, job_cls, spec, *, base_url=None, workspace=None, **_kwargs) -> dict:
captured.update(
{
"job_cls": job_cls,
"spec": spec,
"base_url": base_url,
"workspace": workspace,
}
)
return {"id": "job-123"}

monkeypatch.setattr("nemo_platform_plugin.scheduler.NemoJobScheduler.submit_remote", _capture)

app = _app_with_jobs(_FlatGreetJob)
result = runner.invoke(
app,
["greet", "--spec", '{"name": "Ada"}', "--base-url", "http://platform", "--workspace", "team-alpha"],
)

assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"id": "job-123"}
assert captured == {
"job_cls": _FlatGreetJob,
"spec": {"name": "Ada"},
"base_url": "http://platform",
"workspace": "team-alpha",
}

assert runner.invoke(app, ["greet", "run"]).exit_code != 0
assert runner.invoke(app, ["greet", "submit"]).exit_code != 0


# ---------------------------------------------------------------------------
# explain verb — phase 1 MR 1.2c stubs
Expand Down Expand Up @@ -556,6 +608,10 @@ async def run(self, spec: _WorkspaceSpec, *, is_local: bool) -> dict:
return {"is_local": is_local}


class _FlatGreetFunction(_GreetFunction):
generate_legacy_verbs: ClassVar[bool] = False


def _app_with_functions(*function_classes: type[NemoFunction]) -> typer.Typer:
app = typer.Typer()

Expand Down Expand Up @@ -599,6 +655,15 @@ def test_bare_function_name_exits_non_zero(self) -> None:
result = runner.invoke(app, ["greet"])
assert result.exit_code != 0

def test_non_legacy_function_registers_flat_submit_command(self) -> None:
app = _app_with_functions(_FlatGreetFunction)
help_result = runner.invoke(app, ["greet", "--help"])
assert help_result.exit_code == 0
output = _plain(help_result.output)
assert "COMMAND" not in output
assert "--base-url" in output
assert "--request-id" in output


# ---------------------------------------------------------------------------
# Function `run` verb
Expand Down Expand Up @@ -878,6 +943,39 @@ def _fake_post(url: str, body: dict, *, headers: dict, timeout: float = 30.0, **
assert result.exit_code == 0, result.output
assert captured_url[0].startswith("http://from-env:1234/")

def test_non_legacy_function_name_submits_remotely(self, monkeypatch) -> None:
captured: dict[str, object] = {}

def _fake_post(url: str, body: dict, *, headers: dict, timeout: float = 30.0, **_kwargs) -> None:
captured.update({"url": url, "body": body, "headers": headers})
del timeout
typer.echo(json.dumps({"message": "ok"}))

monkeypatch.setattr("nemo_platform_plugin.commands._post_function_submit", _fake_post)

app = _app_with_functions(_FlatGreetFunction)
result = runner.invoke(
app,
[
"greet",
"--spec",
'{"name": "Ada"}',
"--base-url",
"http://platform",
"--workspace",
"team-alpha",
],
)

assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"message": "ok"}
assert str(captured["url"]).startswith("http://platform/apis/")
assert str(captured["url"]).endswith("/v2/workspaces/team-alpha/greet")
assert captured["body"] == {"name": "Ada"}

assert runner.invoke(app, ["greet", "run"]).exit_code != 0
assert runner.invoke(app, ["greet", "submit"]).exit_code != 0


# ---------------------------------------------------------------------------
# Function URL derivation
Expand Down
Loading