From cc5ef517c4033c0c478c876103275b42689c8779 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 4 Nov 2026 00:49:57 -0500 Subject: [PATCH 1/6] fix: keep the modules cache when the downloaded JSON is malformed _download_modules_json opened the cache with mode w before decoding the response, so an HTTP 200 with a malformed body truncated the cache and the CLI could not start. Decode first and write the replacement to a temporary file that replaces the cache only once complete. Co-Authored-By: Claude Sonnet 5.5 --- lean/models/__init__.py | 12 +++++- tests/models/test_download_modules_json.py | 44 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/models/test_download_modules_json.py diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 115e20e5..885e59b6 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -31,9 +31,17 @@ def _download_modules_json() -> None: res = get(url, timeout=5) if res.ok: from json import dump + # Decode the response and write a temporary file first, so a malformed response + # or a failed write leaves the existing file untouched. + data = res.json() file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf-8') as f: - dump(res.json(), f, ensure_ascii=False, indent=4) + temporary_file_path = file_path.with_name(file_path.name + ".tmp") + try: + with open(temporary_file_path, 'w', encoding='utf-8') as f: + dump(data, f, ensure_ascii=False, indent=4) + temporary_file_path.replace(file_path) + finally: + temporary_file_path.unlink(missing_ok=True) else: res.raise_for_status() diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py new file mode 100644 index 00000000..0376edbb --- /dev/null +++ b/tests/models/test_download_modules_json.py @@ -0,0 +1,44 @@ +import json +from pathlib import Path + +import pytest +from requests.exceptions import JSONDecodeError +from responses import RequestsMock + +import lean.models as models + + +def _write_cache(path: Path) -> str: + content = json.dumps({"modules": {"SavedModule": {"version": "1.0"}}}) + path.write_text(content, encoding="utf-8") + return content + + +def test_download_modules_json_keeps_cache_when_response_is_not_json(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + content = _write_cache(cache) + monkeypatch.setattr(models, "file_path", cache) + requests_mock.add(requests_mock.GET, models.url, body="not json", status=200) + + with pytest.raises(JSONDecodeError): + models._download_modules_json() + + assert cache.read_text(encoding="utf-8") == content + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] + + +def test_download_modules_json_replaces_cache_with_valid_response(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + _write_cache(cache) + monkeypatch.setattr(models, "file_path", cache) + new_modules = {"modules": {"NewModule": {"version": "2.0"}}} + requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) + + models._download_modules_json() + + assert json.loads(cache.read_text(encoding="utf-8")) == new_modules + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] From 4932d674c11eb40e04d772080e81d913f221984b Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 4 Nov 2026 09:51:53 -0500 Subject: [PATCH 2/6] fix: replace the modules cache through os functions pathlib's replace and unlink bypass pyfakefs on Python 3.10, which made test_force_refresh_modules_json_downloads_file fail on the 3.10 CI job. Use os.replace and os.remove instead. Co-Authored-By: Claude Sonnet 5.5 --- lean/models/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 885e59b6..1a462489 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import path +from os import path, remove, replace from json import load from pathlib import Path from time import time @@ -39,9 +39,10 @@ def _download_modules_json() -> None: try: with open(temporary_file_path, 'w', encoding='utf-8') as f: dump(data, f, ensure_ascii=False, indent=4) - temporary_file_path.replace(file_path) + replace(temporary_file_path, file_path) finally: - temporary_file_path.unlink(missing_ok=True) + if path.exists(temporary_file_path): + remove(temporary_file_path) else: res.raise_for_status() From 0468910af3ad015fd4fb5bfe52d5baa06a98a3e0 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:30:45 +0000 Subject: [PATCH 3/6] Use a per-process temporary modules file and add the test license header Two CLI processes refreshing the modules cache at once shared the same temporary file, so one replace could fail. Name it after the process id. Co-Authored-By: Claude Opus 5.5 --- lean/models/__init__.py | 7 ++++--- tests/models/test_download_modules_json.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 388bf9d5..7d297df6 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import path, remove, replace +from os import getpid, path, remove, replace from json import load from pathlib import Path from time import time @@ -32,10 +32,11 @@ def _download_modules_json() -> None: if res.ok: from json import dump # Decode the response and write a temporary file first, so a malformed response - # or a failed write leaves the existing file untouched. + # or a failed write leaves the existing file untouched; the process id keeps + # concurrent refreshes from sharing the temporary file. data = res.json() file_path.parent.mkdir(parents=True, exist_ok=True) - temporary_file_path = file_path.with_name(file_path.name + ".tmp") + temporary_file_path = file_path.with_name(f"{file_path.name}.{getpid()}.tmp") try: with open(temporary_file_path, 'w', encoding='utf-8') as f: dump(data, f, ensure_ascii=False, indent=4) diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py index 0376edbb..d50a0363 100644 --- a/tests/models/test_download_modules_json.py +++ b/tests/models/test_download_modules_json.py @@ -1,3 +1,16 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import json from pathlib import Path From 2df8f3d43346d361aaba82a13894f0a7ea779da1 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:28:52 +0000 Subject: [PATCH 4/6] Keep the modules cache's owner and mode when replacing it The temporary file took the refreshing process's owner and umask, and os.replace carried them to the cache. A refresh run with sudo under umask 077 left a root-owned 0600 cache that the next run without sudo could not read. Copy the existing cache's owner and mode to the temporary file first; if that fails, the old cache is kept. Co-Authored-By: Claude Opus 5.5 --- lean/models/__init__.py | 11 ++++- tests/models/test_download_modules_json.py | 55 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 7d297df6..b36c8e74 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -11,9 +11,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import getpid, path, remove, replace +from os import chmod, getpid, path, remove, replace, stat from json import load from pathlib import Path +from stat import S_IMODE +from sys import platform from time import time json_modules = {} @@ -40,6 +42,13 @@ def _download_modules_json() -> None: try: with open(temporary_file_path, 'w', encoding='utf-8') as f: dump(data, f, ensure_ascii=False, indent=4) + if platform != "win32" and path.exists(file_path): + # The replacement takes the existing file's owner and mode, so a refresh run with sudo + # doesn't leave a file that later runs without it can't read + from os import chown + existing = stat(file_path) + chown(temporary_file_path, existing.st_uid, existing.st_gid) + chmod(temporary_file_path, S_IMODE(existing.st_mode)) replace(temporary_file_path, file_path) finally: if path.exists(temporary_file_path): diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py index d50a0363..d4c6b774 100644 --- a/tests/models/test_download_modules_json.py +++ b/tests/models/test_download_modules_json.py @@ -12,9 +12,13 @@ # limitations under the License. import json +import os +import stat +import sys from pathlib import Path import pytest +from pyfakefs.helpers import reset_ids, set_gid, set_uid from requests.exceptions import JSONDecodeError from responses import RequestsMock @@ -55,3 +59,54 @@ def test_download_modules_json_replaces_cache_with_valid_response(requests_mock: assert json.loads(cache.read_text(encoding="utf-8")) == new_modules assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] + + +@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") +def test_download_modules_json_keeps_cache_owner_and_mode_when_refreshed_as_root(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + _write_cache(cache) + os.chown(cache, 1000, 1000) + os.chmod(cache, 0o644) + monkeypatch.setattr(models, "file_path", cache) + new_modules = {"modules": {"NewModule": {"version": "2.0"}}} + requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) + + set_uid(0) + set_gid(0) + previous_umask = os.umask(0o077) + try: + models._download_modules_json() + finally: + os.umask(previous_umask) + set_uid(1000) + set_gid(1000) + + try: + cache_stat = os.stat(cache) + assert (cache_stat.st_uid, cache_stat.st_gid, stat.S_IMODE(cache_stat.st_mode)) == (1000, 1000, 0o644) + assert json.loads(cache.read_text(encoding="utf-8")) == new_modules + finally: + reset_ids() + + +@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") +def test_download_modules_json_keeps_cache_when_its_owner_cannot_be_kept(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + content = _write_cache(cache) + monkeypatch.setattr(models, "file_path", cache) + requests_mock.add(requests_mock.GET, models.url, json={"modules": {}}, status=200) + + def chown(*args, **kwargs) -> None: + raise PermissionError("Operation not permitted") + + monkeypatch.setattr(os, "chown", chown) + + with pytest.raises(PermissionError): + models._download_modules_json() + + assert cache.read_text(encoding="utf-8") == content + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] From 2d85f0ef50484b79d8847e9520ce2e5a91f6edd0 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:05:27 +0000 Subject: [PATCH 5/6] Change the modules cache's owner only when it differs chown ran on every refresh, so a user whose cache had a group they are not a member of could no longer refresh it, and an empty placeholder then stopped the CLI from starting. Change the owner only when it differs, and keep the group on a best-effort basis: the owner can read the file whatever its group. Co-Authored-By: Claude Opus 5.5 --- lean/models/__init__.py | 10 +++++++- tests/models/test_download_modules_json.py | 27 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lean/models/__init__.py b/lean/models/__init__.py index b36c8e74..c8508f81 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -12,6 +12,7 @@ # limitations under the License. from os import chmod, getpid, path, remove, replace, stat +from contextlib import suppress from json import load from pathlib import Path from stat import S_IMODE @@ -47,7 +48,14 @@ def _download_modules_json() -> None: # doesn't leave a file that later runs without it can't read from os import chown existing = stat(file_path) - chown(temporary_file_path, existing.st_uid, existing.st_gid) + temporary = stat(temporary_file_path) + if temporary.st_uid != existing.st_uid: + chown(temporary_file_path, existing.st_uid, existing.st_gid) + elif temporary.st_gid != existing.st_gid: + # The owner can read the file whatever its group, and changing the group to one + # the owner isn't a member of needs root + with suppress(PermissionError): + chown(temporary_file_path, -1, existing.st_gid) chmod(temporary_file_path, S_IMODE(existing.st_mode)) replace(temporary_file_path, file_path) finally: diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py index d4c6b774..38c20b64 100644 --- a/tests/models/test_download_modules_json.py +++ b/tests/models/test_download_modules_json.py @@ -18,7 +18,7 @@ from pathlib import Path import pytest -from pyfakefs.helpers import reset_ids, set_gid, set_uid +from pyfakefs.helpers import get_gid, get_uid, reset_ids, set_gid, set_uid from requests.exceptions import JSONDecodeError from responses import RequestsMock @@ -97,6 +97,7 @@ def test_download_modules_json_keeps_cache_when_its_owner_cannot_be_kept(request cache = Path.cwd() / "cache" / "modules.json" cache.parent.mkdir() content = _write_cache(cache) + os.chown(cache, get_uid() + 1, get_gid() + 1) monkeypatch.setattr(models, "file_path", cache) requests_mock.add(requests_mock.GET, models.url, json={"modules": {}}, status=200) @@ -110,3 +111,27 @@ def chown(*args, **kwargs) -> None: assert cache.read_text(encoding="utf-8") == content assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] + + +@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") +@pytest.mark.parametrize("group_offset", [0, 1]) +def test_download_modules_json_replaces_cache_when_only_its_group_cannot_be_kept(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch, + group_offset: int) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + _write_cache(cache) + os.chown(cache, get_uid(), get_gid() + group_offset) + monkeypatch.setattr(models, "file_path", cache) + new_modules = {"modules": {"NewModule": {"version": "2.0"}}} + requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) + + def chown(*args, **kwargs) -> None: + raise PermissionError("Operation not permitted") + + monkeypatch.setattr(os, "chown", chown) + + models._download_modules_json() + + assert json.loads(cache.read_text(encoding="utf-8")) == new_modules + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] From fa52e2eb7ab7d050ac0416a339be518c16ba495d Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Wed, 4 Nov 2026 16:27:49 -0300 Subject: [PATCH 6/6] Write the modules file in place, once the response is decoded and has modules The response is decoded and checked for a `modules` key before the file is opened, since opening it for writing empties it. A malformed or empty body, or JSON that isn't a modules file, now raises before the file is touched, and the callers keep the previous modules. Writing in place, as before, keeps the file's owner, permissions and ACLs, which the earlier temporary-file replacement lost, so the owner and mode copying and their tests are gone. Co-Authored-By: Claude Opus 5.5 --- lean/models/__init__.py | 39 ++------- tests/models/test_download_modules_json.py | 99 ++-------------------- 2 files changed, 17 insertions(+), 121 deletions(-) diff --git a/lean/models/__init__.py b/lean/models/__init__.py index c8508f81..9529bd3b 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -11,12 +11,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import chmod, getpid, path, remove, replace, stat -from contextlib import suppress +from os import path from json import load from pathlib import Path -from stat import S_IMODE -from sys import platform from time import time json_modules = {} @@ -33,34 +30,16 @@ def _download_modules_json() -> None: from requests import get res = get(url, timeout=5) if res.ok: - from json import dump - # Decode the response and write a temporary file first, so a malformed response - # or a failed write leaves the existing file untouched; the process id keeps - # concurrent refreshes from sharing the temporary file. + from json import dumps + # Decoded and checked before the file is opened, since opening it for writing empties it: a response that + # isn't a modules file would replace a usable one with one the CLI can't start from data = res.json() + if not isinstance(data, dict) or "modules" not in data: + raise ValueError(f"{url} returned no modules") + content = dumps(data, ensure_ascii=False, indent=4) file_path.parent.mkdir(parents=True, exist_ok=True) - temporary_file_path = file_path.with_name(f"{file_path.name}.{getpid()}.tmp") - try: - with open(temporary_file_path, 'w', encoding='utf-8') as f: - dump(data, f, ensure_ascii=False, indent=4) - if platform != "win32" and path.exists(file_path): - # The replacement takes the existing file's owner and mode, so a refresh run with sudo - # doesn't leave a file that later runs without it can't read - from os import chown - existing = stat(file_path) - temporary = stat(temporary_file_path) - if temporary.st_uid != existing.st_uid: - chown(temporary_file_path, existing.st_uid, existing.st_gid) - elif temporary.st_gid != existing.st_gid: - # The owner can read the file whatever its group, and changing the group to one - # the owner isn't a member of needs root - with suppress(PermissionError): - chown(temporary_file_path, -1, existing.st_gid) - chmod(temporary_file_path, S_IMODE(existing.st_mode)) - replace(temporary_file_path, file_path) - finally: - if path.exists(temporary_file_path): - remove(temporary_file_path) + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) else: res.raise_for_status() diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py index 38c20b64..bfca6049 100644 --- a/tests/models/test_download_modules_json.py +++ b/tests/models/test_download_modules_json.py @@ -12,14 +12,9 @@ # limitations under the License. import json -import os -import stat -import sys from pathlib import Path import pytest -from pyfakefs.helpers import get_gid, get_uid, reset_ids, set_gid, set_uid -from requests.exceptions import JSONDecodeError from responses import RequestsMock import lean.models as models @@ -31,25 +26,24 @@ def _write_cache(path: Path) -> str: return content -def test_download_modules_json_keeps_cache_when_response_is_not_json(requests_mock: RequestsMock, - monkeypatch: pytest.MonkeyPatch) -> None: - cache = Path.cwd() / "cache" / "modules.json" - cache.parent.mkdir() +@pytest.mark.parametrize("body", ["not json", "", "{}", "[]", "null"]) +def test_download_modules_json_keeps_cache_when_response_has_no_modules(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch, + body: str) -> None: + cache = Path.cwd() / "modules.json" content = _write_cache(cache) monkeypatch.setattr(models, "file_path", cache) - requests_mock.add(requests_mock.GET, models.url, body="not json", status=200) + requests_mock.add(requests_mock.GET, models.url, body=body, status=200) - with pytest.raises(JSONDecodeError): + with pytest.raises(ValueError): models._download_modules_json() assert cache.read_text(encoding="utf-8") == content - assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] def test_download_modules_json_replaces_cache_with_valid_response(requests_mock: RequestsMock, monkeypatch: pytest.MonkeyPatch) -> None: - cache = Path.cwd() / "cache" / "modules.json" - cache.parent.mkdir() + cache = Path.cwd() / "modules.json" _write_cache(cache) monkeypatch.setattr(models, "file_path", cache) new_modules = {"modules": {"NewModule": {"version": "2.0"}}} @@ -58,80 +52,3 @@ def test_download_modules_json_replaces_cache_with_valid_response(requests_mock: models._download_modules_json() assert json.loads(cache.read_text(encoding="utf-8")) == new_modules - assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] - - -@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") -def test_download_modules_json_keeps_cache_owner_and_mode_when_refreshed_as_root(requests_mock: RequestsMock, - monkeypatch: pytest.MonkeyPatch) -> None: - cache = Path.cwd() / "cache" / "modules.json" - cache.parent.mkdir() - _write_cache(cache) - os.chown(cache, 1000, 1000) - os.chmod(cache, 0o644) - monkeypatch.setattr(models, "file_path", cache) - new_modules = {"modules": {"NewModule": {"version": "2.0"}}} - requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) - - set_uid(0) - set_gid(0) - previous_umask = os.umask(0o077) - try: - models._download_modules_json() - finally: - os.umask(previous_umask) - set_uid(1000) - set_gid(1000) - - try: - cache_stat = os.stat(cache) - assert (cache_stat.st_uid, cache_stat.st_gid, stat.S_IMODE(cache_stat.st_mode)) == (1000, 1000, 0o644) - assert json.loads(cache.read_text(encoding="utf-8")) == new_modules - finally: - reset_ids() - - -@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") -def test_download_modules_json_keeps_cache_when_its_owner_cannot_be_kept(requests_mock: RequestsMock, - monkeypatch: pytest.MonkeyPatch) -> None: - cache = Path.cwd() / "cache" / "modules.json" - cache.parent.mkdir() - content = _write_cache(cache) - os.chown(cache, get_uid() + 1, get_gid() + 1) - monkeypatch.setattr(models, "file_path", cache) - requests_mock.add(requests_mock.GET, models.url, json={"modules": {}}, status=200) - - def chown(*args, **kwargs) -> None: - raise PermissionError("Operation not permitted") - - monkeypatch.setattr(os, "chown", chown) - - with pytest.raises(PermissionError): - models._download_modules_json() - - assert cache.read_text(encoding="utf-8") == content - assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] - - -@pytest.mark.skipif(sys.platform == "win32", reason="File ownership and modes are POSIX only") -@pytest.mark.parametrize("group_offset", [0, 1]) -def test_download_modules_json_replaces_cache_when_only_its_group_cannot_be_kept(requests_mock: RequestsMock, - monkeypatch: pytest.MonkeyPatch, - group_offset: int) -> None: - cache = Path.cwd() / "cache" / "modules.json" - cache.parent.mkdir() - _write_cache(cache) - os.chown(cache, get_uid(), get_gid() + group_offset) - monkeypatch.setattr(models, "file_path", cache) - new_modules = {"modules": {"NewModule": {"version": "2.0"}}} - requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) - - def chown(*args, **kwargs) -> None: - raise PermissionError("Operation not permitted") - - monkeypatch.setattr(os, "chown", chown) - - models._download_modules_json() - - assert json.loads(cache.read_text(encoding="utf-8")) == new_modules - assert [p.name for p in cache.parent.iterdir()] == ["modules.json"]