Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/env*/
/python-manager/
/pythons/
/.venv/

# Can't seem to stop WiX from creating this directory...
/src/pymanager/obj
Expand Down
38 changes: 38 additions & 0 deletions src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
r"Equivalent to -V:PythonCore\3!B!<VERSION>!W!. The version must begin " +
"with a '3', platform overrides are permitted, and regular Python " +
"options may follow. The runtime will be installed if needed."),
(f"{EXE_NAME} default !B!<TAG>!W!\n",
"Set the default Python version to use when no specific version is " +
"requested. Use without !B!<TAG>!W! to show the current default."),
Comment on lines +88 to +90
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new PYMANAGER_USAGE_DOCS entry for default will make help output list the default command twice (once from PYMANAGER_USAGE_DOCS and again from the per-command loop in BaseCommand.show_usage() that appends all COMMANDS). Consider removing this tuple from PYMANAGER_USAGE_DOCS and relying on the standard USAGE_LINE/HELP_LINE registration, or update show_usage() to de-duplicate entries.

Suggested change
(f"{EXE_NAME} default !B!<TAG>!W!\n",
"Set the default Python version to use when no specific version is " +
"requested. Use without !B!<TAG>!W! to show the current default."),

Copilot uses AI. Check for mistakes.
]


Expand Down Expand Up @@ -216,6 +219,10 @@ def execute(self):
"help": ("show_help", True), # nested to avoid conflict with command
},

"default": {
"help": ("show_help", True), # nested to avoid conflict with command
},

"**first_run": {
"explicit": ("explicit", True),
},
Expand Down Expand Up @@ -260,6 +267,9 @@ def execute(self):
"enable_entrypoints": (config_bool, None),
},

"default": {
},

