|
50 | 50 | import time |
51 | 51 | from dataclasses import dataclass |
52 | 52 | from pathlib import Path |
| 53 | +from typing import ClassVar |
53 | 54 |
|
54 | 55 | from extra_platforms import is_macos, is_windows |
55 | 56 |
|
56 | 57 | from .config import load_repomatic_config |
| 58 | +from .humanize import format_age, format_file_size |
57 | 59 |
|
58 | 60 | TYPE_CHECKING = False |
59 | 61 | if TYPE_CHECKING: |
@@ -95,69 +97,134 @@ def _atomic_write(dest: Path, prefix: str, write: Callable[[Path], object]) -> N |
95 | 97 |
|
96 | 98 |
|
97 | 99 | @dataclass(frozen=True) |
98 | | -class CacheEntry: |
| 100 | +class CachedFile: |
| 101 | + """The filesystem facts every cached entry carries, whatever it holds. |
| 102 | +
|
| 103 | + The three caches (binaries, HTTP responses, tool configs) differ only in |
| 104 | + how they *name* an entry; everything the listing, the age filter and the |
| 105 | + purge loop need is here, so those all take a `CachedFile` and never care |
| 106 | + which subtree it came from. |
| 107 | +
|
| 108 | + Subclasses supply their own identity fields plus {attr}`kind` and |
| 109 | + {attr}`scope`. |
| 110 | + """ |
| 111 | + |
| 112 | + size: int |
| 113 | + """File size in bytes.""" |
| 114 | + |
| 115 | + path: Path |
| 116 | + """Absolute path to the cached file.""" |
| 117 | + |
| 118 | + mtime: float |
| 119 | + """File modification time (seconds since epoch).""" |
| 120 | + |
| 121 | + kind: ClassVar[str] = "" |
| 122 | + """The cache this entry belongs to, as the `repomatic cache show` table |
| 123 | + spells it.""" |
| 124 | + |
| 125 | + @property |
| 126 | + def scope(self) -> str: |
| 127 | + """The name a `cache clean` filter matches this entry on. |
| 128 | +
|
| 129 | + Doubles as the table's subject column: the thing a reader identifies |
| 130 | + the entry by (`--tool ruff`, `--namespace pypi`) is the same thing the |
| 131 | + listing shows them, so one property serves both. |
| 132 | + """ |
| 133 | + raise NotImplementedError |
| 134 | + |
| 135 | + @property |
| 136 | + def detail(self) -> str: |
| 137 | + """What distinguishes this entry from its siblings in the same scope.""" |
| 138 | + raise NotImplementedError |
| 139 | + |
| 140 | + def is_fresh(self, max_age_days: int | None) -> bool: |
| 141 | + """Whether this entry is younger than the age cutoff. |
| 142 | +
|
| 143 | + A `None` cutoff keeps nothing: age-unfiltered clears delete every |
| 144 | + entry the caller's other filters matched. |
| 145 | + """ |
| 146 | + if max_age_days is None: |
| 147 | + return False |
| 148 | + return self.mtime >= time.time() - max_age_days * 86400 |
| 149 | + |
| 150 | + def as_row(self) -> tuple[str, str, str, str, str]: |
| 151 | + """Render this entry as one `repomatic cache show` table row.""" |
| 152 | + return ( |
| 153 | + self.kind, |
| 154 | + self.scope, |
| 155 | + self.detail, |
| 156 | + format_file_size(self.size), |
| 157 | + format_age(self.mtime), |
| 158 | + ) |
| 159 | + |
| 160 | + |
| 161 | +@dataclass(frozen=True) |
| 162 | +class CacheEntry(CachedFile): |
99 | 163 | """A single cached binary with its metadata.""" |
100 | 164 |
|
101 | | - tool: str |
| 165 | + tool: str = "" |
102 | 166 | """Tool name (registry key).""" |
103 | 167 |
|
104 | | - version: str |
| 168 | + version: str = "" |
105 | 169 | """Pinned version string.""" |
106 | 170 |
|
107 | | - platform: str |
| 171 | + platform: str = "" |
108 | 172 | """Platform key (e.g., `linux-x64`, `macos-arm64`).""" |
109 | 173 |
|
110 | | - executable: str |
| 174 | + executable: str = "" |
111 | 175 | """Executable filename.""" |
112 | 176 |
|
113 | | - size: int |
114 | | - """File size in bytes.""" |
| 177 | + kind: ClassVar[str] = "binary" |
115 | 178 |
|
116 | | - path: Path |
117 | | - """Absolute path to the cached binary.""" |
| 179 | + @property |
| 180 | + def scope(self) -> str: |
| 181 | + return self.tool |
118 | 182 |
|
119 | | - mtime: float |
120 | | - """File modification time (seconds since epoch).""" |
| 183 | + @property |
| 184 | + def detail(self) -> str: |
| 185 | + return f"{self.version} ({self.platform})" |
121 | 186 |
|
122 | 187 |
|
123 | 188 | @dataclass(frozen=True) |
124 | | -class HttpCacheEntry: |
| 189 | +class HttpCacheEntry(CachedFile): |
125 | 190 | """A single cached HTTP response with its metadata.""" |
126 | 191 |
|
127 | | - namespace: str |
| 192 | + namespace: str = "" |
128 | 193 | """Cache namespace (e.g., `pypi`, `github-releases`).""" |
129 | 194 |
|
130 | | - key: str |
| 195 | + key: str = "" |
131 | 196 | """Cache key within the namespace (e.g., `requests`, `astral-sh/ruff`).""" |
132 | 197 |
|
133 | | - size: int |
134 | | - """File size in bytes.""" |
| 198 | + kind: ClassVar[str] = "http" |
135 | 199 |
|
136 | | - path: Path |
137 | | - """Absolute path to the cached response file.""" |
| 200 | + @property |
| 201 | + def scope(self) -> str: |
| 202 | + return self.namespace |
138 | 203 |
|
139 | | - mtime: float |
140 | | - """File modification time (seconds since epoch).""" |
| 204 | + @property |
| 205 | + def detail(self) -> str: |
| 206 | + return self.key |
141 | 207 |
|
142 | 208 |
|
143 | 209 | @dataclass(frozen=True) |
144 | | -class ConfigCacheEntry: |
| 210 | +class ConfigCacheEntry(CachedFile): |
145 | 211 | """A single cached tool configuration file with its metadata.""" |
146 | 212 |
|
147 | | - tool: str |
| 213 | + tool: str = "" |
148 | 214 | """Tool name (registry key).""" |
149 | 215 |
|
150 | | - filename: str |
| 216 | + filename: str = "" |
151 | 217 | """Config filename (e.g., `yamllint.yaml`, `biome.json`).""" |
152 | 218 |
|
153 | | - size: int |
154 | | - """File size in bytes.""" |
| 219 | + kind: ClassVar[str] = "config" |
155 | 220 |
|
156 | | - path: Path |
157 | | - """Absolute path to the cached config file.""" |
| 221 | + @property |
| 222 | + def scope(self) -> str: |
| 223 | + return self.tool |
158 | 224 |
|
159 | | - mtime: float |
160 | | - """File modification time (seconds since epoch).""" |
| 225 | + @property |
| 226 | + def detail(self) -> str: |
| 227 | + return self.filename |
161 | 228 |
|
162 | 229 |
|
163 | 230 | def _platform_cache_dir() -> Path: |
@@ -369,16 +436,11 @@ def clear_cache( |
369 | 436 | this many days. Otherwise remove all matching entries. |
370 | 437 | :return: Tuple of (files_deleted, bytes_freed). |
371 | 438 | """ |
372 | | - bin_root = _bin_dir() |
373 | | - if not bin_root.is_dir(): |
374 | | - return 0, 0 |
375 | | - |
376 | | - return _purge( |
| 439 | + return _clear_subtree( |
377 | 440 | cache_info(), |
378 | | - bin_root, |
379 | | - keep=lambda entry: ( |
380 | | - (tool is not None and entry.tool != tool) or _is_fresh(entry, max_age_days) |
381 | | - ), |
| 441 | + _bin_dir(), |
| 442 | + scope=tool, |
| 443 | + max_age_days=max_age_days, |
382 | 444 | sidecars=True, |
383 | 445 | ) |
384 | 446 |
|
@@ -496,17 +558,11 @@ def clear_http_cache( |
496 | 558 | this many days. Otherwise remove all matching entries. |
497 | 559 | :return: Tuple of (files_deleted, bytes_freed). |
498 | 560 | """ |
499 | | - http_root = _http_dir() |
500 | | - if not http_root.is_dir(): |
501 | | - return 0, 0 |
502 | | - |
503 | | - return _purge( |
| 561 | + return _clear_subtree( |
504 | 562 | http_cache_info(), |
505 | | - http_root, |
506 | | - keep=lambda entry: ( |
507 | | - (namespace is not None and entry.namespace != namespace) |
508 | | - or _is_fresh(entry, max_age_days) |
509 | | - ), |
| 563 | + _http_dir(), |
| 564 | + scope=namespace, |
| 565 | + max_age_days=max_age_days, |
510 | 566 | ) |
511 | 567 |
|
512 | 568 |
|
@@ -593,35 +649,66 @@ def clear_config_cache( |
593 | 649 | days, matching {func}`clear_cache` and {func}`clear_http_cache`. |
594 | 650 | :return: Tuple of (files_deleted, bytes_freed). |
595 | 651 | """ |
596 | | - config_root = _config_dir() |
597 | | - if not config_root.is_dir(): |
598 | | - return 0, 0 |
599 | | - |
600 | | - return _purge( |
| 652 | + return _clear_subtree( |
601 | 653 | config_cache_info(), |
602 | | - config_root, |
603 | | - keep=lambda entry: ( |
604 | | - (tool is not None and entry.tool != tool) or _is_fresh(entry, max_age_days) |
605 | | - ), |
| 654 | + _config_dir(), |
| 655 | + scope=tool, |
| 656 | + max_age_days=max_age_days, |
606 | 657 | ) |
607 | 658 |
|
608 | 659 |
|
609 | | -def _is_fresh( |
610 | | - entry: CacheEntry | HttpCacheEntry | ConfigCacheEntry, |
611 | | - max_age_days: int | None, |
612 | | -) -> bool: |
613 | | - """Whether *entry* is younger than the age cutoff. |
| 660 | +def cache_rows() -> tuple[list[tuple[str, str, str, str, str]], int]: |
| 661 | + """List every cached file across the three caches, as table rows. |
| 662 | +
|
| 663 | + Backs `repomatic cache show`: each entry renders itself |
| 664 | + ({meth}`CachedFile.as_row`), so the command stays a print call and a new |
| 665 | + cache kind shows up in the listing by existing. |
614 | 666 |
|
615 | | - A `None` cutoff keeps nothing: age-unfiltered clears delete every entry |
616 | | - the caller's other filters matched. |
| 667 | + :return: `(rows, total_size)`, rows ordered binaries, then HTTP responses, |
| 668 | + then tool configs. |
617 | 669 | """ |
618 | | - if max_age_days is None: |
619 | | - return False |
620 | | - return entry.mtime >= time.time() - max_age_days * 86400 |
| 670 | + entries: list[Any] = [*cache_info(), *http_cache_info(), *config_cache_info()] |
| 671 | + return [entry.as_row() for entry in entries], sum(e.size for e in entries) |
| 672 | + |
| 673 | + |
| 674 | +def _clear_subtree( |
| 675 | + entries: list[Any], |
| 676 | + root: Path, |
| 677 | + *, |
| 678 | + scope: str | None, |
| 679 | + max_age_days: int | None, |
| 680 | + sidecars: bool = False, |
| 681 | +) -> tuple[int, int]: |
| 682 | + """Remove the entries of one cache subtree, honoring both filters. |
| 683 | +
|
| 684 | + The shared body of {func}`clear_cache`, {func}`clear_http_cache` and |
| 685 | + {func}`clear_config_cache`, which differ only in the subtree they walk and |
| 686 | + the name their scope filter goes by on the command line. |
| 687 | +
|
| 688 | + :param entries: Candidate entries, from that cache's `*_cache_info()`. |
| 689 | + :param root: The subtree they live in. A missing one clears nothing. |
| 690 | + :param scope: Keep entries whose {attr}`~CachedFile.scope` differs. `None` |
| 691 | + selects every entry. |
| 692 | + :param max_age_days: Keep entries younger than this. `None` selects every |
| 693 | + entry. |
| 694 | + :param sidecars: Also remove each entry's `.sha256` sidecar. |
| 695 | + :return: Tuple of (files_deleted, bytes_freed). |
| 696 | + """ |
| 697 | + if not root.is_dir(): |
| 698 | + return 0, 0 |
| 699 | + return _purge( |
| 700 | + entries, |
| 701 | + root, |
| 702 | + keep=lambda entry: ( |
| 703 | + (scope is not None and entry.scope != scope) |
| 704 | + or entry.is_fresh(max_age_days) |
| 705 | + ), |
| 706 | + sidecars=sidecars, |
| 707 | + ) |
621 | 708 |
|
622 | 709 |
|
623 | 710 | def _purge( |
624 | | - entries: list[CacheEntry] | list[HttpCacheEntry] | list[ConfigCacheEntry], |
| 711 | + entries: list[Any], |
625 | 712 | root: Path, |
626 | 713 | *, |
627 | 714 | keep: Callable[[Any], bool], |
|
0 commit comments