Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,961 changes: 1,961 additions & 0 deletions constraints-3.10.txt

Large diffs are not rendered by default.

2,261 changes: 2,261 additions & 0 deletions constraints-3.11.txt

Large diffs are not rendered by default.

1,937 changes: 1,937 additions & 0 deletions constraints-3.12.txt

Large diffs are not rendered by default.

1,917 changes: 1,917 additions & 0 deletions constraints-3.13.txt

Large diffs are not rendered by default.

1,917 changes: 1,917 additions & 0 deletions constraints-3.14.txt

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,11 @@ known_third_party = [ "a2a", "google.adk" ]
# hel/serie/strin -> substrings in test fixtures; te -> local variable;
# rouge -> the ROUGE metric; unparseable -> valid spelling variant;
# re-use/re-used -> intentional hyphenation; lamda -> Google LaMDA project.
ignore-words-list = "hel,serie,strin,te,rouge,unparseable,re-use,re-used,lamda"
ignore-words-list = "hel,serie,strin,te,rouge,unparseable,re-use,re-used,lamda,astroid"
# CHANGELOG.md is generated from commit messages; lockfiles, notebooks, JSON
# fixtures, bundled JS/source maps, and the vendored CLI browser bundle are
# generated or data files, not prose we own.
skip = "*CHANGELOG.md,*.lock,*.ipynb,*.json,*.js,*.map,*/cli/browser/*"
skip = "*CHANGELOG.md,*.lock,*.ipynb,*.json,*.js,*.map,*/cli/browser/*,constraints-*.txt"

[tool.mypy]
mypy_path = [ "src" ]
Expand Down
8 changes: 8 additions & 0 deletions src/google/adk/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
import warnings

from ._utils import _list_skills_in_dir as list_skills_in_dir
from ._utils import _list_skills_in_dir_async as list_skills_in_dir_async
from ._utils import _list_skills_in_gcs_dir as list_skills_in_gcs_dir
from ._utils import _list_skills_in_gcs_dir_async as list_skills_in_gcs_dir_async
from ._utils import _load_skill_from_dir as load_skill_from_dir
from ._utils import _load_skill_from_dir_async as load_skill_from_dir_async
from ._utils import _load_skill_from_gcs_dir as load_skill_from_gcs_dir
from ._utils import _load_skill_from_gcs_dir_async as load_skill_from_gcs_dir_async
from ._utils import _load_skills_from_dir as load_skills_from_dir
from .models import Frontmatter
from .models import Resources
Expand All @@ -36,9 +40,13 @@
"Skill",
"SkillRegistry",
"list_skills_in_dir",
"list_skills_in_dir_async",
"list_skills_in_gcs_dir",
"list_skills_in_gcs_dir_async",
"load_skill_from_dir",
"load_skill_from_dir_async",
"load_skill_from_gcs_dir",
"load_skill_from_gcs_dir_async",
"load_skills_from_dir",
]

Expand Down
84 changes: 84 additions & 0 deletions src/google/adk/skills/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import asyncio
import io
import logging
import pathlib
Expand Down Expand Up @@ -586,3 +587,86 @@ def _load_files_in_dir(subdir: str) -> Dict[str, Union[str, bytes]]:
instructions=body,
resources=resources,
)


async def _load_skill_from_dir_async(
skill_dir: Union[str, pathlib.Path],
) -> models.Skill:
"""Load a complete skill from a directory asynchronously.

Args:
skill_dir: Path to the skill directory.

Returns:
Skill object with all components loaded.
"""
return await asyncio.to_thread(_load_skill_from_dir, skill_dir)


async def _load_skill_from_gcs_dir_async(
bucket_name: str,
skill_id: str,
skills_base_path: str = "",
project_id: str | None = None,
credentials: auth.Credentials | None = None,
) -> models.Skill:
"""Load a complete skill from a GCS directory asynchronously.

Args:
bucket_name: Name of the GCS bucket.
skill_id: The ID of the skill (directory name).
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
project_id: Project ID to use for GCS client.
credentials: Credentials to use for GCS client.

Returns:
Skill object with all components loaded.
"""
return await asyncio.to_thread(
_load_skill_from_gcs_dir,
bucket_name,
skill_id,
skills_base_path,
project_id,
credentials,
)


async def _list_skills_in_dir_async(
skills_base_path: Union[str, pathlib.Path],
) -> dict[str, models.Frontmatter]:
"""List skills in a local directory asynchronously.

Args:
skills_base_path: Path to the base directory containing skills.

Returns:
Dictionary mapping skill IDs to their frontmatter.
"""
return await asyncio.to_thread(_list_skills_in_dir, skills_base_path)


async def _list_skills_in_gcs_dir_async(
bucket_name: str,
skills_base_path: str = "",
project_id: str | None = None,
credentials: auth.Credentials | None = None,
) -> dict[str, models.Frontmatter]:
"""List skills in a GCS directory asynchronously.

Args:
bucket_name: Name of the GCS bucket.
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
project_id: Project ID to use for GCS client.
credentials: Credentials to use for GCS client.

Returns:
Dictionary mapping skill IDs to their frontmatter.
"""
return await asyncio.to_thread(
_list_skills_in_gcs_dir,
bucket_name,
skills_base_path,
project_id,
credentials,
)
118 changes: 118 additions & 0 deletions tests/unittests/skills/test__utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
import zipfile

