Skip to content

Commit d39a059

Browse files
marcorudolphflexyaugenst-flex
authored andcommitted
feat(tidy3d): FXC-3903: tidy3d CLI Cache Helpers
1 parent 8dec0cb commit d39a059

File tree

5 files changed

+154
-3
lines changed

5 files changed

+154
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- Validation for `run_only` field in component modelers to catch duplicate or invalid matrix indices early with clear error messages.
2727
- Introduced a profile-based configuration manager with TOML persistence and runtime overrides exposed via `tidy3d.config`.
2828
- Added support of `os.PathLike` objects as paths like `pathlib.Path` alongside `str` paths in all path-related functions.
29-
- Added configurable local simulation result caching with checksum validation, eviction limits, and per-call overrides across `web.run`, `web.load`, and job workflows.
29+
- Added local simulation result caching to avoid rerunning identical simulations. Enable and configure it through `td.config.local_cache` (size limits, cache directory). Use CLI commands `tidy3d cache {info, list, clear}` to inspect or clear the cache.
3030
- Added `DirectivityMonitorSpec` for automated creation and configuration of directivity radiation monitors in `TerminalComponentModeler`.
3131
- Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port.
3232
- Added support for `.lydrc` files for design rule checking in the `klayout` plugin.

tests/test_web/test_local_cache.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import pytest
1414
import xarray as xr
1515
from autograd.core import defvjp
16+
from click.testing import CliRunner
1617
from rich.console import Console
1718

1819
import tidy3d as td
@@ -37,6 +38,7 @@
3738
get_cache_entry_dir,
3839
resolve_local_cache,
3940
)
41+
from tidy3d.web.cli.app import tidy3d_cli
4042
from tidy3d.web.core.task_core import BatchTask
4143

4244
common.CONNECTION_RETRY_TIME = 0.1
@@ -724,6 +726,49 @@ def _test_env_var_overrides(monkeypatch, tmp_path):
724726
manager._reload()
725727

726728

