diff --git a/langfuse/_client/skills.py b/langfuse/_client/skills.py index 64f5d5884..6abd93e8b 100644 --- a/langfuse/_client/skills.py +++ b/langfuse/_client/skills.py @@ -21,12 +21,23 @@ def discover_skills(*, dir: str) -> list[SkillReference]: Unlocked skills use their directory name without a version. Lock records do not verify local edits. - Missing directories return an empty list. Unreadable or malformed lockfiles - warn and fall back to directory names. Entries that cannot be inspected, - invalid lock entries, and duplicate names are skipped with a warning. + Missing directories return an empty list; unreadable ones warn and return + an empty list. Unreadable or malformed lockfiles warn and fall back to + directory names. Entries that cannot be inspected, invalid lock entries, + and duplicate names are skipped with a warning. """ root = Path(os.path.abspath(dir)) - if _stat_if_exists(root) is None: + try: + if _stat_if_exists(root) is None: + return [] + with os.scandir(root) as entries: + candidates = sorted(entries, key=lambda entry: entry.name) + except OSError as error: + langfuse_logger.warning( + "Could not read skills directory %s. No skills discovered: %s", + root, + error, + ) return [] locked: dict[Path, SkillReference] = {} @@ -39,8 +50,6 @@ def discover_skills(*, dir: str) -> list[SkillReference]: error, ) available: dict[str, SkillReference] = {} - with os.scandir(root) as entries: - candidates = sorted(entries, key=lambda entry: entry.name) for candidate in candidates: if candidate.name.startswith("."): continue diff --git a/tests/unit/test_skills.py b/tests/unit/test_skills.py index 9b3d98cc3..d4914956d 100644 --- a/tests/unit/test_skills.py +++ b/tests/unit/test_skills.py @@ -150,6 +150,21 @@ def test_discovery_skips_entries_with_filesystem_errors( assert "missing-target" not in caplog.text +@pytest.mark.parametrize("kind", ["file", "symlink-loop"]) +def test_unreadable_skills_directory_returns_empty_list( + tmp_path, monkeypatch, kind, caplog +): + monkeypatch.chdir(tmp_path) + root = tmp_path / "skills" + if kind == "file": + root.write_text("not a directory", encoding="utf-8") + else: + root.symlink_to(root) + + assert discover_skills(dir=str(root)) == [] + assert "Could not read skills directory" in caplog.text + + def test_serialization_preserves_extra_fields(): skills = [{"name": "customer", "version": 2, "futureField": {"nested": True}}] assert json.loads(serialize_available_skills(skills)) == skills