from google.adk.skills import list_skills_in_dir
from google.adk.skills import list_skills_in_dir_async as _list_skills_in_dir_async
from google.adk.skills import list_skills_in_gcs_dir as _list_skills_in_gcs_dir
from google.adk.skills import list_skills_in_gcs_dir_async as _list_skills_in_gcs_dir_async
from google.adk.skills import load_skill_from_dir as _load_skill_from_dir
from google.adk.skills import load_skill_from_dir_async as _load_skill_from_dir_async
from google.adk.skills import load_skill_from_gcs_dir as _load_skill_from_gcs_dir
from google.adk.skills import load_skill_from_gcs_dir_async as _load_skill_from_gcs_dir_async
from google.adk.skills import load_skills_from_dir as _load_skills_from_dir
from google.adk.skills._utils import _load_skill_from_zip_bytes
from google.adk.skills._utils import _read_skill_properties
Expand Down Expand Up @@ -396,6 +400,120 @@ def mock_import(name, globals=None, locals=None, fromlist=(), level=0):
_load_skill_from_gcs_dir("my-bucket", "skills/my-skill/")


@pytest.mark.asyncio
async def test_load_skill_from_dir_async(tmp_path):
"""Tests loading a skill from a directory asynchronously."""
skill_dir = tmp_path / "test-skill"
skill_dir.mkdir()

skill_md_content = """---
name: test-skill
description: Test description
---
Test instructions
"""
(skill_dir / "SKILL.md").write_text(skill_md_content)

# Create references
ref_dir = skill_dir / "references"
ref_dir.mkdir()
(ref_dir / "ref1.md").write_text("ref1 content")

skill = await _load_skill_from_dir_async(skill_dir)

assert skill.name == "test-skill"
assert skill.description == "Test description"
assert skill.instructions == "Test instructions"
assert skill.resources.get_reference("ref1.md") == "ref1 content"


@pytest.mark.asyncio
async def test_list_skills_in_dir_async(tmp_path):
"""Tests listing skills in a directory asynchronously."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()

# Valid skill 1
skill1_dir = skills_dir / "skill1"
skill1_dir.mkdir()
(skill1_dir / "SKILL.md").write_text(
"---\nname: skill1\ndescription: desc1\n---\nbody"
)

skills = await _list_skills_in_dir_async(skills_dir)

assert len(skills) == 1
assert "skill1" in skills
assert skills["skill1"].name == "skill1"


@pytest.mark.asyncio
@mock.patch("google.cloud.storage.Client")
async def test_load_skill_from_gcs_dir_async(mock_client_class):
"""Tests loading a skill from GCS asynchronously."""
mock_client = mock.MagicMock()
mock_client_class.return_value = mock_client
mock_bucket = mock.MagicMock()
mock_client.bucket.return_value = mock_bucket

def mock_blob_side_effect(path):
m = mock.MagicMock()
if path.endswith("SKILL.md"):
m.exists.return_value = True
m.download_as_text.return_value = (
"---\nname: my-skill\ndescription: Test description\n---\nTest"
" instructions"
)
else:
m.exists.return_value = False
return m

mock_bucket.blob.side_effect = mock_blob_side_effect

# For resources
def list_blobs_side_effect(prefix=None):
if prefix.endswith("references/"):
m = mock.MagicMock()
m.name = prefix + "ref1.md"
m.download_as_text.return_value = "ref1 content"
return [m]
return []

mock_bucket.list_blobs.side_effect = list_blobs_side_effect

skill = await _load_skill_from_gcs_dir_async("my-bucket", "skills/my-skill/")

assert skill.name == "my-skill"
assert skill.description == "Test description"
assert skill.instructions == "Test instructions"
assert skill.resources.get_reference("ref1.md") == "ref1 content"


@pytest.mark.asyncio
@mock.patch("google.cloud.storage.Client")
async def test_list_skills_in_gcs_dir_async(mock_client_class):
"""Tests listing skills in GCS asynchronously."""
mock_client = mock.MagicMock()
mock_client_class.return_value = mock_client
mock_bucket = mock.MagicMock()
mock_client.bucket.return_value = mock_bucket

mock_iterator = mock.MagicMock()
mock_iterator.prefixes = ["skills/my-skill/"]
mock_bucket.list_blobs.return_value = mock_iterator

mock_blob = mock.MagicMock()
mock_blob.exists.return_value = True
mock_blob.download_as_text.return_value = (
"---\nname: my-skill\ndescription: A skill\n---\nBody"
)
mock_bucket.blob.return_value = mock_blob

skills = await _list_skills_in_gcs_dir_async("my-bucket", "skills/")
assert "my-skill" in skills
assert skills["my-skill"].name == "my-skill"


def test__load_skills_from_dir(tmp_path):
"""Tests loading multiple skills from a directory."""
skills_dir = tmp_path / "skills"
Expand Down
Loading