`hermes_home_key` called `Path.resolve()` on every call, which is a filesystem call. `ToolRegistry.current_scope_key()` calls it, and `_merged_tools()` calls that, so every single registry lookup went to the disk: `get_entry`, `get_toolset_for_tool`, `get_emoji`, and the rest. Anything that looks up many tools in a row paid for it. Building the deferred-tool catalog for `tool_search` calls `get_entry` once per tool, so with 400 deferrable tools one search did 800 path resolutions. On Windows that is `nt._getfinalpathname`, and it took 132ms of the 148ms the catalog build spent. The tokenizing everyone would suspect first was under 4ms of it. The resolved value is now remembered per input path. A process only sees a handful of home paths, so the dict stays tiny. Measured on the same 400-tool catalog build, the lookups drop from 132ms to 3.1ms with one entry cached. A path that does not exist yet is resolved but not remembered, because the answer can change once it is created, for instance when part of the path turns out to be a link. A different home path is a different key, so switching profiles or `HERMES_HOME` gets its own entry rather than reusing a stale one.
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
"""Tests for the remembered results in `hermes_home_key`.
|
|
|
|
`Path.resolve()` is a filesystem call. `hermes_home_key` sits under
|
|
`ToolRegistry.current_scope_key()`, which runs on every registry lookup, so
|
|
before the results were remembered the registry paid a syscall per lookup.
|
|
|
|
The value this returns must not change, so most of these tests compare
|
|
against the plain uncached calculation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_constants as hc
|
|
|
|
|
|
def _uncached(path=None) -> str:
|
|
"""The calculation as it was before results were remembered."""
|
|
candidate = Path(path) if path is not None else hc.get_hermes_home()
|
|
return os.path.normcase(str(candidate.expanduser().resolve(strict=False)))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache():
|
|
hc.reset_hermes_home_key_cache()
|
|
yield
|
|
hc.reset_hermes_home_key_cache()
|
|
|
|
|
|
class TestSameAnswerAsBefore:
|
|
"""Remembering a result must not change what comes back."""
|
|
|
|
@pytest.mark.parametrize("case", ["real", "missing", "trailing_sep", "dot_dot"])
|
|
def test_matches_the_uncached_calculation(self, tmp_path, case):
|
|
(tmp_path / "real").mkdir()
|
|
target = {
|
|
"real": str(tmp_path / "real"),
|
|
"missing": str(tmp_path / "not_there"),
|
|
"trailing_sep": str(tmp_path / "real") + os.sep,
|
|
"dot_dot": str(tmp_path / "real" / ".." / "real"),
|
|
}[case]
|
|
assert hc.hermes_home_key(target) == _uncached(target)
|
|
|
|
def test_matches_for_the_default_home(self):
|
|
assert hc.hermes_home_key() == _uncached()
|
|
|
|
def test_matches_for_a_tilde_path(self):
|
|
assert hc.hermes_home_key("~") == _uncached("~")
|
|
|
|
def test_accepts_a_path_object(self, tmp_path):
|
|
(tmp_path / "real").mkdir()
|
|
assert hc.hermes_home_key(tmp_path / "real") == _uncached(tmp_path / "real")
|
|
|
|
def test_second_call_returns_the_same_string(self, tmp_path):
|
|
(tmp_path / "real").mkdir()
|
|
first = hc.hermes_home_key(str(tmp_path / "real"))
|
|
second = hc.hermes_home_key(str(tmp_path / "real"))
|
|
assert first == second == _uncached(str(tmp_path / "real"))
|
|
|
|
|
|
class TestWhatGetsRemembered:
|
|
def test_an_existing_path_is_remembered(self, tmp_path):
|
|
(tmp_path / "real").mkdir()
|
|
hc.hermes_home_key(str(tmp_path / "real"))
|
|
assert len(hc._HOME_KEY_CACHE) == 1
|
|
|
|
def test_a_missing_path_is_not_remembered(self, tmp_path):
|
|
# The answer can change once the directory is created, for example
|
|
# when part of the path turns out to be a link, so it must not stick.
|
|
hc.hermes_home_key(str(tmp_path / "not_there"))
|
|
assert hc._HOME_KEY_CACHE == {}
|
|
|
|
def test_a_path_created_later_picks_up_the_real_answer(self, tmp_path):
|
|
later = tmp_path / "later"
|
|
before = hc.hermes_home_key(str(later))
|
|
later.mkdir()
|
|
after = hc.hermes_home_key(str(later))
|
|
assert after == _uncached(str(later))
|
|
assert hc._HOME_KEY_CACHE == {str(later): after}
|
|
# On a plain directory both answers agree anyway. The point is that
|
|
# the first one was never stored.
|
|
assert before == after
|
|
|
|
def test_different_paths_get_their_own_entries(self, tmp_path):
|
|
for name in ("a", "b", "c"):
|
|
(tmp_path / name).mkdir()
|
|
hc.hermes_home_key(str(tmp_path / name))
|
|
assert len(hc._HOME_KEY_CACHE) == 3
|
|
|
|
def test_reset_clears_everything(self, tmp_path):
|
|
(tmp_path / "real").mkdir()
|
|
hc.hermes_home_key(str(tmp_path / "real"))
|
|
assert hc._HOME_KEY_CACHE
|
|
hc.reset_hermes_home_key_cache()
|
|
assert hc._HOME_KEY_CACHE == {}
|
|
|
|
|
|
class TestHomeChanges:
|
|
def test_pointing_hermes_home_somewhere_else_gives_a_new_key(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
# A different home is a different input path, so it lands on its own
|
|
# entry rather than reusing the first one.
|
|
first = tmp_path / "home_one"
|
|
second = tmp_path / "home_two"
|
|
first.mkdir()
|
|
second.mkdir()
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(first))
|
|
key_one = hc.hermes_home_key()
|
|
monkeypatch.setenv("HERMES_HOME", str(second))
|
|
key_two = hc.hermes_home_key()
|
|
|
|
assert key_one != key_two
|
|
assert key_one == _uncached(str(first))
|
|
assert key_two == _uncached(str(second))
|
|
|
|
|
|
class TestSymlinks:
|
|
def test_a_link_resolves_to_its_target(self, tmp_path):
|
|
target = tmp_path / "target"
|
|
target.mkdir()
|
|
link = tmp_path / "link"
|
|
try:
|
|
link.symlink_to(target, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("this platform or account cannot create symlinks")
|
|
assert hc.hermes_home_key(str(link)) == _uncached(str(link))
|
|
assert hc.hermes_home_key(str(link)) == hc.hermes_home_key(str(target))
|
|
|
|
|
|
class TestRegistryLookupsDoNotHitTheDisk:
|
|
def test_scope_key_resolves_the_path_once(self, monkeypatch):
|
|
# The reason this cache exists. ToolRegistry.current_scope_key() runs
|
|
# on every registry lookup, so it must not resolve the home path on
|
|
# the filesystem every time.
|
|
from tools.registry import registry
|
|
|
|
calls = {"n": 0}
|
|
real_resolve = Path.resolve
|
|
|
|
def counting_resolve(self, *a, **kw):
|
|
calls["n"] += 1
|
|
return real_resolve(self, *a, **kw)
|
|
|
|
monkeypatch.setattr(Path, "resolve", counting_resolve)
|
|
|
|
registry.current_scope_key()
|
|
after_first = calls["n"]
|
|
for _ in range(50):
|
|
registry.current_scope_key()
|
|
|
|
assert calls["n"] == after_first, (
|
|
f"current_scope_key resolved the path on the filesystem "
|
|
f"{calls['n'] - after_first} extra times across 50 calls"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(pytest.main([__file__, "-q"]))
|