Lint all toolkits (#183)
# PR Description
* Adds/updates the following files to all toolkits:
- `.pre-commit-config.yaml`
- `.ruff.toml`
- `LICENSE`
- `Makefile`
- `pyproject.toml`
* Lint all toolkits such that they pass `make check` and `make test` (a
total doozy). This includes adding some unit tests and evals.
* Github workflow for testing toolkits before merge into main (courtesy
of @sdreyer)
* Added a QOL improvement for tool developers for when they need to get
the context's auth token.
* Minor updates to `arcade new` template.
This commit is contained in:
parent
950a8600f8
commit
ab889f9f1d
127 changed files with 2958 additions and 516 deletions
12
.github/scripts/get_toolkits.sh
vendored
Executable file
12
.github/scripts/get_toolkits.sh
vendored
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Get all directories and format as JSON array
|
||||
echo -n '['
|
||||
ls -d toolkits/*/ | cut -d'/' -f2 | sort -u | awk '{
|
||||
if (NR==1) {
|
||||
printf "\"%s\"", $0
|
||||
} else {
|
||||
printf ", \"%s\"", $0
|
||||
}
|
||||
}'
|
||||
echo ']'
|
||||
5
.github/workflows/publish-toolkit.yml
vendored
5
.github/workflows/publish-toolkit.yml
vendored
|
|
@ -39,15 +39,12 @@ jobs:
|
|||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Python Dependencies
|
||||
run: pip install ./arcade
|
||||
|
||||
- name: Test Toolkit
|
||||
id: Test_Toolkit
|
||||
working-directory: toolkits/${{ steps.set-toolkit.outputs.toolkit }}
|
||||
run: |
|
||||
make check
|
||||
make install
|
||||
make check
|
||||
make test
|
||||
|
||||
- name: Publish Toolkit
|
||||
|
|
|
|||
54
.github/workflows/test-toolkits.yml
vendored
Normal file
54
.github/workflows/test-toolkits.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: Test Toolkits
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tool_matrix: ${{ steps.dataStep.outputs.tools }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Toolkits
|
||||
id: dataStep
|
||||
run: |
|
||||
TARGETS=$(./.github/scripts/get_toolkits.sh)
|
||||
echo "tools=$(jq -cn --argjson environments "$TARGETS" '{target: $environments}')" >> $GITHUB_OUTPUT
|
||||
|
||||
test-toolkits:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.setup.outputs.tool_matrix) }}
|
||||
steps:
|
||||
- run: echo ${{ matrix.target }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Test Toolkit
|
||||
id: Test_Toolkit
|
||||
working-directory: toolkits/${{ matrix.target }}
|
||||
run: |
|
||||
make install
|
||||
make check
|
||||
make test
|
||||
|
|
@ -234,6 +234,10 @@ class ToolContext(BaseModel):
|
|||
user_id: str | None = None
|
||||
"""The user ID for the tool invocation (if any)."""
|
||||
|
||||
def get_auth_token_or_empty(self) -> str:
|
||||
"""Retrieve the authorization token, or return an empty string if not available."""
|
||||
return self.authorization.token if self.authorization and self.authorization.token else ""
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""The request to call (invoke) a tool."""
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ install: ## Install the poetry environment and install the pre-commit hooks
|
|||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "📦 Checking for poetry.lock file"
|
||||
@if [ ! -f poetry.lock ]; then \
|
||||
echo "📦 Creating poetry.lock file"; \
|
||||
poetry lock; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
|
|
@ -28,12 +23,8 @@ build: clean-build ## Build wheel file using poetry
|
|||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: clean-dist
|
||||
clean-dist: ## Clean all built distributions
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
@rm -rf dist
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
|
|
@ -54,9 +45,9 @@ bump-version: ## Bump the version in the pyproject.toml file
|
|||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock"
|
||||
@poetry check --lock
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy $(git ls-files '*.py')
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from arcade.sdk import ToolCatalog
|
|||
from arcade.sdk.eval import (
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
|
@ -35,7 +36,12 @@ def {{ toolkit_name }}_eval_suite() -> EvalSuite:
|
|||
suite.add_case(
|
||||
name="Saying hello",
|
||||
user_message="He's actually right here, say hi to him!",
|
||||
expected_tool_calls=[(say_hello, {"name": "John Doe"})],
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=say_hello,
|
||||
args={"name": "John Doe"}
|
||||
)
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
SimilarityCritic(critic_field="name", weight=0.5),
|
||||
|
|
|
|||
24
arcade/tests/core/test_schema.py
Normal file
24
arcade/tests/core/test_schema.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from arcade.core.schema import ToolAuthorizationContext, ToolContext
|
||||
|
||||
|
||||
def test_get_auth_token_or_empty_with_token():
|
||||
expected_token = "test_token" # noqa: S105
|
||||
auth_context = ToolAuthorizationContext(token=expected_token)
|
||||
tool_context = ToolContext(authorization=auth_context)
|
||||
|
||||
actual_token = tool_context.get_auth_token_or_empty()
|
||||
|
||||
assert actual_token == expected_token
|
||||
|
||||
|
||||
def test_get_auth_token_or_empty_without_token():
|
||||
auth_context = ToolAuthorizationContext(token=None)
|
||||
tool_context = ToolContext(authorization=auth_context)
|
||||
|
||||
assert tool_context.get_auth_token_or_empty() == ""
|
||||
|
||||
|
||||
def test_get_auth_token_or_empty_no_authorization():
|
||||
tool_context = ToolContext(authorization=None)
|
||||
|
||||
assert tool_context.get_auth_token_or_empty() == ""
|
||||
18
toolkits/code_sandbox/.pre-commit-config.yaml
Normal file
18
toolkits/code_sandbox/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
47
toolkits/code_sandbox/.ruff.toml
Normal file
47
toolkits/code_sandbox/.ruff.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/code_sandbox/LICENSE
Normal file
21
toolkits/code_sandbox/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/code_sandbox/Makefile
Normal file
53
toolkits/code_sandbox/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ code_sandbox Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
from typing import Annotated
|
||||
|
||||
from arcade.sdk import tool
|
||||
from e2b_code_interpreter import Sandbox
|
||||
|
||||
from arcade.sdk import tool
|
||||
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
|
||||
from arcade_code_sandbox.tools.utils import get_secret
|
||||
|
||||
|
|
@ -24,16 +24,18 @@ def run_code(
|
|||
with Sandbox(api_key=api_key) as sbx:
|
||||
execution = sbx.run_code(code=code, language=language)
|
||||
|
||||
return execution.to_json()
|
||||
return str(execution.to_json())
|
||||
|
||||
|
||||
# Note: Not recommended to use tool_choice='generate' with this tool since it contains base64 encoded image.
|
||||
# Note: Not recommended to use tool_choice='generate' with this tool
|
||||
# since it contains base64 encoded image.
|
||||
@tool
|
||||
def create_static_matplotlib_chart(
|
||||
code: Annotated[str, "The Python code to run"],
|
||||
) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]:
|
||||
"""
|
||||
Run the provided Python code to generate a static matplotlib chart. The resulting chart is returned as a base64 encoded image.
|
||||
Run the provided Python code to generate a static matplotlib chart.
|
||||
The resulting chart is returned as a base64 encoded image.
|
||||
"""
|
||||
api_key = get_secret("E2B_API_KEY")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import arcade_code_sandbox
|
||||
from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code
|
||||
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -12,6 +8,10 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_code_sandbox
|
||||
from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code
|
||||
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
|
||||
|
||||
merge_sort_code = """
|
||||
def merge_sort(arr):
|
||||
if len(arr) <= 1:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_code_sandbox"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "LLM tools for running code in a sandbox"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -11,7 +11,32 @@ e2b-code-interpreter = "^1.0.1"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_code_sandbox/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
0
toolkits/code_sandbox/tests/__init__.py
Normal file
0
toolkits/code_sandbox/tests/__init__.py
Normal file
61
toolkits/code_sandbox/tests/test_e2b.py
Normal file
61
toolkits/code_sandbox/tests/test_e2b.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code
|
||||
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sandbox():
|
||||
with patch("arcade_code_sandbox.tools.e2b.Sandbox") as mock:
|
||||
yield mock.return_value.__enter__.return_value
|
||||
|
||||
|
||||
def test_run_code_success(mock_sandbox):
|
||||
mock_execution = MagicMock()
|
||||
mock_execution.to_json.return_value = '{"result": "success"}'
|
||||
mock_sandbox.run_code.return_value = mock_execution
|
||||
|
||||
result = run_code("print('Hello, World!')", E2BSupportedLanguage.PYTHON)
|
||||
assert result == '{"result": "success"}'
|
||||
|
||||
|
||||
def test_run_code_error(mock_sandbox):
|
||||
mock_execution = MagicMock()
|
||||
mock_execution.to_json.side_effect = ToolExecutionError("Execution failed")
|
||||
mock_sandbox.run_code.return_value = mock_execution
|
||||
|
||||
with pytest.raises(ToolExecutionError, match="Execution failed"):
|
||||
run_code("print('Hello, World!')", E2BSupportedLanguage.PYTHON)
|
||||
|
||||
|
||||
def test_create_static_matplotlib_chart_success(mock_sandbox):
|
||||
mock_execution = MagicMock()
|
||||
mock_execution.results = [MagicMock(png="base64encodedimage")]
|
||||
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
|
||||
mock_execution.error = None
|
||||
mock_sandbox.run_code.return_value = mock_execution
|
||||
|
||||
result = create_static_matplotlib_chart("import matplotlib.pyplot as plt")
|
||||
assert result == {
|
||||
"base64_image": "base64encodedimage",
|
||||
"logs": '{"logs": "log data"}',
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def test_create_static_matplotlib_chart_error(mock_sandbox):
|
||||
mock_execution = MagicMock()
|
||||
mock_execution.results = []
|
||||
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
|
||||
mock_execution.error.to_json.return_value = '{"error": "some error"}'
|
||||
mock_sandbox.run_code.return_value = mock_execution
|
||||
|
||||
result = create_static_matplotlib_chart("import matplotlib.pyplot as plt")
|
||||
assert result == {
|
||||
"base64_image": None,
|
||||
"logs": '{"logs": "log data"}',
|
||||
"error": '{"error": "some error"}',
|
||||
}
|
||||
18
toolkits/github/.pre-commit-config.yaml
Normal file
18
toolkits/github/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
46
toolkits/github/.ruff.toml
Normal file
46
toolkits/github/.ruff.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/github/LICENSE
Normal file
21
toolkits/github/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/github/Makefile
Normal file
53
toolkits/github/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ github Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
from typing import Annotated, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import GitHub
|
||||
|
||||
from arcade_github.tools.utils import get_github_json_headers, get_url, handle_github_response
|
||||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#star-a-repository-for-the-authenticated-user and https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#unstar-a-repository-for-the-authenticated-user
|
||||
# Implements https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#star-a-repository-for-the-authenticated-user and https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#unstar-a-repository-for-the-authenticated-user # noqa: E501
|
||||
# Example `arcade chat` usage: "star the vscode repo owned by microsoft"
|
||||
@tool(requires_auth=GitHub())
|
||||
async def set_starred(
|
||||
|
|
@ -26,7 +26,9 @@ async def set_starred(
|
|||
```
|
||||
"""
|
||||
url = get_url("user_starred", owner=owner, repo=name)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
if starred:
|
||||
|
|
@ -49,20 +51,23 @@ async def list_stargazers(
|
|||
repo: Annotated[str, "The name of the repository"],
|
||||
limit: Annotated[
|
||||
Optional[int],
|
||||
"The maximum number of stargazers to return. If not provided, all stargazers will be returned.",
|
||||
"The maximum number of stargazers to return. "
|
||||
"If not provided, all stargazers will be returned.",
|
||||
] = None,
|
||||
) -> Annotated[dict, "A dictionary containing the stargazers for the specified repository"]:
|
||||
"""List the stargazers for a GitHub repository."""
|
||||
url = get_url("repo_stargazers", owner=owner, repo=repo)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
|
||||
per_page = min(limit, 100)
|
||||
page = 1
|
||||
stargazers = []
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
if limit is None:
|
||||
limit = 2**64 - 1
|
||||
|
||||
per_page = min(limit, 100)
|
||||
page = 1
|
||||
stargazers: list[dict] = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
while len(stargazers) < limit:
|
||||
response = await client.get(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ ENDPOINTS = {
|
|||
"repo_pull": "/repos/{owner}/{repo}/pulls/{pull_number}",
|
||||
"repo_pull_commits": "/repos/{owner}/{repo}/pulls/{pull_number}/commits",
|
||||
"repo_pull_comments": "/repos/{owner}/{repo}/pulls/{pull_number}/comments",
|
||||
"repo_pull_comment_replies": "/repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies",
|
||||
"repo_pull_comment_replies": "/repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies", # noqa: E501
|
||||
"user_starred": "/user/starred/{owner}/{repo}",
|
||||
"repo_stargazers": "/repos/{owner}/{repo}/stargazers",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import json
|
|||
from typing import Annotated, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import GitHub
|
||||
|
||||
from arcade_github.tools.utils import (
|
||||
get_github_json_headers,
|
||||
get_url,
|
||||
|
|
@ -14,7 +14,10 @@ from arcade_github.tools.utils import (
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue
|
||||
# Example `arcade chat` usage: "create an issue in the <REPO> repo owned by <OWNER> titled 'Found a bug' with the body 'I'm having a problem with this.' Assign it to <USER> and label it 'bug'"
|
||||
# Example `arcade chat` usage:
|
||||
# "create an issue in the <REPO> repo owned by <OWNER> titled
|
||||
# 'Found a bug' with the body 'I'm having a problem with this.'
|
||||
# Assign it to <USER> and label it 'bug'"
|
||||
@tool(requires_auth=GitHub())
|
||||
async def create_issue(
|
||||
context: ToolContext,
|
||||
|
|
@ -32,18 +35,29 @@ async def create_issue(
|
|||
labels: Annotated[Optional[list[str]], "Labels to associate with this issue."] = None,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the pull requests. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"A JSON string containing the created issue's details, including id, url, title, body, state, html_url, creation and update timestamps, user, assignees, and labels. If include_extra_data is True, returns all available data about the issue.",
|
||||
"A JSON string containing the created issue's details, including id, url, title, body, state, "
|
||||
"html_url, creation and update timestamps, user, assignees, and labels. "
|
||||
"If include_extra_data is True, returns all available data about the issue.",
|
||||
]:
|
||||
"""
|
||||
Create an issue in a GitHub repository.
|
||||
|
||||
Example:
|
||||
```
|
||||
create_issue(owner="octocat", repo="Hello-World", title="Found a bug", body="I'm having a problem with this.", assignees=["octocat"], milestone=1, labels=["bug"])
|
||||
create_issue(
|
||||
owner="octocat",
|
||||
repo="Hello-World",
|
||||
title="Found a bug",
|
||||
body="I'm having a problem with this.",
|
||||
assignees=["octocat"],
|
||||
milestone=1,
|
||||
labels=["bug"],
|
||||
)
|
||||
```
|
||||
"""
|
||||
url = get_url("repo_issues", owner=owner, repo=repo)
|
||||
|
|
@ -55,7 +69,9 @@ async def create_issue(
|
|||
"assignees": assignees,
|
||||
}
|
||||
data = remove_none_values(data)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
|
|
@ -83,7 +99,8 @@ async def create_issue(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment
|
||||
# Example `arcade chat` usage: "create a comment in the vscode repo owned by microsoft for issue 1347 that says 'Me too'"
|
||||
# Example `arcade chat` usage:
|
||||
# "create a comment in the vscode repo owned by microsoft for issue 1347 that says 'Me too'"
|
||||
@tool(requires_auth=GitHub())
|
||||
async def create_issue_comment(
|
||||
context: ToolContext,
|
||||
|
|
@ -96,11 +113,14 @@ async def create_issue_comment(
|
|||
body: Annotated[str, "The contents of the comment."],
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the pull requests. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"A JSON string containing the created comment's details, including id, url, body, user, and creation and update timestamps. If include_extra_data is True, returns all available data about the comment.",
|
||||
"A JSON string containing the created comment's details, including id, url, body, user, "
|
||||
"and creation and update timestamps. If include_extra_data is True, returns all available "
|
||||
"data about the comment.",
|
||||
]:
|
||||
"""
|
||||
Create a comment on an issue in a GitHub repository.
|
||||
|
|
@ -114,7 +134,9 @@ async def create_issue_comment(
|
|||
data = {
|
||||
"body": body,
|
||||
}
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ class DiffSide(str, Enum):
|
|||
"""
|
||||
The side of the diff that the pull request's changes appear on.
|
||||
Use LEFT for deletions that appear in red.
|
||||
Use RIGHT for additions that appear in green or unchanged lines that appear in white and are shown for context
|
||||
Use RIGHT for additions that appear in green or unchanged
|
||||
lines that appear in white and are shown for context
|
||||
"""
|
||||
|
||||
LEFT = "LEFT"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import json
|
|||
from typing import Annotated, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import GitHub
|
||||
from arcade.sdk.errors import RetryableToolError
|
||||
|
||||
from arcade_github.tools.models import (
|
||||
DiffSide,
|
||||
PRSortProperty,
|
||||
|
|
@ -24,9 +24,12 @@ from arcade_github.tools.utils import (
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests
|
||||
# Example `arcade chat` usage: "get all open PRs that <USER> has that are in the <OWNER>/<REPO> repo"
|
||||
# TODO: Validate owner/repo combination is valid for the authenticated user. If not, return RetryableToolError with available repos.
|
||||
# TODO: list repo's branches and validate base is in the list (or default to main). If not, return RetryableToolError with available branches.
|
||||
# Example `arcade chat` usage:
|
||||
# "get all open PRs that <USER> has that are in the <OWNER>/<REPO> repo"
|
||||
# TODO: Validate owner/repo combination is valid for the authenticated user.
|
||||
# If not, return RetryableToolError with available repos.
|
||||
# TODO: list repo's branches and validate base is in the list (or default to main).
|
||||
# If not, return RetryableToolError with available branches.
|
||||
@tool(requires_auth=GitHub())
|
||||
async def list_pull_requests(
|
||||
context: ToolContext,
|
||||
|
|
@ -38,18 +41,20 @@ async def list_pull_requests(
|
|||
state: Annotated[Optional[PRState], "The state of the pull requests to return."] = PRState.OPEN,
|
||||
head: Annotated[
|
||||
Optional[str],
|
||||
"Filter pulls by head user or head organization and branch name in the format of user:ref-name or organization:ref-name.",
|
||||
"Filter pulls by head user or head organization and branch name in the format of "
|
||||
"user:ref-name or organization:ref-name.",
|
||||
] = None,
|
||||
base: Annotated[Optional[str], "Filter pulls by base branch name."] = "main",
|
||||
sort: Annotated[
|
||||
Optional[PRSortProperty], "The property to sort the results by."
|
||||
] = PRSortProperty.CREATED,
|
||||
direction: Annotated[Optional[SortDirection], "The direction of the sort."] = None,
|
||||
per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[Optional[int], "The page number of the results to fetch."] = 1,
|
||||
per_page: Annotated[int, "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[int, "The page number of the results to fetch."] = 1,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the pull requests. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[str, "JSON string containing a list of pull requests with their details"]:
|
||||
"""
|
||||
|
|
@ -63,15 +68,17 @@ async def list_pull_requests(
|
|||
url = get_url("repo_pulls", owner=owner, repo=repo)
|
||||
params = {
|
||||
"base": base,
|
||||
"state": state.value,
|
||||
"sort": sort.value,
|
||||
"state": state.value if state else None,
|
||||
"sort": sort.value if sort else None,
|
||||
"per_page": min(max(1, per_page), 100), # clamp per_page to 1-100
|
||||
"page": page,
|
||||
"head": head,
|
||||
"direction": direction, # Note: Github defaults to desc when sort is 'created' or not specified, otherwise defaults to asc
|
||||
"direction": direction, # defaults to desc when sort is 'created'/'not specified', else asc
|
||||
}
|
||||
params = remove_none_values(params)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
@ -101,7 +108,8 @@ async def list_pull_requests(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request
|
||||
# Example `arcade chat` usage: "get the PR #72 in the <OWNER>/<REPO> repo. Include diff content in your response."
|
||||
# Example `arcade chat` usage:
|
||||
# "get the PR #72 in the <OWNER>/<REPO> repo. Include diff content in your response."
|
||||
@tool(requires_auth=GitHub())
|
||||
async def get_pull_request(
|
||||
context: ToolContext,
|
||||
|
|
@ -117,11 +125,13 @@ async def get_pull_request(
|
|||
] = False,
|
||||
include_extra_data: Annotated[
|
||||
Optional[bool],
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the pull requests. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"JSON string containing details of the specified pull request, optionally including diff content",
|
||||
"JSON string containing details of the specified pull request, "
|
||||
"optionally including diff content",
|
||||
]:
|
||||
"""
|
||||
Get details of a pull request in a GitHub repository.
|
||||
|
|
@ -132,8 +142,12 @@ async def get_pull_request(
|
|||
```
|
||||
"""
|
||||
url = get_url("repo_pull", owner=owner, repo=repo, pull_number=pull_number)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
diff_headers = get_github_diff_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
diff_headers = get_github_diff_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
|
@ -174,7 +188,9 @@ async def get_pull_request(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request
|
||||
# Example `arcade chat` usage: "update PR #72 in the <OWNER>/<REPO> repo by changing the title to 'New Title' and setting the body to 'This PR description was added via arcade chat!'."
|
||||
# Example `arcade chat` usage:
|
||||
# "update PR #72 in the <OWNER>/<REPO> repo by changing the title to 'New Title' and
|
||||
# setting the body to 'This PR description was added via arcade chat!'."
|
||||
# TODO: Enable this tool to append to the PR contents instead of only replacing content.
|
||||
@tool(requires_auth=GitHub())
|
||||
async def update_pull_request(
|
||||
|
|
@ -202,7 +218,13 @@ async def update_pull_request(
|
|||
|
||||
Example:
|
||||
```
|
||||
update_pull_request(owner="octocat", repo="Hello-World", pull_number=1347, title="new title", body="updated body")
|
||||
update_pull_request(
|
||||
owner="octocat",
|
||||
repo="Hello-World",
|
||||
pull_number=1347,
|
||||
title="new title",
|
||||
body="updated body",
|
||||
)
|
||||
```
|
||||
"""
|
||||
url = get_url("repo_pull", owner=owner, repo=repo, pull_number=pull_number)
|
||||
|
|
@ -216,7 +238,9 @@ async def update_pull_request(
|
|||
}
|
||||
data = remove_none_values(data)
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.patch(url, headers=headers, json=data)
|
||||
|
|
@ -250,11 +274,12 @@ async def list_pull_request_commits(
|
|||
"The name of the repository without the .git extension. The name is not case sensitive.",
|
||||
],
|
||||
pull_number: Annotated[int, "The number that identifies the pull request."],
|
||||
per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[Optional[int], "The page number of the results to fetch."] = 1,
|
||||
per_page: Annotated[int, "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[int, "The page number of the results to fetch."] = 1,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the pull requests. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[str, "JSON string containing a list of commits for the specified pull request"]:
|
||||
"""
|
||||
|
|
@ -272,7 +297,9 @@ async def list_pull_request_commits(
|
|||
"page": page,
|
||||
}
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
@ -304,9 +331,13 @@ async def list_pull_request_commits(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-reply-for-a-review-comment
|
||||
# Example `arcade chat` usage: "create a reply to the review comment 1778019974 in arcadeai/arcade-ai for the PR 72 that says 'Thanks for the suggestion.'"
|
||||
# Note: This tool requires the ID of the review comment to reply to. To obtain this ID, you should first call the `list_review_comments_on_pull_request` function.
|
||||
# The returned JSON will contain the `id` field for each comment, which can be used as the `comment_id` parameter in this function.
|
||||
# Example `arcade chat` usage:
|
||||
# "create a reply to the review comment 1778019974 in arcadeai/arcade-ai for
|
||||
# the PR 72 that says 'Thanks for the suggestion.'"
|
||||
# Note: This tool requires the ID of the review comment to reply to. To obtain this ID,
|
||||
# you should first call the `list_review_comments_on_pull_request` function.
|
||||
# The returned JSON will contain the `id` field for each comment, which can be used as the
|
||||
# `comment_id` parameter in this function.
|
||||
@tool(requires_auth=GitHub())
|
||||
async def create_reply_for_review_comment(
|
||||
context: ToolContext,
|
||||
|
|
@ -324,7 +355,13 @@ async def create_reply_for_review_comment(
|
|||
|
||||
Example:
|
||||
```
|
||||
create_reply_for_review_comment(owner="octocat", repo="Hello-World", pull_number=1347, comment_id=42, body="Looks good to me!")
|
||||
create_reply_for_review_comment(
|
||||
owner="octocat",
|
||||
repo="Hello-World",
|
||||
pull_number=1347,
|
||||
comment_id=42,
|
||||
body="Looks good to me!",
|
||||
)
|
||||
```
|
||||
"""
|
||||
url = get_url(
|
||||
|
|
@ -335,7 +372,9 @@ async def create_reply_for_review_comment(
|
|||
comment_id=comment_id,
|
||||
)
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
data = {"body": body}
|
||||
|
||||
|
|
@ -367,13 +406,15 @@ async def list_review_comments_on_pull_request(
|
|||
] = SortDirection.DESC,
|
||||
since: Annotated[
|
||||
Optional[str],
|
||||
"Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.",
|
||||
"Only show results that were last updated after the given time. "
|
||||
"This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.",
|
||||
] = None,
|
||||
per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[Optional[int], "The page number of the results to fetch."] = 1,
|
||||
per_page: Annotated[int, "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[int, "The page number of the results to fetch."] = 1,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the review comments. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str, "JSON string containing a list of review comments for the specified pull request"
|
||||
|
|
@ -397,7 +438,9 @@ async def list_review_comments_on_pull_request(
|
|||
}
|
||||
params = remove_none_values(params)
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
@ -434,8 +477,11 @@ async def list_review_comments_on_pull_request(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-review-comment-for-a-pull-request
|
||||
# Example `arcade chat` usage: "create a review comment for PR 72 in <OWNER>/<REPO> that says 'Great stuff! This looks good to merge. Add the comment to README.md file.'"
|
||||
# TODO: Verify that path parameter exists in the PR's files that have changed (Or should we allow for any file in the repo?). If not, then throw RetryableToolError with all valid file paths.
|
||||
# Example `arcade chat` usage: "create a review comment for PR 72 in <OWNER>/<REPO> that says
|
||||
# 'Great stuff! This looks good to merge. Add the comment to README.md file.'"
|
||||
# TODO: Verify that path parameter exists in the PR's files that have changed
|
||||
# (Or should we allow for any file in the repo?).
|
||||
# If not, then throw RetryableToolError with all valid file paths.
|
||||
@tool(requires_auth=GitHub())
|
||||
async def create_review_comment(
|
||||
context: ToolContext,
|
||||
|
|
@ -449,19 +495,24 @@ async def create_review_comment(
|
|||
path: Annotated[str, "The relative path to the file that necessitates a comment."],
|
||||
commit_id: Annotated[
|
||||
Optional[str],
|
||||
"The SHA of the commit needing a comment. If not provided, the latest commit SHA of the PR's base branch will be used.",
|
||||
"The SHA of the commit needing a comment. If not provided, the latest commit SHA of the "
|
||||
"PR's base branch will be used.",
|
||||
] = None,
|
||||
start_line: Annotated[
|
||||
Optional[int],
|
||||
"The start line of the range of lines in the pull request diff that the comment applies to. Required unless 'subject_type' is 'file'.",
|
||||
"The start line of the range of lines in the pull request diff that the "
|
||||
"comment applies to. Required unless 'subject_type' is 'file'.",
|
||||
] = None,
|
||||
end_line: Annotated[
|
||||
Optional[int],
|
||||
"The end line of the range of lines in the pull request diff that the comment applies to. Required unless 'subject_type' is 'file'.",
|
||||
"The end line of the range of lines in the pull request diff that the "
|
||||
"comment applies to. Required unless 'subject_type' is 'file'.",
|
||||
] = None,
|
||||
side: Annotated[
|
||||
Optional[DiffSide],
|
||||
"The side of the diff that the pull request's changes appear on. Use LEFT for deletions that appear in red. Use RIGHT for additions that appear in green or unchanged lines that appear in white and are shown for context",
|
||||
"The side of the diff that the pull request's changes appear on. "
|
||||
"Use LEFT for deletions that appear in red. Use RIGHT for additions that appear in green "
|
||||
"or unchanged lines that appear in white and are shown for context",
|
||||
] = DiffSide.RIGHT,
|
||||
start_side: Annotated[
|
||||
Optional[str], "The starting side of the diff that the comment applies to."
|
||||
|
|
@ -472,7 +523,8 @@ async def create_review_comment(
|
|||
] = ReviewCommentSubjectType.FILE,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the review comment. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the review comment. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[str, "JSON string containing details of the created review comment"]:
|
||||
"""
|
||||
|
|
@ -484,7 +536,16 @@ async def create_review_comment(
|
|||
|
||||
Example:
|
||||
```
|
||||
create_review_comment(owner="octocat", repo="Hello-World", pull_number=1347, body="Great stuff!", commit_id="6dcb09b5b57875f334f61aebed695e2e4193db5e", path="file1.txt", line=2, side="RIGHT")
|
||||
create_review_comment(
|
||||
owner="octocat",
|
||||
repo="Hello-World",
|
||||
pull_number=1347,
|
||||
body="Great stuff!",
|
||||
commit_id="6dcb09b5b57875f334f61aebed695e2e4193db5e",
|
||||
path="file1.txt",
|
||||
line=2,
|
||||
side="RIGHT"
|
||||
)
|
||||
```
|
||||
"""
|
||||
# If the subject_type is 'file', then the line_range parameter is ignored
|
||||
|
|
@ -492,8 +553,15 @@ async def create_review_comment(
|
|||
start_line, end_line = None, None
|
||||
|
||||
if (start_line is None or end_line is None) and subject_type != ReviewCommentSubjectType.FILE:
|
||||
message = (
|
||||
"'start_line' and 'end_line' parameters are required when 'subject_type' "
|
||||
"parameter is not 'file'. Either provide both a start_line and end_line or set "
|
||||
"subject_type to 'file'."
|
||||
)
|
||||
raise RetryableToolError(
|
||||
"'start_line' and 'end_line' parameters are required when 'subject_type' parameter is not 'file'. Either provide both a start_line and end_line or set subject_type to 'file'."
|
||||
message=message,
|
||||
developer_message=message,
|
||||
additional_prompt_content=message,
|
||||
)
|
||||
|
||||
# Ensure the line range goes from lowest to highest
|
||||
|
|
@ -509,8 +577,14 @@ async def create_review_comment(
|
|||
commit_id = latest_commit.get("sha")
|
||||
|
||||
if not commit_id:
|
||||
message = (
|
||||
f"Failed to get the latest commit SHA of PR {pull_number} in repo {repo} owned by "
|
||||
"{owner}. Does the PR exist?"
|
||||
)
|
||||
raise RetryableToolError(
|
||||
f"Failed to get the latest commit SHA of PR {pull_number} in repo {repo} owned by {owner}. Does the PR exist?"
|
||||
message=message,
|
||||
developer_message=message,
|
||||
additional_prompt_content=message,
|
||||
)
|
||||
|
||||
url = get_url("repo_pull_comments", owner=owner, repo=repo, pull_number=pull_number)
|
||||
|
|
@ -526,7 +600,9 @@ async def create_review_comment(
|
|||
"start_side": start_side,
|
||||
}
|
||||
data = remove_none_values(data)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import json
|
|||
from typing import Annotated, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import GitHub
|
||||
|
||||
from arcade_github.tools.models import (
|
||||
ActivityType,
|
||||
RepoSortProperty,
|
||||
|
|
@ -21,7 +21,8 @@ from arcade_github.tools.utils import (
|
|||
)
|
||||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository and returns only the stargazers_count field.
|
||||
# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository
|
||||
# and returns only the stargazers_count field.
|
||||
# Example arcade chat usage: "How many stargazers does the <OWNER>/<REPO> repo have?"
|
||||
@tool(requires_auth=GitHub())
|
||||
async def count_stargazers(
|
||||
|
|
@ -36,7 +37,9 @@ async def count_stargazers(
|
|||
```
|
||||
"""
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
url = get_url("repo", owner=owner, repo=name)
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
|
@ -46,11 +49,12 @@ async def count_stargazers(
|
|||
|
||||
data = response.json()
|
||||
stargazers_count = data.get("stargazers_count", 0)
|
||||
return stargazers_count
|
||||
return int(stargazers_count)
|
||||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-organization-repositories
|
||||
# Example arcade chat usage: "List all repositories for the <ORG> organization. Sort by creation date in descending order."
|
||||
# Example arcade chat usage:
|
||||
# "List all repositories for the <ORG> organization. Sort by creation date in descending order."
|
||||
@tool(requires_auth=GitHub())
|
||||
async def list_org_repositories(
|
||||
context: ToolContext,
|
||||
|
|
@ -60,15 +64,18 @@ async def list_org_repositories(
|
|||
RepoSortProperty, "The property to sort the results by"
|
||||
] = RepoSortProperty.CREATED,
|
||||
sort_direction: Annotated[SortDirection, "The order to sort by"] = SortDirection.ASC,
|
||||
per_page: Annotated[Optional[int], "The number of results per page"] = 30,
|
||||
page: Annotated[Optional[int], "The page number of the results to fetch"] = 1,
|
||||
per_page: Annotated[int, "The number of results per page"] = 30,
|
||||
page: Annotated[int, "The page number of the results to fetch"] = 1,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the repositories. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
dict[str, list[dict]],
|
||||
"A dictionary with key 'repositories' containing a list of repositories, each with details such as name, full_name, html_url, description, clone_url, private status, creation/update/push timestamps, and star/watcher/fork counts",
|
||||
"A dictionary with key 'repositories' containing a list of repositories, each with details "
|
||||
"such as name, full_name, html_url, description, clone_url, private status, "
|
||||
"creation/update/push timestamps, and star/watcher/fork counts",
|
||||
]:
|
||||
"""List repositories for the specified organization."""
|
||||
url = get_url("org_repos", org=org)
|
||||
|
|
@ -80,7 +87,9 @@ async def list_org_repositories(
|
|||
"page": page,
|
||||
}
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
@ -123,11 +132,13 @@ async def get_repository(
|
|||
],
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the repository. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
dict,
|
||||
"A dictionary containing repository details such as name, full_name, html_url, description, clone_url, private status, creation/update/push timestamps, and star/watcher/fork counts",
|
||||
"A dictionary containing repository details such as name, full_name, html_url, description, "
|
||||
"clone_url, private status, creation/update/push timestamps, and star/watcher/fork counts",
|
||||
]:
|
||||
"""Get a repository.
|
||||
|
||||
|
|
@ -139,7 +150,9 @@ async def get_repository(
|
|||
```
|
||||
"""
|
||||
url = get_url("repo", owner=owner, repo=repo)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
|
@ -148,7 +161,7 @@ async def get_repository(
|
|||
|
||||
repo_data = response.json()
|
||||
if include_extra_data:
|
||||
return json.dumps(repo_data)
|
||||
return dict(repo_data)
|
||||
|
||||
return {
|
||||
"name": repo_data["name"],
|
||||
|
|
@ -167,7 +180,8 @@ async def get_repository(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-activities
|
||||
# Example arcade chat usage: "List all merges into main for the <OWNER>/<REPO> repo in the last week by <USER>"
|
||||
# Example arcade chat usage:
|
||||
# "List all merges into main for the <OWNER>/<REPO> repo in the last week by <USER>"
|
||||
@tool(requires_auth=GitHub())
|
||||
async def list_repository_activities(
|
||||
context: ToolContext,
|
||||
|
|
@ -179,18 +193,20 @@ async def list_repository_activities(
|
|||
direction: Annotated[
|
||||
Optional[SortDirection], "The direction to sort the results by."
|
||||
] = SortDirection.DESC,
|
||||
per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30,
|
||||
per_page: Annotated[int, "The number of results per page (max 100)."] = 30,
|
||||
before: Annotated[
|
||||
Optional[str],
|
||||
"A cursor (unique identifier, e.g., a SHA of a commit) to search for results before this cursor.",
|
||||
"A cursor (unique ID, e.g., a SHA of a commit) to search for results before this cursor.",
|
||||
] = None,
|
||||
after: Annotated[
|
||||
Optional[str],
|
||||
"A cursor (unique identifier, e.g., a SHA of a commit) to search for results after this cursor.",
|
||||
"A cursor (unique ID, e.g., a SHA of a commit) to search for results after this cursor.",
|
||||
] = None,
|
||||
ref: Annotated[
|
||||
Optional[str],
|
||||
"The Git reference for the activities you want to list. The ref for a branch can be formatted either as refs/heads/BRANCH_NAME or BRANCH_NAME, where BRANCH_NAME is the name of your branch.",
|
||||
"The Git reference for the activities you want to list. The ref for a branch can be "
|
||||
"formatted either as refs/heads/BRANCH_NAME or BRANCH_NAME, where BRANCH_NAME is the name "
|
||||
"of your branch.",
|
||||
] = None,
|
||||
actor: Annotated[
|
||||
Optional[str], "The GitHub username to filter by the actor who performed the activity."
|
||||
|
|
@ -199,16 +215,19 @@ async def list_repository_activities(
|
|||
activity_type: Annotated[Optional[ActivityType], "The activity type to filter by."] = None,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the repository activities. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"A JSON string containing a dictionary with key 'activities', which is a list of repository activities. Each activity includes id, node_id, before and after states, ref, timestamp, activity_type, and actor information",
|
||||
"A JSON string containing a dictionary with key 'activities', which is a list of repository "
|
||||
"activities. Each activity includes id, node_id, before and after states, ref, timestamp, "
|
||||
"activity_type, and actor information",
|
||||
]:
|
||||
"""List repository activities.
|
||||
|
||||
Retrieves a detailed history of changes to a repository, such as pushes, merges, force pushes, and branch changes,
|
||||
and associates these changes with commits and users.
|
||||
Retrieves a detailed history of changes to a repository, such as pushes, merges,
|
||||
force pushes, and branch changes, and associates these changes with commits and users.
|
||||
|
||||
Example:
|
||||
```
|
||||
|
|
@ -222,7 +241,7 @@ async def list_repository_activities(
|
|||
"""
|
||||
url = get_url("repo_activity", owner=owner, repo=repo)
|
||||
params = {
|
||||
"direction": direction.value,
|
||||
"direction": direction.value if direction else None,
|
||||
"per_page": min(100, per_page), # The API only allows up to 100 per page
|
||||
"before": before,
|
||||
"after": after,
|
||||
|
|
@ -233,7 +252,9 @@ async def list_repository_activities(
|
|||
}
|
||||
params = remove_none_values(params)
|
||||
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
@ -260,8 +281,9 @@ async def list_repository_activities(
|
|||
|
||||
|
||||
# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#list-review-comments-in-a-repository
|
||||
# Example arcade chat usage: "List all review comments for the <OWNER>/<REPO> repo. Sort by update date in descending order."
|
||||
# TODO: Improve the 'since' input parameter such that language model can more easily specify a valid date/time.
|
||||
# Example arcade chat usage:
|
||||
# "List all review comments for the <OWNER>/<REPO> repo. Sort by update date in descending order."
|
||||
# TODO: Improve the 'since' input param such that LLM can more easily specify a valid date/time.
|
||||
@tool(requires_auth=GitHub())
|
||||
async def list_review_comments_in_a_repository(
|
||||
context: ToolContext,
|
||||
|
|
@ -279,17 +301,21 @@ async def list_review_comments_in_a_repository(
|
|||
] = SortDirection.DESC,
|
||||
since: Annotated[
|
||||
Optional[str],
|
||||
"Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.",
|
||||
"Only show results that were last updated after the given time. "
|
||||
"This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.",
|
||||
] = None,
|
||||
per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[Optional[int], "The page number of the results to fetch."] = 1,
|
||||
per_page: Annotated[int, "The number of results per page (max 100)."] = 30,
|
||||
page: Annotated[int, "The page number of the results to fetch."] = 1,
|
||||
include_extra_data: Annotated[
|
||||
bool,
|
||||
"If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.",
|
||||
"If true, return all the data available about the review comments. "
|
||||
"This is a large payload and may impact performance - use with caution.",
|
||||
] = False,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"A JSON string containing a dictionary with key 'review_comments', which is a list of review comments. Each comment includes id, url, diff_hunk, path, position details, commit information, user, body, timestamps, and related URLs",
|
||||
"A JSON string containing a dictionary with key 'review_comments', which is a list of "
|
||||
"review comments. Each comment includes id, url, diff_hunk, path, position details, commit "
|
||||
"information, user, body, timestamps, and related URLs",
|
||||
]:
|
||||
"""
|
||||
List review comments in a GitHub repository.
|
||||
|
|
@ -309,7 +335,9 @@ async def list_review_comments_in_a_repository(
|
|||
"since": since,
|
||||
}
|
||||
params = remove_none_values(params)
|
||||
headers = get_github_json_headers(context.authorization.token)
|
||||
headers = get_github_json_headers(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_github.tools.constants import ENDPOINTS, GITHUB_API_BASE_URL
|
||||
|
||||
|
||||
def handle_github_response(response: dict, url: str) -> None:
|
||||
def handle_github_response(response: httpx.Response, url: str) -> None:
|
||||
"""
|
||||
Handle GitHub API response and raise appropriate exceptions for non-200 status codes.
|
||||
|
||||
|
|
@ -30,13 +34,14 @@ def handle_github_response(response: dict, url: str) -> None:
|
|||
raise ToolExecutionError(f"Error accessing '{url}': {error_message}")
|
||||
|
||||
|
||||
def get_github_json_headers(token: str) -> dict:
|
||||
def get_github_json_headers(token: str | None) -> dict:
|
||||
"""
|
||||
Generate common headers for GitHub API requests.
|
||||
|
||||
:param token: The authorization token
|
||||
:return: A dictionary of headers
|
||||
"""
|
||||
token = token or ""
|
||||
return {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
|
|
@ -44,13 +49,14 @@ def get_github_json_headers(token: str) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def get_github_diff_headers(token: str) -> dict:
|
||||
def get_github_diff_headers(token: str | None) -> dict:
|
||||
"""
|
||||
Generate headers for GitHub API requests for diff content.
|
||||
|
||||
:param token: The authorization token
|
||||
:return: A dictionary of headers
|
||||
"""
|
||||
token = token or ""
|
||||
return {
|
||||
"Accept": "application/vnd.github.diff",
|
||||
"Authorization": f"Bearer {token}",
|
||||
|
|
@ -68,7 +74,7 @@ def remove_none_values(params: dict) -> dict:
|
|||
return {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
|
||||
def get_url(endpoint: str, **kwargs) -> str:
|
||||
def get_url(endpoint: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
Get the full URL for a given endpoint.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import arcade_github
|
||||
from arcade_github.tools.activity import list_stargazers, set_starred
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -10,6 +7,9 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_github
|
||||
from arcade_github.tools.activity import list_stargazers, set_starred
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
import arcade_github
|
||||
from arcade_github.tools.issues import (
|
||||
create_issue,
|
||||
create_issue_comment,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -14,6 +8,12 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_github
|
||||
from arcade_github.tools.issues import (
|
||||
create_issue,
|
||||
create_issue_comment,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_github
|
||||
from arcade_github.tools.models import (
|
||||
DiffSide,
|
||||
|
|
@ -14,16 +24,6 @@ from arcade_github.tools.pull_requests import (
|
|||
update_pull_request,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_github
|
||||
from arcade_github.tools.models import SortDirection
|
||||
from arcade_github.tools.repositories import (
|
||||
|
|
@ -8,15 +17,6 @@ from arcade_github.tools.repositories import (
|
|||
list_review_comments_in_a_repository,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_github"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "LLM tools for interacting with Github"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -11,7 +11,32 @@ httpx = "^0.27.2"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_github/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
0
toolkits/github/tests/__init__.py
Normal file
0
toolkits/github/tests/__init__.py
Normal file
|
|
@ -1,10 +1,10 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade_github.tools.activity import list_stargazers, set_starred
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from httpx import Response
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from arcade_github.tools.activity import list_stargazers, set_starred
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade_github.tools.issues import create_issue, create_issue_comment
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from httpx import Response
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from arcade_github.tools.issues import create_issue, create_issue_comment
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
|
||||
from httpx import Response
|
||||
|
||||
from arcade_github.tools.models import (
|
||||
DiffSide,
|
||||
ReviewCommentSubjectType,
|
||||
|
|
@ -14,9 +17,6 @@ from arcade_github.tools.pull_requests import (
|
|||
list_review_comments_on_pull_request,
|
||||
update_pull_request,
|
||||
)
|
||||
from httpx import Response
|
||||
|
||||
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from httpx import Response
|
||||
|
||||
from arcade_github.tools.models import RepoType
|
||||
from arcade_github.tools.repositories import (
|
||||
count_stargazers,
|
||||
|
|
@ -9,9 +12,6 @@ from arcade_github.tools.repositories import (
|
|||
list_repository_activities,
|
||||
list_review_comments_in_a_repository,
|
||||
)
|
||||
from httpx import Response
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
18
toolkits/google/.pre-commit-config.yaml
Normal file
18
toolkits/google/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
46
toolkits/google/.ruff.toml
Normal file
46
toolkits/google/.ruff.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/google/LICENSE
Normal file
21
toolkits/google/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/google/Makefile
Normal file
53
toolkits/google/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ google Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
from typing import Annotated, Any
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Google
|
||||
from arcade.sdk.errors import RetryableToolError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.models import EventVisibility, SendUpdatesOptions
|
||||
from arcade_google.tools.utils import parse_datetime
|
||||
|
||||
|
|
@ -44,7 +44,15 @@ async def create_event(
|
|||
) -> Annotated[dict, "A dictionary containing the created event details"]:
|
||||
"""Create a new event/meeting/sync/meetup in the specified calendar."""
|
||||
|
||||
service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"calendar",
|
||||
"v3",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
# Get the calendar's time zone
|
||||
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
||||
|
|
@ -54,7 +62,7 @@ async def create_event(
|
|||
start_dt = parse_datetime(start_datetime, time_zone)
|
||||
end_dt = parse_datetime(end_datetime, time_zone)
|
||||
|
||||
event = {
|
||||
event: dict[str, Any] = {
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"location": location,
|
||||
|
|
@ -82,11 +90,13 @@ async def list_events(
|
|||
context: ToolContext,
|
||||
min_end_datetime: Annotated[
|
||||
str,
|
||||
"Filter by events that end on or after this datetime in ISO 8601 format, e.g., '2024-09-15T09:00:00'.",
|
||||
"Filter by events that end on or after this datetime in ISO 8601 format, "
|
||||
"e.g., '2024-09-15T09:00:00'.",
|
||||
],
|
||||
max_start_datetime: Annotated[
|
||||
str,
|
||||
"Filter by events that start before this datetime in ISO 8601 format, e.g., '2024-09-16T17:00:00'.",
|
||||
"Filter by events that start before this datetime in ISO 8601 format, "
|
||||
"e.g., '2024-09-16T17:00:00'.",
|
||||
],
|
||||
calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
|
||||
max_results: Annotated[int, "The maximum number of events to return"] = 10,
|
||||
|
|
@ -98,14 +108,23 @@ async def list_events(
|
|||
max_start_datetime serves as the upper bound (exclusive) for an event's start time.
|
||||
|
||||
For example:
|
||||
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime is set to 2024-09-16T17:00:00,
|
||||
the function will return events that:
|
||||
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
|
||||
is set to 2024-09-16T17:00:00, the function will return events that:
|
||||
1. End after 09:00 on September 15, 2024 (exclusive)
|
||||
2. Start before 17:00 on September 16, 2024 (exclusive)
|
||||
This means an event starting at 08:00 on September 15 and ending at 10:00 on September 15 would be included,
|
||||
but an event starting at 17:00 on September 16 would not be included.
|
||||
This means an event starting at 08:00 on September 15 and
|
||||
ending at 10:00 on September 15 would be included, but an
|
||||
event starting at 17:00 on September 16 would not be included.
|
||||
"""
|
||||
service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"calendar",
|
||||
"v3",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
# Get the calendar's time zone
|
||||
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
||||
|
|
@ -165,7 +184,8 @@ async def update_event(
|
|||
event_id: Annotated[str, "The ID of the event to update"],
|
||||
updated_start_datetime: Annotated[
|
||||
str | None,
|
||||
"The updated datetime that the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
|
||||
"The updated datetime that the event starts in ISO 8601 format, "
|
||||
"e.g., '2024-12-31T15:30:00'.",
|
||||
] = None,
|
||||
updated_end_datetime: Annotated[
|
||||
str | None,
|
||||
|
|
@ -180,26 +200,39 @@ async def update_event(
|
|||
updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
|
||||
attendee_emails_to_add: Annotated[
|
||||
list[str] | None,
|
||||
"The list of attendee emails to add. Must be valid email addresses e.g., username@domain.com.",
|
||||
"The list of attendee emails to add. Must be valid email addresses "
|
||||
"e.g., username@domain.com.",
|
||||
] = None,
|
||||
attendee_emails_to_remove: Annotated[
|
||||
list[str] | None,
|
||||
"The list of attendee emails to remove. Must be valid email addresses e.g., username@domain.com.",
|
||||
"The list of attendee emails to remove. Must be valid email addresses "
|
||||
"e.g., username@domain.com.",
|
||||
] = None,
|
||||
send_updates: Annotated[
|
||||
SendUpdatesOptions, "Should attendees be notified of the update? (none, all, external_only)"
|
||||
SendUpdatesOptions,
|
||||
"Should attendees be notified of the update? (none, all, external_only)",
|
||||
] = SendUpdatesOptions.ALL,
|
||||
) -> Annotated[
|
||||
str,
|
||||
"A string containing the updated event details, including the event ID, update timestamp, and a link to view the updated event.",
|
||||
"A string containing the updated event details, including the event ID, update timestamp, "
|
||||
"and a link to view the updated event.",
|
||||
]:
|
||||
"""
|
||||
Update an existing event in the specified calendar with the provided details.
|
||||
Only the provided fields will be updated; others will remain unchanged.
|
||||
|
||||
`updated_start_datetime` and `updated_end_datetime` are independent and can be provided separately.
|
||||
`updated_start_datetime` and `updated_end_datetime` are
|
||||
independent and can be provided separately.
|
||||
"""
|
||||
service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"calendar",
|
||||
"v3",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
calendar = service.calendars().get(calendarId="primary").execute()
|
||||
time_zone = calendar["timeZone"]
|
||||
|
|
@ -221,16 +254,21 @@ async def update_event(
|
|||
)
|
||||
raise RetryableToolError(
|
||||
f"Event with ID {event_id} not found.",
|
||||
additional_prompt_content=f"Here is a list of valid events. The event_id parameter must match one of these: {valid_events_with_id}",
|
||||
additional_prompt_content=(
|
||||
f"Here is a list of valid events. The event_id parameter must match one of these: "
|
||||
f"{valid_events_with_id}"
|
||||
),
|
||||
retry_after_ms=1000,
|
||||
developer_message=f"Event with ID {event_id} not found. Please try again with a valid event ID.",
|
||||
developer_message=(
|
||||
f"Event with ID {event_id} not found. Please try again with a valid event ID."
|
||||
),
|
||||
)
|
||||
|
||||
update_fields = {
|
||||
"start": {"dateTime": updated_start_datetime.isoformat(), "timeZone": time_zone}
|
||||
"start": {"dateTime": updated_start_datetime, "timeZone": time_zone}
|
||||
if updated_start_datetime
|
||||
else None,
|
||||
"end": {"dateTime": updated_end_datetime.isoformat(), "timeZone": time_zone}
|
||||
"end": {"dateTime": updated_end_datetime, "timeZone": time_zone}
|
||||
if updated_end_datetime
|
||||
else None,
|
||||
"calendarId": updated_calendar_id,
|
||||
|
|
@ -272,7 +310,10 @@ async def update_event(
|
|||
)
|
||||
.execute()
|
||||
)
|
||||
return f"Event with ID {event_id} successfully updated at {updated_event['updated']}. View updated event at {updated_event['htmlLink']}"
|
||||
return (
|
||||
f"Event with ID {event_id} successfully updated at {updated_event['updated']}. "
|
||||
f"View updated event at {updated_event['htmlLink']}"
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -289,7 +330,15 @@ async def delete_event(
|
|||
] = SendUpdatesOptions.ALL,
|
||||
) -> Annotated[str, "A string containing the deletion confirmation message"]:
|
||||
"""Delete an event from Google Calendar."""
|
||||
service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"calendar",
|
||||
"v3",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
service.events().delete(
|
||||
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
|
||||
|
|
@ -303,4 +352,7 @@ async def delete_event(
|
|||
elif send_updates == SendUpdatesOptions.NONE:
|
||||
notification_message = "No notifications were sent to attendees."
|
||||
|
||||
return f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. {notification_message}"
|
||||
return (
|
||||
f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. "
|
||||
f"{notification_message}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import Annotated
|
|||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Google
|
||||
|
||||
from arcade_google.tools.utils import build_docs_service
|
||||
|
||||
|
||||
|
|
@ -22,13 +23,15 @@ async def get_document_by_id(
|
|||
"""
|
||||
Get the latest version of the specified Google Docs document.
|
||||
"""
|
||||
service = build_docs_service(context.authorization.token)
|
||||
service = build_docs_service(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
# Execute the documents().get() method. Returns a Document object
|
||||
# https://developers.google.com/docs/api/reference/rest/v1/documents#Document
|
||||
request = service.documents().get(documentId=document_id)
|
||||
response = request.execute()
|
||||
return response
|
||||
return dict(response)
|
||||
|
||||
|
||||
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
|
||||
|
|
@ -52,7 +55,9 @@ async def insert_text_at_end_of_document(
|
|||
|
||||
end_index = document["body"]["content"][-1]["endIndex"]
|
||||
|
||||
service = build_docs_service(context.authorization.token)
|
||||
service = build_docs_service(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
requests = [
|
||||
{
|
||||
|
|
@ -72,7 +77,7 @@ async def insert_text_at_end_of_document(
|
|||
.execute()
|
||||
)
|
||||
|
||||
return response
|
||||
return dict(response)
|
||||
|
||||
|
||||
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create
|
||||
|
|
@ -90,7 +95,9 @@ async def create_blank_document(
|
|||
"""
|
||||
Create a blank Google Docs document with the specified title.
|
||||
"""
|
||||
service = build_docs_service(context.authorization.token)
|
||||
service = build_docs_service(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
body = {"title": title}
|
||||
|
||||
|
|
@ -106,7 +113,8 @@ async def create_blank_document(
|
|||
|
||||
|
||||
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
|
||||
# Example `arcade chat` query: `create document with title "My New Document" and text content "Hello, World!"`
|
||||
# Example `arcade chat` query:
|
||||
# `create document with title "My New Document" and text content "Hello, World!"`
|
||||
@tool(
|
||||
requires_auth=Google(
|
||||
scopes=[
|
||||
|
|
@ -125,7 +133,9 @@ async def create_document_from_text(
|
|||
# First, create a blank document
|
||||
document = await create_blank_document(context, title)
|
||||
|
||||
service = build_docs_service(context.authorization.token)
|
||||
service = build_docs_service(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
requests = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from typing import Annotated, Optional
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Google
|
||||
|
||||
from arcade_google.tools.utils import build_drive_service, remove_none_values
|
||||
|
||||
from .models import Corpora, OrderBy
|
||||
|
|
@ -9,7 +10,8 @@ from .models import Corpora, OrderBy
|
|||
|
||||
# Implements: https://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.files.html#list
|
||||
# Example `arcade chat` query: `list my 5 most recently modified documents`
|
||||
# TODO: Support query with natural language. Currently, the tool expects a fully formed query string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files
|
||||
# TODO: Support query with natural language. Currently, the tool expects a fully formed query
|
||||
# string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files
|
||||
@tool(
|
||||
requires_auth=Google(
|
||||
scopes=["https://www.googleapis.com/auth/drive.readonly"],
|
||||
|
|
@ -17,31 +19,34 @@ from .models import Corpora, OrderBy
|
|||
)
|
||||
async def list_documents(
|
||||
context: ToolContext,
|
||||
corpora: Annotated[Optional[Corpora], "The source of files to list"] = Corpora.USER,
|
||||
corpora: Annotated[Corpora, "The source of files to list"] = Corpora.USER,
|
||||
title_keywords: Annotated[
|
||||
Optional[list[str]], "Keywords or phrases that must be in the document title"
|
||||
] = None,
|
||||
order_by: Annotated[
|
||||
Optional[OrderBy],
|
||||
OrderBy,
|
||||
"Sort order. Defaults to listing the most recently modified documents first",
|
||||
] = OrderBy.MODIFIED_TIME_DESC,
|
||||
supports_all_drives: Annotated[
|
||||
Optional[bool],
|
||||
bool,
|
||||
"Whether the requesting application supports both My Drives and shared drives",
|
||||
] = False,
|
||||
limit: Annotated[Optional[int], "The number of documents to list"] = 50,
|
||||
limit: Annotated[int, "The number of documents to list"] = 50,
|
||||
) -> Annotated[
|
||||
dict,
|
||||
"A dictionary containing 'documents_count' (number of documents returned) and 'documents' (a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)",
|
||||
"A dictionary containing 'documents_count' (number of documents returned) and 'documents' "
|
||||
"(a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)",
|
||||
]:
|
||||
"""
|
||||
List documents in the user's Google Drive. Excludes documents that are in the trash.
|
||||
"""
|
||||
page_size = min(10, limit)
|
||||
page_token = None # The page token is used for continuing a previous request on the next page
|
||||
files = []
|
||||
files: list[dict[str, Any]] = []
|
||||
|
||||
service = build_drive_service(context.authorization.token)
|
||||
service = build_drive_service(
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
|
||||
query = "mimeType = 'application/vnd.google-apps.document' and trashed = false"
|
||||
if title_keywords:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import base64
|
||||
from email.message import EmailMessage
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Google
|
||||
from arcade.sdk.errors import RetryableToolError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.utils import (
|
||||
DateRange,
|
||||
build_query_string,
|
||||
|
|
@ -42,7 +42,15 @@ async def send_email(
|
|||
"""
|
||||
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message.set_content(body)
|
||||
|
|
@ -80,7 +88,15 @@ async def send_draft_email(
|
|||
"""
|
||||
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
# Send the draft email
|
||||
sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute()
|
||||
|
|
@ -108,7 +124,15 @@ async def write_draft_email(
|
|||
Compose a new email draft using the Gmail API.
|
||||
"""
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
message = MIMEText(body)
|
||||
message["to"] = recipient
|
||||
|
|
@ -149,7 +173,15 @@ async def update_draft_email(
|
|||
"""
|
||||
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
message = MIMEText(body)
|
||||
message["to"] = recipient
|
||||
|
|
@ -188,7 +220,15 @@ async def delete_draft_email(
|
|||
"""
|
||||
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
# Delete the draft
|
||||
service.users().drafts().delete(userId="me", id=draft_email_id).execute()
|
||||
|
|
@ -209,7 +249,15 @@ async def trash_email(
|
|||
"""
|
||||
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
# Trash the email
|
||||
trashed_email = service.users().messages().trash(userId="me", id=email_id).execute()
|
||||
|
|
@ -233,7 +281,15 @@ async def list_draft_emails(
|
|||
Lists draft emails in the user's draft mailbox using the Gmail API.
|
||||
"""
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
listed_drafts = service.users().drafts().list(userId="me").execute()
|
||||
|
||||
|
|
@ -268,7 +324,7 @@ async def list_emails_by_header(
|
|||
subject: Annotated[Optional[str], "Words to find in the subject of the email"] = None,
|
||||
body: Annotated[Optional[str], "Words to find in the body of the email"] = None,
|
||||
date_range: Annotated[Optional[DateRange], "The date range of the email"] = None,
|
||||
limit: Annotated[Optional[int], "The maximum number of emails to return"] = 25,
|
||||
limit: Annotated[int, "The maximum number of emails to return"] = 25,
|
||||
) -> Annotated[
|
||||
dict, "A dictionary containing a list of email details matching the search criteria"
|
||||
]:
|
||||
|
|
@ -279,12 +335,22 @@ async def list_emails_by_header(
|
|||
if not any([sender, recipient, subject, body]):
|
||||
raise RetryableToolError(
|
||||
message="At least one of sender, recipient, subject, or body must be provided.",
|
||||
developer_message="At least one of sender, recipient, subject, or body must be provided.",
|
||||
developer_message=(
|
||||
"At least one of sender, recipient, subject, or body must be provided."
|
||||
),
|
||||
)
|
||||
|
||||
query = build_query_string(sender, recipient, subject, body, date_range)
|
||||
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
messages = fetch_messages(service, query, limit)
|
||||
|
||||
if not messages:
|
||||
|
|
@ -294,7 +360,7 @@ async def list_emails_by_header(
|
|||
return {"emails": emails}
|
||||
|
||||
|
||||
def process_messages(service, messages):
|
||||
def process_messages(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
emails = []
|
||||
for msg in messages:
|
||||
try:
|
||||
|
|
@ -319,7 +385,15 @@ async def list_emails(
|
|||
Read emails from a Gmail account and extract plain text content.
|
||||
"""
|
||||
# Set up the Gmail API client
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
messages = service.users().messages().list(userId="me").execute().get("messages", [])
|
||||
|
||||
|
|
@ -359,7 +433,15 @@ async def search_threads(
|
|||
date_range: Annotated[Optional[DateRange], "The date range of the email"] = None,
|
||||
) -> Annotated[dict, "A dictionary containing a list of thread details"]:
|
||||
"""Search for threads in the user's mailbox"""
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
query = (
|
||||
build_query_string(sender, recipient, subject, body, date_range)
|
||||
|
|
@ -377,7 +459,7 @@ async def search_threads(
|
|||
}
|
||||
params = remove_none_values(params)
|
||||
|
||||
threads = []
|
||||
threads: list[dict[str, Any]] = []
|
||||
next_page_token = None
|
||||
# Paginate through thread pages until we have the desired number of threads
|
||||
while len(threads) < max_results:
|
||||
|
|
@ -413,7 +495,10 @@ async def list_threads(
|
|||
include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
|
||||
) -> Annotated[dict, "A dictionary containing a list of thread details"]:
|
||||
"""List threads in the user's mailbox."""
|
||||
return await search_threads(context, page_token, max_results, include_spam_trash)
|
||||
threads: dict[str, Any] = await search_threads(
|
||||
context, page_token, max_results, include_spam_trash
|
||||
)
|
||||
return threads
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -437,8 +522,16 @@ async def get_thread(
|
|||
}
|
||||
params = remove_none_values(params)
|
||||
|
||||
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
|
||||
service = build(
|
||||
"gmail",
|
||||
"v1",
|
||||
credentials=Credentials(
|
||||
context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
),
|
||||
)
|
||||
thread = service.users().threads().get(**params).execute()
|
||||
thread["messages"] = [parse_email(message) for message in thread.get("messages", [])]
|
||||
|
||||
return thread
|
||||
return dict(thread)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from enum import Enum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ class DateRange(Enum):
|
|||
THIS_MONTH = "this_month"
|
||||
NEXT_MONTH = "next_month"
|
||||
|
||||
def to_date_range(self):
|
||||
def to_date_range(self) -> tuple[date, date]:
|
||||
today = datetime.now().date()
|
||||
if self == DateRange.TODAY:
|
||||
return today, today + timedelta(days=1)
|
||||
|
|
@ -67,7 +67,7 @@ class Day(Enum):
|
|||
NEXT_FRIDAY = "next_friday"
|
||||
NEXT_SATURDAY = "next_saturday"
|
||||
|
||||
def to_date(self, time_zone_name: str):
|
||||
def to_date(self, time_zone_name: str) -> date:
|
||||
time_zone = ZoneInfo(time_zone_name)
|
||||
today = datetime.now(time_zone).date()
|
||||
weekday = today.weekday()
|
||||
|
|
@ -206,7 +206,7 @@ class TimeSlot(Enum):
|
|||
_2330 = "23:30"
|
||||
_2345 = "23:45"
|
||||
|
||||
def to_time(self):
|
||||
def to_time(self) -> time:
|
||||
return datetime.strptime(self.value, "%H:%M").time()
|
||||
|
||||
|
||||
|
|
@ -254,43 +254,91 @@ class OrderBy(str, Enum):
|
|||
Each key has both ascending and descending options.
|
||||
"""
|
||||
|
||||
CREATED_TIME = "createdTime" # When the file was created (ascending)
|
||||
CREATED_TIME_DESC = "createdTime desc" # When the file was created (descending)
|
||||
FOLDER = "folder" # The folder ID, sorted using alphabetical ordering (ascending)
|
||||
FOLDER_DESC = "folder desc" # The folder ID, sorted using alphabetical ordering (descending)
|
||||
CREATED_TIME = (
|
||||
# When the file was created (ascending)
|
||||
"createdTime"
|
||||
)
|
||||
CREATED_TIME_DESC = (
|
||||
# When the file was created (descending)
|
||||
"createdTime desc"
|
||||
)
|
||||
FOLDER = (
|
||||
# The folder ID, sorted using alphabetical ordering (ascending)
|
||||
"folder"
|
||||
)
|
||||
FOLDER_DESC = (
|
||||
# The folder ID, sorted using alphabetical ordering (descending)
|
||||
"folder desc"
|
||||
)
|
||||
MODIFIED_BY_ME_TIME = (
|
||||
"modifiedByMeTime" # The last time the file was modified by the user (ascending)
|
||||
# The last time the file was modified by the user (ascending)
|
||||
"modifiedByMeTime"
|
||||
)
|
||||
MODIFIED_BY_ME_TIME_DESC = (
|
||||
"modifiedByMeTime desc" # The last time the file was modified by the user (descending)
|
||||
# The last time the file was modified by the user (descending)
|
||||
"modifiedByMeTime desc"
|
||||
)
|
||||
MODIFIED_TIME = (
|
||||
# The last time the file was modified by anyone (ascending)
|
||||
"modifiedTime"
|
||||
)
|
||||
MODIFIED_TIME = "modifiedTime" # The last time the file was modified by anyone (ascending)
|
||||
MODIFIED_TIME_DESC = (
|
||||
"modifiedTime desc" # The last time the file was modified by anyone (descending)
|
||||
# The last time the file was modified by anyone (descending)
|
||||
"modifiedTime desc"
|
||||
)
|
||||
NAME = (
|
||||
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending)
|
||||
"name"
|
||||
)
|
||||
NAME_DESC = (
|
||||
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending)
|
||||
"name desc"
|
||||
)
|
||||
NAME_NATURAL = (
|
||||
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending)
|
||||
"name_natural"
|
||||
)
|
||||
NAME_NATURAL_DESC = (
|
||||
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending)
|
||||
"name_natural desc"
|
||||
)
|
||||
NAME = "name" # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending)
|
||||
NAME_DESC = "name desc" # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending)
|
||||
NAME_NATURAL = "name_natural" # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending)
|
||||
NAME_NATURAL_DESC = "name_natural desc" # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending)
|
||||
QUOTA_BYTES_USED = (
|
||||
"quotaBytesUsed" # The number of storage quota bytes used by the file (ascending)
|
||||
# The number of storage quota bytes used by the file (ascending)
|
||||
"quotaBytesUsed"
|
||||
)
|
||||
QUOTA_BYTES_USED_DESC = (
|
||||
"quotaBytesUsed desc" # The number of storage quota bytes used by the file (descending)
|
||||
# The number of storage quota bytes used by the file (descending)
|
||||
"quotaBytesUsed desc"
|
||||
)
|
||||
RECENCY = (
|
||||
# The most recent timestamp from the file's date-time fields (ascending)
|
||||
"recency"
|
||||
)
|
||||
RECENCY = "recency" # The most recent timestamp from the file's date-time fields (ascending)
|
||||
RECENCY_DESC = (
|
||||
"recency desc" # The most recent timestamp from the file's date-time fields (descending)
|
||||
# The most recent timestamp from the file's date-time fields (descending)
|
||||
"recency desc"
|
||||
)
|
||||
SHARED_WITH_ME_TIME = (
|
||||
"sharedWithMeTime" # When the file was shared with the user, if applicable (ascending)
|
||||
# When the file was shared with the user, if applicable (ascending)
|
||||
"sharedWithMeTime"
|
||||
)
|
||||
SHARED_WITH_ME_TIME_DESC = (
|
||||
# When the file was shared with the user, if applicable (descending)
|
||||
"sharedWithMeTime desc"
|
||||
)
|
||||
STARRED = (
|
||||
# Whether the user has starred the file (ascending)
|
||||
"starred"
|
||||
)
|
||||
STARRED_DESC = (
|
||||
# Whether the user has starred the file (descending)
|
||||
"starred desc"
|
||||
)
|
||||
SHARED_WITH_ME_TIME_DESC = "sharedWithMeTime desc" # When the file was shared with the user, if applicable (descending)
|
||||
STARRED = "starred" # Whether the user has starred the file (ascending)
|
||||
STARRED_DESC = "starred desc" # Whether the user has starred the file (descending)
|
||||
VIEWED_BY_ME_TIME = (
|
||||
"viewedByMeTime" # The last time the file was viewed by the user (ascending)
|
||||
# The last time the file was viewed by the user (ascending)
|
||||
"viewedByMeTime"
|
||||
)
|
||||
VIEWED_BY_ME_TIME_DESC = (
|
||||
"viewedByMeTime desc" # The last time the file was viewed by the user (descending)
|
||||
# The last time the file was viewed by the user (descending)
|
||||
"viewedByMeTime desc"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo
|
|||
|
||||
from bs4 import BeautifulSoup
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.discovery import Resource, build
|
||||
|
||||
from arcade_google.tools.models import Day, TimeSlot
|
||||
|
||||
|
|
@ -32,7 +32,8 @@ def parse_datetime(datetime_str: str, time_zone: str) -> datetime:
|
|||
dt = dt.replace(tzinfo=ZoneInfo(time_zone))
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid datetime format: '{datetime_str}'. Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'."
|
||||
f"Invalid datetime format: '{datetime_str}'. "
|
||||
"Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'."
|
||||
) from e
|
||||
return dt
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ class DateRange(Enum):
|
|||
LAST_MONTH = "last_month"
|
||||
THIS_YEAR = "this_year"
|
||||
|
||||
def to_date_query(self):
|
||||
def to_date_query(self) -> str:
|
||||
today = datetime.now()
|
||||
result = "after:"
|
||||
comparison_date = today
|
||||
|
|
@ -71,7 +72,7 @@ class DateRange(Enum):
|
|||
return result + comparison_date.strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
def parse_email(email_data: dict[str, Any]) -> Optional[dict[str, str]]:
|
||||
def parse_email(email_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Parse email data and extract relevant information.
|
||||
|
||||
|
|
@ -97,10 +98,10 @@ def parse_email(email_data: dict[str, Any]) -> Optional[dict[str, str]]:
|
|||
}
|
||||
except Exception as e:
|
||||
print(f"Error parsing email {email_data.get('id', 'unknown')}: {e}")
|
||||
return None
|
||||
return email_data
|
||||
|
||||
|
||||
def parse_draft_email(draft_email_data: dict[str, Any]) -> Optional[dict[str, str]]:
|
||||
def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
|
||||
"""
|
||||
Parse draft email data and extract relevant information.
|
||||
|
||||
|
|
@ -127,18 +128,18 @@ def parse_draft_email(draft_email_data: dict[str, Any]) -> Optional[dict[str, st
|
|||
}
|
||||
except Exception as e:
|
||||
print(f"Error parsing draft email {draft_email_data.get('id', 'unknown')}: {e}")
|
||||
return None
|
||||
return draft_email_data
|
||||
|
||||
|
||||
def get_draft_url(draft_id):
|
||||
def get_draft_url(draft_id: str) -> str:
|
||||
return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
|
||||
|
||||
|
||||
def get_sent_email_url(sent_email_id):
|
||||
def get_sent_email_url(sent_email_id: str) -> str:
|
||||
return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
|
||||
|
||||
|
||||
def get_email_in_trash_url(email_id):
|
||||
def get_email_in_trash_url(email_id: str) -> str:
|
||||
return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
|
||||
|
||||
|
||||
|
|
@ -178,9 +179,9 @@ def _clean_email_body(body: str) -> str:
|
|||
text = soup.get_text(separator=" ")
|
||||
|
||||
# Clean up the text
|
||||
text = _clean_text(text)
|
||||
cleaned_text = _clean_text(text)
|
||||
|
||||
return text.strip()
|
||||
return cleaned_text.strip()
|
||||
except Exception as e:
|
||||
print(f"Error cleaning email body: {e}")
|
||||
return body
|
||||
|
|
@ -226,9 +227,15 @@ def _update_datetime(day: Day | None, time: TimeSlot | None, time_zone: str) ->
|
|||
return None
|
||||
|
||||
|
||||
def build_query_string(sender, recipient, subject, body, date_range):
|
||||
"""
|
||||
Helper function to build a query string for Gmail list_emails_by_header and search_threads tools.
|
||||
def build_query_string(
|
||||
sender: str | None,
|
||||
recipient: str | None,
|
||||
subject: str | None,
|
||||
body: str | None,
|
||||
date_range: DateRange | None,
|
||||
) -> str:
|
||||
"""Helper function to build a query string
|
||||
for Gmail list_emails_by_header and search_threads tools.
|
||||
"""
|
||||
query = []
|
||||
if sender:
|
||||
|
|
@ -244,7 +251,7 @@ def build_query_string(sender, recipient, subject, body, date_range):
|
|||
return " ".join(query)
|
||||
|
||||
|
||||
def fetch_messages(service, query_string, limit):
|
||||
def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
|
||||
"""
|
||||
|
|
@ -254,7 +261,7 @@ def fetch_messages(service, query_string, limit):
|
|||
.list(userId="me", q=query_string, maxResults=limit or 100)
|
||||
.execute()
|
||||
)
|
||||
return response.get("messages", [])
|
||||
return response.get("messages", []) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def remove_none_values(params: dict) -> dict:
|
||||
|
|
@ -267,16 +274,18 @@ def remove_none_values(params: dict) -> dict:
|
|||
|
||||
|
||||
# Drive utils
|
||||
def build_drive_service(token: str):
|
||||
def build_drive_service(auth_token: Optional[str]) -> Resource: # type: ignore[no-any-unimported]
|
||||
"""
|
||||
Build a Drive service object.
|
||||
"""
|
||||
return build("drive", "v3", credentials=Credentials(token))
|
||||
auth_token = auth_token or ""
|
||||
return build("drive", "v3", credentials=Credentials(auth_token))
|
||||
|
||||
|
||||
# Docs utils
|
||||
def build_docs_service(token: str):
|
||||
def build_docs_service(auth_token: Optional[str]) -> Resource: # type: ignore[no-any-unimported]
|
||||
"""
|
||||
Build a Drive service object.
|
||||
"""
|
||||
return build("docs", "v1", credentials=Credentials(token))
|
||||
auth_token = auth_token or ""
|
||||
return build("docs", "v1", credentials=Credentials(auth_token))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
from datetime import timedelta
|
||||
|
||||
import arcade_google
|
||||
from arcade_google.tools.calendar import (
|
||||
EventVisibility,
|
||||
SendUpdatesOptions,
|
||||
create_event,
|
||||
delete_event,
|
||||
list_events,
|
||||
update_event,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -20,6 +10,16 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_google
|
||||
from arcade_google.tools.calendar import (
|
||||
EventVisibility,
|
||||
SendUpdatesOptions,
|
||||
create_event,
|
||||
delete_event,
|
||||
list_events,
|
||||
update_event,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
import arcade_google
|
||||
from arcade_google.tools.docs import (
|
||||
create_blank_document,
|
||||
create_document_from_text,
|
||||
get_document_by_id,
|
||||
insert_text_at_end_of_document,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -16,6 +8,14 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_google
|
||||
from arcade_google.tools.docs import (
|
||||
create_blank_document,
|
||||
create_document_from_text,
|
||||
get_document_by_id,
|
||||
insert_text_at_end_of_document,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import arcade_google
|
||||
from arcade_google.tools.drive import list_documents
|
||||
from arcade_google.tools.models import Corpora, OrderBy
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -11,6 +7,10 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_google
|
||||
from arcade_google.tools.drive import list_documents
|
||||
from arcade_google.tools.models import Corpora, OrderBy
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
import arcade_google
|
||||
from arcade_google.tools.gmail import (
|
||||
get_thread,
|
||||
list_threads,
|
||||
search_threads,
|
||||
send_email,
|
||||
)
|
||||
from arcade_google.tools.utils import DateRange
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -17,6 +8,15 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_google
|
||||
from arcade_google.tools.gmail import (
|
||||
get_thread,
|
||||
list_threads,
|
||||
search_threads,
|
||||
send_email,
|
||||
)
|
||||
from arcade_google.tools.utils import DateRange
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_google"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "Arcade tools for the entire google suite"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -17,7 +17,32 @@ beautifulsoup4 = "^4.10.0"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_google/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade_google.tools.calendar import create_event, delete_event, list_events, update_event
|
||||
from arcade_google.tools.models import EventVisibility, SendUpdatesOptions
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.calendar import create_event, delete_event, list_events, update_event
|
||||
from arcade_google.tools.models import EventVisibility, SendUpdatesOptions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.docs import (
|
||||
create_blank_document,
|
||||
create_document_from_text,
|
||||
|
|
@ -8,9 +11,6 @@ from arcade_google.tools.docs import (
|
|||
insert_text_at_end_of_document,
|
||||
)
|
||||
from arcade_google.tools.utils import build_docs_service
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.drive import list_documents
|
||||
from arcade_google.tools.models import Corpora, OrderBy
|
||||
from arcade_google.tools.utils import build_drive_service
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade_google.tools.gmail import (
|
||||
delete_draft_email,
|
||||
get_thread,
|
||||
|
|
@ -16,10 +20,6 @@ from arcade_google.tools.gmail import (
|
|||
write_draft_email,
|
||||
)
|
||||
from arcade_google.tools.utils import parse_draft_email, parse_email
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
18
toolkits/linkedin/.pre-commit-config.yaml
Normal file
18
toolkits/linkedin/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
46
toolkits/linkedin/.ruff.toml
Normal file
46
toolkits/linkedin/.ruff.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/linkedin/LICENSE
Normal file
21
toolkits/linkedin/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/linkedin/Makefile
Normal file
53
toolkits/linkedin/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ linkedin Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import LinkedIn
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
|
@ -33,7 +32,10 @@ async def _send_linkedin_request(
|
|||
ToolExecutionError: If the request fails for any reason.
|
||||
"""
|
||||
url = f"{LINKEDIN_BASE_URL}{endpoint}"
|
||||
headers = {"Authorization": f"Bearer {context.authorization.token}"}
|
||||
token = (
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
|
|
@ -47,7 +49,7 @@ async def _send_linkedin_request(
|
|||
return response
|
||||
|
||||
|
||||
def _handle_linkedin_api_error(response: httpx.Response):
|
||||
def _handle_linkedin_api_error(response: httpx.Response) -> None:
|
||||
"""
|
||||
Handle errors from the LinkedIn API by mapping common status codes to ToolExecutionErrors.
|
||||
|
||||
|
|
@ -81,11 +83,13 @@ async def create_text_post(
|
|||
"""Share a new text post to LinkedIn."""
|
||||
endpoint = "/ugcPosts"
|
||||
|
||||
# The LinkedIn user ID is required to create a post, even though we're using the user's access token.
|
||||
# Arcade Engine gets the current user's info from LinkedIn and automatically populates context.authorization.user_info.
|
||||
# The LinkedIn user ID is required to create a post, even though we're using
|
||||
# the user's access token.
|
||||
# Arcade Engine gets the current user's info from LinkedIn and automatically
|
||||
# populates context.authorization.user_info.
|
||||
# LinkedIn calls the user ID "sub" in their user_info data payload. See:
|
||||
# https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details
|
||||
user_id = context.authorization.user_info.get("sub")
|
||||
user_id = context.authorization.user_info.get("sub") if context.authorization else None
|
||||
if not user_id:
|
||||
raise ToolExecutionError(
|
||||
"User ID not found.",
|
||||
|
|
@ -109,5 +113,6 @@ async def create_text_post(
|
|||
if response.status_code >= 200 and response.status_code < 300:
|
||||
share_id = response.json().get("id")
|
||||
return f"https://www.linkedin.com/feed/update/{share_id}/"
|
||||
else:
|
||||
_handle_linkedin_api_error(response)
|
||||
|
||||
_handle_linkedin_api_error(response)
|
||||
return ""
|
||||
|
|
|
|||
20
toolkits/linkedin/conftest.py
Normal file
20
toolkits/linkedin/conftest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import pytest
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_context():
|
||||
"""Fixture for the ToolContext with mock authorization."""
|
||||
return ToolContext(
|
||||
authorization=ToolAuthorizationContext(token="test_token", user_info={"sub": "test_user"}), # noqa: S106
|
||||
user_id="test_user",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client(mocker):
|
||||
"""Fixture to mock the httpx.AsyncClient."""
|
||||
# Mock the AsyncClient context manager
|
||||
mock_client = mocker.patch("httpx.AsyncClient", autospec=True)
|
||||
async_mock_client = mock_client.return_value.__aenter__.return_value
|
||||
return async_mock_client
|
||||
48
toolkits/linkedin/evals/eval_linkedin.py
Normal file
48
toolkits/linkedin/evals/eval_linkedin.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_linkedin
|
||||
from arcade_linkedin.tools.share import create_text_post
|
||||
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.85,
|
||||
warn_threshold=0.95,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_linkedin)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def linkedin_eval_suite():
|
||||
suite = EvalSuite(
|
||||
name="LinkedIn Tools Evaluation",
|
||||
system_message="You are an AI assistant with access to LinkedIn tools. Use them to help the user with their tasks.",
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Run code",
|
||||
user_message="post this transcription to linkedin. there may be some things that you need to clean up since it was spoken.: 'It is with great pleasure that I announce that I am now a member of the LinkedIn community! I'd like to thank the LinkedIn team for their support and encouragement in my journey to success. hash tag Y2K'",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=create_text_post,
|
||||
args={
|
||||
"text": "It is with great pleasure that I announce that I am now a member of the LinkedIn community! I'd like to thank the LinkedIn team for their support and encouragement in my journey to success. #Y2K",
|
||||
},
|
||||
)
|
||||
],
|
||||
critics=[
|
||||
SimilarityCritic(critic_field="text", weight=1.0),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_linkedin"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "Arcade tools for LinkedIn"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -11,7 +11,32 @@ httpx = "^0.27.2"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_linkedin/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
0
toolkits/linkedin/tests/__init__.py
Normal file
0
toolkits/linkedin/tests/__init__.py
Normal file
35
toolkits/linkedin/tests/test_share.py
Normal file
35
toolkits/linkedin/tests/test_share.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_linkedin.tools.share import create_text_post
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_text_post_success(tool_context, mock_httpx_client):
|
||||
"""Test successful creation of a LinkedIn text post."""
|
||||
# Mock response for a successful post creation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "1234567890"}
|
||||
# Ensure the mock is awaited properly
|
||||
mock_httpx_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
post_text = "Hello, LinkedIn!"
|
||||
result = await create_text_post(tool_context, post_text)
|
||||
|
||||
expected_url = "https://www.linkedin.com/feed/update/1234567890/"
|
||||
assert result == expected_url
|
||||
mock_httpx_client.request.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_text_post_no_user_id(tool_context):
|
||||
"""Test error when user ID is not found in the context."""
|
||||
# Simulate missing user ID in the context
|
||||
tool_context.authorization.user_info = {}
|
||||
|
||||
post_text = "Hello, LinkedIn!"
|
||||
with pytest.raises(ToolExecutionError, match="User ID not found"):
|
||||
await create_text_post(tool_context, post_text)
|
||||
18
toolkits/math/.pre-commit-config.yaml
Normal file
18
toolkits/math/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
47
toolkits/math/.ruff.toml
Normal file
47
toolkits/math/.ruff.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import arcade_math
|
||||
from arcade_math.tools.arithmetic import add, sqrt
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -10,6 +7,9 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_math
|
||||
from arcade_math.tools.arithmetic import add, sqrt
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.85,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_math.tools.arithmetic import (
|
||||
add,
|
||||
divide,
|
||||
|
|
@ -9,8 +11,6 @@ from arcade_math.tools.arithmetic import (
|
|||
sum_range,
|
||||
)
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
|
||||
def test_add():
|
||||
assert add(1, 2) == 3
|
||||
|
|
|
|||
18
toolkits/search/.pre-commit-config.yaml
Normal file
18
toolkits/search/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
47
toolkits/search/.ruff.toml
Normal file
47
toolkits/search/.ruff.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/search/LICENSE
Normal file
21
toolkits/search/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/search/Makefile
Normal file
53
toolkits/search/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ search Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -3,7 +3,6 @@ import os
|
|||
from typing import Annotated, Any, Optional
|
||||
|
||||
import serpapi
|
||||
|
||||
from arcade.sdk import tool
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import arcade_search
|
||||
from arcade_search.tools.google import search_google
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
EvalRubric,
|
||||
|
|
@ -11,6 +8,9 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_search
|
||||
from arcade_search.tools.google import search_google
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.8,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_search"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "Tools for searching the web"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -11,7 +11,32 @@ serpapi = "^0.1.5"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_search/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
0
toolkits/search/tests/__init__.py
Normal file
0
toolkits/search/tests/__init__.py
Normal file
47
toolkits/search/tests/test_google.py
Normal file
47
toolkits/search/tests/test_google.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from arcade_search.tools.google import search_google
|
||||
|
||||
GET_SECRET_PATCH_TARGET = "arcade_search.tools.google.get_secret" # noqa: S105
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_google_success():
|
||||
with (
|
||||
patch(GET_SECRET_PATCH_TARGET, return_value="fake_api_key"),
|
||||
patch("serpapi.Client") as MockClient,
|
||||
):
|
||||
mock_client_instance = MockClient.return_value
|
||||
mock_client_instance.search.return_value.as_dict.return_value = {
|
||||
"organic_results": [
|
||||
{"title": "Result 1", "link": "http://example.com/1"},
|
||||
{"title": "Result 2", "link": "http://example.com/2"},
|
||||
{"title": "Result 3", "link": "http://example.com/3"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await search_google("test query", 2)
|
||||
|
||||
expected_result = json.dumps([
|
||||
{"title": "Result 1", "link": "http://example.com/1"},
|
||||
{"title": "Result 2", "link": "http://example.com/2"},
|
||||
])
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_google_no_results():
|
||||
with (
|
||||
patch(GET_SECRET_PATCH_TARGET, return_value="fake_api_key"),
|
||||
patch("serpapi.Client") as MockClient,
|
||||
):
|
||||
mock_client_instance = MockClient.return_value
|
||||
mock_client_instance.search.return_value.as_dict.return_value = {"organic_results": []}
|
||||
|
||||
result = await search_google("test query", 2)
|
||||
|
||||
expected_result = json.dumps([])
|
||||
assert result == expected_result
|
||||
18
toolkits/slack/.pre-commit-config.yaml
Normal file
18
toolkits/slack/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
47
toolkits/slack/.ruff.toml
Normal file
47
toolkits/slack/.ruff.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/slack/LICENSE
Normal file
21
toolkits/slack/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/slack/Makefile
Normal file
53
toolkits/slack/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ slack Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
from typing import Annotated
|
||||
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Slack
|
||||
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
|
||||
from arcade.sdk.errors import RetryableToolError
|
||||
from slack_sdk import WebClient
|
||||
|
||||
from arcade_slack.tools.utils import format_channels, format_users
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -22,54 +22,45 @@ def send_dm_to_user(
|
|||
context: ToolContext,
|
||||
user_name: Annotated[
|
||||
str,
|
||||
"The Slack username of the person you want to message. Slack usernames are ALWAYS lowercase.",
|
||||
"The Slack username of the person you want to message. "
|
||||
"Slack usernames are ALWAYS lowercase.",
|
||||
],
|
||||
message: Annotated[str, "The message you want to send"],
|
||||
):
|
||||
) -> Annotated[dict, "The response from the Slack API"]:
|
||||
"""Send a direct message to a user in Slack."""
|
||||
slackClient = WebClient(
|
||||
token=context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
)
|
||||
|
||||
slackClient = WebClient(token=context.authorization.token)
|
||||
# Step 1: Retrieve the user's Slack ID based on their username
|
||||
userListResponse = slackClient.users_list()
|
||||
user_id = None
|
||||
for user in userListResponse["members"]:
|
||||
if user["name"].lower() == user_name.lower():
|
||||
user_id = user["id"]
|
||||
break
|
||||
|
||||
try:
|
||||
# Step 1: Retrieve the user's Slack ID based on their username
|
||||
userListResponse = slackClient.users_list()
|
||||
user_id = None
|
||||
for user in userListResponse["members"]:
|
||||
if user["name"].lower() == user_name.lower():
|
||||
user_id = user["id"]
|
||||
break
|
||||
|
||||
if not user_id:
|
||||
raise RetryableToolError(
|
||||
"User not found",
|
||||
developer_message=f"User with username '{user_name}' not found.",
|
||||
additional_prompt_content=format_users(userListResponse),
|
||||
retry_after_ms=500, # Play nice with Slack API rate limits
|
||||
)
|
||||
|
||||
# Step 2: Retrieve the DM channel ID with the user
|
||||
im_response = slackClient.conversations_open(users=[user_id])
|
||||
dm_channel_id = im_response["channel"]["id"]
|
||||
|
||||
# Step 3: Send the message as if it's from you (because we're using a user token)
|
||||
slackClient.chat_postMessage(channel=dm_channel_id, text=message)
|
||||
|
||||
except SlackApiError as e:
|
||||
error_message = e.response["error"] if "error" in e.response else str(e)
|
||||
raise ToolExecutionError(
|
||||
"Error sending message",
|
||||
developer_message=f"Slack API Error: {error_message}",
|
||||
if not user_id:
|
||||
raise RetryableToolError(
|
||||
"User not found",
|
||||
developer_message=f"User with username '{user_name}' not found.",
|
||||
additional_prompt_content=format_users(userListResponse),
|
||||
retry_after_ms=500, # Play nice with Slack API rate limits
|
||||
)
|
||||
|
||||
# Step 2: Retrieve the DM channel ID with the user
|
||||
im_response = slackClient.conversations_open(users=[user_id])
|
||||
dm_channel_id = im_response["channel"]["id"]
|
||||
|
||||
def format_users(userListResponse: dict) -> str:
|
||||
csv_string = "All active Slack users:\n\nname,real_name\n"
|
||||
for user in userListResponse["members"]:
|
||||
if not user.get("deleted", False):
|
||||
name = user.get("name", "")
|
||||
real_name = user.get("profile", {}).get("real_name", "")
|
||||
csv_string += f"{name},{real_name}\n"
|
||||
return csv_string.strip()
|
||||
# Step 3: Send the message as if it's from you (because we're using a user token)
|
||||
response = slackClient.chat_postMessage(channel=dm_channel_id, text=message)
|
||||
response.validate()
|
||||
|
||||
if isinstance(response.data, dict):
|
||||
return response.data
|
||||
return {}
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -85,46 +76,39 @@ def send_message_to_channel(
|
|||
context: ToolContext,
|
||||
channel_name: Annotated[
|
||||
str,
|
||||
"The Slack channel name where you want to send the message. Slack channel names are ALWAYS lowercase.",
|
||||
"The Slack channel name where you want to send the message. "
|
||||
"Slack channel names are ALWAYS lowercase.",
|
||||
],
|
||||
message: Annotated[str, "The message you want to send"],
|
||||
):
|
||||
) -> Annotated[dict, "The response from the Slack API"]:
|
||||
"""Send a message to a channel in Slack."""
|
||||
|
||||
slackClient = WebClient(token=context.authorization.token)
|
||||
slackClient = WebClient(
|
||||
token=context.authorization.token
|
||||
if context.authorization and context.authorization.token
|
||||
else ""
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Retrieve the list of channels
|
||||
channels_response = slackClient.conversations_list()
|
||||
channel_id = None
|
||||
for channel in channels_response["channels"]:
|
||||
if channel["name"].lower() == channel_name.lower():
|
||||
channel_id = channel["id"]
|
||||
break
|
||||
# Step 1: Retrieve the list of channels
|
||||
channels_response = slackClient.conversations_list()
|
||||
channel_id = None
|
||||
for channel in channels_response["channels"]:
|
||||
if channel["name"].lower() == channel_name.lower():
|
||||
channel_id = channel["id"]
|
||||
break
|
||||
|
||||
if not channel_id:
|
||||
raise RetryableToolError(
|
||||
"Channel not found",
|
||||
developer_message=f"Channel with name '{channel_name}' not found.",
|
||||
additional_prompt_content=format_channels(channels_response),
|
||||
retry_after_ms=500, # Play nice with Slack API rate limits
|
||||
)
|
||||
|
||||
# Step 2: Send the message to the channel
|
||||
slackClient.chat_postMessage(channel=channel_id, text=message)
|
||||
|
||||
except SlackApiError as e:
|
||||
error_message = e.response["error"] if "error" in e.response else str(e)
|
||||
raise ToolExecutionError(
|
||||
"Error sending message",
|
||||
developer_message=f"Slack API Error: {error_message}",
|
||||
if not channel_id:
|
||||
raise RetryableToolError(
|
||||
"Channel not found",
|
||||
developer_message=f"Channel with name '{channel_name}' not found.",
|
||||
additional_prompt_content=format_channels(channels_response),
|
||||
retry_after_ms=500, # Play nice with Slack API rate limits
|
||||
)
|
||||
|
||||
# Step 2: Send the message to the channel
|
||||
response = slackClient.chat_postMessage(channel=channel_id, text=message)
|
||||
response.validate()
|
||||
|
||||
def format_channels(channels_response: dict) -> str:
|
||||
csv_string = "All active Slack channels:\n\nname\n"
|
||||
for channel in channels_response["channels"]:
|
||||
if not channel.get("is_archived", False):
|
||||
name = channel.get("name", "")
|
||||
csv_string += f"{name}\n"
|
||||
return csv_string.strip()
|
||||
if isinstance(response.data, dict):
|
||||
return response.data
|
||||
return {}
|
||||
|
|
|
|||
20
toolkits/slack/arcade_slack/tools/utils.py
Normal file
20
toolkits/slack/arcade_slack/tools/utils.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from slack_sdk.web import SlackResponse
|
||||
|
||||
|
||||
def format_channels(channels_response: SlackResponse) -> str:
|
||||
csv_string = "All active Slack channels:\n\nname\n"
|
||||
for channel in channels_response["channels"]:
|
||||
if not channel.get("is_archived", False):
|
||||
name = channel.get("name", "")
|
||||
csv_string += f"{name}\n"
|
||||
return csv_string.strip()
|
||||
|
||||
|
||||
def format_users(userListResponse: SlackResponse) -> str:
|
||||
csv_string = "All active Slack users:\n\nname,real_name\n"
|
||||
for user in userListResponse["members"]:
|
||||
if not user.get("deleted", False):
|
||||
name = user.get("name", "")
|
||||
real_name = user.get("profile", {}).get("real_name", "")
|
||||
csv_string += f"{name},{real_name}\n"
|
||||
return csv_string.strip()
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import arcade_slack
|
||||
from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -11,6 +8,9 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_slack
|
||||
from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.8,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_slack"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "Slack tools for LLMs"
|
||||
authors = ["Arcade AI <dev@arcade-ai.com>"]
|
||||
|
||||
|
|
@ -11,7 +11,32 @@ slack-sdk = "^3.31.0"
|
|||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_slack/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
|
|
|||
0
toolkits/slack/tests/__init__.py
Normal file
0
toolkits/slack/tests/__init__.py
Normal file
44
toolkits/slack/tests/test_chat.py
Normal file
44
toolkits/slack/tests/test_chat.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
|
||||
from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106
|
||||
return ToolContext(authorization=mock_auth)
|
||||
|
||||
|
||||
def test_send_dm_to_user(mock_context):
|
||||
with patch("arcade_slack.tools.chat.WebClient") as MockWebClient:
|
||||
mock_client = MockWebClient.return_value
|
||||
mock_client.users_list.return_value = {"members": [{"name": "testuser", "id": "U12345"}]}
|
||||
mock_client.conversations_open.return_value = {"channel": {"id": "D12345"}}
|
||||
mock_client.chat_postMessage.return_value = MagicMock(data={"ok": True})
|
||||
|
||||
response = send_dm_to_user(mock_context, "testuser", "Hello!")
|
||||
|
||||
assert response["ok"] is True
|
||||
mock_client.users_list.assert_called_once()
|
||||
mock_client.conversations_open.assert_called_once_with(users=["U12345"])
|
||||
mock_client.chat_postMessage.assert_called_once_with(channel="D12345", text="Hello!")
|
||||
|
||||
|
||||
def test_send_message_to_channel(mock_context):
|
||||
with patch("arcade_slack.tools.chat.WebClient") as MockWebClient:
|
||||
mock_client = MockWebClient.return_value
|
||||
mock_client.conversations_list.return_value = {
|
||||
"channels": [{"name": "general", "id": "C12345"}]
|
||||
}
|
||||
mock_client.chat_postMessage.return_value = MagicMock(data={"ok": True})
|
||||
|
||||
response = send_message_to_channel(mock_context, "general", "Hello, channel!")
|
||||
|
||||
assert response["ok"] is True
|
||||
mock_client.conversations_list.assert_called_once()
|
||||
mock_client.chat_postMessage.assert_called_once_with(
|
||||
channel="C12345", text="Hello, channel!"
|
||||
)
|
||||
18
toolkits/spotify/.pre-commit-config.yaml
Normal file
18
toolkits/spotify/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
47
toolkits/spotify/.ruff.toml
Normal file
47
toolkits/spotify/.ruff.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/spotify/LICENSE
Normal file
21
toolkits/spotify/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/spotify/Makefile
Normal file
53
toolkits/spotify/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ spotify Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
|
|
@ -21,9 +21,11 @@ class PlaybackState:
|
|||
track_spotify_url: Optional[str] = None
|
||||
track_artists: list[str] = field(default_factory=list)
|
||||
track_artists_ids: list[str] = field(default_factory=list)
|
||||
show_name: Optional[str] = None
|
||||
show_id: Optional[str] = None
|
||||
show_spotify_url: Optional[str] = None
|
||||
episode_name: Optional[str] = None
|
||||
episode_id: Optional[str] = None
|
||||
episode_spotify_url: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from typing import Annotated, Optional
|
|||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Spotify
|
||||
from arcade.sdk.errors import RetryableToolError
|
||||
|
||||
from arcade_spotify.tools.models import Device, SearchType
|
||||
from arcade_spotify.tools.search import search
|
||||
from arcade_spotify.tools.utils import (
|
||||
|
|
@ -39,7 +40,10 @@ async def adjust_playback_position(
|
|||
if (absolute_position_ms is None) == (relative_position_ms is None):
|
||||
raise RetryableToolError(
|
||||
"Either absolute_position_ms or relative_position_ms must be provided, but not both",
|
||||
additional_prompt_content="Provide a value for either absolute_position_ms or relative_position_ms, but not both.",
|
||||
additional_prompt_content=(
|
||||
"Provide a value for either absolute_position_ms or "
|
||||
"relative_position_ms, but not both."
|
||||
),
|
||||
retry_after_ms=500,
|
||||
)
|
||||
|
||||
|
|
@ -50,7 +54,7 @@ async def adjust_playback_position(
|
|||
|
||||
absolute_position_ms = playback_state["progress_ms"] + relative_position_ms
|
||||
|
||||
absolute_position_ms = max(0, absolute_position_ms)
|
||||
absolute_position_ms = max(0, absolute_position_ms or 0)
|
||||
|
||||
url = get_url("player_seek_to_position")
|
||||
params = {"position_ms": absolute_position_ms}
|
||||
|
|
@ -174,7 +178,8 @@ async def start_tracks_playback_by_id(
|
|||
Device(**device) for device in (await get_available_devices(context)).get("devices", [])
|
||||
]
|
||||
|
||||
# If no active device is available, pick the first one. Otherwise, Spotify defaults to the active device.
|
||||
# If no active device is available, pick the first one.
|
||||
# Otherwise, Spotify defaults to the active device.
|
||||
device_id = None
|
||||
if devices and not any(device.is_active for device in devices):
|
||||
device_id = devices[0].id
|
||||
|
|
@ -202,7 +207,8 @@ async def get_playback_state(
|
|||
context: ToolContext,
|
||||
) -> Annotated[dict, "Information about the user's current playback state"]:
|
||||
"""
|
||||
Get information about the user's current playback state, including track or episode, and active device.
|
||||
Get information about the user's current playback state,
|
||||
including track or episode, and active device.
|
||||
This tool does not perform any actions. Use other tools to control playback.
|
||||
"""
|
||||
url = get_url("player_get_playback_state")
|
||||
|
|
@ -247,7 +253,7 @@ async def play_artist_by_name(
|
|||
track_ids = [item["id"] for item in search_results["tracks"]["items"]]
|
||||
response = await start_tracks_playback_by_id(context, track_ids)
|
||||
|
||||
return response
|
||||
return str(response)
|
||||
|
||||
|
||||
# NOTE: This tool only works for Spotify Premium users
|
||||
|
|
@ -280,7 +286,7 @@ async def play_track_by_name(
|
|||
track_id = search_results["tracks"]["items"][0]["id"]
|
||||
response = await start_tracks_playback_by_id(context, [track_id])
|
||||
|
||||
return response
|
||||
return str(response)
|
||||
|
||||
|
||||
# NOTE: This tool only works for Spotify Premium users
|
||||
|
|
@ -292,4 +298,4 @@ async def get_available_devices(
|
|||
url = get_url("player_get_available_devices")
|
||||
response = await send_spotify_request(context, "GET", url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return dict(response.json())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import Annotated
|
|||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Spotify
|
||||
|
||||
from arcade_spotify.tools.models import SearchType
|
||||
from arcade_spotify.tools.utils import (
|
||||
get_url,
|
||||
|
|
@ -19,13 +20,18 @@ async def search(
|
|||
"""Search Spotify catalog information
|
||||
|
||||
Explanation of the q parameter:
|
||||
You can narrow down your search using field filters. The available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. Each field filter only applies to certain result types.
|
||||
You can narrow down your search using field filters.
|
||||
Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and
|
||||
genre. Each field filter only applies to certain result types.
|
||||
|
||||
The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1955-1960).
|
||||
The artist and year filters can be used while searching albums, artists and tracks.
|
||||
You can filter on a single year or a range (e.g. 1955-1960).
|
||||
The album filter can be used while searching albums and tracks.
|
||||
The genre filter can be used while searching artists and tracks.
|
||||
The isrc and track filters can be used while searching tracks.
|
||||
The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to return only albums with the lowest 10% popularity.
|
||||
The upc, tag:new and tag:hipster filters can only be used while searching albums.
|
||||
The tag:new filter will return albums released in the past two weeks and tag:hipster
|
||||
can be used to return only albums with the lowest 10% popularity.
|
||||
|
||||
Example: q="remaster track:Doxy artist:Miles Davis"
|
||||
"""
|
||||
|
|
@ -36,4 +42,4 @@ async def search(
|
|||
context, "GET", url, params={"q": q, "type": ",".join(types), "limit": limit}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return dict(response.json())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import Annotated, Optional
|
|||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Spotify
|
||||
|
||||
from arcade_spotify.tools.utils import (
|
||||
get_url,
|
||||
send_spotify_request,
|
||||
|
|
@ -18,7 +19,7 @@ async def get_track_from_id(
|
|||
|
||||
response = await send_spotify_request(context, "GET", url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return dict(response.json())
|
||||
|
||||
|
||||
@tool(requires_auth=Spotify())
|
||||
|
|
@ -92,7 +93,8 @@ async def get_recommendations(
|
|||
] = None,
|
||||
) -> Annotated[dict, "A list of recommended tracks"]:
|
||||
"""Get track (song) recommendations based on seed artists, genres, and tracks
|
||||
If a provided target value is outside of the expected range, it will clamp to the nearest valid value.
|
||||
If a provided target value is outside of the expected range,
|
||||
it will clamp to the nearest valid value.
|
||||
"""
|
||||
url = get_url("tracks_get_recommendations")
|
||||
params = {
|
||||
|
|
@ -119,7 +121,7 @@ async def get_recommendations(
|
|||
|
||||
response = await send_spotify_request(context, "GET", url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return dict(response.json())
|
||||
|
||||
|
||||
@tool(requires_auth=Spotify())
|
||||
|
|
@ -133,4 +135,4 @@ async def get_tracks_audio_features(
|
|||
|
||||
response = await send_spotify_request(context, "GET", url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return dict(response.json())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import httpx
|
||||
|
||||
from arcade.sdk import ToolContext
|
||||
|
||||
from arcade_spotify.tools.constants import ENDPOINTS, SPOTIFY_BASE_URL
|
||||
from arcade_spotify.tools.models import PlaybackState
|
||||
|
||||
|
|
@ -28,7 +28,10 @@ async def send_spotify_request(
|
|||
Raises:
|
||||
ToolExecutionError: If the request fails for any reason.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {context.authorization.token}"}
|
||||
token = (
|
||||
context.authorization.token if context.authorization and context.authorization.token else ""
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.request(method, url, headers=headers, params=params, json=json_data)
|
||||
|
|
@ -36,7 +39,7 @@ async def send_spotify_request(
|
|||
return response
|
||||
|
||||
|
||||
def get_url(endpoint: str, **kwargs) -> str:
|
||||
def get_url(endpoint: str, **kwargs: object) -> str:
|
||||
"""
|
||||
Get the full Spotify URL for a given endpoint.
|
||||
|
||||
|
|
@ -67,7 +70,7 @@ def convert_to_playback_state(data: dict) -> PlaybackState:
|
|||
)
|
||||
|
||||
if data.get("currently_playing_type") == "track":
|
||||
item = data.get("item", {})
|
||||
item = data.get("item") or {}
|
||||
album = item.get("album", {})
|
||||
playback_state.album_name = album.get("name")
|
||||
playback_state.album_id = album.get("id")
|
||||
|
|
@ -75,11 +78,11 @@ def convert_to_playback_state(data: dict) -> PlaybackState:
|
|||
playback_state.album_spotify_url = album.get("external_urls", {}).get("spotify")
|
||||
playback_state.track_name = item.get("name")
|
||||
playback_state.track_id = item.get("id")
|
||||
playback_state.track_spotify_url = item.get("external_urls").get("spotify")
|
||||
playback_state.track_spotify_url = item.get("external_urls", {}).get("spotify")
|
||||
playback_state.track_artists = [artist.get("name") for artist in item.get("artists", [])]
|
||||
playback_state.track_artists_ids = [artist.get("id") for artist in item.get("artists", [])]
|
||||
elif data.get("currently_playing_type") == "episode":
|
||||
item = data.get("item", {})
|
||||
item = data.get("item") or {}
|
||||
show = item.get("show", {})
|
||||
playback_state.show_name = show.get("name")
|
||||
playback_state.show_id = show.get("id")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
NumericCritic,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
from arcade_spotify.tools.player import (
|
||||
adjust_playback_position,
|
||||
get_currently_playing,
|
||||
|
|
@ -11,17 +22,6 @@ from arcade_spotify.tools.player import (
|
|||
start_tracks_playback_by_id,
|
||||
)
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
NumericCritic,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
from arcade_spotify.tools.models import SearchType
|
||||
from arcade_spotify.tools.search import search
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
|
|
@ -11,6 +8,9 @@ from arcade.sdk.eval import (
|
|||
tool_eval,
|
||||
)
|
||||
|
||||
from arcade_spotify.tools.models import SearchType
|
||||
from arcade_spotify.tools.search import search
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.9,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue