Skip to content

Commit cd3fada

Browse files
committed
fix(skills): report inaccessible skill directories to on_error
_list_skills_in_dir checked Path.is_dir() before its try block. A stat failure on a child either escaped without reaching on_error (Python 3.11-3.13) or, on 3.14+, was swallowed as False and the skill was silently dropped. Use stat() with S_ISDIR and route OSError through the same on_error / warning path as other per-skill failures. Missing or dangling entries are still skipped as non-directories. Fixes google#7749
1 parent 2084efb commit cd3fada

2 files changed

Lines changed: 109 additions & 12 deletions

File tree

‎src/google/adk/skills/_utils.py‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import logging
2222
import pathlib
2323
import re
24+
import stat
2425
from typing import Any
2526
from typing import Callable
2627
from typing import cast
@@ -597,11 +598,34 @@ def _list_skills_in_dir(
597598
)
598599
return skills
599600

601+
def report(skill_id: str, error: Exception) -> None:
602+
if on_error is not None:
603+
on_error(skill_id, error)
604+
return
605+
# log invalid skills during listing and skip them
606+
logging.warning(
607+
"Skipping invalid skill '%s' in directory '%s': %s",
608+
skill_id,
609+
skills_base_path,
610+
error,
611+
)
612+
600613
for skill_dir in sorted(skills_base_path.iterdir()):
601-
if not skill_dir.is_dir():
614+
skill_id = skill_dir.name
615+
# Path.is_dir() returns False for any OSError on Python 3.14+, which would
616+
# hide an inaccessible entry from `on_error`. Use stat() so that failures
617+
# are surfaced the same way on every supported Python version.
618+
try:
619+
is_dir = stat.S_ISDIR(skill_dir.stat().st_mode)
620+
except (FileNotFoundError, NotADirectoryError):
621+
# Dangling symlink or an entry removed mid-listing: not a skill dir.
622+
continue
623+
except OSError as e:
624+
report(skill_id, e)
625+
continue
626+
if not is_dir:
602627
continue
603628

604-
skill_id = skill_dir.name
605629
try:
606630
frontmatter = _read_skill_properties(skill_dir)
607631
if skill_id != frontmatter.name:
@@ -611,16 +635,7 @@ def _list_skills_in_dir(
611635
)
612636
skills[skill_id] = frontmatter
613637
except (FileNotFoundError, ValueError, ValidationError) as e:
614-
if on_error is not None:
615-
on_error(skill_id, e)
616-
continue
617-
# log invalid skills during listing and skip them
618-
logging.warning(
619-
"Skipping invalid skill '%s' in directory '%s': %s",
620-
skill_id,
621-
skills_base_path,
622-
e,
623-
)
638+
report(skill_id, e)
624639
return skills
625640

626641

‎tests/unittests/skills/test__utils.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,88 @@ def strict(skill_id, error):
716716
list_skills_in_dir(_skills_dir_with_one_invalid(tmp_path), on_error=strict)
717717

718718

719+
def _skills_dir_with_blocked_child(tmp_path):
720+
"""Builds a skills dir with a valid 'good' skill and an empty 'blocked' dir."""
721+
skills_dir = tmp_path / "skills"
722+
(skills_dir / "good").mkdir(parents=True)
723+
(skills_dir / "good" / "SKILL.md").write_text(
724+
"---\nname: good\ndescription: desc\n---\nbody"
725+
)
726+
(skills_dir / "blocked").mkdir()
727+
return skills_dir
728+
729+
730+
def _stat_denying(blocked_name):
731+
"""Returns a Path.stat replacement that denies one child by name."""
732+
real_stat = pathlib.Path.stat
733+
734+
def fake_stat(self, *args, **kwargs):
735+
if self.name == blocked_name:
736+
raise PermissionError(13, "Permission denied", str(self))
737+
return real_stat(self, *args, **kwargs)
738+
739+
return fake_stat
740+
741+
742+
def test_list_skills_in_dir_reports_inaccessible_child_to_on_error(tmp_path):
743+
"""A child whose metadata cannot be read is reported, not silently dropped."""
744+
skills_dir = _skills_dir_with_blocked_child(tmp_path)
745+
failures = []
746+
747+
with mock.patch.object(pathlib.Path, "stat", _stat_denying("blocked")):
748+
skills = list_skills_in_dir(
749+
skills_dir,
750+
on_error=lambda skill_id, error: failures.append((skill_id, error)),
751+
)
752+
753+
assert list(skills) == ["good"]
754+
assert [skill_id for skill_id, _ in failures] == ["blocked"]
755+
assert isinstance(failures[0][1], PermissionError)
756+
757+
758+
def test_list_skills_in_dir_on_error_can_abort_on_inaccessible_child(tmp_path):
759+
"""Raising from on_error aborts the listing for an inaccessible child."""
760+
skills_dir = _skills_dir_with_blocked_child(tmp_path)
761+
762+
def strict(skill_id, error):
763+
del skill_id # Unused: the error already names the skill.
764+
raise error
765+
766+
with mock.patch.object(pathlib.Path, "stat", _stat_denying("blocked")):
767+
with pytest.raises(PermissionError):
768+
list_skills_in_dir(skills_dir, on_error=strict)
769+
770+
771+
def test_list_skills_in_dir_logs_inaccessible_child_without_on_error(
772+
tmp_path, caplog
773+
):
774+
"""Without on_error an inaccessible child is logged and skipped."""
775+
skills_dir = _skills_dir_with_blocked_child(tmp_path)
776+
777+
with mock.patch.object(pathlib.Path, "stat", _stat_denying("blocked")):
778+
skills = list_skills_in_dir(skills_dir)
779+
780+
assert list(skills) == ["good"]
781+
assert "Skipping invalid skill 'blocked'" in caplog.text
782+
783+
784+
def test_list_skills_in_dir_ignores_dangling_symlink_and_files(tmp_path):
785+
"""Non-directory entries are skipped without invoking on_error."""
786+
skills_dir = _skills_dir_with_blocked_child(tmp_path)
787+
(skills_dir / "blocked").rmdir()
788+
(skills_dir / "notes.txt").write_text("not a skill")
789+
(skills_dir / "dangling").symlink_to(tmp_path / "missing")
790+
failures = []
791+
792+
skills = list_skills_in_dir(
793+
skills_dir,
794+
on_error=lambda skill_id, error: failures.append((skill_id, error)),
795+
)
796+
797+
assert list(skills) == ["good"]
798+
assert not failures
799+
800+
719801
def test__load_skill_from_zip_bytes():
720802
"""Tests loading a skill directly from in-memory zip file bytes."""
721803

0 commit comments

Comments
 (0)