From afc0e371356b69deb64e27ed82e7e1b177fca565 Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Tue, 4 Aug 2026 10:39:49 -0600 Subject: [PATCH] improve multi-context UX and Config.write API Signed-off-by: anastasia-nesterenko --- docs/cli/configuration.mdx | 70 +++-------- docs/cli/connect-to-deployments.mdx | 97 +++++++++++++++ docs/cli/reference.mdx | 2 +- docs/fern/versions/latest.yml | 2 + docs/get-started/setup.mdx | 2 + packages/nemo_platform_ext/README.md | 15 ++- packages/nemo_platform_ext/docs/runbook.md | 117 ++++-------------- .../nemo_platform_ext/cli/commands/auth.py | 2 +- .../nemo_platform_ext/cli/commands/config.py | 65 ++++++---- .../cli/commands/config_help.py | 19 +++ .../cli/commands/manifest_registry.py | 12 +- .../src/nemo_platform_ext/config/config.py | 68 ++++++++-- .../tests/cli/commands/test_config.py | 110 +++++++++++++++- .../tests/config/test_config.py | 92 ++++++++++++++ .../src/nemo_platform/cli/commands/auth.py | 2 +- .../src/nemo_platform/cli/commands/config.py | 65 ++++++---- .../nemo_platform/cli/commands/config_help.py | 19 +++ .../cli/commands/manifest_registry.py | 12 +- .../src/nemo_platform/config/config.py | 68 ++++++++-- .../cli/commands/test_config.py | 110 +++++++++++++++- .../nemo_platform_ext/config/test_config.py | 92 ++++++++++++++ 21 files changed, 797 insertions(+), 244 deletions(-) create mode 100644 docs/cli/connect-to-deployments.mdx create mode 100644 packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config_help.py create mode 100644 sdk/python/nemo-platform/src/nemo_platform/cli/commands/config_help.py diff --git a/docs/cli/configuration.mdx b/docs/cli/configuration.mdx index 5b724ab33a..2a3c48821a 100644 --- a/docs/cli/configuration.mdx +++ b/docs/cli/configuration.mdx @@ -1,33 +1,21 @@ --- title: "Configuration" -description: "" +description: "Reference for NeMo CLI configuration fields, precedence, telemetry, and shell completion." --- -The NeMo CLI uses a configuration file to store connection settings, credentials, and preferences. This allows you to work with multiple environments and switch between them easily. +The NeMo CLI uses a configuration file to store connection settings, credentials, and preferences for one or more contexts. -## Quick Setup +## Prerequisites -The quickest way to connect to an existing deployment is: +- Install the NeMo CLI as described in [Setup](/documentation/get-started). +- To create connections or switch contexts, follow [Connect the CLI to Deployments](/documentation/reference/cli-reference/connect-to-deployments). -```bash -# Set base URL and authenticate in one command -nemo auth login --base-url https://nmp.example.com -``` - -During interactive onboarding, `nemo setup` also offers to connect to a remote deployment when the currently configured platform is unreachable. It verifies and saves the new URL in the active context, then runs the same authentication flow. - -To configure a named context: - -```bash -nemo --context prod auth login --base-url https://nmp.prod.example.com -``` - -## Configuration File +## Configuration file The configuration is stored in `~/.config/nmp/config.yaml`. If the `XDG_CONFIG_HOME` environment variable is set, the file is stored in `$XDG_CONFIG_HOME/nmp/config.yaml` instead. You can also specify a custom location with the `NMP_CONFIG_FILE` environment variable. -## Managing Configuration +## Manage configuration -### View Configuration +### View configuration Display configuration for the current context (secrets are redacted): @@ -38,36 +26,12 @@ nemo config view Display all contexts: ```bash -nemo config view --all-contexts +nemo config view --all-contexts --output-format json ``` -### Switch Contexts +The full view includes every saved context, cluster, and user. JSON output is useful for scripts and configuration audits; omit `--output-format json` for the default table view. -Inspect the currently active context and resolved references: - -```bash -nemo config view -``` - -Or print only the current context name: - -```bash -nemo config current-context -``` - -Switch to a different context: - -```bash -nemo config use-context prod -``` - -Use a context for a single command without switching: - -```bash -nemo --context prod workspaces list -``` - -### Modify Configuration +### Modify configuration Set specific values: @@ -79,7 +43,7 @@ nemo config set --access-token - When setting an access token, you'll be prompted to enter it securely (input is hidden). -## Environment Variables +## Environment variables Environment variables override configuration file settings. This is useful for CI/CD pipelines or temporary overrides. @@ -99,13 +63,13 @@ Example: ```bash # Use a different context for this command -NMP_CURRENT_CONTEXT=prod nemo workspaces list +NMP_CURRENT_CONTEXT=production nemo workspaces list # Override base URL NMP_BASE_URL=https://nmp.example.com nemo models list ``` -## Configuration Precedence +## Configuration precedence Settings are resolved in this order (highest priority first): @@ -231,3 +195,9 @@ On your first run, the CLI writes this notice to standard error: > NeMo Platform collects anonymous usage data to improve the product. No prompts, data, or personal information leave your machine. Turn it off at any time with NEMO_TELEMETRY_ENABLED=false. The notice appears only once, and you can opt out at any time using one of the methods above. + +## Next Steps + +- [Connect the CLI to one or more deployments](/documentation/reference/cli-reference/connect-to-deployments). +- See the [full CLI reference](/documentation/reference/cli-reference/full-cli-reference) for every command and option. +- Use [CLI troubleshooting](/documentation/reference/cli-reference/troubleshooting) if configuration does not resolve as expected. diff --git a/docs/cli/connect-to-deployments.mdx b/docs/cli/connect-to-deployments.mdx new file mode 100644 index 0000000000..b08260e7fc --- /dev/null +++ b/docs/cli/connect-to-deployments.mdx @@ -0,0 +1,97 @@ +--- +title: "Connect the CLI to Deployments" +description: "Configure, authenticate, and switch between NeMo Platform deployments." +--- + +Use named CLI contexts to connect to one or more local or remote NeMo Platform deployments. + +## Prerequisites + +- Install the NeMo CLI as described in [Setup](/documentation/get-started). +- Obtain the base URL for each deployment. +- For an authenticated deployment, obtain an account that can complete its login flow. + +## Connect to a remote deployment + +Save the deployment URL in the current CLI context: + +```bash +nemo config set --base-url https://nmp.example.com +``` + +Verify the saved URL: + +```bash +nemo config view +``` + +If the deployment requires authentication, log in after configuring the URL: + +```bash +nemo auth login +``` + +You can also run `nemo setup`. During interactive onboarding, setup offers to connect to a remote deployment when the configured platform is unreachable. It verifies and saves the URL, then runs the same authentication flow. + +## Connect to multiple deployments + +A context is a user-defined name for a set of connection settings. Names such as `staging`, `production`, and `local` are labels you choose. Each context keeps its own URL, authentication credentials, workspace, and output preferences. + +Create a context for each deployment. Use `--activate` to make one of them current: + +```bash +nemo config set --context staging --base-url https://nmp.staging.example.com +nemo config set --context production --base-url https://nmp.example.com --activate +``` + +If the saved configuration has no current context, the next written context becomes current automatically. Otherwise, creating another context does not switch the current context. + +For authenticated deployments, log in to each context separately: + +```bash +nemo auth login --context staging +nemo auth login --context production +``` + +Switch the current context: + +```bash +nemo config use-context staging +``` + +Verify the switch: + +```bash +nemo config current-context +nemo config view +``` + +Inspect all saved connections: + +```bash +nemo config view --all-contexts +``` + +To target a context for one command without switching, use the global `--context` option: + +```bash +nemo --context production models list +``` + +## Connect to a local deployment + +A local platform can coexist with remote contexts: + +```bash +nemo config set --context local --base-url http://localhost:8080 +``` + + +`NMP_BASE_URL` and `NMP_CURRENT_CONTEXT` override saved configuration for all contexts. If switching contexts does not change the target deployment, unset those variables or update them for the current shell. + + +## Next Steps + +- Review the [configuration reference](/documentation/reference/cli-reference/configuration) for fields and precedence. +- Run `nemo workspaces list` to verify platform access. +- Use [CLI troubleshooting](/documentation/reference/cli-reference/troubleshooting) if the connection fails. diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index fd51f9326c..cc1d86dbfc 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -140,7 +140,7 @@ For quickstart, use **`--unsigned-token`** to generate an unsigned JWT. # Set base URL and log in nemo auth login --base-url https://nemo.example.com # Context-specific login -nemo auth login --context dev --base-url https://nemo.dev.example.com +nemo auth login --context staging --base-url https://nmp.staging.example.com # Device flow, open browser nemo auth login # Device flow, show code only diff --git a/docs/fern/versions/latest.yml b/docs/fern/versions/latest.yml index 719205c200..6c73561e55 100644 --- a/docs/fern/versions/latest.yml +++ b/docs/fern/versions/latest.yml @@ -489,6 +489,8 @@ navigation: - section: CLI Reference path: ../../cli/index.mdx contents: + - page: Connect to Deployments + path: ../../cli/connect-to-deployments.mdx - page: Configuration path: ../../cli/configuration.mdx - page: Working with Resources diff --git a/docs/get-started/setup.mdx b/docs/get-started/setup.mdx index fb99f940ba..1a58418048 100644 --- a/docs/get-started/setup.mdx +++ b/docs/get-started/setup.mdx @@ -91,6 +91,8 @@ Choose the remote option and enter the deployment's base URL. Setup verifies the This connects the local CLI to an existing deployment; it does not install the platform on a remote host. +To configure remote access without rerunning setup, or to keep separate local and remote targets, follow [Connect the CLI to Deployments](/documentation/reference/cli-reference/connect-to-deployments). + ### Auth and OIDC source installs `nemo setup` and `nemo services run` do not create authorization role bindings by themselves. If you enable auth in source config with `auth.enabled: true` and set `auth.admin_email`, run the platform seed task so the bootstrap IAM bindings exist. diff --git a/packages/nemo_platform_ext/README.md b/packages/nemo_platform_ext/README.md index 0a364e57b1..ace953e85d 100644 --- a/packages/nemo_platform_ext/README.md +++ b/packages/nemo_platform_ext/README.md @@ -50,13 +50,13 @@ The CLI reads configuration from `~/.config/nmp/config.yaml`: current_context: production clusters: - - name: prod-cluster + - name: production-cluster base_url: https://nmp.example.com - name: local base_url: http://localhost:8080 users: - - name: prod-admin + - name: production-admin type: api-key api_key: your-api-key-here - name: local-user @@ -64,8 +64,8 @@ users: contexts: - name: production - cluster: prod-cluster - user: prod-admin + cluster: production-cluster + user: production-admin workspace: default preferences: output_format: table @@ -85,8 +85,7 @@ contexts: Configure a context with `nemo config set`: ```bash -nemo config set --base-url https://nmp.example.com -nemo config set --context prod --base-url https://nmp.prod.example.com --activate +nemo config set --context production --base-url https://nmp.example.com --activate ``` ### Priority @@ -146,10 +145,10 @@ nemo config set --api-key YOUR_API_KEY nemo config set --workspace my-workspace --output-format json # Configure and activate a named context in one step -nemo config set --context prod --base-url https://api.prod.example.com --activate +nemo config set --context production --base-url https://nmp.example.com --activate # Switch to an existing context -nemo config use-context prod +nemo config use-context production ``` ## Setup (Local Development) diff --git a/packages/nemo_platform_ext/docs/runbook.md b/packages/nemo_platform_ext/docs/runbook.md index 1d1b2e1e15..4f99e3e345 100644 --- a/packages/nemo_platform_ext/docs/runbook.md +++ b/packages/nemo_platform_ext/docs/runbook.md @@ -6,36 +6,20 @@ ```bash # Set the base URL and authenticate -nemo config set --base-url https://nmp.dev.example.com +nemo config set --base-url https://nmp.staging.example.com nemo auth login # Or configure a named context -nemo config set --context prod --base-url https://nmp.prod.example.com --activate +nemo config set --context production --base-url https://nmp.example.com --activate nemo auth login ``` ### View Config ```bash -nemo config view # Full config (YAML format) -nemo config view -f json # JSON format -nemo config view --minify # Show only current context and its references -``` - -### List Resources - -```bash -# List all clusters -nemo config get-clusters -nemo config get-clusters -f json - -# List all contexts (shows current context indicator) -nemo config get-contexts -nemo config get-contexts my-context # Get specific context details - -# List all users -nemo config get-users -nemo config get-users -f json +nemo config view # Current context and its references +nemo config view -f json # Current context in JSON format +nemo config view --all-contexts # All contexts, clusters, and users ``` ### Quick Configuration @@ -57,76 +41,29 @@ nemo config set --workspace production nemo config set --output-format json --timestamp-format relative # Set values on a specific context -nemo config set --context staging --workspace dev +nemo config set --context staging --workspace staging # Activate a context while setting values -nemo config set --context prod --activate --workspace production +nemo config set --context production --activate --workspace production ``` ### Context Management ```bash -nemo config current-context # Show current context name +nemo config current-context # Show the effective current context name nemo config use-context staging # Switch to a different context -``` - -### Cluster Management - -```bash -# Create a new cluster -nemo config set-cluster my-cluster --base-url https://api.example.com - -# Update existing cluster -nemo config set-cluster my-cluster --base-url https://new-api.example.com - -# Delete a cluster (will fail if any contexts reference it) -nemo config delete-cluster my-cluster - -# To delete a cluster that is referenced by contexts: -# 1. First delete the referencing contexts or update them to reference a different cluster -nemo config delete-context my-context -# 2. Then delete the cluster -nemo config delete-cluster my-cluster -``` - -### User Management - -```bash -# Create user with access token -nemo config set-user my-user --access-token YOUR_ACCESS_TOKEN - -# Create user with secure token prompt -nemo config set-user my-user --access-token - - -# Create user without authentication -nemo config set-user anonymous --no-auth - -# Delete a user (will fail if any contexts reference it) -nemo config delete-user my-user -# To delete a user that is referenced by contexts: -# 1. First delete the referencing contexts or update them to reference a different user -nemo config set-context my-context --user different-user -# 2. Then delete the user -nemo config delete-user my-user -``` - -### Context Configuration - -```bash -# Create a new context (requires existing cluster and user) -nemo config set-context my-context --cluster my-cluster --user my-user +# Create another context without switching from the current one +nemo config set --context development --base-url https://nmp.dev.example.com -# Create context with workspace and preferences -nemo config set-context my-context --cluster my-cluster --user my-user \ - --workspace production --output-format json --page-size 50 +# Create a context and make it current +nemo config set --context production --base-url https://nmp.example.com --activate -# Update existing context -nemo config set-context my-context --workspace staging -nemo config set-context my-context --output-format table --timestamp-format relative +# Update a context without changing the current context +nemo config set --context development --workspace development -# Delete a context -nemo config delete-context my-context +# Authenticate a specific context +nemo auth login --context development ``` ## Common Operations @@ -203,24 +140,18 @@ nemo workspaces list -f csv > workspaces.csv ## Multiple Contexts ```bash -# Create cluster and user first -nemo config set-cluster prod-cluster --base-url https://api.prod.com -nemo config set-user prod-user --access-token - - -# Create production context -nemo config set-context prod --cluster prod-cluster --user prod-user - -# Create staging cluster and context -nemo config set-cluster staging-cluster --base-url https://api.stage.com -nemo config set-user staging-user --access-token - -nemo config set-context staging --cluster staging-cluster --user staging-user +# Create contexts with isolated connection settings +nemo config set --context production --base-url https://nmp.example.com --activate +nemo auth login --context production +nemo config set --context staging --base-url https://nmp.staging.example.com +nemo auth login --context staging # Switch between contexts nemo config use-context staging nemo config current-context # Shows: staging # Use context override for single command -nemo --context prod workspaces list +nemo --context production workspaces list ``` ## Environment Variables @@ -236,7 +167,7 @@ NMP_BASE_URL=http://localhost:8080 nemo workspaces list # Set defaults for session export NMP_OUTPUT_FORMAT=json -export NMP_WORKSPACE=dev +export NMP_WORKSPACE=staging ``` ## Troubleshooting @@ -249,7 +180,7 @@ Error: Base URL not specified Fix: Set via config, env var, or CLI flag: ```bash -nemo config set-cluster my-cluster --base-url https://nmp.example.com +nemo config set --base-url https://nmp.example.com # or export NMP_BASE_URL=https://nmp.example.com # or diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py index 9a2d6d009d..32a7069ef0 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py @@ -478,7 +478,7 @@ def login( # Set base URL and log in nemo auth login --base-url https://nemo.example.com # Context-specific login - nemo auth login --context dev --base-url https://nemo.dev.example.com + nemo auth login --context staging --base-url https://nmp.staging.example.com # Device flow, open browser nemo auth login # Device flow, show code only diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config.py index 3c3da4cf76..bc4d43d91e 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config.py @@ -5,10 +5,12 @@ from __future__ import annotations +import shlex from typing import Annotated import typer +from nemo_platform_ext.cli.commands.config_help import CONFIG_APP_HELP from nemo_platform_ext.cli.core.context import CLIContext from nemo_platform_ext.cli.core.errors import handle_errors from nemo_platform_ext.cli.core.formatters import format_output @@ -19,16 +21,7 @@ app = create_typer_app( name="config", - help="""\ -Manage NeMo Platform CLI configuration. - -Examples: -# Set the cluster base URL (most common first step). -nemo config set --base-url https://nmp.example.com -# View current effective configuration. -nemo config view -# Switch to a named context. -nemo config use-context dev""", + help=CONFIG_APP_HELP, ) @@ -77,18 +70,20 @@ def config_callback(ctx: typer.Context) -> None: @app.command("current-context") @handle_errors -def current_context() -> None: - """Display the current context name. +def current_context(ctx: typer.Context) -> None: + """Display the effective current context name. For full context details, use: nemo config view """ from nemo_platform_ext.config.config import Config + cli_context: CLIContext = ctx.obj config = Config.load() config_file = config.get_config_file() + context_name = cli_context.overrides.get("current_context") or config.current_context or config_file.current_context - if config_file.current_context: - typer.echo(config_file.current_context) + if context_name: + typer.echo(context_name) else: raise ConfigError("No current context set") @@ -124,7 +119,10 @@ def set_config( truncate: Annotated[bool | None, typer.Option(help="Truncate long output values")] = None, context_name: Annotated[ str | None, - typer.Option("--context", help="Context to modify (default: current context)"), + typer.Option( + "--context", + help="Context name to create or modify (user-defined label; default: current context)", + ), ] = None, activate: Annotated[ bool, @@ -132,13 +130,19 @@ def set_config( ] = False, ) -> None: """ - Set configuration values in the active or provided context. + Set configuration values in the current or specified context. - If no config file exists, creates one with a 'default' context. + If no config file exists, creates one. Without --context, uses the effective + current context, or 'default' when no current context is selected. + If the saved config has no current context, the next written context becomes + current automatically. Otherwise, creating additional contexts does not switch + the current context; use --activate or use-context. At least one option must be provided. Examples: nemo config set --base-url https://api.example.com + nemo config set --context staging --base-url https://nmp.staging.example.com + nemo config set --context production --base-url https://nmp.example.com --activate nemo config set --workspace my-workspace --output-format json nemo config set --api-key YOUR_API_KEY nemo config set --api-key - # prompts for API key securely @@ -205,9 +209,28 @@ def set_config( if activate and target_context_name: params["current_context"] = target_context_name - config = Config.write(params, context_name=target_context_name, set_current_on_create=True) - effective_name = target_context_name or config.get_config_file().current_context - typer.echo(f"Configuration updated for context '{effective_name}'") + write_result = Config.write_with_result( + params, + context_name=target_context_name, + ) + effective_name = write_result.context_name + # is_current checks the persisted file value; --activate writes current_context into params, + # so a newly-created context with --activate will already appear here as current. + is_current = write_result.config.get_config_file().current_context == effective_name + if write_result.created and is_current: + typer.echo(f"Context '{effective_name}' created and set as current") + elif write_result.created: + typer.echo(f"Context '{effective_name}' created") + context_arg = shlex.quote(effective_name) + if effective_name.startswith("-"): + context_arg = f"-- {context_arg}" + typer.echo(f"To make it current, run: nemo config use-context {context_arg}") + elif activate and has_options: + typer.echo(f"Configuration updated for context '{effective_name}' and set as current") + elif activate: + typer.echo(f"Switched to context '{effective_name}'") + else: + typer.echo(f"Configuration updated for context '{effective_name}'") @app.command("use-context") @@ -220,7 +243,7 @@ def use_context( config = Config.load() config.set_current_context(context_name) - typer.echo(f'Switched to context "{context_name}"') + typer.echo(f"Switched to context '{context_name}'") @app.command("view") diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config_help.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config_help.py new file mode 100644 index 0000000000..847aa4b30c --- /dev/null +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/config_help.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared help text for the config command group.""" + +CONFIG_APP_HELP = """\ +Manage NeMo Platform CLI configuration. + +Examples: +# Connect the current context to a remote deployment. +nemo config set --base-url https://nmp.example.com +# Keep contexts separate. +nemo config set --context staging --base-url https://nmp.staging.example.com +nemo config set --context production --base-url https://nmp.example.com --activate +# Switch the current context and inspect its configuration. +nemo config use-context staging +nemo config view + +NMP_BASE_URL and NMP_CURRENT_CONTEXT override saved configuration.""" diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py index 962452d81d..62e08ae5cf 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py @@ -3,6 +3,7 @@ from __future__ import annotations +from nemo_platform_ext.cli.commands.config_help import CONFIG_APP_HELP from nemo_platform_ext.cli.manifest import TopLevelEntry TOP_LEVEL_ENTRIES = ( @@ -15,16 +16,7 @@ ), TopLevelEntry( import_path="nemo_platform_ext.cli.commands.config:app", - help="""\ -Manage NeMo Platform CLI configuration. - -Examples: -# Set the cluster base URL (most common first step). -nemo config set --base-url https://nmp.example.com -# View current effective configuration. -nemo config view -# Switch to a named context. -nemo config use-context dev""", + help=CONFIG_APP_HELP, name="config", panel="Setup", kind="group", diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py b/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py index 341d41924d..fe819b9d97 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py @@ -10,6 +10,7 @@ import stat from dataclasses import dataclass from pathlib import Path +from typing import Generic, TypeVar import yaml from pydantic import BaseModel, Field, HttpUrl, PrivateAttr, SecretStr @@ -315,31 +316,66 @@ def write( config_path: Path | None = None, *, set_current_on_create: bool = False, + set_current_if_unset: bool = True, ) -> Self: + """Write configuration settings and return the updated configuration.""" + return cls.write_with_result( + params, + context_name=context_name, + config_path=config_path, + set_current_on_create=set_current_on_create, + set_current_if_unset=set_current_if_unset, + ).config + + @classmethod + def write_with_result( + cls, + params: ConfigParams, + context_name: str | None = None, + config_path: Path | None = None, + *, + set_current_on_create: bool = False, + set_current_if_unset: bool = True, + ) -> ConfigWriteResult[Self]: """ - Write configuration settings. Creates the config file if it doesn't exist. - If this context doesn't exist, it will be created with its own cluster and user. + Write configuration settings and describe the affected context. + + Creates the config file if it doesn't exist. If the context doesn't exist, + it will be created with its own cluster and user. Args: params: Config params to apply - context_name: Name for context to write, if not provided the active context will be used or "default" + context_name: Context to write; uses the current context or "default" when omitted config_path: Optional path override - set_current_on_create: If True, automatically switch to the context when it is newly created + set_current_on_create: If True, automatically switch to the context when it is newly created. + Superseded by set_current_if_unset for most callers; kept for backward compatibility. + set_current_if_unset: If True, use the written context when no current context is configured Returns: - Config instance with applied settings + Updated config, resolved context name, and whether the context was created """ # 1. Determine path path = config_path or cls.get_default_config_path() + context_name_was_explicit = context_name is not None # 2. Load existing or create empty if path.exists(): config = cls.load(config_path=path) - # Use resolve() so env var overrides (e.g., NMP_CURRENT_CONTEXT) are respected - context_name = context_name or config.resolve().context_name + if not context_name_was_explicit and config.current_context is not None: + available_contexts = [context.name for context in config.get_config_file().contexts] + if config.current_context not in available_contexts: + raise ValueError( + f"Context '{config.current_context}' not found. " + f"Available contexts: {', '.join(available_contexts)}" + ) + # Respect NMP_CURRENT_CONTEXT (config.current_context) before the stored value. + # Avoid config.resolve() here: it raises when contexts exist but current_context is None. + context_name = ( + context_name or config.current_context or config.get_config_file().current_context or DEFAULT_CONTEXT + ) else: config = cls.create(path, ConfigFile()) - context_name = context_name or DEFAULT_CONTEXT + context_name = context_name or config.current_context or DEFAULT_CONTEXT config_file = config._config_file @@ -352,13 +388,13 @@ def write( # 5. Set current_context if "current_context" in params: config_file.current_context = params["current_context"] - elif config_file.current_context is None or (set_current_on_create and is_new): + elif (set_current_if_unset and config_file.current_context is None) or (set_current_on_create and is_new): config_file.current_context = context_name # 6. Save config.save() - return config + return ConfigWriteResult(config=config, context_name=context_name, created=is_new) def set_current_context(self, context_name: str) -> None: """ @@ -555,6 +591,18 @@ def _create_default_config(self) -> Context: ) +_T = TypeVar("_T", bound=Config) + + +@dataclass(frozen=True) +class ConfigWriteResult(Generic[_T]): + """Result metadata for a configuration write.""" + + config: _T + context_name: str + created: bool + + def get_context( config_path: Path | None = None, overrides: ConfigParams | None = None, diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_config.py b/packages/nemo_platform_ext/tests/cli/commands/test_config.py index 516c68c4e7..4f146d5fa7 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_config.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_config.py @@ -50,6 +50,24 @@ def test_current_context(config_file: Path): assert "local" in result.stdout +def test_current_context_respects_environment_override(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "production") + + result = runner.invoke(app, "config current-context") + + assert_exit_code(result, 0) + assert result.output.strip() == "production" + + +def test_current_context_cli_flag_beats_env_var(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "local") + + result = runner.invoke(app, "--context production config current-context") + + assert_exit_code(result, 0) + assert result.output.strip() == "production" + + def test_current_context_no_config_file(tmp_path: Path, monkeypatch): monkeypatch.setenv("NMP_CONFIG_FILE", str(tmp_path / "nonexistent.yaml")) result = runner.invoke(app, "config current-context") @@ -111,7 +129,7 @@ def test_view_redacts_secrets(config_file: Path): def test_use_context(config_file: Path): result = runner.invoke(app, "config use-context production") assert_exit_code(result, 0) - assert 'Switched to context "production"' in result.stdout + assert result.output.strip() == "Switched to context 'production'" with open(config_file) as f: data = yaml.safe_load(f) @@ -171,6 +189,7 @@ def test_set_activate_requires_context(config_file: Path): def test_set_can_create_and_activate_named_context(config_file: Path): result = runner.invoke(app, "config set --context staging --base-url https://staging.example.com --activate") assert_exit_code(result, 0) + assert result.output.strip() == "Context 'staging' created and set as current" with open(config_file) as f: data = yaml.safe_load(f) @@ -182,6 +201,30 @@ def test_set_can_create_and_activate_named_context(config_file: Path): assert cluster["base_url"] == "https://staging.example.com/" +def test_set_activate_existing_context_reports_switch(config_file: Path): + result = runner.invoke(app, "config set --context production --activate") + assert_exit_code(result, 0) + assert result.output.strip() == "Switched to context 'production'" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "production" + + +def test_set_update_and_activate_existing_context_reports_both_actions(config_file: Path): + result = runner.invoke(app, "config set --context production --workspace updated-ws --activate") + assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production' and set as current" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "production" + production = next(context for context in data["contexts"] if context["name"] == "production") + assert production["workspace"] == "updated-ws" + + def test_set_creates_isolated_cluster_per_context(config_file: Path): """Creating two contexts via config set should produce distinct clusters.""" runner.invoke(app, "config set --context alpha --base-url https://alpha.example.com") @@ -222,21 +265,51 @@ def test_set_base_url_does_not_affect_other_contexts(config_file: Path): assert a_cluster["base_url"] == "https://a-updated.example.com/" -def test_set_new_context_auto_activates(config_file: Path): - """Creating a new context via config set should auto-switch to it.""" +def test_set_new_named_context_does_not_switch(config_file: Path): result = runner.invoke(app, "config set --context new-ctx --base-url https://new.example.com") assert_exit_code(result, 0) + assert result.output.strip() == ( + "Context 'new-ctx' created\nTo make it current, run: nemo config use-context new-ctx" + ) with open(config_file) as f: data = yaml.safe_load(f) - assert data["current_context"] == "new-ctx" + assert data["current_context"] == "local" + + +def test_set_first_named_context_becomes_current(tmp_path: Path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + result = runner.invoke(app, "config set --context staging --base-url https://staging.example.com") + assert_exit_code(result, 0) + assert result.output.strip() == "Context 'staging' created and set as current" + + with open(config_path) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "staging" + + +def test_set_new_context_hint_handles_leading_hyphen(config_file: Path): + _ = config_file # fixture sets up existing config so use-context below can succeed + result = runner.invoke( + app, + ["config", "set", "--context", "-qa", "--base-url", "https://qa.example.com"], + ) + assert_exit_code(result, 0) + assert "To make it current, run: nemo config use-context -- -qa" in result.output + + switch_result = runner.invoke(app, ["config", "use-context", "--", "-qa"]) + assert_exit_code(switch_result, 0) def test_set_existing_context_does_not_switch(config_file: Path): """Modifying an existing context should not change the current context.""" result = runner.invoke(app, "config set --context production --workspace updated-ws") assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production'" with open(config_file) as f: data = yaml.safe_load(f) @@ -244,6 +317,35 @@ def test_set_existing_context_does_not_switch(config_file: Path): assert data["current_context"] == "local", "Should stay on 'local', not switch to 'production'" +def test_set_reports_context_selected_by_environment(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "production") + + result = runner.invoke(app, "config set --workspace updated-ws") + assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production'" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "local" + production = next(context for context in data["contexts"] if context["name"] == "production") + assert production["workspace"] == "updated-ws" + + +def test_set_rejects_unknown_context_selected_by_environment(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + result = runner.invoke(app, "config set --workspace updated-ws") + + assert_exit_code(result, 1) + assert "Context 'prodction' not found" in result.output + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert "prodction" not in {context["name"] for context in data["contexts"]} + + def test_set_new_context_creates_dedicated_cluster_and_user(config_file: Path): """New contexts should get their own cluster and user with descriptive names.""" result = runner.invoke(app, "config set --context myctx --base-url https://my.example.com") diff --git a/packages/nemo_platform_ext/tests/config/test_config.py b/packages/nemo_platform_ext/tests/config/test_config.py index 49bc1ae069..a457b35601 100644 --- a/packages/nemo_platform_ext/tests/config/test_config.py +++ b/packages/nemo_platform_ext/tests/config/test_config.py @@ -568,6 +568,85 @@ def test_config_file_env_var_not_found(self, tmp_path: Path, monkeypatch: pytest class TestConfigWrite: """Test Config.write() method for creating/updating config files.""" + def test_write_with_result_reports_context_and_creation(self, tmp_path: Path): + config_path = tmp_path / "config.yaml" + + created = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=config_path, + ) + + assert created.context_name == "staging" + assert created.created is True + assert created.config.get_config_file().current_context == "staging" + + updated = Config.write_with_result( + {"workspace": "updated-workspace"}, + context_name="staging", + config_path=config_path, + ) + + assert updated.context_name == "staging" + assert updated.created is False + assert updated.config.resolve().workspace == "updated-workspace" + + def test_write_can_leave_current_context_unset(self, tmp_path: Path): + result = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=tmp_path / "config.yaml", + set_current_if_unset=False, + ) + + assert result.created is True + assert result.config.get_config_file().current_context is None + + def test_write_without_context_name_falls_back_to_default_when_current_context_unset(self, tmp_path: Path): + config_path = tmp_path / "config.yaml" + # Create a named context without activating it (current_context stays None). + Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=config_path, + set_current_if_unset=False, + ) + assert Config.load(config_path=config_path).get_config_file().current_context is None + + # A subsequent write without context_name must not raise (regression: config.resolve() raised + # ValueError when contexts existed but current_context was None). It targets DEFAULT_CONTEXT. + result = Config.write_with_result({"base_url": "https://default.example.com"}, config_path=config_path) + + assert result.context_name == "default" + + def test_write_rejects_unknown_context_selected_by_environment( + self, temp_config_file: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + with pytest.raises( + ValueError, + match="Context 'prodction' not found. Available contexts: production, development, local", + ): + Config.write_with_result({"workspace": "updated-workspace"}, config_path=temp_config_file) + + config = Config.load(config_path=temp_config_file) + assert "prodction" not in {context.name for context in config.get_config_file().contexts} + + def test_write_allows_explicit_context_when_environment_selects_unknown_context( + self, temp_config_file: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + result = Config.write_with_result( + {"base_url": "https://new.example.com", "workspace": "new-workspace"}, + context_name="new-context", + config_path=temp_config_file, + ) + + assert result.context_name == "new-context" + assert result.created is True + def test_write_creates_new_config_file(self, tmp_path: Path): """Test that write() creates a new config file when none exists.""" config_path = tmp_path / "new_config.yaml" @@ -833,6 +912,19 @@ def test_write_without_context_name_uses_default_when_no_file_exists(self, tmp_p assert len(config_file.contexts) == 1 assert config_file.contexts[0].name == "default" + def test_first_write_respects_current_context_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + config_path = tmp_path / "new_config.yaml" + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "staging") + + result = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + config_path=config_path, + ) + + assert result.context_name == "staging" + assert result.config.get_config_file().current_context == "staging" + assert result.config.resolve().context_name == "staging" + class TestConfigFilePermissions: """Test that config files are created with secure permissions.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py index e2b7abf350..821ed27fa7 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py @@ -478,7 +478,7 @@ def login( # Set base URL and log in nemo auth login --base-url https://nemo.example.com # Context-specific login - nemo auth login --context dev --base-url https://nemo.dev.example.com + nemo auth login --context staging --base-url https://nmp.staging.example.com # Device flow, open browser nemo auth login # Device flow, show code only diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config.py index a071643574..6efbfda045 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config.py @@ -5,10 +5,12 @@ from __future__ import annotations +import shlex from typing import Annotated import typer +from nemo_platform.cli.commands.config_help import CONFIG_APP_HELP from nemo_platform.cli.core.context import CLIContext from nemo_platform.cli.core.errors import handle_errors from nemo_platform.cli.core.formatters import format_output @@ -19,16 +21,7 @@ app = create_typer_app( name="config", - help="""\ -Manage NeMo Platform CLI configuration. - -Examples: -# Set the cluster base URL (most common first step). -nemo config set --base-url https://nmp.example.com -# View current effective configuration. -nemo config view -# Switch to a named context. -nemo config use-context dev""", + help=CONFIG_APP_HELP, ) @@ -77,18 +70,20 @@ def config_callback(ctx: typer.Context) -> None: @app.command("current-context") @handle_errors -def current_context() -> None: - """Display the current context name. +def current_context(ctx: typer.Context) -> None: + """Display the effective current context name. For full context details, use: nemo config view """ from nemo_platform.config.config import Config + cli_context: CLIContext = ctx.obj config = Config.load() config_file = config.get_config_file() + context_name = cli_context.overrides.get("current_context") or config.current_context or config_file.current_context - if config_file.current_context: - typer.echo(config_file.current_context) + if context_name: + typer.echo(context_name) else: raise ConfigError("No current context set") @@ -124,7 +119,10 @@ def set_config( truncate: Annotated[bool | None, typer.Option(help="Truncate long output values")] = None, context_name: Annotated[ str | None, - typer.Option("--context", help="Context to modify (default: current context)"), + typer.Option( + "--context", + help="Context name to create or modify (user-defined label; default: current context)", + ), ] = None, activate: Annotated[ bool, @@ -132,13 +130,19 @@ def set_config( ] = False, ) -> None: """ - Set configuration values in the active or provided context. + Set configuration values in the current or specified context. - If no config file exists, creates one with a 'default' context. + If no config file exists, creates one. Without --context, uses the effective + current context, or 'default' when no current context is selected. + If the saved config has no current context, the next written context becomes + current automatically. Otherwise, creating additional contexts does not switch + the current context; use --activate or use-context. At least one option must be provided. Examples: nemo config set --base-url https://api.example.com + nemo config set --context staging --base-url https://nmp.staging.example.com + nemo config set --context production --base-url https://nmp.example.com --activate nemo config set --workspace my-workspace --output-format json nemo config set --api-key YOUR_API_KEY nemo config set --api-key - # prompts for API key securely @@ -205,9 +209,28 @@ def set_config( if activate and target_context_name: params["current_context"] = target_context_name - config = Config.write(params, context_name=target_context_name, set_current_on_create=True) - effective_name = target_context_name or config.get_config_file().current_context - typer.echo(f"Configuration updated for context '{effective_name}'") + write_result = Config.write_with_result( + params, + context_name=target_context_name, + ) + effective_name = write_result.context_name + # is_current checks the persisted file value; --activate writes current_context into params, + # so a newly-created context with --activate will already appear here as current. + is_current = write_result.config.get_config_file().current_context == effective_name + if write_result.created and is_current: + typer.echo(f"Context '{effective_name}' created and set as current") + elif write_result.created: + typer.echo(f"Context '{effective_name}' created") + context_arg = shlex.quote(effective_name) + if effective_name.startswith("-"): + context_arg = f"-- {context_arg}" + typer.echo(f"To make it current, run: nemo config use-context {context_arg}") + elif activate and has_options: + typer.echo(f"Configuration updated for context '{effective_name}' and set as current") + elif activate: + typer.echo(f"Switched to context '{effective_name}'") + else: + typer.echo(f"Configuration updated for context '{effective_name}'") @app.command("use-context") @@ -220,7 +243,7 @@ def use_context( config = Config.load() config.set_current_context(context_name) - typer.echo(f'Switched to context "{context_name}"') + typer.echo(f"Switched to context '{context_name}'") @app.command("view") diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config_help.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config_help.py new file mode 100644 index 0000000000..847aa4b30c --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/config_help.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared help text for the config command group.""" + +CONFIG_APP_HELP = """\ +Manage NeMo Platform CLI configuration. + +Examples: +# Connect the current context to a remote deployment. +nemo config set --base-url https://nmp.example.com +# Keep contexts separate. +nemo config set --context staging --base-url https://nmp.staging.example.com +nemo config set --context production --base-url https://nmp.example.com --activate +# Switch the current context and inspect its configuration. +nemo config use-context staging +nemo config view + +NMP_BASE_URL and NMP_CURRENT_CONTEXT override saved configuration.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py index 88a718a0df..5bbd13c92a 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py @@ -3,6 +3,7 @@ from __future__ import annotations +from nemo_platform.cli.commands.config_help import CONFIG_APP_HELP from nemo_platform.cli.manifest import TopLevelEntry TOP_LEVEL_ENTRIES = ( @@ -15,16 +16,7 @@ ), TopLevelEntry( import_path="nemo_platform.cli.commands.config:app", - help="""\ -Manage NeMo Platform CLI configuration. - -Examples: -# Set the cluster base URL (most common first step). -nemo config set --base-url https://nmp.example.com -# View current effective configuration. -nemo config view -# Switch to a named context. -nemo config use-context dev""", + help=CONFIG_APP_HELP, name="config", panel="Setup", kind="group", diff --git a/sdk/python/nemo-platform/src/nemo_platform/config/config.py b/sdk/python/nemo-platform/src/nemo_platform/config/config.py index 8c5f3e4b20..d8de661194 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/config/config.py +++ b/sdk/python/nemo-platform/src/nemo_platform/config/config.py @@ -10,6 +10,7 @@ import stat from dataclasses import dataclass from pathlib import Path +from typing import Generic, TypeVar import yaml from pydantic import BaseModel, Field, HttpUrl, PrivateAttr, SecretStr @@ -315,31 +316,66 @@ def write( config_path: Path | None = None, *, set_current_on_create: bool = False, + set_current_if_unset: bool = True, ) -> Self: + """Write configuration settings and return the updated configuration.""" + return cls.write_with_result( + params, + context_name=context_name, + config_path=config_path, + set_current_on_create=set_current_on_create, + set_current_if_unset=set_current_if_unset, + ).config + + @classmethod + def write_with_result( + cls, + params: ConfigParams, + context_name: str | None = None, + config_path: Path | None = None, + *, + set_current_on_create: bool = False, + set_current_if_unset: bool = True, + ) -> ConfigWriteResult[Self]: """ - Write configuration settings. Creates the config file if it doesn't exist. - If this context doesn't exist, it will be created with its own cluster and user. + Write configuration settings and describe the affected context. + + Creates the config file if it doesn't exist. If the context doesn't exist, + it will be created with its own cluster and user. Args: params: Config params to apply - context_name: Name for context to write, if not provided the active context will be used or "default" + context_name: Context to write; uses the current context or "default" when omitted config_path: Optional path override - set_current_on_create: If True, automatically switch to the context when it is newly created + set_current_on_create: If True, automatically switch to the context when it is newly created. + Superseded by set_current_if_unset for most callers; kept for backward compatibility. + set_current_if_unset: If True, use the written context when no current context is configured Returns: - Config instance with applied settings + Updated config, resolved context name, and whether the context was created """ # 1. Determine path path = config_path or cls.get_default_config_path() + context_name_was_explicit = context_name is not None # 2. Load existing or create empty if path.exists(): config = cls.load(config_path=path) - # Use resolve() so env var overrides (e.g., NMP_CURRENT_CONTEXT) are respected - context_name = context_name or config.resolve().context_name + if not context_name_was_explicit and config.current_context is not None: + available_contexts = [context.name for context in config.get_config_file().contexts] + if config.current_context not in available_contexts: + raise ValueError( + f"Context '{config.current_context}' not found. " + f"Available contexts: {', '.join(available_contexts)}" + ) + # Respect NMP_CURRENT_CONTEXT (config.current_context) before the stored value. + # Avoid config.resolve() here: it raises when contexts exist but current_context is None. + context_name = ( + context_name or config.current_context or config.get_config_file().current_context or DEFAULT_CONTEXT + ) else: config = cls.create(path, ConfigFile()) - context_name = context_name or DEFAULT_CONTEXT + context_name = context_name or config.current_context or DEFAULT_CONTEXT config_file = config._config_file @@ -352,13 +388,13 @@ def write( # 5. Set current_context if "current_context" in params: config_file.current_context = params["current_context"] - elif config_file.current_context is None or (set_current_on_create and is_new): + elif (set_current_if_unset and config_file.current_context is None) or (set_current_on_create and is_new): config_file.current_context = context_name # 6. Save config.save() - return config + return ConfigWriteResult(config=config, context_name=context_name, created=is_new) def set_current_context(self, context_name: str) -> None: """ @@ -555,6 +591,18 @@ def _create_default_config(self) -> Context: ) +_T = TypeVar("_T", bound=Config) + + +@dataclass(frozen=True) +class ConfigWriteResult(Generic[_T]): + """Result metadata for a configuration write.""" + + config: _T + context_name: str + created: bool + + def get_context( config_path: Path | None = None, overrides: ConfigParams | None = None, diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_config.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_config.py index 08465e9e30..a78d85a036 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_config.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_config.py @@ -50,6 +50,24 @@ def test_current_context(config_file: Path): assert "local" in result.stdout +def test_current_context_respects_environment_override(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "production") + + result = runner.invoke(app, "config current-context") + + assert_exit_code(result, 0) + assert result.output.strip() == "production" + + +def test_current_context_cli_flag_beats_env_var(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "local") + + result = runner.invoke(app, "--context production config current-context") + + assert_exit_code(result, 0) + assert result.output.strip() == "production" + + def test_current_context_no_config_file(tmp_path: Path, monkeypatch): monkeypatch.setenv("NMP_CONFIG_FILE", str(tmp_path / "nonexistent.yaml")) result = runner.invoke(app, "config current-context") @@ -111,7 +129,7 @@ def test_view_redacts_secrets(config_file: Path): def test_use_context(config_file: Path): result = runner.invoke(app, "config use-context production") assert_exit_code(result, 0) - assert 'Switched to context "production"' in result.stdout + assert result.output.strip() == "Switched to context 'production'" with open(config_file) as f: data = yaml.safe_load(f) @@ -171,6 +189,7 @@ def test_set_activate_requires_context(config_file: Path): def test_set_can_create_and_activate_named_context(config_file: Path): result = runner.invoke(app, "config set --context staging --base-url https://staging.example.com --activate") assert_exit_code(result, 0) + assert result.output.strip() == "Context 'staging' created and set as current" with open(config_file) as f: data = yaml.safe_load(f) @@ -182,6 +201,30 @@ def test_set_can_create_and_activate_named_context(config_file: Path): assert cluster["base_url"] == "https://staging.example.com/" +def test_set_activate_existing_context_reports_switch(config_file: Path): + result = runner.invoke(app, "config set --context production --activate") + assert_exit_code(result, 0) + assert result.output.strip() == "Switched to context 'production'" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "production" + + +def test_set_update_and_activate_existing_context_reports_both_actions(config_file: Path): + result = runner.invoke(app, "config set --context production --workspace updated-ws --activate") + assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production' and set as current" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "production" + production = next(context for context in data["contexts"] if context["name"] == "production") + assert production["workspace"] == "updated-ws" + + def test_set_creates_isolated_cluster_per_context(config_file: Path): """Creating two contexts via config set should produce distinct clusters.""" runner.invoke(app, "config set --context alpha --base-url https://alpha.example.com") @@ -222,21 +265,51 @@ def test_set_base_url_does_not_affect_other_contexts(config_file: Path): assert a_cluster["base_url"] == "https://a-updated.example.com/" -def test_set_new_context_auto_activates(config_file: Path): - """Creating a new context via config set should auto-switch to it.""" +def test_set_new_named_context_does_not_switch(config_file: Path): result = runner.invoke(app, "config set --context new-ctx --base-url https://new.example.com") assert_exit_code(result, 0) + assert result.output.strip() == ( + "Context 'new-ctx' created\nTo make it current, run: nemo config use-context new-ctx" + ) with open(config_file) as f: data = yaml.safe_load(f) - assert data["current_context"] == "new-ctx" + assert data["current_context"] == "local" + + +def test_set_first_named_context_becomes_current(tmp_path: Path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + result = runner.invoke(app, "config set --context staging --base-url https://staging.example.com") + assert_exit_code(result, 0) + assert result.output.strip() == "Context 'staging' created and set as current" + + with open(config_path) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "staging" + + +def test_set_new_context_hint_handles_leading_hyphen(config_file: Path): + _ = config_file # fixture sets up existing config so use-context below can succeed + result = runner.invoke( + app, + ["config", "set", "--context", "-qa", "--base-url", "https://qa.example.com"], + ) + assert_exit_code(result, 0) + assert "To make it current, run: nemo config use-context -- -qa" in result.output + + switch_result = runner.invoke(app, ["config", "use-context", "--", "-qa"]) + assert_exit_code(switch_result, 0) def test_set_existing_context_does_not_switch(config_file: Path): """Modifying an existing context should not change the current context.""" result = runner.invoke(app, "config set --context production --workspace updated-ws") assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production'" with open(config_file) as f: data = yaml.safe_load(f) @@ -244,6 +317,35 @@ def test_set_existing_context_does_not_switch(config_file: Path): assert data["current_context"] == "local", "Should stay on 'local', not switch to 'production'" +def test_set_reports_context_selected_by_environment(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "production") + + result = runner.invoke(app, "config set --workspace updated-ws") + assert_exit_code(result, 0) + assert result.output.strip() == "Configuration updated for context 'production'" + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert data["current_context"] == "local" + production = next(context for context in data["contexts"] if context["name"] == "production") + assert production["workspace"] == "updated-ws" + + +def test_set_rejects_unknown_context_selected_by_environment(config_file: Path, monkeypatch): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + result = runner.invoke(app, "config set --workspace updated-ws") + + assert_exit_code(result, 1) + assert "Context 'prodction' not found" in result.output + + with open(config_file) as f: + data = yaml.safe_load(f) + + assert "prodction" not in {context["name"] for context in data["contexts"]} + + def test_set_new_context_creates_dedicated_cluster_and_user(config_file: Path): """New contexts should get their own cluster and user with descriptive names.""" result = runner.invoke(app, "config set --context myctx --base-url https://my.example.com") diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py index 47391148e1..09f7dc46d8 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py @@ -568,6 +568,85 @@ def test_config_file_env_var_not_found(self, tmp_path: Path, monkeypatch: pytest class TestConfigWrite: """Test Config.write() method for creating/updating config files.""" + def test_write_with_result_reports_context_and_creation(self, tmp_path: Path): + config_path = tmp_path / "config.yaml" + + created = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=config_path, + ) + + assert created.context_name == "staging" + assert created.created is True + assert created.config.get_config_file().current_context == "staging" + + updated = Config.write_with_result( + {"workspace": "updated-workspace"}, + context_name="staging", + config_path=config_path, + ) + + assert updated.context_name == "staging" + assert updated.created is False + assert updated.config.resolve().workspace == "updated-workspace" + + def test_write_can_leave_current_context_unset(self, tmp_path: Path): + result = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=tmp_path / "config.yaml", + set_current_if_unset=False, + ) + + assert result.created is True + assert result.config.get_config_file().current_context is None + + def test_write_without_context_name_falls_back_to_default_when_current_context_unset(self, tmp_path: Path): + config_path = tmp_path / "config.yaml" + # Create a named context without activating it (current_context stays None). + Config.write_with_result( + {"base_url": "https://staging.example.com"}, + context_name="staging", + config_path=config_path, + set_current_if_unset=False, + ) + assert Config.load(config_path=config_path).get_config_file().current_context is None + + # A subsequent write without context_name must not raise (regression: config.resolve() raised + # ValueError when contexts existed but current_context was None). It targets DEFAULT_CONTEXT. + result = Config.write_with_result({"base_url": "https://default.example.com"}, config_path=config_path) + + assert result.context_name == "default" + + def test_write_rejects_unknown_context_selected_by_environment( + self, temp_config_file: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + with pytest.raises( + ValueError, + match="Context 'prodction' not found. Available contexts: production, development, local", + ): + Config.write_with_result({"workspace": "updated-workspace"}, config_path=temp_config_file) + + config = Config.load(config_path=temp_config_file) + assert "prodction" not in {context.name for context in config.get_config_file().contexts} + + def test_write_allows_explicit_context_when_environment_selects_unknown_context( + self, temp_config_file: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "prodction") + + result = Config.write_with_result( + {"base_url": "https://new.example.com", "workspace": "new-workspace"}, + context_name="new-context", + config_path=temp_config_file, + ) + + assert result.context_name == "new-context" + assert result.created is True + def test_write_creates_new_config_file(self, tmp_path: Path): """Test that write() creates a new config file when none exists.""" config_path = tmp_path / "new_config.yaml" @@ -833,6 +912,19 @@ def test_write_without_context_name_uses_default_when_no_file_exists(self, tmp_p assert len(config_file.contexts) == 1 assert config_file.contexts[0].name == "default" + def test_first_write_respects_current_context_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + config_path = tmp_path / "new_config.yaml" + monkeypatch.setenv("NMP_CURRENT_CONTEXT", "staging") + + result = Config.write_with_result( + {"base_url": "https://staging.example.com"}, + config_path=config_path, + ) + + assert result.context_name == "staging" + assert result.config.get_config_file().current_context == "staging" + assert result.config.resolve().context_name == "staging" + class TestConfigFilePermissions: """Test that config files are created with secure permissions."""