"first_run": {
"enabled": (config_bool, None, "env"),
"explicit": (config_bool, None),
Expand Down Expand Up @@ -1022,6 +1032,34 @@ def execute(self):
os.startfile(HELP_URL)


class DefaultCommand(BaseCommand):
CMD = "default"
HELP_LINE = ("Show or change the default Python runtime version.")
USAGE_LINE = "default !B![<TAG>]!W!"
HELP_TEXT = r"""!G!Default command!W!
Show or change the default Python version used by the system.

> py default !B![options] [<TAG>]!W!

With no arguments, shows the currently configured default Python version.
With a !B!<TAG>!W!, sets the default Python version.

!G!Examples:!W!
> py default
!W!Shows the current default Python version

> py default 3.13
!W!Sets Python 3.13 as the default version

> py default 3
!W!Sets the latest Python 3 as the default version
"""

def execute(self):
from .default_command import execute
execute(self)


def load_default_config(root):
return DefaultConfig(root)

Expand Down
116 changes: 116 additions & 0 deletions src/manage/default_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Implementation of the 'default' command to manage default Python version."""

import json
from pathlib import Path as PathlibPath

from .exceptions import ArgumentError, NoInstallsError, NoInstallFoundError
from .installs import get_installs, get_matching_install_tags
Comment on lines +3 to +7
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports: json, PathlibPath, and get_installs are not used in this module. Please remove them to keep the module minimal and avoid lint/static-analysis warnings.

Suggested change
import json
from pathlib import Path as PathlibPath
from .exceptions import ArgumentError, NoInstallsError, NoInstallFoundError
from .installs import get_installs, get_matching_install_tags
from .exceptions import ArgumentError, NoInstallsError, NoInstallFoundError
from .installs import get_matching_install_tags

Copilot uses AI. Check for mistakes.
from .logging import LOGGER
from .pathutils import Path
from .tagutils import tag_or_range


def _get_default_config_file(install_dir):
"""Get the path to the default install marker file."""
return Path(install_dir) / ".default"


def _load_default_install_id(install_dir):
"""Load the saved default install ID from the marker file."""
try:
default_file = _get_default_config_file(install_dir)
if default_file.exists():
Comment on lines +18 to +22
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_load_default_install_id() is currently unused. Either remove it, or use it in _show_current_default() so the command can report the saved default ID even when it isn’t present in the current installs list.

Copilot uses AI. Check for mistakes.
return default_file.read_text(encoding="utf-8").strip()
except Exception as e:
LOGGER.debug("Failed to load default install ID: %s", e)
return None


def _save_default_install_id(install_dir, install_id):
"""Save the default install ID to the marker file."""
try:
default_file = _get_default_config_file(install_dir)
default_file.parent.mkdir(parents=True, exist_ok=True)
default_file.write_text(install_id, encoding="utf-8")
LOGGER.info("Default Python version set to: !G!%s!W!", install_id)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logs a “Default … set” message, and _set_default_version() logs another one after calling _save_default_install_id(). Consider keeping a single user-facing log line to avoid duplicate console output.

Suggested change
LOGGER.info("Default Python version set to: !G!%s!W!", install_id)
LOGGER.debug("Saved default install ID: %s", install_id)

Copilot uses AI. Check for mistakes.
except Exception as e:
LOGGER.error("Failed to save default install ID: %s", e)
raise ArgumentError(f"Could not save default version: {e}") from e


def _show_current_default(cmd):
"""Show the currently configured default Python version."""
try:
installs = cmd.get_installs(set_default=False)
except NoInstallsError:
LOGGER.info("No Python installations found.")
return

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_show_current_default() expects cmd.get_installs() to raise NoInstallsError, but BaseCommand.get_installs() returns an empty list when there are no installs. As written, the no-installs case will print “No explicit default set” instead of “No Python installations found.” Handle if not installs: explicitly (and consider removing the unreachable except NoInstallsError:).

Suggested change
if not installs:
LOGGER.info("No Python installations found.")
return

Copilot uses AI. Check for mistakes.
# Check if there's an explicit default marked
default_install = None
for install in installs:
if install.get("default"):
default_install = install
break

if default_install:
LOGGER.print("!G!Current default:!W! %s", default_install["display-name"])
LOGGER.print(" ID: %s", default_install["id"])
LOGGER.print(" Version: %s", default_install.get("sort-version", "unknown"))
else:
LOGGER.print("!Y!No explicit default set.!W!")
LOGGER.print("Using tag-based default: !B!%s!W!", cmd.default_tag)


def _set_default_version(cmd, tag):
"""Set a specific Python version as the default."""
try:
installs = cmd.get_installs(set_default=False)
except NoInstallsError:
raise ArgumentError("No Python installations found. Install a version first with 'py install'.") from None

if not installs:
raise ArgumentError("No Python installations found. Install a version first with 'py install'.")

# Find the install matching the provided tag
try:
tag_obj = tag_or_range(tag)
except Exception as e:
raise ArgumentError(f"Invalid tag format: {tag}") from e

matching = get_matching_install_tags(
installs,
tag_obj,
default_platform=cmd.default_platform,
single_tag=False,
)

if not matching:
raise NoInstallFoundError(tag=tag)

selected_install, selected_run_for = matching[0]
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selected_run_for is assigned but never used. Rename it to _ (or otherwise use it) to avoid unused-variable warnings and make intent clearer.

Suggested change
selected_install, selected_run_for = matching[0]
selected_install = matching[0][0]

Copilot uses AI. Check for mistakes.

# Save the install ID as the default
_save_default_install_id(cmd.install_dir, selected_install["id"])

LOGGER.info("Default Python version set to: !G!%s!W! (%s)",
selected_install["display-name"],
selected_install["id"])


def execute(cmd):
"""Execute the default command."""
cmd.show_welcome()

if cmd.show_help:
cmd.help()
return

if not cmd.args:
# Show current default
_show_current_default(cmd)
else:
# Set new default
tag = " ".join(cmd.args[0:1]) # Take the first argument as the tag
_set_default_version(cmd, tag)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_set_default_version() may raise NoInstallFoundError, but execute() doesn’t handle it. Since manage.main() treats uncaught exceptions as INTERNAL ERROR, py default <TAG> on an unknown tag will look like a crash. Catch NoInstallFoundError here (log a user-facing message and raise SystemExit(1), or convert to ArgumentError) to match other commands’ error-handling patterns.

Suggested change
_set_default_version(cmd, tag)
try:
_set_default_version(cmd, tag)
except NoInstallFoundError:
LOGGER.error("No Python installation found matching tag '%s'.", tag)
raise ArgumentError(f"No Python installation found matching tag '{tag}'.") from None

Copilot uses AI. Check for mistakes.

15 changes: 15 additions & 0 deletions src/manage/installs.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,24 @@ def get_installs(
except LookupError:
LOGGER.debug("No virtual environment found")

# Check for a saved default install marker
try:
default_file = Path(install_dir) / ".default"
if default_file.exists():
default_id = default_file.read_text(encoding="utf-8").strip()
Comment on lines +120 to +124
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new .default marker behavior introduced here isn’t covered by tests. Please add a test (e.g., using patched_installs + tmp_path) that writes an install ID into <install_dir>/.default and asserts get_installs() marks the matching install with default=True (and that it affects get_install_to_run(..., tag='default') as intended).

Copilot uses AI. Check for mistakes.
LOGGER.debug("Found saved default install ID: %s", default_id)
for install in installs:
if install["id"] == default_id:
install["default"] = True
LOGGER.debug("Marked %s as default", default_id)
break
except Exception as ex:
LOGGER.debug("Could not load default install marker: %s", ex)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the exception handler, consider logging the traceback (exc_info=True) rather than only the exception message. This matches the pattern used elsewhere in this module (e.g., unmanaged installs failures) and makes diagnosing permission/decoding issues with .default easier.

Suggested change
LOGGER.debug("Could not load default install marker: %s", ex)
LOGGER.debug("Could not load default install marker: %s", ex, exc_info=True)

Copilot uses AI. Check for mistakes.

return installs



def _make_alias_key(alias):
n1, sep, n3 = alias.rpartition(".")
n2 = ""
Expand Down
59 changes: 59 additions & 0 deletions tests/test_default_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for the default command."""

import pytest
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'pytest' is not used.

Suggested change
import pytest

Copilot uses AI. Check for mistakes.
from manage import commands
from manage.exceptions import ArgumentError, NoInstallsError


def test_default_command_help(assert_log):
"""Test the default command help output."""
cmd = commands.DefaultCommand([commands.DefaultCommand.CMD, "--help"], None)
cmd.execute()
assert_log(
assert_log.skip_until(".*Default command.*"),
)


def test_default_command_no_args_no_installs(assert_log):
"""Test default command with no arguments and no installations."""
cmd = commands.DefaultCommand([commands.DefaultCommand.CMD], None)
# This should handle the case gracefully
# We expect it to either show a message about no installs or show current default
Comment on lines +17 to +21
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is non-deterministic and doesn’t assert behavior. DefaultCommand(..., root=None) may read installs from the real environment (sys.prefix/pkgs), and the test passes even if execute() produces incorrect output. Use tmp_path/monkeypatch (e.g., patched_installs) and assert on output/state so the test validates the no-installs behavior.

Copilot uses AI. Check for mistakes.
# The actual behavior depends on how get_installs works
try:
cmd.execute()
except NoInstallsError:
# This is acceptable - no installs available
pass


def test_default_command_with_invalid_tag():
"""Test default command with an invalid tag."""
cmd = commands.DefaultCommand([commands.DefaultCommand.CMD, "invalid-tag"], None)
try:
cmd.execute()
except (ArgumentError, NoInstallsError):
# Expected - no matching install found or invalid tag
pass
Comment on lines +33 to +37
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching and ignoring exceptions here makes the test unable to detect regressions (it will pass even if the command errors for the wrong reason, or if it succeeds unexpectedly). Prefer pytest.raises(...) with a specific expected exception and/or assert on the logged output.

Copilot uses AI. Check for mistakes.


def test_default_command_args_parsing():
"""Test that default command properly parses arguments."""
cmd = commands.DefaultCommand([commands.DefaultCommand.CMD, "3.13"], None)
assert cmd.args == ["3.13"]
assert cmd.show_help is False


def test_default_command_help_flag():
"""Test that --help flag is recognized."""
cmd = commands.DefaultCommand([commands.DefaultCommand.CMD, "--help"], None)
assert cmd.show_help is True


def test_default_command_class_attributes():
"""Test that DefaultCommand has required attributes."""
assert commands.DefaultCommand.CMD == "default"
assert hasattr(commands.DefaultCommand, "HELP_LINE")
assert hasattr(commands.DefaultCommand, "USAGE_LINE")
Comment on lines +53 to +57
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite doesn’t currently validate the core new behavior (writing <install_dir>/.default and having subsequent resolution respect it). Consider adding an integration-style test that runs py default <TAG>, asserts the marker file contents, and verifies the chosen default via cmd.get_install_to_run('default', ...) or cmd.get_installs().

Copilot uses AI. Check for mistakes.
assert hasattr(commands.DefaultCommand, "HELP_TEXT")
assert hasattr(commands.DefaultCommand, "execute")
Loading