From cadac9e91d93e09442aee2962fa000bc4723aff6 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 26 Jun 2026 12:05:16 -0500 Subject: [PATCH 1/2] Add new commands for removing cached JDKs The clear-cache command clears everything. But I often find myself wanting to remove "stale" versions of JDKs without starting from scratch. This commit adds two new commands for doing so: * cjdk rm - to target specifically named cached JDKs * cjdk prune - to remove older JDKs when newer ones are also cached Co-authored-by: Claude Opus 4.8 --- docs/api.md | 15 +++ docs/changelog.md | 11 ++ docs/cli.md | 34 ++++++ src/cjdk/__init__.py | 6 ++ src/cjdk/__main__.py | 158 ++++++++++++++++++++++++++- src/cjdk/_api.py | 190 +++++++++++++++++++++++++++++++++ src/cjdk/_cache.py | 32 +++++- src/cjdk/_index.py | 17 +++ src/cjdk/_jdk.py | 89 +++++++++++++-- tests/test_cache_remove.py | 45 ++++++++ tests/test_prune.py | 88 +++++++++++++++ tests/test_remove_prune_api.py | 111 +++++++++++++++++++ 12 files changed, 782 insertions(+), 14 deletions(-) create mode 100644 tests/test_cache_remove.py create mode 100644 tests/test_prune.py create mode 100644 tests/test_remove_prune_api.py diff --git a/docs/api.md b/docs/api.md index 3cd79f8..5332bab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,6 +59,21 @@ install an application JAR. ## Managing the cache +```{eval-rst} +.. autofunction:: cjdk.remove_jdks +.. versionadded:: 0.6.0 +``` + +```{eval-rst} +.. autofunction:: cjdk.prune_jdks +.. versionadded:: 0.6.0 +``` + +```{eval-rst} +.. autofunction:: cjdk.cache_directory +.. versionadded:: 0.6.0 +``` + ```{eval-rst} .. autofunction:: cjdk.clear_cache .. versionadded:: 0.5.0 diff --git a/docs/changelog.md b/docs/changelog.md index 1460f57..7dc420f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,10 +10,21 @@ See also the section on [versioning](versioning-scheme). ## [Unreleased] +### Added + +- Add `cjdk rm` and `remove_jdks()` to remove specific cached JDKs. +- Add `cjdk prune` and `prune_jdks()` to remove obsolete cached JDKs, keeping + the newest of each vendor and major version (configurable with + `--per-vendor`/`--across-vendors` and `--per-major`/`--across-majors`). +- Add `cache_directory()` to the Python API. + ### Changed - `list_vendors()` and `ls-vendors` now filter vendors by OS and architecture, defaulting to the current platform. +- `cjdk clear-cache` now prompts for confirmation before deleting; pass `--yes` + to skip the prompt, or `--dry-run` to preview. `rm` and `prune` prompt the + same way. ### Removed diff --git a/docs/cli.md b/docs/cli.md index 1460ad9..dd66fc3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,6 +118,37 @@ shown was on macOS.) ## Managing the cache +### `rm` + +```{command-output} cjdk rm --help +``` + +For example, to remove all cached Zulu 26 JDKs: + +```text +$ cjdk -j zulu:26 rm +``` + +```{eval-rst} +.. versionadded:: 0.6.0 +``` + +### `prune` + +```{command-output} cjdk prune --help +``` + +For example, if both `zulu:26.0.0` and `zulu:26.0.1` are cached, the older one +is removed while the newest of each vendor and major version is kept: + +```text +$ cjdk prune +``` + +```{eval-rst} +.. versionadded:: 0.6.0 +``` + (cli-clear-cache)= ### `clear-cache` @@ -127,4 +158,7 @@ shown was on macOS.) ```{eval-rst} .. versionadded:: 0.5.0 +.. versionchanged:: 0.6.0 + Now prompts for confirmation before deleting. Pass ``--yes`` to skip the + prompt, or ``--dry-run`` to preview. ``` diff --git a/src/cjdk/__init__.py b/src/cjdk/__init__.py index 25f1769..a54f964 100644 --- a/src/cjdk/__init__.py +++ b/src/cjdk/__init__.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT from ._api import ( + cache_directory, cache_file, cache_jdk, cache_package, @@ -11,6 +12,8 @@ java_home, list_jdks, list_vendors, + prune_jdks, + remove_jdks, ) from ._exceptions import ( CjdkError, @@ -21,6 +24,7 @@ from ._version import __version__ as __version__ __all__ = [ + "cache_directory", "cache_file", "cache_jdk", "cache_package", @@ -33,4 +37,6 @@ "JdkNotFoundError", "list_jdks", "list_vendors", + "prune_jdks", + "remove_jdks", ] diff --git a/src/cjdk/__main__.py b/src/cjdk/__main__.py index 1b0814e..09aa672 100644 --- a/src/cjdk/__main__.py +++ b/src/cjdk/__main__.py @@ -285,24 +285,176 @@ def cache_package( ) +@click.command(short_help="Remove specific cached JDKs.") +@click.pass_context +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Show what would be removed, without removing anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Do not prompt for confirmation before removing.", +) +def rm(ctx: click.Context, dry_run: bool, yes: bool) -> None: + """ + Remove the cached JDKs matching the requested criteria. + + Specify which JDKs to remove using the common --jdk/-j option (together + with --os/--arch if desired); for example, 'cjdk -j zulu:26 rm' removes all + cached Zulu 26 JDKs. Only cached JDKs are removed; cached files, packages, + and the index are left untouched. + + To remove everything in the cache, use 'clear-cache' instead. To prune + obsolete JDKs while keeping the newest of each, use 'prune'. + + When removing JDKs, ensure that no other processes are using cjdk or the + affected JDKs. + + See 'cjdk --help' for the common options used to specify the JDK. + """ + if not ctx.obj.get("jdk"): + raise click.UsageError( + "Specify which JDK(s) to remove with -j VENDOR:VERSION " + "(or use 'clear-cache' to remove everything)." + ) + matched = _api.remove_jdks(**ctx.obj, dry_run=True) + if not matched: + click.echo("No matching cached JDKs.") + return + _echo_jdk_list("JDKs to remove:", matched) + if dry_run: + return + if not yes: + click.confirm(f"Remove {len(matched)} JDK(s)?", abort=True) + removed = _api.remove_jdks(**ctx.obj) + click.echo(f"Removed {len(removed)} JDK(s).") + + +@click.command(short_help="Prune obsolete cached JDKs.") +@click.pass_context +@click.option( + "--per-vendor/--across-vendors", + default=True, + help="Prune each vendor separately (default), or pool all vendors " + "together so only the newest version survives.", +) +@click.option( + "--per-major/--across-majors", + default=True, + help="Keep the newest of each major version (default), or only the single " + "newest version overall.", +) +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Show what would be removed, without removing anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Do not prompt for confirmation before removing.", +) +def prune( + ctx: click.Context, + per_vendor: bool, + per_major: bool, + dry_run: bool, + yes: bool, +) -> None: + """ + Remove obsolete cached JDKs, keeping only the newest of each. + + By default, the newest cached version of each vendor and major version is + kept, and older versions are removed. For example, if both Zulu 26.0.0 and + 26.0.1 are cached, 26.0.0 is removed. + + Use --across-vendors and/or --across-majors to widen the grouping (removing + more), and the common --jdk/-j option to limit pruning to a particular + vendor. Only cached JDKs are removed; cached files, packages, and the index + are left untouched. + + When pruning JDKs, ensure that no other processes are using cjdk or the + affected JDKs. + + See 'cjdk --help' for the common options. + """ + matched = _api.prune_jdks( + **ctx.obj, + per_vendor=per_vendor, + per_major=per_major, + dry_run=True, + ) + if not matched: + click.echo("Nothing to prune.") + return + _echo_jdk_list("JDKs to remove:", matched) + if dry_run: + return + if not yes: + click.confirm(f"Remove {len(matched)} JDK(s)?", abort=True) + removed = _api.prune_jdks( + **ctx.obj, per_vendor=per_vendor, per_major=per_major + ) + click.echo(f"Removed {len(removed)} JDK(s).") + + @click.command(short_help="Remove all cached files.") @click.pass_context -def clear_cache(ctx: click.Context) -> None: +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Show what would be removed, without removing anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Do not prompt for confirmation before removing.", +) +def clear_cache(ctx: click.Context, dry_run: bool, yes: bool) -> None: """ Remove all cached JDKs, files, and packages from the cache directory. This permanently deletes everything in the cache. Subsequent commands will - re-download any needed files. + re-download any needed files. To remove only specific JDKs, use 'rm'; to + prune obsolete JDKs, use 'prune'. + + Unless --yes is given, you are prompted to confirm before anything is + deleted. When clearing the cache, ensure that no other processes are using cjdk or the JDKs, files, or packages installed by cjdk. See 'cjdk --help' for the common options (only --cache-dir is relevant). """ + cache_dir = _api.cache_directory(**ctx.obj) + if not cache_dir.exists(): + click.echo(f"Cache directory does not exist: {cache_dir}") + return + if dry_run: + click.echo(f"Would remove entire cache directory: {cache_dir}") + return + if not yes: + click.echo("This will permanently remove the entire cache directory:") + click.echo(f" {cache_dir}") + click.confirm("Proceed?", abort=True) cleared = _api.clear_cache(**ctx.obj) click.echo(f"Cleared cache: {cleared}") +def _echo_jdk_list(header: str, jdks: list[str]) -> None: + click.echo(header) + for jdk in jdks: + click.echo(f" {jdk}") + + # Register current commands. _cli.add_command(java_home) _cli.add_command(exec) @@ -311,6 +463,8 @@ def clear_cache(ctx: click.Context) -> None: _cli.add_command(cache) _cli.add_command(cache_file) _cli.add_command(cache_package) +_cli.add_command(rm) +_cli.add_command(prune) _cli.add_command(clear_cache) # Register hidden/deprecated commands, for backwards compatibility. diff --git a/src/cjdk/_api.py b/src/cjdk/_api.py index 34f456e..833a515 100644 --- a/src/cjdk/_api.py +++ b/src/cjdk/_api.py @@ -34,6 +34,7 @@ from ._conf import ConfigKwargs __all__ = [ + "cache_directory", "cache_file", "cache_jdk", "cache_package", @@ -42,6 +43,8 @@ "java_home", "list_jdks", "list_vendors", + "prune_jdks", + "remove_jdks", ] @@ -149,6 +152,193 @@ def list_jdks( # type: ignore [misc] # overlap with kwargs return _jdk.matching_jdks(conf, cached_only=cached_only) +def remove_jdks( # type: ignore [misc] # overlap with kwargs + *, + vendor: str | None = None, + version: str | None = None, + dry_run: bool = False, + **kwargs: Unpack[ConfigKwargs], +) -> list[str]: + """ + Remove cached JDKs matching the given criteria. + + Only cached JDKs are affected; cached files, packages, and the index are + left untouched. This should not be called when other processes may be using + cjdk or the JDKs installed by cjdk. + + Parameters + ---------- + vendor : str, optional + JDK vendor name, such as "adoptium". + version : str, optional + JDK version expression, such as "17+". + dry_run : bool, default: False + If True, return the matching JDKs without removing anything. + + Other Parameters + ---------------- + jdk : str, optional + JDK vendor and version, such as "adoptium:17+". Cannot be specified + together with `vendor` or `version`. + cache_dir : pathlib.Path or str, optional + Override the root cache directory. + index_url : str, optional + Alternative URL for the JDK index. + os : str, optional + Operating system for the JDK (default: current operating system). + arch : str, optional + CPU architecture for the JDK (default: current architecture). + + Returns + ------- + list[str] + The JDKs (vendor:version) that were removed (or, if `dry_run`, that + would be removed). + + Raises + ------ + ConfigError + If configuration is invalid. + InstallError + If fetching the index fails. + CjdkError + If a cached JDK could not be removed. + """ + jdk = kwargs.pop("jdk", None) + if jdk: + parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk) + vendor = vendor or parsed_vendor or None + version = version or parsed_version or None + + if vendor is None: + conf = _conf.configure(**kwargs) + return [ + removed + for v in sorted(_jdk.available_vendors(conf)) + for removed in remove_jdks( + vendor=v, version=version, dry_run=dry_run, **kwargs + ) + ] + + conf = _conf.configure(vendor=vendor, version=version, **kwargs) + versions = _jdk.cached_jdk_versions(conf) + if dry_run: + return [f"{conf.vendor}:{v}" for v in versions] + return _jdk.remove_jdks(conf, versions) + + +def prune_jdks( # type: ignore [misc] # overlap with kwargs + *, + vendor: str | None = None, + version: str | None = None, + per_vendor: bool = True, + per_major: bool = True, + dry_run: bool = False, + **kwargs: Unpack[ConfigKwargs], +) -> list[str]: + """ + Remove obsolete cached JDKs, keeping only the newest of each group. + + By default, the newest cached version of every (vendor, major version) is + kept and older versions are removed. Only cached JDKs are affected; cached + files, packages, and the index are left untouched. This should not be + called when other processes may be using cjdk or the JDKs installed by + cjdk. + + Parameters + ---------- + vendor : str, optional + Limit pruning to this JDK vendor (default: all cached vendors). + version : str, optional + Limit pruning to versions matching this expression, such as "17+". + per_vendor : bool, default: True + If True, prune each vendor separately. If False, pool all vendors + together so that only the newest version survives in each group. + per_major : bool, default: True + If True, keep the newest of each major version. If False, keep only the + single newest version per group. + dry_run : bool, default: False + If True, return the obsolete JDKs without removing anything. + + Other Parameters + ---------------- + jdk : str, optional + JDK vendor and version, such as "adoptium:17+". Cannot be specified + together with `vendor` or `version`. + cache_dir : pathlib.Path or str, optional + Override the root cache directory. + index_url : str, optional + Alternative URL for the JDK index. + os : str, optional + Operating system for the JDK (default: current operating system). + arch : str, optional + CPU architecture for the JDK (default: current architecture). + + Returns + ------- + list[str] + The JDKs (vendor:version) that were removed (or, if `dry_run`, that + would be removed). + + Raises + ------ + ConfigError + If configuration is invalid. + InstallError + If fetching the index fails. + CjdkError + If a cached JDK could not be removed. + """ + jdk = kwargs.pop("jdk", None) + if jdk: + parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk) + vendor = vendor or parsed_vendor or None + version = version or parsed_version or None + + if vendor is None: + vendors = sorted(_jdk.available_vendors(_conf.configure(**kwargs))) + else: + vendors = [vendor] + + cached: list[tuple[str, str]] = [] + for v in vendors: + conf = _conf.configure(vendor=v, version=version, **kwargs) + cached.extend((v, ver) for ver in _jdk.cached_jdk_versions(conf)) + + to_prune = _jdk.jdks_to_prune( + cached, per_vendor=per_vendor, per_major=per_major + ) + + if dry_run: + return [f"{v}:{ver}" for v, ver in to_prune] + + removed = [] + by_vendor: dict[str, list[str]] = {} + for v, ver in to_prune: + by_vendor.setdefault(v, []).append(ver) + for v, vers in by_vendor.items(): + conf = _conf.configure(vendor=v, version=version, **kwargs) + removed.extend(_jdk.remove_jdks(conf, vers)) + return removed + + +def cache_directory(**kwargs: Unpack[ConfigKwargs]) -> Path: + """ + Return the configured root cache directory. + + Other Parameters + ---------------- + cache_dir : pathlib.Path or str, optional + Override the root cache directory. + + Returns + ------- + pathlib.Path + The root cache directory. + """ + return _conf.configure(**kwargs).cache_dir + + def clear_cache(**kwargs: Unpack[ConfigKwargs]) -> Path: """ Remove all cached files and directories. diff --git a/src/cjdk/_cache.py b/src/cjdk/_cache.py index 1a3d763..1d03cea 100644 --- a/src/cjdk/_cache.py +++ b/src/cjdk/_cache.py @@ -16,6 +16,7 @@ from __future__ import annotations import hashlib +import shutil import sys import time import urllib.parse @@ -27,12 +28,13 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator -from ._exceptions import ConfigError, InstallError +from ._exceptions import CjdkError, ConfigError, InstallError __all__ = [ "atomic_file", "is_cached", "permanent_directory", + "remove", ] @@ -42,6 +44,34 @@ def is_cached(prefix: str, key_url: str, *, cache_dir: Path) -> bool: return _key_directory(cache_dir, key).is_dir() +def remove(prefix: str, key_url: str, *, cache_dir: Path) -> bool: + """ + Remove cached content for the given prefix and URL. + + Both the key directory and its sibling ``.url`` file are removed. Returns + True if a cached key directory was present and removed, False otherwise. + + This should not be called when other processes may be using the cached + content. + """ + if not isinstance(cache_dir, Path): + cache_dir = Path(cache_dir) + key = (prefix, _key_for_url(key_url)) + keydir = _key_directory(cache_dir, key) + url_file = keydir.parent / (keydir.name + ".url") + existed = keydir.is_dir() + try: + if existed: + shutil.rmtree(keydir) + if url_file.exists(): + url_file.unlink() + except OSError as e: + raise CjdkError( + f"Failed to remove cached directory {keydir}: {e}" + ) from e + return existed + + def _key_for_url(url: str | urllib.parse.ParseResult) -> str: """ Return a cache key suitable to cache content retrieved from the given URL. diff --git a/src/cjdk/_index.py b/src/cjdk/_index.py index 9d4c912..aad1d03 100644 --- a/src/cjdk/_index.py +++ b/src/cjdk/_index.py @@ -33,6 +33,7 @@ "jdk_url", "matching_jdk_versions", "resolve_jdk_version", + "version_sort_key", ] @@ -258,6 +259,22 @@ def _is_version_compatible_with_spec( return len(version) >= len(spec) and version[: len(spec)] == spec +def version_sort_key(vendor: str, version: str) -> tuple[int | str, ...]: + """ + Return a normalized, sortable key for the given vendor's version string. + + The key compares element by element, so that, e.g., "26.0.0" sorts before + "26.0.1". Returns an empty tuple for versions that cannot be normalized. + + The first element of the (non-empty) key is the major version. + """ + is_graal = "graalvm" in vendor.lower() + try: + return _normalize_version(version, remove_prefix_1=not is_graal) + except ValueError: + return () + + def matching_jdk_versions(index: Index, conf: Configuration) -> list[str]: """ Return all version strings matching the configuration, sorted by version. diff --git a/src/cjdk/_jdk.py b/src/cjdk/_jdk.py index b2c940e..84ec38e 100644 --- a/src/cjdk/_jdk.py +++ b/src/cjdk/_jdk.py @@ -18,9 +18,12 @@ __all__ = [ "available_vendors", + "cached_jdk_versions", "find_home", "install_jdk", + "jdks_to_prune", "matching_jdks", + "remove_jdks", ] @@ -50,21 +53,85 @@ def matching_jdks(conf: Configuration, cached_only: bool = True) -> list[str]: """ Return JDKs matching the configuration, optionally filtered to cached only. """ + if cached_only: + versions = cached_jdk_versions(conf) + else: + index = _index.jdk_index(conf) + versions = _index.matching_jdk_versions(index, conf) + + return [f"{conf.vendor}:{v}" for v in versions] + + +def cached_jdk_versions(conf: Configuration) -> list[str]: + """ + Return the exact version strings of cached JDKs matching the configuration. + + The versions are sorted from oldest to newest. + """ index = _index.jdk_index(conf) versions = _index.matching_jdk_versions(index, conf) + return [ + v + for v in versions + if _cache.is_cached( + _JDK_KEY_PREFIX, + _index.jdk_url(index, conf, v), + cache_dir=conf.cache_dir, + ) + ] + + +def remove_jdks(conf: Configuration, versions: list[str]) -> list[str]: + """ + Remove the given cached JDK versions for the configured vendor. - if cached_only: - versions = [ - v - for v in versions - if _cache.is_cached( - _JDK_KEY_PREFIX, - _index.jdk_url(index, conf, v), - cache_dir=conf.cache_dir, - ) - ] + Returns the list of "vendor:version" strings that were actually removed. + """ + index = _index.jdk_index(conf) + removed = [] + for version in versions: + url = _index.jdk_url(index, conf, version) + if _cache.remove(_JDK_KEY_PREFIX, url, cache_dir=conf.cache_dir): + removed.append(f"{conf.vendor}:{version}") + return removed + + +def jdks_to_prune( + jdks: list[tuple[str, str]], + *, + per_vendor: bool = True, + per_major: bool = True, +) -> list[tuple[str, str]]: + """ + Select obsolete JDKs to prune, keeping the newest of each group. - return [f"{conf.vendor}:{v}" for v in versions] + Arguments: + jdks -- A list of (vendor, version) tuples (the cached JDKs). + per_vendor -- If True, prune each vendor separately; if False, pool all + vendors together so that only the newest version survives in + each (remaining) group, regardless of vendor. + per_major -- If True, keep the newest of each major version; if False, + keep only the single newest version per (remaining) group. + + Within each group, the single newest version is kept; the rest are + returned. The returned list preserves the order of the input. + """ + keys = [ + _index.version_sort_key(vendor, version) for vendor, version in jdks + ] + + groups: dict[tuple[str | None, int | str | None], list[int]] = {} + for i, (vendor, _version) in enumerate(jdks): + key = keys[i] + group_key = ( + vendor if per_vendor else None, + key[0] if (per_major and key) else None, + ) + groups.setdefault(group_key, []).append(i) + + keep = {max(members, key=lambda i: keys[i]) for members in groups.values()} + + return [jdk for i, jdk in enumerate(jdks) if i not in keep] def install_jdk(conf: Configuration) -> Path: diff --git a/tests/test_cache_remove.py b/tests/test_cache_remove.py new file mode 100644 index 0000000..e1c4ea1 --- /dev/null +++ b/tests/test_cache_remove.py @@ -0,0 +1,45 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +from cjdk import _cache + + +def test_remove(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + keydir = cache_dir / "v0" / "jdks" / _cache._key_for_url(url) + keydir.mkdir(parents=True) + (keydir / "file.txt").touch() + url_file = keydir.parent / (keydir.name + ".url") + url_file.write_text(url) + + assert _cache.is_cached("jdks", url, cache_dir=cache_dir) + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is True + assert not keydir.exists() + assert not url_file.exists() + assert not _cache.is_cached("jdks", url, cache_dir=cache_dir) + + +def test_remove_nonexistent(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is False # Nothing to remove, but no error + + +def test_remove_without_url_file(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + keydir = cache_dir / "v0" / "jdks" / _cache._key_for_url(url) + keydir.mkdir(parents=True) + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is True + assert not keydir.exists() diff --git a/tests/test_prune.py b/tests/test_prune.py new file mode 100644 index 0000000..240761f --- /dev/null +++ b/tests/test_prune.py @@ -0,0 +1,88 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +from cjdk._jdk import jdks_to_prune + + +def test_prune_keeps_newest_per_vendor_major(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # Default: per vendor, per major. Only the older Zulu 25 is obsolete. + assert jdks_to_prune(jdks) == [("zulu", "25.0.0")] + + +def test_prune_keeps_each_major(): + jdks = [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("zulu", "26.0.1"), + ] + # Each major line keeps its own newest. + assert jdks_to_prune(jdks) == [("zulu", "21.0.8"), ("zulu", "26.0.0")] + + +def test_prune_across_majors(): + jdks = [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("zulu", "26.0.1"), + ] + # Collapsing majors keeps only the single newest Zulu. + assert jdks_to_prune(jdks, per_major=False) == [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ] + + +def test_prune_across_vendors(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # Pooling vendors: only the newest version of major 25 survives. + assert jdks_to_prune(jdks, per_vendor=False) == [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ] + + +def test_prune_across_vendors_and_majors(): + jdks = [ + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("corretto", "26.0.1"), + ] + # Everything but the single newest is obsolete. + assert jdks_to_prune(jdks, per_vendor=False, per_major=False) == [ + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ] + + +def test_prune_nothing_when_distinct_majors(): + jdks = [ + ("zulu", "21.0.9"), + ("adoptium", "17.0.3"), + ] + assert jdks_to_prune(jdks) == [] + + +def test_prune_empty(): + assert jdks_to_prune([]) == [] + + +def test_prune_handles_jdk_1_x_prefix(): + # JDK 1.8 normalizes to major 8; keep the newest 1.8. + jdks = [ + ("adoptium", "1.8.0-292"), + ("adoptium", "1.8.0-302"), + ] + assert jdks_to_prune(jdks) == [("adoptium", "1.8.0-292")] diff --git a/tests/test_remove_prune_api.py b/tests/test_remove_prune_api.py new file mode 100644 index 0000000..b9485fb --- /dev/null +++ b/tests/test_remove_prune_api.py @@ -0,0 +1,111 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +import mock_server + +from cjdk import _api, _cache, _jdk + +_INDEX_DATA = { + "linux": { + "amd64": { + "jdk@zulu": { + "25.0.0": "tgz+http://example.com/zulu-25.0.0.tar.gz", + "25.0.1": "tgz+http://example.com/zulu-25.0.1.tar.gz", + "21.0.9": "tgz+http://example.com/zulu-21.0.9.tar.gz", + }, + "jdk@adoptium": { + "17.0.3": "tgz+http://example.com/adoptium-17.0.3.tar.gz", + }, + }, + }, +} + + +def _populate(cache_dir, url): + keydir = cache_dir / "v0" / _jdk._JDK_KEY_PREFIX / _cache._key_for_url(url) + keydir.mkdir(parents=True) + (keydir / "marker").touch() + (keydir.parent / (keydir.name + ".url")).write_text(url) + + +def _populate_all(cache_dir): + for versions in _INDEX_DATA["linux"]["amd64"].values(): + for url in versions.values(): + _populate(cache_dir, url) + + +def _common(server, cache_dir): + return dict( + os="linux", + arch="amd64", + cache_dir=cache_dir, + index_url=server.url("/index.json"), + _allow_insecure_for_testing=True, + ) + + +def test_remove_jdks_exact(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + # Dry run reports but does not remove. + dry = _api.remove_jdks(jdk="zulu:25.0.0", dry_run=True, **common) + assert dry == ["zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.0", + "zulu:25.0.1", + ] + + removed = _api.remove_jdks(jdk="zulu:25.0.0", **common) + assert removed == ["zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.1", + ] + # Untouched vendor still present. + assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] + + +def test_remove_jdks_by_vendor(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + removed = _api.remove_jdks(jdk="zulu", **common) + assert removed == ["zulu:21.0.9", "zulu:25.0.0", "zulu:25.0.1"] + assert _api.list_jdks(jdk="zulu", **common) == [] + assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] + + +def test_prune_jdks_default(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + dry = _api.prune_jdks(dry_run=True, **common) + assert dry == ["zulu:25.0.0"] + + removed = _api.prune_jdks(**common) + assert removed == ["zulu:25.0.0"] + # 21.0.9 (different major) and 25.0.1 (newest) survive. + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.1", + ] + + +def test_prune_jdks_across_majors(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + removed = _api.prune_jdks(per_major=False, **common) + assert sorted(removed) == ["zulu:21.0.9", "zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == ["zulu:25.0.1"] From 20ef14c33fbc8a06dd18f80b361a47359a045a26 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 2 Jul 2026 11:36:59 -0500 Subject: [PATCH 2/2] Collapse rm into prune --keep-none Rather than a standalone rm command with package-manager semantics, express "remove specific cached JDKs" as an advanced prune: --keep-none removes every matching JDK instead of keeping the newest of each, and combines with -j for precision. This keeps the cache semantics honest and shrinks the command/API surface. - Drop the rm command and the public remove_jdks(); fold both into prune / prune_jdks(keep_none=True). - Error when --keep-none is combined with --across-(vendors|majors) (grouping is meaningless when keeping none). - Update docs, changelog, and tests accordingly. Co-Authored-By: Claude Opus 4.8 --- docs/api.md | 5 -- docs/changelog.md | 8 +- docs/cli.md | 23 ++--- src/cjdk/__init__.py | 2 - src/cjdk/__main__.py | 77 +++++----------- src/cjdk/_api.py | 87 +++---------------- src/cjdk/_jdk.py | 6 ++ tests/test_prune.py | 14 +++ ..._remove_prune_api.py => test_prune_api.py} | 32 +++++-- 9 files changed, 92 insertions(+), 162 deletions(-) rename tests/{test_remove_prune_api.py => test_prune_api.py} (75%) diff --git a/docs/api.md b/docs/api.md index 5332bab..e2487d5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,11 +59,6 @@ install an application JAR. ## Managing the cache -```{eval-rst} -.. autofunction:: cjdk.remove_jdks -.. versionadded:: 0.6.0 -``` - ```{eval-rst} .. autofunction:: cjdk.prune_jdks .. versionadded:: 0.6.0 diff --git a/docs/changelog.md b/docs/changelog.md index 7dc420f..a2405dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,10 +12,11 @@ See also the section on [versioning](versioning-scheme). ### Added -- Add `cjdk rm` and `remove_jdks()` to remove specific cached JDKs. - Add `cjdk prune` and `prune_jdks()` to remove obsolete cached JDKs, keeping the newest of each vendor and major version (configurable with - `--per-vendor`/`--across-vendors` and `--per-major`/`--across-majors`). + `--per-vendor`/`--across-vendors` and `--per-major`/`--across-majors`). Pass + `--keep-none` (with `-j` for precision) to remove specific cached JDKs + outright. - Add `cache_directory()` to the Python API. ### Changed @@ -23,8 +24,7 @@ See also the section on [versioning](versioning-scheme). - `list_vendors()` and `ls-vendors` now filter vendors by OS and architecture, defaulting to the current platform. - `cjdk clear-cache` now prompts for confirmation before deleting; pass `--yes` - to skip the prompt, or `--dry-run` to preview. `rm` and `prune` prompt the - same way. + to skip the prompt, or `--dry-run` to preview. `prune` prompts the same way. ### Removed diff --git a/docs/cli.md b/docs/cli.md index dd66fc3..1ee03a8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,21 +118,6 @@ shown was on macOS.) ## Managing the cache -### `rm` - -```{command-output} cjdk rm --help -``` - -For example, to remove all cached Zulu 26 JDKs: - -```text -$ cjdk -j zulu:26 rm -``` - -```{eval-rst} -.. versionadded:: 0.6.0 -``` - ### `prune` ```{command-output} cjdk prune --help @@ -145,6 +130,14 @@ is removed while the newest of each vendor and major version is kept: $ cjdk prune ``` +To remove specific cached JDKs instead of keeping the newest of each, add +`--keep-none` and narrow the selection with `-j`. For example, to remove all +cached Zulu 26 JDKs: + +```text +$ cjdk -j zulu:26 prune --keep-none +``` + ```{eval-rst} .. versionadded:: 0.6.0 ``` diff --git a/src/cjdk/__init__.py b/src/cjdk/__init__.py index a54f964..8b76dbe 100644 --- a/src/cjdk/__init__.py +++ b/src/cjdk/__init__.py @@ -13,7 +13,6 @@ list_jdks, list_vendors, prune_jdks, - remove_jdks, ) from ._exceptions import ( CjdkError, @@ -38,5 +37,4 @@ "list_jdks", "list_vendors", "prune_jdks", - "remove_jdks", ] diff --git a/src/cjdk/__main__.py b/src/cjdk/__main__.py index 09aa672..38f5c32 100644 --- a/src/cjdk/__main__.py +++ b/src/cjdk/__main__.py @@ -285,57 +285,14 @@ def cache_package( ) -@click.command(short_help="Remove specific cached JDKs.") +@click.command(short_help="Prune obsolete cached JDKs.") @click.pass_context @click.option( - "--dry-run", - "-n", + "--keep-none", is_flag=True, - help="Show what would be removed, without removing anything.", + help="Remove every matching JDK, keeping none. Combine with --jdk/-j to " + "remove specific JDKs, e.g. 'cjdk -j zulu:26 prune --keep-none'.", ) -@click.option( - "--yes", - "-y", - is_flag=True, - help="Do not prompt for confirmation before removing.", -) -def rm(ctx: click.Context, dry_run: bool, yes: bool) -> None: - """ - Remove the cached JDKs matching the requested criteria. - - Specify which JDKs to remove using the common --jdk/-j option (together - with --os/--arch if desired); for example, 'cjdk -j zulu:26 rm' removes all - cached Zulu 26 JDKs. Only cached JDKs are removed; cached files, packages, - and the index are left untouched. - - To remove everything in the cache, use 'clear-cache' instead. To prune - obsolete JDKs while keeping the newest of each, use 'prune'. - - When removing JDKs, ensure that no other processes are using cjdk or the - affected JDKs. - - See 'cjdk --help' for the common options used to specify the JDK. - """ - if not ctx.obj.get("jdk"): - raise click.UsageError( - "Specify which JDK(s) to remove with -j VENDOR:VERSION " - "(or use 'clear-cache' to remove everything)." - ) - matched = _api.remove_jdks(**ctx.obj, dry_run=True) - if not matched: - click.echo("No matching cached JDKs.") - return - _echo_jdk_list("JDKs to remove:", matched) - if dry_run: - return - if not yes: - click.confirm(f"Remove {len(matched)} JDK(s)?", abort=True) - removed = _api.remove_jdks(**ctx.obj) - click.echo(f"Removed {len(removed)} JDK(s).") - - -@click.command(short_help="Prune obsolete cached JDKs.") -@click.pass_context @click.option( "--per-vendor/--across-vendors", default=True, @@ -362,6 +319,7 @@ def rm(ctx: click.Context, dry_run: bool, yes: bool) -> None: ) def prune( ctx: click.Context, + keep_none: bool, per_vendor: bool, per_major: bool, dry_run: bool, @@ -376,18 +334,29 @@ def prune( Use --across-vendors and/or --across-majors to widen the grouping (removing more), and the common --jdk/-j option to limit pruning to a particular - vendor. Only cached JDKs are removed; cached files, packages, and the index - are left untouched. + vendor or version. Only cached JDKs are removed; cached files, packages, + and the index are left untouched. + + Pass --keep-none to remove every matching JDK instead of keeping the newest + of each. Combined with -j this is a precise way to remove specific JDKs + (e.g. 'cjdk -j zulu:26 prune --keep-none'); with no -j it removes all cached + JDKs (but, unlike 'clear-cache', leaves cached files and packages alone). When pruning JDKs, ensure that no other processes are using cjdk or the affected JDKs. See 'cjdk --help' for the common options. """ + if keep_none and not (per_vendor and per_major): + raise click.UsageError( + "--keep-none cannot be combined with --across-vendors or " + "--across-majors (there is nothing to group when keeping none)." + ) matched = _api.prune_jdks( **ctx.obj, per_vendor=per_vendor, per_major=per_major, + keep_none=keep_none, dry_run=True, ) if not matched: @@ -399,7 +368,10 @@ def prune( if not yes: click.confirm(f"Remove {len(matched)} JDK(s)?", abort=True) removed = _api.prune_jdks( - **ctx.obj, per_vendor=per_vendor, per_major=per_major + **ctx.obj, + per_vendor=per_vendor, + per_major=per_major, + keep_none=keep_none, ) click.echo(f"Removed {len(removed)} JDK(s).") @@ -423,8 +395,8 @@ def clear_cache(ctx: click.Context, dry_run: bool, yes: bool) -> None: Remove all cached JDKs, files, and packages from the cache directory. This permanently deletes everything in the cache. Subsequent commands will - re-download any needed files. To remove only specific JDKs, use 'rm'; to - prune obsolete JDKs, use 'prune'. + re-download any needed files. To remove obsolete or specific JDKs while + leaving other cached items alone, use 'prune'. Unless --yes is given, you are prompted to confirm before anything is deleted. @@ -463,7 +435,6 @@ def _echo_jdk_list(header: str, jdks: list[str]) -> None: _cli.add_command(cache) _cli.add_command(cache_file) _cli.add_command(cache_package) -_cli.add_command(rm) _cli.add_command(prune) _cli.add_command(clear_cache) diff --git a/src/cjdk/_api.py b/src/cjdk/_api.py index 833a515..f57084d 100644 --- a/src/cjdk/_api.py +++ b/src/cjdk/_api.py @@ -44,7 +44,6 @@ "list_jdks", "list_vendors", "prune_jdks", - "remove_jdks", ] @@ -152,87 +151,13 @@ def list_jdks( # type: ignore [misc] # overlap with kwargs return _jdk.matching_jdks(conf, cached_only=cached_only) -def remove_jdks( # type: ignore [misc] # overlap with kwargs - *, - vendor: str | None = None, - version: str | None = None, - dry_run: bool = False, - **kwargs: Unpack[ConfigKwargs], -) -> list[str]: - """ - Remove cached JDKs matching the given criteria. - - Only cached JDKs are affected; cached files, packages, and the index are - left untouched. This should not be called when other processes may be using - cjdk or the JDKs installed by cjdk. - - Parameters - ---------- - vendor : str, optional - JDK vendor name, such as "adoptium". - version : str, optional - JDK version expression, such as "17+". - dry_run : bool, default: False - If True, return the matching JDKs without removing anything. - - Other Parameters - ---------------- - jdk : str, optional - JDK vendor and version, such as "adoptium:17+". Cannot be specified - together with `vendor` or `version`. - cache_dir : pathlib.Path or str, optional - Override the root cache directory. - index_url : str, optional - Alternative URL for the JDK index. - os : str, optional - Operating system for the JDK (default: current operating system). - arch : str, optional - CPU architecture for the JDK (default: current architecture). - - Returns - ------- - list[str] - The JDKs (vendor:version) that were removed (or, if `dry_run`, that - would be removed). - - Raises - ------ - ConfigError - If configuration is invalid. - InstallError - If fetching the index fails. - CjdkError - If a cached JDK could not be removed. - """ - jdk = kwargs.pop("jdk", None) - if jdk: - parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk) - vendor = vendor or parsed_vendor or None - version = version or parsed_version or None - - if vendor is None: - conf = _conf.configure(**kwargs) - return [ - removed - for v in sorted(_jdk.available_vendors(conf)) - for removed in remove_jdks( - vendor=v, version=version, dry_run=dry_run, **kwargs - ) - ] - - conf = _conf.configure(vendor=vendor, version=version, **kwargs) - versions = _jdk.cached_jdk_versions(conf) - if dry_run: - return [f"{conf.vendor}:{v}" for v in versions] - return _jdk.remove_jdks(conf, versions) - - def prune_jdks( # type: ignore [misc] # overlap with kwargs *, vendor: str | None = None, version: str | None = None, per_vendor: bool = True, per_major: bool = True, + keep_none: bool = False, dry_run: bool = False, **kwargs: Unpack[ConfigKwargs], ) -> list[str]: @@ -257,6 +182,11 @@ def prune_jdks( # type: ignore [misc] # overlap with kwargs per_major : bool, default: True If True, keep the newest of each major version. If False, keep only the single newest version per group. + keep_none : bool, default: False + If True, remove every matching JDK, keeping none (this ignores + `per_vendor` and `per_major`). Combined with `vendor`/`version` (or + `jdk`), it removes specific JDKs; with no such filter, it removes all + cached JDKs. dry_run : bool, default: False If True, return the obsolete JDKs without removing anything. @@ -306,7 +236,10 @@ def prune_jdks( # type: ignore [misc] # overlap with kwargs cached.extend((v, ver) for ver in _jdk.cached_jdk_versions(conf)) to_prune = _jdk.jdks_to_prune( - cached, per_vendor=per_vendor, per_major=per_major + cached, + per_vendor=per_vendor, + per_major=per_major, + keep_none=keep_none, ) if dry_run: diff --git a/src/cjdk/_jdk.py b/src/cjdk/_jdk.py index 84ec38e..d2d7ec4 100644 --- a/src/cjdk/_jdk.py +++ b/src/cjdk/_jdk.py @@ -101,6 +101,7 @@ def jdks_to_prune( *, per_vendor: bool = True, per_major: bool = True, + keep_none: bool = False, ) -> list[tuple[str, str]]: """ Select obsolete JDKs to prune, keeping the newest of each group. @@ -112,10 +113,15 @@ def jdks_to_prune( each (remaining) group, regardless of vendor. per_major -- If True, keep the newest of each major version; if False, keep only the single newest version per (remaining) group. + keep_none -- If True, prune every JDK, keeping none (per_vendor and + per_major are ignored). Within each group, the single newest version is kept; the rest are returned. The returned list preserves the order of the input. """ + if keep_none: + return list(jdks) + keys = [ _index.version_sort_key(vendor, version) for vendor, version in jdks ] diff --git a/tests/test_prune.py b/tests/test_prune.py index 240761f..8a8536d 100644 --- a/tests/test_prune.py +++ b/tests/test_prune.py @@ -79,6 +79,20 @@ def test_prune_empty(): assert jdks_to_prune([]) == [] +def test_prune_keep_none(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # keep_none prunes everything, ignoring the grouping options. + assert jdks_to_prune(jdks, keep_none=True) == jdks + assert ( + jdks_to_prune(jdks, keep_none=True, per_vendor=False, per_major=False) + == jdks + ) + + def test_prune_handles_jdk_1_x_prefix(): # JDK 1.8 normalizes to major 8; keep the newest 1.8. jdks = [ diff --git a/tests/test_remove_prune_api.py b/tests/test_prune_api.py similarity index 75% rename from tests/test_remove_prune_api.py rename to tests/test_prune_api.py index b9485fb..c836234 100644 --- a/tests/test_remove_prune_api.py +++ b/tests/test_prune_api.py @@ -45,14 +45,16 @@ def _common(server, cache_dir): ) -def test_remove_jdks_exact(tmp_path): +def test_prune_keep_none_exact(tmp_path): cache_dir = tmp_path / "cache" _populate_all(cache_dir) with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: common = _common(server, cache_dir) # Dry run reports but does not remove. - dry = _api.remove_jdks(jdk="zulu:25.0.0", dry_run=True, **common) + dry = _api.prune_jdks( + jdk="zulu:25.0.0", keep_none=True, dry_run=True, **common + ) assert dry == ["zulu:25.0.0"] assert _api.list_jdks(jdk="zulu", **common) == [ "zulu:21.0.9", @@ -60,7 +62,7 @@ def test_remove_jdks_exact(tmp_path): "zulu:25.0.1", ] - removed = _api.remove_jdks(jdk="zulu:25.0.0", **common) + removed = _api.prune_jdks(jdk="zulu:25.0.0", keep_none=True, **common) assert removed == ["zulu:25.0.0"] assert _api.list_jdks(jdk="zulu", **common) == [ "zulu:21.0.9", @@ -70,18 +72,36 @@ def test_remove_jdks_exact(tmp_path): assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] -def test_remove_jdks_by_vendor(tmp_path): +def test_prune_keep_none_by_vendor(tmp_path): cache_dir = tmp_path / "cache" _populate_all(cache_dir) with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: common = _common(server, cache_dir) - removed = _api.remove_jdks(jdk="zulu", **common) - assert removed == ["zulu:21.0.9", "zulu:25.0.0", "zulu:25.0.1"] + removed = _api.prune_jdks(jdk="zulu", keep_none=True, **common) + assert sorted(removed) == ["zulu:21.0.9", "zulu:25.0.0", "zulu:25.0.1"] assert _api.list_jdks(jdk="zulu", **common) == [] assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] +def test_prune_keep_none_all(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + # With no vendor filter, keep_none removes every cached JDK. + removed = _api.prune_jdks(keep_none=True, **common) + assert sorted(removed) == [ + "adoptium:17.0.3", + "zulu:21.0.9", + "zulu:25.0.0", + "zulu:25.0.1", + ] + assert _api.list_jdks(jdk="zulu", **common) == [] + assert _api.list_jdks(jdk="adoptium", **common) == [] + + def test_prune_jdks_default(tmp_path): cache_dir = tmp_path / "cache" _populate_all(cache_dir)