729+
def _test_cache_cli_commands(monkeypatch, tmp_path_factory, basic_simulation):
730+
runner = CliRunner()
731+
cache_dir = tmp_path_factory.mktemp("cli_cache")
732+
artifact_dir = tmp_path_factory.mktemp("cli_cache_artifact")
733+
734+
monkeypatch.setattr(config.local_cache, "enabled", True)
735+
monkeypatch.setattr(config.local_cache, "directory", cache_dir)
736+
monkeypatch.setattr(config.local_cache, "max_entries", 3)
737+
monkeypatch.setattr(config.local_cache, "max_size_gb", 2.5)
738+
739+
cache = resolve_local_cache(use_cache=True)
740+
cache.clear()
741+
742+
artifact = artifact_dir / CACHE_ARTIFACT_NAME
743+
artifact.write_text("payload_cli")
744+
cache.store_result(
745+
_FakeStubData(basic_simulation), f"{MOCK_TASK_ID}-cli", str(artifact), "FDTD"
746+
)
747+
748+
info_result = runner.invoke(tidy3d_cli, ["cache", "info"])
749+
assert info_result.exit_code == 0
750+
assert "Enabled: yes" in info_result.output
751+
assert "Entries: 1" in info_result.output
752+
assert "Max entries: 3" in info_result.output
753+
assert "Max size: 2.50 GB" in info_result.output
754+
755+
list_result = runner.invoke(tidy3d_cli, ["cache", "list"])
756+
assert list_result.exit_code == 0
757+
out = list_result.output
758+
assert "Cache Entry #1" in out
759+
assert "Workflow type: FDTD" in out
760+
assert "File size:" in out
761+
762+
clear_result = runner.invoke(tidy3d_cli, ["cache", "clear"])
763+
assert clear_result.exit_code == 0
764+
assert "Local cache cleared." in clear_result.output
765+
assert len(cache) == 0
766+
767+
list_after = runner.invoke(tidy3d_cli, ["cache", "list"])
768+
assert list_after.exit_code == 0
769+
assert "Cache is empty." in list_after.output
770+
771+
727772
def test_cache_sequential(
728773
monkeypatch, tmp_path, tmp_path_factory, basic_simulation, fake_data, request
729774
):
@@ -746,3 +791,4 @@ def test_cache_sequential(
746791
_test_store_and_fetch_do_not_iterate(monkeypatch, tmp_path, basic_simulation)
747792
_test_mode_solver_caching(monkeypatch, tmp_path)
748793
_test_verbosity(monkeypatch, basic_simulation)
794+
_test_cache_cli_commands(monkeypatch, tmp_path_factory, basic_simulation)

tidy3d/web/cache.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -842,8 +842,16 @@ def resolve_local_cache(use_cache: Optional[bool] = None) -> Optional[LocalCache
842842
return None
843843

844844
if _CACHE is not None and _CACHE._root != Path(config.local_cache.directory):
845-
log.debug(f"Clearing old cache directory {_CACHE._root}")
846-
_CACHE.clear(hard=True)
845+
old_root = _CACHE._root
846+
new_root = Path(config.local_cache.directory)
847+
log.debug(f"Moving cache directory from {old_root}{new_root}")
848+
try:
849+
new_root.parent.mkdir(parents=True, exist_ok=True)
850+
if old_root.exists():
851+
shutil.move(old_root, new_root)
852+
except Exception as e:
853+
log.warning(f"Failed to move cache directory: {e}. Delete old cache.")
854+
shutil.rmtree(old_root)
847855

848856
_CACHE = LocalCache(
849857
directory=config.local_cache.directory,

tidy3d/web/cli/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
legacy_config_directory,
1919
migrate_legacy_config,
2020
)
21+
from tidy3d.web.cli.cache import cache_group
2122
from tidy3d.web.cli.constants import TIDY3D_DIR
2223
from tidy3d.web.core.constants import HEADER_APIKEY
2324

@@ -217,3 +218,4 @@ def config_group() -> None:
217218
tidy3d_cli.add_command(convert)
218219
tidy3d_cli.add_command(develop)
219220
tidy3d_cli.add_command(config_group, name="config")
221+
tidy3d_cli.add_command(cache_group)

tidy3d/web/cli/cache.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Cache-related CLI commands."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
import click
8+
9+
from tidy3d import config
10+
from tidy3d.web.cache import LocalCache, resolve_local_cache
11+
from tidy3d.web.cache import clear as clear_cache
12+
13+
14+
def _fmt_size(num_bytes: int) -> str:
15+
"""Format bytes into human-readable form."""
16+
for unit in ["B", "KB", "MB", "GB", "TB"]:
17+
if num_bytes < 1024.0 or unit == "TB":
18+
return f"{num_bytes:.2f} {unit}"
19+
num_bytes /= 1024.0
20+
return f"{num_bytes:.2f} B" # fallback, though unreachable
21+
22+
23+
def _get_cache(ensure: bool = True) -> Optional[LocalCache]:
24+
"""Resolve the local cache object, surfacing errors as ClickExceptions."""
25+
try:
26+
cache = resolve_local_cache(use_cache=True)
27+
except Exception as exc: # pragma: no cover - defensive guard
28+
raise click.ClickException(f"Failed to access local cache: {exc}") from exc
29+
if cache is None and ensure:
30+
raise click.ClickException("Local cache is disabled in the current configuration.")
31+
return cache
32+
33+
34+
@click.group(name="cache")
35+
def cache_group() -> None:
36+
"""Inspect or manage the local cache."""
37+
38+
39+
@cache_group.command()
40+
def info() -> None:
41+
"""Display current cache configuration and usage statistics."""
42+
43+
enabled = bool(config.local_cache.enabled)
44+
directory = config.local_cache.directory
45+
max_entries = config.local_cache.max_entries
46+
max_size_gb = config.local_cache.max_size_gb
47+
48+
cache = _get_cache(ensure=False)
49+
entries = 0
50+
total_size = 0
51+
if cache is not None:
52+
stats = cache.sync_stats()
53+
entries = stats.total_entries
54+
total_size = stats.total_size
55+
56+
click.echo(f"Enabled: {'yes' if enabled else 'no'}")
57+
click.echo(f"Directory: {directory}")
58+
click.echo(f"Entries: {entries}")
59+
click.echo(f"Total size: {_fmt_size(total_size)}")
60+
click.echo("Max entries: " + (str(max_entries) if max_entries else "unlimited"))
61+
click.echo(
62+
"Max size: " + (f"{_fmt_size(max_size_gb * 1024**3)}" if max_size_gb else "unlimited")
63+
)
64+
65+
66+
@cache_group.command(name="list")
67+
def list() -> None:
68+
"""List cached entries in a readable, separated format."""
69+
70+
cache = _get_cache()
71+
entries = cache.list()
72+
if not entries:
73+
click.echo("Cache is empty.")
74+
return
75+
76+
def fmt_key(key: str) -> str:
77+
return key.replace("_", " ").capitalize()
78+
79+
for i, entry in enumerate(entries, start=1):
80+
entry.pop("simulation_hash", None)
81+
entry.pop("checksum", None)
82+
entry["file_size"] = _fmt_size(entry["file_size"])
83+
84+
click.echo(f"\n=== Cache Entry #{i} ===")
85+
for k, v in entry.items():
86+
click.echo(f"{fmt_key(k)}: {v}")
87+
click.echo("")
88+
89+
90+
@cache_group.command()
91+
def clear() -> None:
92+
"""Remove all cache contents."""
93+
94+
clear_cache()
95+
click.echo("Local cache cleared.")

0 commit comments

Comments
 (0)