From b5a1adfc9855fa9a0b65c2750c785ec8c7594dcb Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Wed, 15 Apr 2026 14:09:48 +0100 Subject: [PATCH 1/3] implementation... --- tests/clean/runs/test.sh | 3 +++ tmt/base/core.py | 31 ++++++++++++++++++++++++++----- tmt/steps/provision/testcloud.py | 13 +++++++++++-- tmt/utils/__init__.py | 9 +++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/tests/clean/runs/test.sh b/tests/clean/runs/test.sh index fc9b26e3b2..4f559ae554 100755 --- a/tests/clean/runs/test.sh +++ b/tests/clean/runs/test.sh @@ -22,6 +22,7 @@ rlJournalStart rlRun -s "tmt clean runs --dry -v --workdir-root $tmprun" rlAssertGrep "Would remove workdir '$run1'" "$rlRun_LOG" rlAssertGrep "Would remove workdir '$run2'" "$rlRun_LOG" + rlAssertGrep "Summary: Would free.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertGrep "(done\s+){1}(todo\s+){6}$run1\s+/plan1" "$rlRun_LOG" -E rlAssertGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E @@ -30,12 +31,14 @@ rlJournalStart rlPhaseStartTest "Specify ID" rlRun -s "tmt clean runs -v -i $run1" rlAssertGrep "Removing workdir '$run1'" "$rlRun_LOG" + rlAssertGrep "Summary: Freed.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertNotGrep "(done\s+){1}(todo\s+){6}$run1\s+/plan1" "$rlRun_LOG" -E rlAssertGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E rlRun -s "tmt clean runs -v -l --workdir-root $tmprun" rlAssertGrep "Removing workdir '$run2'" "$rlRun_LOG" + rlAssertGrep "Summary: Freed.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertNotGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E diff --git a/tmt/base/core.py b/tmt/base/core.py index 11a0a84d2b..a8b67d43ce 100644 --- a/tmt/base/core.py +++ b/tmt/base/core.py @@ -3613,6 +3613,11 @@ def show(self) -> None: CleanCallback = Callable[[], bool] +def _dir_size(path: Path) -> int: + """Return the total size in bytes of all files under path.""" + return sum(f.stat().st_size for f in path.rglob('*') if f.is_file()) + + class Clean(tmt.utils.Common): """ A class for cleaning up workdirs, guests or images @@ -3751,14 +3756,15 @@ def guests(self, run_ids: tuple[str, ...], keep: Optional[int]) -> bool: successful = False return successful - def _clean_workdir(self, path: Path) -> bool: + def _clean_workdir(self, path: Path, size: int) -> bool: """ Remove a workdir (unless in dry mode) """ + formatted_size = tmt.utils.format_size(size) if self.is_dry_run: - self.verbose(f"Would remove workdir '{path}'.", shift=1) + self.verbose(f"Would remove workdir '{path}' ({formatted_size}).", shift=1) else: - self.verbose(f"Removing workdir '{path}'.", shift=1) + self.verbose(f"Removing workdir '{path}' ({formatted_size}).", shift=1) try: shutil.rmtree(path) except OSError as error: @@ -3777,7 +3783,14 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: # the correct one. last_run = Run(logger=self._logger, cli_invocation=self.cli_invocation) last_run.load_workdir(with_logfiles=False) - return self._clean_workdir(last_run.run_workdir) + size = _dir_size(last_run.run_workdir) + success = self._clean_workdir(last_run.run_workdir, size) + self.verbose( + f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " + f"{tmt.utils.format_size(size)} of disk space.", + shift=1, + ) + return success all_workdirs = list(tmt.utils.generate_runs(self.workdir_root, id_, all_=True)) if keep is not None: # Sort by change time of the workdirs and keep the newest workdirs @@ -3785,10 +3798,18 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: all_workdirs = all_workdirs[keep:] successful = True + total_size = 0 for workdir in all_workdirs: - if not self._clean_workdir(workdir): + size = _dir_size(workdir) + total_size += size + if not self._clean_workdir(workdir, size): successful = False + self.verbose( + f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " + f"{tmt.utils.format_size(total_size)} of disk space.", + shift=1, + ) return successful diff --git a/tmt/steps/provision/testcloud.py b/tmt/steps/provision/testcloud.py index 4acc8ae033..ab526e848c 100644 --- a/tmt/steps/provision/testcloud.py +++ b/tmt/steps/provision/testcloud.py @@ -1589,16 +1589,25 @@ def clean_images(cls, clean: 'tmt.base.core.Clean', dry: bool, workdir_root: Pat clean.warn(f"Directory '{testcloud_images}' does not exist.", shift=2) return True successful = True + total_size = 0 for image in testcloud_images.iterdir(): + size = image.stat().st_size + total_size += size + formatted_size = tmt.utils.format_size(size) if dry: - clean.verbose(f"Would remove '{image}'.", shift=2) + clean.verbose(f"Would remove '{image}' ({formatted_size}).", shift=2) else: - clean.verbose(f"Removing '{image}'.", shift=2) + clean.verbose(f"Removing '{image}' ({formatted_size}).", shift=2) try: image.unlink() except OSError: clean.fail(f"Failed to remove '{image}'.", shift=2) successful = False + clean.verbose( + f"Summary: {'Would free' if dry else 'Freed'} " + f"{tmt.utils.format_size(total_size)} of disk space.", + shift=2, + ) return successful diff --git a/tmt/utils/__init__.py b/tmt/utils/__init__.py index 40f002609b..225129293c 100644 --- a/tmt/utils/__init__.py +++ b/tmt/utils/__init__.py @@ -86,6 +86,15 @@ from tmt.hardware import Size +def format_size(size: int) -> str: + """Format a byte count into a human-readable string.""" + for unit in ('B', 'KB', 'MB', 'GB', 'TB'): + if size < 1024: + return f'{size:.1f} {unit}' + size = int(size / 1024) + return f'{size:.1f} PB' + + def sanitize_string(text: str) -> str: """Remove invalid Unicode characters from a string""" try: From d0ba40424905c99c75ffa7dc41ebce8ff49e4858 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Wed, 15 Apr 2026 14:19:53 +0100 Subject: [PATCH 2/3] docs: added a release note... --- docs/releases/pending/4593.fmf | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/releases/pending/4593.fmf diff --git a/docs/releases/pending/4593.fmf b/docs/releases/pending/4593.fmf new file mode 100644 index 0000000000..08369fa94e --- /dev/null +++ b/docs/releases/pending/4593.fmf @@ -0,0 +1,3 @@ +description: | + The ``tmt clean`` command now shows the size of each workdir + and a summary of total disk space freed. From c4b334ac850d37eecc72b564f3a1f8c7956b2a93 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Wed, 15 Apr 2026 21:53:36 +0100 Subject: [PATCH 3/3] address comments... --- tmt/base/core.py | 25 +++++++++++++------------ tmt/steps/provision/testcloud.py | 4 ++-- tmt/utils/__init__.py | 9 --------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/tmt/base/core.py b/tmt/base/core.py index a8b67d43ce..bda619d807 100644 --- a/tmt/base/core.py +++ b/tmt/base/core.py @@ -42,6 +42,7 @@ import tmt.convert import tmt.export import tmt.frameworks +import tmt.hardware import tmt.identifier import tmt.lint import tmt.log @@ -3615,7 +3616,7 @@ def show(self) -> None: def _dir_size(path: Path) -> int: """Return the total size in bytes of all files under path.""" - return sum(f.stat().st_size for f in path.rglob('*') if f.is_file()) + return sum(f.lstat().st_size for f in path.rglob('*')) class Clean(tmt.utils.Common): @@ -3756,11 +3757,12 @@ def guests(self, run_ids: tuple[str, ...], keep: Optional[int]) -> bool: successful = False return successful - def _clean_workdir(self, path: Path, size: int) -> bool: + def _clean_workdir(self, path: Path) -> tuple[bool, int]: """ Remove a workdir (unless in dry mode) """ - formatted_size = tmt.utils.format_size(size) + size = _dir_size(path) + formatted_size = round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1) if self.is_dry_run: self.verbose(f"Would remove workdir '{path}' ({formatted_size}).", shift=1) else: @@ -3769,8 +3771,8 @@ def _clean_workdir(self, path: Path, size: int) -> bool: shutil.rmtree(path) except OSError as error: self.warn(f"Failed to remove '{path}': {error}.", shift=1) - return False - return True + return False, 0 + return True, size def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: """ @@ -3783,11 +3785,10 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: # the correct one. last_run = Run(logger=self._logger, cli_invocation=self.cli_invocation) last_run.load_workdir(with_logfiles=False) - size = _dir_size(last_run.run_workdir) - success = self._clean_workdir(last_run.run_workdir, size) + success, size = self._clean_workdir(last_run.run_workdir) self.verbose( f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " - f"{tmt.utils.format_size(size)} of disk space.", + f"{round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1)} of disk space.", shift=1, ) return success @@ -3800,14 +3801,14 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: successful = True total_size = 0 for workdir in all_workdirs: - size = _dir_size(workdir) - total_size += size - if not self._clean_workdir(workdir, size): + success, size = self._clean_workdir(workdir) + if not success: successful = False + total_size += size self.verbose( f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " - f"{tmt.utils.format_size(total_size)} of disk space.", + f"{round(tmt.hardware.UNITS(f'{total_size} bytes').to_compact(), 1)} of disk space.", shift=1, ) return successful diff --git a/tmt/steps/provision/testcloud.py b/tmt/steps/provision/testcloud.py index ab526e848c..b1eac1ca36 100644 --- a/tmt/steps/provision/testcloud.py +++ b/tmt/steps/provision/testcloud.py @@ -1593,7 +1593,7 @@ def clean_images(cls, clean: 'tmt.base.core.Clean', dry: bool, workdir_root: Pat for image in testcloud_images.iterdir(): size = image.stat().st_size total_size += size - formatted_size = tmt.utils.format_size(size) + formatted_size = round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1) if dry: clean.verbose(f"Would remove '{image}' ({formatted_size}).", shift=2) else: @@ -1605,7 +1605,7 @@ def clean_images(cls, clean: 'tmt.base.core.Clean', dry: bool, workdir_root: Pat successful = False clean.verbose( f"Summary: {'Would free' if dry else 'Freed'} " - f"{tmt.utils.format_size(total_size)} of disk space.", + f"{round(tmt.hardware.UNITS(f'{total_size} bytes').to_compact(), 1)} of disk space.", shift=2, ) return successful diff --git a/tmt/utils/__init__.py b/tmt/utils/__init__.py index 225129293c..40f002609b 100644 --- a/tmt/utils/__init__.py +++ b/tmt/utils/__init__.py @@ -86,15 +86,6 @@ from tmt.hardware import Size -def format_size(size: int) -> str: - """Format a byte count into a human-readable string.""" - for unit in ('B', 'KB', 'MB', 'GB', 'TB'): - if size < 1024: - return f'{size:.1f} {unit}' - size = int(size / 1024) - return f'{size:.1f} PB' - - def sanitize_string(text: str) -> str: """Remove invalid Unicode characters from a string""" try: