Tool SDK, Schemas (#2)

Co-authored-by: Nate Barbettini <nathanaelb@gmail.com>
This commit is contained in:
Sam Partee 2024-07-14 23:37:46 -07:00 committed by GitHub
parent a5decd4483
commit 7f3abfd1f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 5276 additions and 3342 deletions

5
.editorconfig Normal file
View file

@ -0,0 +1,5 @@
max_line_length = 120
[*.json]
indent_style = space
indent_size = 4

183
.gitignore vendored
View file

@ -1,13 +1,172 @@
__pycache__/
.idea/
.env
venv/
.mypy_cache/
backend/app/log/
backend/app/alembic/versions/
backend/app/static/media/
.ruff_cache/
.pytest_cache/
# example data
examples/data
examples/set_secrets.sh
scratch
scratch
docs/source
# From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Vscode config files
.vscode/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

22
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,22 @@
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.1.6"
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
- id: prettier

133
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,133 @@
# Contributing to `arcade-ai`
Contributions are welcome, and they are greatly appreciated!
Every little bit helps, and credit will always be given.
You can contribute in many ways:
# Types of Contributions
## Report Bugs
Report bugs at https://github.com/spartee/arcade-ai/issues
If you are reporting a bug, please include:
- Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug.
## Fix Bugs
Look through the GitHub issues for bugs.
Anything tagged with "bug" and "help wanted" is open to whoever wants to implement a fix for it.
## Implement Features
Look through the GitHub issues for features.
Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it.
## Write Documentation
Cookiecutter PyPackage could always use more documentation, whether as part of the official docs, in docstrings, or even on the web in blog posts, articles, and such.
## Submit Feedback
The best way to send feedback is to file an issue at https://github.com/spartee/arcade-ai/issues.
If you are proposing a new feature:
- Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions
are welcome :)
# Get Started!
Ready to contribute? Here's how to set up `arcade-ai` for local development.
Please note this documentation assumes you already have `poetry` and `Git` installed and ready to go.
1. Fork the `arcade-ai` repo on GitHub.
2. Clone your fork locally:
```bash
cd <directory_in_which_repo_should_be_created>
git clone git@github.com:YOUR_NAME/arcade-ai.git
```
3. Now we need to install the environment. Navigate into the directory
```bash
cd arcade-ai
```
If you are using `pyenv`, select a version to use locally. (See installed versions with `pyenv versions`)
```bash
pyenv local <x.y.z>
```
Then, install and activate the environment with:
```bash
poetry install
poetry shell
```
4. Install pre-commit to run linters/formatters at commit time:
```bash
poetry run pre-commit install
```
5. Create a branch for local development:
```bash
git checkout -b name-of-your-bugfix-or-feature
```
Now you can make your changes locally.
6. Don't forget to add test cases for your added functionality to the `tests` directory.
7. When you're done making changes, check that your changes pass the formatting tests.
```bash
make check
```
Now, validate that all unit tests are passing:
```bash
make test
```
9. Before raising a pull request you should also run tox.
This will run the tests across different versions of Python:
```bash
tox
```
This requires you to have multiple versions of python installed.
This step is also triggered in the CI/CD pipeline, so you could also choose to skip this step locally.
10. Commit your changes and push your branch to GitHub:
```bash
git add .
git commit -m "Your detailed description of your changes."
git push origin name-of-your-bugfix-or-feature
```
11. Submit a pull request through the GitHub website.
# Pull Request Guidelines
Before you submit a pull request, check that it meets these guidelines:
1. The pull request should include tests.
2. If the pull request adds functionality, the docs should be updated.
Put your new functionality into a function with a docstring, and add the feature to the list in `README.md`.

21
LICENSE Normal file
View 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.

View file

@ -1,4 +1,10 @@
# ToolServe
[![Release](https://img.shields.io/github/v/release/spartee/arcade-ai)](https://img.shields.io/github/v/release/spartee/arcade-ai)
[![Build status](https://img.shields.io/github/actions/workflow/status/spartee/arcade-ai/main.yml?branch=main)](https://github.com/spartee/arcade-ai/actions/workflows/main.yml?query=branch%3Amain)
[![codecov](https://codecov.io/gh/spartee/arcade-ai/branch/main/graph/badge.svg)](https://codecov.io/gh/spartee/arcade-ai)
[![Commit activity](https://img.shields.io/github/commit-activity/m/spartee/arcade-ai)](https://img.shields.io/github/commit-activity/m/spartee/arcade-ai)
[![License](https://img.shields.io/github/license/spartee/arcade-ai)](https://img.shields.io/github/license/spartee/arcade-ai)
# Arcade-AI
ToolServe is a framework specifically designed to manage and orchestrate Language Learning Models (LLMs) or "tools" with high efficiency. It distinctively separates the tools from the orchestration framework to improve dependency management, packaging, and execution.
@ -9,15 +15,19 @@ This functionality is especially beneficial for agents tasked with performing ac
## Components
### 1. Command Line Interface (CLI)
The CLI component, located at `toolserve/cli/main.py`, offers commands to package, serve, and inspect LLM "tools". It utilizes the Typer library to manage command-line arguments and options.
### 2. Server
The server component, which manages the storage of artifacts, data, and logs generated by the tools, is implemented using FastAPI and can be found at `toolserve/server/main.py`. The server configuration includes routes, middleware, and database connections.
### 3. SDK
Located at `toolserve/sdk`, the SDK streamlines the development of tools by providing decorators and helper functions that abstract routine tasks, allowing developers to concentrate on the logic of the tool rather than on repetitive code.
### 4. Builtins
Built-in tools for common tasks such as SQL queries are available at `toolserve/builtin/default`. These tools are ready to use and require no additional setup.
## Installation

56
arcade/Makefile Normal file
View file

@ -0,0 +1,56 @@
.PHONY: install
install: ## Install the poetry environment and install the pre-commit hooks
@echo "🚀 Creating virtual environment using pyenv and poetry"
@poetry install
@ poetry run pre-commit install
@poetry shell
.PHONY: check
check: ## Run code quality tools.
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock"
@poetry check --lock
@echo "🚀 Linting code: Running pre-commit"
@poetry run pre-commit run -a
@echo "🚀 Static type checking: Running mypy"
@poetry run mypy
@echo "🚀 Checking for obsolete dependencies: Running deptry"
@poetry run deptry .
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@poetry run pytest -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
@poetry build
.PHONY: clean
clean: ## clean build artifacts
@rm -rf dist
.PHONY: publish
publish: ## publish a release to pypi.
@echo "🚀 Publishing: Dry run."
@poetry config pypi-token.pypi $(PYPI_TOKEN)
@poetry publish --dry-run
@echo "🚀 Publishing."
@poetry publish
.PHONY: build-and-publish
build-and-publish: build publish ## Build and publish.
.PHONY: docs-test
docs-test: ## Test if documentation can be built without warnings or errors
@poetry run mkdocs build -s
.PHONY: docs
docs: ## Build and serve the documentation
@poetry run mkdocs serve
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help

View file

@ -0,0 +1 @@
#!/usr/bin/env python3

View file

@ -1,16 +1,21 @@
from typing import Any
from typing import Any, Optional
from fastapi import HTTPException
from starlette.background import BackgroundTask
from toolserve.server.common.response_code import CustomErrorCode, StandardResponseCode
from arcade.actor.common.response_code import CustomErrorCode, StandardResponseCode
class BaseExceptionMixin(Exception):
code: int
def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None):
def __init__(
self,
*,
msg: Optional[str] = None,
data: Any = None,
background: BackgroundTask | None = None,
):
self.msg = msg
self.data = data
# The original background task: https://www.starlette.io/background/
@ -23,7 +28,9 @@ class HTTPError(HTTPException):
class CustomError(BaseExceptionMixin):
def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None):
def __init__(
self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None
):
self.code = error.code
super().__init__(msg=error.msg, data=data, background=background)
@ -31,21 +38,31 @@ class CustomError(BaseExceptionMixin):
class RequestError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_400
def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self,
*,
msg: str = "Bad Request",
data: Any = None,
background: BackgroundTask | None = None,
):
super().__init__(msg=msg, data=data, background=background)
class ForbiddenError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_403
def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self, *, msg: str = "Forbidden", data: Any = None, background: BackgroundTask | None = None
):
super().__init__(msg=msg, data=data, background=background)
class NotFoundError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_404
def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self, *, msg: str = "Not Found", data: Any = None, background: BackgroundTask | None = None
):
super().__init__(msg=msg, data=data, background=background)
@ -53,7 +70,11 @@ class ServerError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_500
def __init__(
self, *, msg: str = 'Internal Server Error', data: Any = None, background: BackgroundTask | None = None
self,
*,
msg: str = "Internal Server Error",
data: Any = None,
background: BackgroundTask | None = None,
):
super().__init__(msg=msg, data=data, background=background)
@ -61,19 +82,31 @@ class ServerError(BaseExceptionMixin):
class GatewayError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_502
def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self,
*,
msg: str = "Bad Gateway",
data: Any = None,
background: BackgroundTask | None = None,
):
super().__init__(msg=msg, data=data, background=background)
class AuthorizationError(BaseExceptionMixin):
code = StandardResponseCode.HTTP_401
def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self,
*,
msg: str = "Permission Denied",
data: Any = None,
background: BackgroundTask | None = None,
):
super().__init__(msg=msg, data=data, background=background)
class TokenError(HTTPError):
code = StandardResponseCode.HTTP_401
def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None):
super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'})
def __init__(self, *, msg: str = "Not Authenticated", headers: dict[str, Any] | None = None):
super().__init__(code=self.code, msg=msg, headers=headers or {"WWW-Authenticate": "Bearer"})

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
@ -7,19 +6,23 @@ from pydantic.errors import PydanticUserError
from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
from toolserve.server.common.exception.errors import BaseExceptionMixin
from toolserve.server.common.log import log
from toolserve.server.common.response_code import CustomResponseCode, StandardResponseCode
from toolserve.server.common.response_code import response_base
from toolserve.server.core.conf import settings
from toolserve.server.schemas.base import (
CUSTOM_USAGE_ERROR_MESSAGES,
CUSTOM_VALIDATION_ERROR_MESSAGES,
from arcade.actor.common.exception.errors import BaseExceptionMixin
from arcade.actor.common.log import log
from arcade.actor.common.response_code import (
CustomResponseCode,
StandardResponseCode,
response_base,
)
from toolserve.server.utils.serializers import MsgSpecJSONResponse
from arcade.actor.core.conf import settings
from arcade.actor.schemas.base import (
CUSTOM_USAGE_ERROR_MESSAGES,
)
from arcade.actor.utils.serializers import MsgSpecJSONResponse
async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError):
async def _validation_exception_handler(
request: Request, e: RequestValidationError | ValidationError
):
"""
Data validation exception handling
@ -27,25 +30,27 @@ async def _validation_exception_handler(request: Request, e: RequestValidationEr
:return:
"""
error = e.errors()[0]
if error.get('type') == 'json_invalid':
message = 'JSON parsing failed'
if error.get("type") == "json_invalid":
message = "JSON parsing failed"
else:
error_input = error.get('input')
field = str(error.get('loc')[-1])
error_msg = error.get('msg')
message = f'{field} {error_msg}, input: {error_input}'
msg = f'Invalid request parameters: {message}'
data = {'errors': error} if settings.ENVIRONMENT == 'dev' else None
error_input = error.get("input")
field = str(error.get("loc")[-1])
error_msg = error.get("msg")
message = f"{field} {error_msg}, input: {error_input}"
msg = f"Invalid request parameters: {message}"
data = {"errors": error} if settings.ENVIRONMENT == "dev" else None
content = {
'code': StandardResponseCode.HTTP_422,
'msg': msg,
'data': data,
"code": StandardResponseCode.HTTP_422,
"msg": msg,
"data": data,
}
request.state.__request_validation_exception__ = content # For obtaining exception information in middleware
request.state.__request_validation_exception__ = (
content # For obtaining exception information in middleware
)
return MsgSpecJSONResponse(status_code=422, content=content)
def register_exception(app: FastAPI):
def register_exception(app: FastAPI): # noqa: C901
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
@ -55,16 +60,18 @@ def register_exception(app: FastAPI):
:param exc:
:return:
"""
if settings.ENVIRONMENT == 'dev':
if settings.ENVIRONMENT == "dev":
content = {
'code': exc.status_code,
'msg': exc.detail,
'data': None,
"code": exc.status_code,
"msg": exc.detail,
"data": None,
}
else:
res = await response_base.fail(res=CustomResponseCode.HTTP_400)
content = res.model_dump()
request.state.__request_http_exception__ = content # For obtaining exception information in middleware
request.state.__request_http_exception__ = (
content # For obtaining exception information in middleware
)
return MsgSpecJSONResponse(
status_code=StandardResponseCode.HTTP_400,
content=content,
@ -105,9 +112,9 @@ def register_exception(app: FastAPI):
return MsgSpecJSONResponse(
status_code=StandardResponseCode.HTTP_500,
content={
'code': StandardResponseCode.HTTP_500,
'msg': CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
'data': None,
"code": StandardResponseCode.HTTP_500,
"msg": CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
"data": None,
},
)
@ -120,11 +127,11 @@ def register_exception(app: FastAPI):
:param exc:
:return:
"""
if settings.ENVIRONMENT == 'dev':
if settings.ENVIRONMENT == "dev":
content = {
'code': StandardResponseCode.HTTP_500,
'msg': str(''.join(exc.args) if exc.args else exc.__doc__),
'data': None,
"code": StandardResponseCode.HTTP_500,
"msg": str("".join(exc.args) if exc.args else exc.__doc__),
"data": None,
}
else:
res = await response_base.fail(res=CustomResponseCode.HTTP_500)
@ -147,22 +154,22 @@ def register_exception(app: FastAPI):
return MsgSpecJSONResponse(
status_code=StandardResponseCode.HTTP_400,
content={
'code': exc.code,
'msg': str(exc.msg),
'data': exc.data if exc.data else None,
"code": exc.code,
"msg": str(exc.msg),
"data": exc.data if exc.data else None,
},
background=exc.background,
)
else:
import traceback
log.error(f'Unknown exception: {exc}')
log.error(f"Unknown exception: {exc}")
log.error(traceback.format_exc())
if settings.ENVIRONMENT == 'dev':
if settings.ENVIRONMENT == "dev":
content = {
'code': 500,
'msg': str(exc),
'data': None,
"code": 500,
"msg": str(exc),
"data": None,
}
else:
res = await response_base.fail(res=CustomResponseCode.HTTP_500)
@ -184,39 +191,41 @@ def register_exception(app: FastAPI):
"""
if isinstance(exc, BaseExceptionMixin):
content = {
'code': exc.code,
'msg': exc.msg,
'data': exc.data,
"code": exc.code,
"msg": exc.msg,
"data": exc.data,
}
else:
if settings.ENVIRONMENT == 'dev':
if settings.ENVIRONMENT == "dev":
content = {
'code': StandardResponseCode.HTTP_500,
'msg': str(exc),
'data': None,
"code": StandardResponseCode.HTTP_500,
"msg": str(exc),
"data": None,
}
else:
res = await response_base.fail(res=CustomResponseCode.HTTP_500)
content = res.model_dump()
response = MsgSpecJSONResponse(
status_code=exc.code if isinstance(exc, BaseExceptionMixin) else StandardResponseCode.HTTP_500,
status_code=exc.code
if isinstance(exc, BaseExceptionMixin)
else StandardResponseCode.HTTP_500,
content=content,
background=exc.background if isinstance(exc, BaseExceptionMixin) else None,
)
origin = request.headers.get('origin')
origin = request.headers.get("origin")
if origin:
cors = CORSMiddleware(
app=app,
allow_origins=['*'],
allow_origins=["*"],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
allow_methods=["*"],
allow_headers=["*"],
)
response.headers.update(cors.simple_headers)
has_cookie = 'cookie' in request.headers
has_cookie = "cookie" in request.headers
if cors.allow_all_origins and has_cookie:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers["Access-Control-Allow-Origin"] = origin
elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers.add_vary_header('Origin')
response.headers["Access-Control-Allow-Origin"] = origin
response.headers.add_vary_header("Origin")
return response

View file

@ -1,18 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from loguru import logger
from toolserve.server.core.conf import settings
from arcade.actor.core.conf import settings
from pathlib import Path
server_log_path = os.path.join(settings.WORK_DIR, 'server_logs')
actor_log_path = os.path.join(settings.WORK_DIR, "actor_logs")
if TYPE_CHECKING:
@ -21,7 +17,7 @@ if TYPE_CHECKING:
class Logger:
def __init__(self):
self.log_path = server_log_path
self.log_path = actor_log_path
def log(self) -> loguru.Logger:
if not os.path.exists(self.log_path):
@ -30,12 +26,17 @@ class Logger:
log_stdout_file = os.path.join(self.log_path, settings.LOG_STDOUT_FILENAME)
log_stderr_file = os.path.join(self.log_path, settings.LOG_STDERR_FILENAME)
log_config = dict(rotation='10 MB', retention='15 days', compression='tar.gz', enqueue=True)
log_config = {
"rotation": "10 MB",
"retention": "15 days",
"compression": "tar.gz",
"enqueue": True,
}
# stdout
logger.add(
log_stdout_file,
level='INFO',
filter=lambda record: record['level'].name == 'INFO' or record['level'].no <= 25,
level="INFO",
filter=lambda record: record["level"].name == "INFO" or record["level"].no <= 25,
**log_config,
backtrace=False,
diagnose=False,
@ -43,8 +44,8 @@ class Logger:
# stderr
logger.add(
log_stderr_file,
level='ERROR',
filter=lambda record: record['level'].name == 'ERROR' or record['level'].no >= 30,
level="ERROR",
filter=lambda record: record["level"].name == "ERROR" or record["level"].no >= 30,
**log_config,
backtrace=True,
diagnose=True,
@ -52,4 +53,5 @@ class Logger:
return logger
log = Logger().log()
log = Logger().log()

View file

@ -1,16 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
from toolserve.server.common.response_code import CustomResponse, CustomResponseCode
from toolserve.server.core.conf import settings
from arcade.actor.common.response_code import CustomResponse, CustomResponseCode
from arcade.actor.core.conf import settings
_ExcludeData = set[int | str] | dict[int | str, Any]
__all__ = ['ResponseModel', 'response_base']
__all__ = ["ResponseModel", "response_base"]
class ResponseModel(BaseModel):
@ -33,7 +32,9 @@ class ResponseModel(BaseModel):
"""
# TODO: json_encoders: https://github.com/tiangolo/fastapi/discussions/10252
model_config = ConfigDict(json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)})
model_config = ConfigDict(
json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)}
)
code: int = CustomResponseCode.HTTP_200.code
msg: str = CustomResponseCode.HTTP_200.msg
@ -56,7 +57,12 @@ class ResponseBase:
"""
@staticmethod
async def __response(*, res: CustomResponseCode | CustomResponse = None, msg: str | None = None, data: Any | None = None) -> ResponseModel:
async def __response(
*,
res: CustomResponseCode | CustomResponse = None,
msg: str | None = None,
data: Any | None = None,
) -> ResponseModel:
"""
General method for successful response

View file

@ -1,7 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import dataclasses
from enum import Enum
@ -25,27 +23,30 @@ class CustomCodeBase(Enum):
class CustomResponseCode(CustomCodeBase):
"""自定义响应状态码"""
HTTP_200 = (200, 'Request Successful')
HTTP_201 = (201, 'Created Successfully')
HTTP_202 = (202, 'Request Accepted, but Processing Not Yet Complete')
HTTP_204 = (204, 'Request Successful, but No Content Returned')
HTTP_400 = (400, 'Bad Request')
HTTP_401 = (401, 'Unauthorized')
HTTP_403 = (403, 'Forbidden Access')
HTTP_404 = (404, 'Requested Resource Not Found')
HTTP_410 = (410, 'Requested Resource Permanently Deleted')
HTTP_422 = (422, 'Invalid Request Parameters')
HTTP_425 = (425, 'Request Unexecutable, as Server Cannot Meet Requirements')
HTTP_429 = (429, 'Too Many Requests, Server Limiting')
HTTP_500 = (500, 'Internal Server Error')
HTTP_502 = (502, 'Gateway Error')
HTTP_503 = (503, 'Server Temporarily Unable to Process Request')
HTTP_504 = (504, 'Gateway Timeout')
HTTP_200 = (200, "Request Successful")
HTTP_201 = (201, "Created Successfully")
HTTP_202 = (202, "Request Accepted, but Processing Not Yet Complete")
HTTP_204 = (204, "Request Successful, but No Content Returned")
HTTP_400 = (400, "Bad Request")
HTTP_401 = (401, "Unauthorized")
HTTP_403 = (403, "Forbidden Access")
HTTP_404 = (404, "Requested Resource Not Found")
HTTP_410 = (410, "Requested Resource Permanently Deleted")
HTTP_422 = (422, "Invalid Request Parameters")
HTTP_425 = (425, "Request Unexecutable, as Server Cannot Meet Requirements")
HTTP_429 = (429, "Too Many Requests, Server Limiting")
HTTP_500 = (500, "Internal Server Error")
HTTP_502 = (502, "Gateway Error")
HTTP_503 = (503, "Server Temporarily Unable to Process Request")
HTTP_504 = (504, "Gateway Timeout")
class CustomErrorCode(CustomCodeBase):
"""自定义错误状态码"""
CAPTCHA_ERROR = (40001, 'CAPTCHA Error')
CAPTCHA_ERROR = (40001, "CAPTCHA Error")
@dataclasses.dataclass
class CustomResponse:
@ -56,6 +57,7 @@ class CustomResponse:
code: int
msg: str
class StandardResponseCode:
"""Standard response status codes"""

View file

@ -0,0 +1,13 @@
from typing import Any
import msgspec
from starlette.responses import JSONResponse
class MsgSpecJSONResponse(JSONResponse):
"""
JSON response using the high-performance msgspec library to serialize data to JSON.
"""
def render(self, content: Any) -> bytes:
return msgspec.json.encode(content)

View file

@ -0,0 +1,71 @@
import os
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
# https://docs.pydantic.dev/latest/concepts/pydantic_settings/
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
WORK_DIR: Path = Path.home() / ".arcade"
TOOLS_DIR: Path = os.getcwd()
# Env Config
ENVIRONMENT: Literal["dev", "pro"] = "dev"
# FastAPI
API_V1_STR: str = "/v1"
API_ACTION_STR: str = "/tool"
TITLE: str = "Arcade AI Toolserver"
VERSION: str = "0.1.0"
DESCRIPTION: str = "Arcade AI Toolserver API"
DOCS_URL: str | None = f"{API_V1_STR}/docs"
REDOCS_URL: str | None = f"{API_V1_STR}/redocs"
OPENAPI_URL: str | None = f"{API_V1_STR}/openapi"
# @model_validator(mode='before')
# @classmethod
# def validate_openapi_url(cls, values):
# if values['ENVIRONMENT'] == 'pro':
# values['OPENAPI_URL'] = None
# return values
# Uvicorn
UVICORN_HOST: str = "127.0.0.1"
UVICORN_PORT: int = 8000
UVICORN_RELOAD: bool = True
# Static Server
STATIC_FILES: bool = False
# Logs
LOG_STDOUT_FILENAME: str = "actor.log"
LOG_STDERR_FILENAME: str = "actor.err"
# DateTime
DATETIME_TIMEZONE: str = "US/Pacific"
DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"
# Middleware
MIDDLEWARE_CORS: bool = True
MIDDLEWARE_GZIP: bool = True
MIDDLEWARE_ACCESS: bool = False
# these should be set in .env
TOKEN_SECRET_KEY: str = "secret"
OPERA_LOG_ENCRYPT_SECRET_KEY: str = "secret"
@lru_cache
def get_settings():
try:
env_path = Path(os.environ["TOOLSERVE_ENV"])
except KeyError:
env_path = Path(__file__).parent.parent / ".env"
return Settings(_env_file=env_path)
settings = get_settings()

View file

@ -1,4 +1,5 @@
from starlette.requests import Request
def get_catalog(request: Request):
return request.app.state.catalog
return request.app.state.catalog

View file

@ -1,19 +1,14 @@
import os
import sys
import traceback
import inspect
from textwrap import dedent
from typing import List, Optional, Type, Annotated, Dict
from pathlib import Path
from fastapi import APIRouter, Body, Depends, Path, HTTPException
from pydantic import BaseModel, ValidationError, create_model
from importlib import import_module
from fastapi import APIRouter
from pydantic import BaseModel, ValidationError
from toolserve.server.core.catalog import ToolSchema
from toolserve.server.core.conf import settings
from toolserve.server.common.response_code import CustomResponseCode
from toolserve.server.common.response import ResponseModel, response_base
from arcade.actor.common.response import response_base
from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.conf import settings
from arcade.tool.catalog import ToolDefinition
from arcade.utils import snake_to_pascal_case
def create_endpoint_function(name, description, func, input_model, output_model):
@ -38,35 +33,35 @@ def create_endpoint_function(name, description, func, input_model, output_model)
return run
def generate_endpoint(schemas: List[ToolSchema]) -> APIRouter:
def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
routers = []
top_level_router = APIRouter(prefix=settings.API_ACTION_STR)
for schema in schemas:
router = APIRouter(prefix="/" + schema.meta.module)
define = schema.definition
# Create the endpoint function
run = create_endpoint_function(
name=schema.name,
description=schema.description,
name=snake_to_pascal_case(define.name),
description=define.description,
func=schema.tool,
input_model=schema.input_model,
output_model=schema.output_model
output_model=schema.output_model,
)
# Add the endpoint to the FastAPI app
router.post(
f"/{schema.name}",
name=schema.name,
summary=schema.description,
f"/{snake_to_pascal_case(define.name)}",
name=snake_to_pascal_case(define.name),
summary=define.description,
tags=[schema.meta.module],
response_model=schema.output_model,
response_model_exclude_unset=True,
response_model_exclude_none=True,
response_description=create_output_description(schema.output_model)
)(run)
response_description=create_output_description(schema.output_model),
)(run)
routers.append(router)
for router in routers:
@ -74,8 +69,7 @@ def generate_endpoint(schemas: List[ToolSchema]) -> APIRouter:
return top_level_router
def create_output_description(output_model: Type[BaseModel]) -> str:
def create_output_description(output_model: type[BaseModel]) -> str:
"""
Create a description string for the output model.
"""
@ -88,4 +82,4 @@ def create_output_description(output_model: Type[BaseModel]) -> str:
for name, field in output_model.model_fields.items():
output_description += f"- **{name}** ({field.annotation.__name__})\n"
return output_description
return output_description

View file

@ -1,13 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from toolserve.server.routes import v1
from toolserve.server.database.db_sqlite import create_table
from toolserve.server.core.conf import settings
from toolserve.server.common.serializers import MsgSpecJSONResponse
from arcade.actor.common.serializers import MsgSpecJSONResponse
from arcade.actor.core.conf import settings
from arcade.actor.routes import v1
@asynccontextmanager
@ -16,9 +14,7 @@ async def register_init(app: FastAPI):
:return:
"""
# create database tables
await create_table()
# eventually lifecycle hooks will be added here
yield
@ -41,7 +37,7 @@ def register_app():
register_router(app)
#register_exception(app)
# register_exception(app)
generate_actions_routers(app)
@ -59,9 +55,9 @@ def register_static_file(app: FastAPI):
from fastapi.staticfiles import StaticFiles
if not os.path.exists('./static'):
os.mkdir('./static')
app.mount('/static', StaticFiles(directory='static'), name='static')
if not os.path.exists("./static"):
os.mkdir("./static")
app.mount("/static", StaticFiles(directory="static"), name="static")
def register_middleware(app: FastAPI):
@ -82,10 +78,10 @@ def register_middleware(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_origins=["*"],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
allow_methods=["*"],
allow_headers=["*"],
)
@ -108,10 +104,10 @@ def generate_actions_routers(app: FastAPI):
:param app: FastAPI
:return:
"""
from toolserve.server.core.generate import generate_endpoint
from toolserve.server.core.catalog import ToolCatalog
from arcade.actor.core.generate import generate_endpoint
from arcade.tool.catalog import ToolCatalog
catalog = ToolCatalog()
router = generate_endpoint(catalog.tools.values())
app.include_router(router)
app.state.catalog = catalog
app.state.catalog = catalog

View file

@ -0,0 +1,21 @@
from pathlib import Path
import uvicorn
from arcade.actor.common.log import log
from arcade.actor.core.conf import settings
from arcade.actor.core.registrar import register_app
app = register_app()
if __name__ == "__main__":
try:
log.info("Arcade AI Toolserve is starting...")
uvicorn.run(
app=f"{Path(__file__).stem}:app",
host=settings.UVICORN_HOST,
port=settings.UVICORN_PORT,
reload=settings.UVICORN_RELOAD,
)
except Exception as e:
log.error(f"FastAPI start filed: {e}")

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from arcade.actor.core.conf import settings
from arcade.actor.routes.tool import router as tool_router
v1 = APIRouter(prefix=settings.API_V1_STR)
v1.include_router(tool_router, prefix="/tools", tags=["Tool Catalog"])

View file

@ -0,0 +1,59 @@
from fastapi import APIRouter, Body, Depends, Query
from pydantic import ValidationError
from arcade.actor.common.response import ResponseModel, response_base
from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.depends import get_catalog
from arcade.tool.openai import schema_to_openai_tool
router = APIRouter()
@router.get(
"/list",
summary="List available tools",
)
async def list_tools(catalog=Depends(get_catalog)) -> ResponseModel:
"""List all available tools"""
tools = catalog.list_tools()
return await response_base.success(data=tools)
@router.get("/json", summary="Get the JSON (openai) format of a tool")
async def get_oai_function(
tool_name: str = Query(..., title="Tool Name", description="The name of the tool"),
catalog=Depends(get_catalog),
) -> ResponseModel:
"""Get the OpenAI function format of an tool"""
try:
# TODO handle keyerror
tool = catalog[tool_name]
json_data = schema_to_openai_tool(tool)
return await response_base.success(data=json_data)
except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))
except Exception as e:
return await response_base.fail(res=CustomResponseCode.HTTP_500, data=str(e))
@router.post("/execute", summary="Execute a tool")
async def execute_tool(
tool_name: str = Query(..., title="Tool Name", description="The name of the tool"),
data: dict[str, str] = Body(
..., title="Tool Data", description="The data to execute the tool with"
),
catalog=Depends(get_catalog),
) -> ResponseModel:
"""Execute a tool"""
try:
tool = catalog.get_tool(tool_name)
result = await tool(**data)
return await response_base.success(data=result)
except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))
except Exception as e:
return await response_base.fail(res=CustomResponseCode.HTTP_500, data=str(e))

View file

@ -0,0 +1,145 @@
from pydantic import BaseModel, ConfigDict, EmailStr, validate_email
# Custom validation error messages do not include the expected content of validation (i.e., input content). For supported expected content fields, refer to the following link:
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
# For replacing expected content fields, refer to the following link:
# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
CUSTOM_VALIDATION_ERROR_MESSAGES = {
"arguments_type": "Incorrect argument type input",
"assertion_error": "Assertion execution error",
"bool_parsing": "Boolean value parsing error",
"bool_type": "Boolean type input error",
"bytes_too_long": "Byte length input too long",
"bytes_too_short": "Byte length input too short",
"bytes_type": "Byte type input error",
"callable_type": "Callable object type input error",
"dataclass_exact_type": "Dataclass instance type input error",
"dataclass_type": "Dataclass type input error",
"date_from_datetime_inexact": "Non-zero date component input",
"date_from_datetime_parsing": "Date input parsing error",
"date_future": "Date input is not in the future",
"date_parsing": "Date input validation error",
"date_past": "Date input is not in the past",
"date_type": "Date type input error",
"datetime_future": "Datetime input is not in the future",
"datetime_object_invalid": "Datetime input object invalid",
"datetime_parsing": "Datetime input parsing error",
"datetime_past": "Datetime input is not in the past",
"datetime_type": "Datetime type input error",
"decimal_max_digits": "Decimal input has too many digits",
"decimal_max_places": "Decimal places input error",
"decimal_parsing": "Decimal input parsing error",
"decimal_type": "Decimal type input error",
"decimal_whole_digits": "Decimal whole digits input error",
"dict_type": "Dictionary type input error",
"enum": "Enum member input error, allowed {expected}",
"extra_forbidden": "Extra fields input forbidden",
"finite_number": "Finite value input error",
"float_parsing": "Float parsing error",
"float_type": "Float type input error",
"frozen_field": "Frozen field input error",
"frozen_instance": "Modification of frozen instance forbidden",
"frozen_set_type": "Frozen set type input forbidden",
"get_attribute_error": "Attribute retrieval error",
"greater_than": "Input value too large",
"greater_than_equal": "Input value too large or equal",
"int_from_float": "Integer type input error",
"int_parsing": "Integer input parsing error",
"int_parsing_size": "Integer input parsing size error",
"int_type": "Integer type input error",
"invalid_key": "Invalid key input",
"is_instance_of": "Instance type input error",
"is_subclass_of": "Subclass type input error",
"iterable_type": "Iterable type input error",
"iteration_error": "Iteration value input error",
"json_invalid": "JSON string input error",
"json_type": "JSON type input error",
"less_than": "Input value too small",
"less_than_equal": "Input value too small or equal",
"list_type": "List type input error",
"literal_error": "Literal input error",
"mapping_type": "Mapping type input error",
"missing": "Missing required field",
"missing_argument": "Missing argument",
"missing_keyword_only_argument": "Missing keyword-only argument",
"missing_positional_only_argument": "Missing positional-only argument",
"model_attributes_type": "Model attributes type input error",
"model_type": "Model instance input error",
"multiple_argument_values": "Multiple argument values input",
"multiple_of": "Input value not a multiple",
"no_such_attribute": "Invalid attribute assignment",
"none_required": "Input value must be None",
"recursion_loop": "Recursion loop in input",
"set_type": "Set type input error",
"string_pattern_mismatch": "String pattern mismatch input",
"string_sub_type": "String subtype (non-strict instance) input error",
"string_too_long": "String input too long",
"string_too_short": "String input too short",
"string_type": "String type input error",
"string_unicode": "String input not Unicode",
"time_delta_parsing": "Time delta parsing error",
"time_delta_type": "Time delta type input error",
"time_parsing": "Time input parsing error",
"time_type": "Time type input error",
"timezone_aware": "Missing timezone input",
"timezone_naive": "Timezone input forbidden",
"too_long": "Input too long",
"too_short": "Input too short",
"tuple_type": "Tuple type input error",
"unexpected_keyword_argument": "Unexpected keyword argument input",
"unexpected_positional_argument": "Unexpected positional argument input",
"union_tag_invalid": "Union tag literal input error",
"union_tag_not_found": "Union tag argument not found",
"url_parsing": "URL input parsing error",
"url_scheme": "URL scheme input error",
"url_syntax_violation": "URL syntax violation",
"url_too_long": "URL input too long",
"url_type": "URL type input error",
"uuid_parsing": "UUID parsing error",
"uuid_type": "UUID type input error",
"uuid_version": "UUID version type input error",
"value_error": "Value input error",
}
CUSTOM_USAGE_ERROR_MESSAGES = {
"class-not-fully-defined": "Class attributes type not fully defined",
"custom-json-schema": "__modify_schema__ method deprecated in V2",
"decorator-missing-field": "Invalid field validator defined",
"discriminator-no-field": "Discriminator field not fully defined",
"discriminator-alias-type": "Discriminator field defined using non-string type",
"discriminator-needs-literal": "Discriminator field requires literal definition",
"discriminator-alias": "Inconsistent discriminator field alias definition",
"discriminator-validator": "Field validator forbidden on discriminator field",
"model-field-overridden": "Typeless field override forbidden",
"model-field-missing-annotation": "Missing field type definition",
"config-both": "Duplicate configuration item defined",
"removed-kwargs": "Removed keyword configuration parameter called",
"invalid-for-json-schema": "Invalid JSON type present",
"base-model-instantiated": "Instantiation of base model forbidden",
"undefined-annotation": "Missing type definition",
"schema-for-unknown-type": "Unknown type definition",
"create-model-field-definitions": "Field definition error",
"create-model-config-base": "Configuration item definition error",
"validator-no-fields": "Field validator without specified fields",
"validator-invalid-fields": "Field validator fields definition error",
"validator-instance-method": "Field validator must be a class method",
"model-serializer-instance-method": "Serializer must be an instance method",
"validator-v1-signature": "V1 field validator error deprecated",
"validator-signature": "Field validator signature error",
"field-serializer-signature": "Field serializer signature unrecognized",
"model-serializer-signature": "Model serializer signature unrecognized",
"multiple-field-serializers": "Field serializers defined multiple times",
"invalid_annotated_type": "Invalid type definition",
"type-adapter-config-unused": "Type adapter configuration item definition error",
"root-model-extra": "Extra fields on root model forbidden",
}
class CustomEmailStr(EmailStr):
@classmethod
def _validate(cls, __input_value: str) -> str:
return None if __input_value == "" else validate_email(__input_value)[1]
class SchemaBase(BaseModel):
model_config = ConfigDict(use_enum_values=True)

View file

@ -1,10 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import zoneinfo
from datetime import datetime
from toolserve.server.core.conf import settings
from arcade.actor.core.conf import settings
class TimeZone:

View file

@ -1,12 +1,10 @@
import os
import toml
import json
import tomlkit
from pathlib import Path
from pydantic import BaseModel, ValidationError, EmailStr, Field
from typing import Dict, List, Optional, TypeVar, Any, Tuple, Union
from typing import Optional, Union
import toml
import tomlkit
from pydantic import BaseModel, EmailStr
class PackInfo(BaseModel):
@ -19,11 +17,11 @@ class PackInfo(BaseModel):
class ToolPack(BaseModel):
pack: PackInfo
depends: Optional[Dict[str, str]] = None
tools: Optional[Dict[str, str]] = {}
depends: Optional[dict[str, str]] = None
tools: Optional[dict[str, str]] = {}
def write_lock_file(self, pack_dir: Union[str, os.PathLike]):
lock_file = Path(pack_dir) / 'pack.lock.toml'
lock_file = Path(pack_dir) / "pack.lock.toml"
pack_dict = self.dict(by_alias=True, exclude_none=True)
pack_ordered_dict = {
"pack": pack_dict.get("pack"),
@ -37,13 +35,13 @@ class ToolPack(BaseModel):
doc[key] = value
# Write the tomlkit document to file
with open(lock_file, 'w') as f:
with open(lock_file, "w") as f:
f.write(tomlkit.dumps(doc))
@classmethod
def from_lock_file(cls, pack_dir: Union[str, os.PathLike]):
pack_dir = Path(pack_dir).resolve()
lock_file = pack_dir / 'pack.lock.toml'
with open(lock_file, 'r') as f:
lock_file = pack_dir / "pack.lock.toml"
with open(lock_file) as f:
data = toml.load(f)
return cls(**data)
return cls(**data)

View file

@ -1,28 +1,27 @@
import os
import json
import toml
import shutil
from pathlib import Path
from typing import List, Optional, Dict, Union
from pydantic import BaseModel, Field, ValidationError, EmailStr
from typing import Union
import toml
from pydantic import ValidationError
from arcade.apm.base import PackInfo, ToolPack
from arcade.apm.parse import get_tools_from_file
from arcade.utils import snake_to_pascal_case
from toolserve.apm.base import PackInfo, ToolPack
from toolserve.apm.parse import get_tools_from_file
from toolserve.utils import snake_to_camel
class Packer:
def __init__(self, pack_dir: Union[str, os.PathLike]):
self.pack_dir = Path(pack_dir).resolve()
self.tools_dir = self.pack_dir / 'tools'
self.tools_dir = self.pack_dir / "tools"
# Load the action pack configuration from a TOML file
try:
with open(self.pack_dir / 'pack.toml', 'r') as f:
with open(self.pack_dir / "pack.toml") as f:
pack_data = toml.load(f)
self.pack = PackInfo(**pack_data['pack'])
self.modules = pack_data['modules']
self.pack = PackInfo(**pack_data["pack"])
self.modules = pack_data["modules"]
except FileNotFoundError:
raise FileNotFoundError(f"No 'pack.toml' found in {self.tools_dir}")
@ -30,14 +29,13 @@ class Packer:
raise ValueError(f"Invalid 'pack.toml' format: {e}")
self.tools = self.load_tools()
self.depends = {} # TODO
self.packs = [] # TODO
self.depends = {} # TODO
self.packs = [] # TODO
def load_tools(self) -> Dict[str, str]:
def load_tools(self) -> dict[str, str]:
tools = {}
for tool_file in self.tools_dir.rglob('*.py'):
if '__init__.py' in tool_file.name:
for tool_file in self.tools_dir.rglob("*.py"):
if "__init__.py" in tool_file.name:
continue
try:
module = tool_file.stem
@ -46,15 +44,14 @@ class Packer:
found_tools = get_tools_from_file(tool_file)
for tool in found_tools:
tool_name = module + "." + tool + "@" + version
tools[snake_to_camel(tool)] = tool_name
tools[snake_to_pascal_case(tool)] = tool_name
except Exception as e:
print(f"Error loading tool from {tool_file}: {e}")
return tools
def _create_pack_dir(self, pack: ToolPack) -> Path:
# Make "packs" directory if it doesn't exist
packs_dir = self.pack_dir / 'packs'
packs_dir = self.pack_dir / "packs"
os.makedirs(packs_dir, exist_ok=True)
# make the dir for the action pack and the version (making parent dirs if needed)
top_pack_dir = packs_dir / pack.pack.name / pack.pack.version
@ -66,11 +63,7 @@ class Packer:
def create_pack(self):
# Create an ActionPack instance from the loaded data
pack = ToolPack(
pack=self.pack,
depends=self.depends,
tools=self.tools
)
#pack_dir = self._create_pack_dir(pack)
pack = ToolPack(pack=self.pack, depends=self.depends, tools=self.tools)
# pack_dir = self._create_pack_dir(pack)
# Write the action pack to a TOML file
pack.write_lock_file(self.pack_dir)

View file

@ -1,13 +1,12 @@
import ast
import sys
import os
import shutil
from typing import Dict, List, Optional, Tuple, Any
import importlib.metadata
import importlib.util
import toml
import sys
from typing import Optional
from stdlib_list import stdlib_list
def load_ast_tree(filepath: str) -> ast.AST:
"""
Load and parse the Abstract Syntax Tree (AST) from a Python file.
@ -16,11 +15,12 @@ def load_ast_tree(filepath: str) -> ast.AST:
:return: AST of the Python file.
"""
try:
with open(filepath, "r") as file:
with open(filepath) as file:
return ast.parse(file.read(), filename=filepath)
except FileNotFoundError:
raise FileNotFoundError(f"File {filepath} not found")
def get_python_version() -> str:
"""
Get the current Python version.
@ -29,7 +29,8 @@ def get_python_version() -> str:
"""
return f"{sys.version_info.major}.{sys.version_info.minor}"
def retrieve_imported_libraries(tree: ast.AST) -> Dict[str, Optional[str]]:
def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]:
"""
Retrieve non-standard libraries imported in the AST.
@ -42,8 +43,8 @@ def retrieve_imported_libraries(tree: ast.AST) -> Dict[str, Optional[str]]:
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
package_name = node.module.split('.')[0] if node.module else None
if package_name == 'dstar' or package_name in stdlib_modules:
package_name = node.module.split(".")[0] if node.module else None
if package_name == "dstar" or package_name in stdlib_modules:
continue
try:
package_version = importlib.metadata.version(package_name)
@ -60,13 +61,14 @@ def get_function_name_if_decorated(node: ast.FunctionDef) -> Optional[str]:
:param node: The function definition node from the AST.
:return: The name of the function if it has the specified decorators, otherwise None.
"""
decorator_ids = {'toolserve.tool', 'tool'}
decorator_ids = {"toolserve.tool", "tool"}
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id in decorator_ids:
return node.name
return None
def get_tools_from_file(filepath: str) -> List[str]:
def get_tools_from_file(filepath: str) -> list[str]:
"""
Get the names of all functions in a Python file that are decorated with either "@toolserve.tool" or "@tool".
@ -80,4 +82,4 @@ def get_tools_from_file(filepath: str) -> List[str]:
tool_name = get_function_name_if_decorated(node)
if tool_name:
tools.append(tool_name)
return tools
return tools

View file

@ -1,35 +1,30 @@
import os
import typer
import uvicorn
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from toolserve.server.core.conf import settings
from arcade.actor.core.conf import settings
cli = typer.Typer()
console = Console()
@cli.command(help="Starts the ToolServer with specified configurations.")
def serve(
host: str = typer.Option(
settings.UVICORN_HOST,
help="Host for the app, from settings by default.",
show_default=True
settings.UVICORN_HOST, help="Host for the app, from settings by default.", show_default=True
),
port: int = typer.Option(
settings.UVICORN_PORT,
help="Port for the app, settings default.",
show_default=True
settings.UVICORN_PORT, help="Port for the app, settings default.", show_default=True
),
):
"""
Starts the server with host, port, and reload options. Uses
Uvicorn as ASGI server. Parameters allow runtime configuration.
Starts the actor with host, port, and reload options. Uses
Uvicorn as ASGI actor. Parameters allow runtime configuration.
"""
from toolserve.server.main import app
from arcade.actor.main import app
try:
uvicorn.run(
@ -38,30 +33,27 @@ def serve(
port=port,
)
except KeyboardInterrupt:
console.print("Server stopped by user.", style="bold red")
console.print("actor stopped by user.", style="bold red")
typer.Exit()
except Exception as e:
error_message = f'❌ Failed to start Toolserver: {escape(str(e))}'
error_message = f"❌ Failed to start Toolserver: {escape(str(e))}"
console.print(error_message, style="bold red")
raise typer.Exit(code=1)
@cli.command(help="Build a new Tool Pack")
def pack(
directory: str = typer.Option(
os.getcwd(),
"--dir",
help="tools directory path with pack.toml"
),
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path with pack.toml"),
):
"""
Creates a new tool pack with the given name, description, and result type.
"""
from toolserve.apm.pack import Packer
from arcade.apm.pack import Packer
try:
pack = Packer(directory)
pack.create_pack()
except Exception as e:
error_message = f'❌ Failed to build Tool Pack: {escape(str(e))}'
error_message = f"❌ Failed to build Tool Pack: {escape(str(e))}"
console.print(error_message, style="bold red")
raise typer.Exit(code=1)
raise typer.Exit(code=1)

View file

@ -0,0 +1,8 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Inferrable:
"""An annotation indicating that a parameter can be inferred by a model (default: True)."""
value: bool = True

View file

@ -0,0 +1,14 @@
class ToolError(Exception):
"""
Base class for all errors related to tools.
"""
pass
class ToolDefinitionError(ToolError):
"""
Raised when there is an error in the definition of a tool.
"""
pass

View file

@ -0,0 +1,71 @@
from abc import ABC
from typing import Literal, Optional, Union
from pydantic import AnyUrl, BaseModel, Field, conlist
class ValueSchema(BaseModel):
val_type: Literal["string", "integer", "float", "boolean", "json"]
enum: Optional[list[str]] = None
class InputParameter(BaseModel):
name: str = Field(..., description="The human-readable name of this parameter.")
required: bool = Field(
...,
description="Whether this parameter is required (true) or optional (false).",
)
description: Optional[str] = Field(
None, description="A descriptive, human-readable explanation of the parameter."
)
value_schema: ValueSchema = Field(
...,
description="The schema of the value of this parameter.",
)
inferrable: bool = Field(
True,
description="Whether a value for this parameter can be inferred by a model. Defaults to `true`.",
)
class ToolInputs(BaseModel):
parameters: conlist(InputParameter)
class ToolOutput(BaseModel):
description: Optional[str] = Field(
None, description="A descriptive, human-readable explanation of the output."
)
available_modes: conlist(
Literal["value", "error", "null", "artifact", "requires_authorization"],
min_length=1,
) = Field(
...,
description="The available modes for the output.",
default_factory=lambda: ["value", "error", "null"],
)
value_schema: Optional[ValueSchema] = Field(
None, description="The schema of the value of the output."
)
class ToolAuthorizationRequirement(BaseModel, ABC):
pass
class OAuth2AuthorizationRequirement(ToolAuthorizationRequirement):
url: AnyUrl
scope: Optional[list[str]] = None
class ToolRequirements(BaseModel):
authorization: Union[ToolAuthorizationRequirement, None] = None
class ToolDefinition(BaseModel):
name: str
description: str
version: str
inputs: ToolInputs
output: ToolOutput
requirements: ToolRequirements

34
arcade/arcade/sdk/tool.py Normal file
View file

@ -0,0 +1,34 @@
import os
from typing import Any, Callable, Optional, TypeVar, Union
from arcade.sdk.schemas import ToolAuthorizationRequirement
from arcade.utils import snake_to_pascal_case
T = TypeVar("T")
def tool(
func: Callable | None = None,
desc: str | None = None,
name: str | None = None,
requires_auth: Union[ToolAuthorizationRequirement, None] = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
func.__tool_name__ = name or snake_to_pascal_case(getattr(func, "__name__", None))
func.__tool_description__ = desc or func.__doc__
func.__tool_requires_auth__ = requires_auth
return func
if func: # This means the decorator is used without parameters
return decorator(func)
return decorator
def get_secret(name: str, default: Optional[Any] = None) -> str:
secret = os.getenv(name)
if secret is None:
if default is not None:
return default
raise ValueError(f"Secret {name} is not set.")
return secret

View file

@ -0,0 +1,407 @@
import asyncio
import inspect
import sys
from datetime import datetime
from importlib import import_module
from pathlib import Path
from typing import (
Annotated,
Callable,
Literal,
Optional,
Union,
cast,
get_args,
get_origin,
)
from pydantic import BaseModel, Field, create_model
from arcade.actor.common.response import ResponseModel
from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.conf import settings
from arcade.apm.base import ToolPack
from arcade.sdk.annotations import Inferrable
from arcade.sdk.errors import ToolDefinitionError
from arcade.sdk.schemas import (
InputParameter,
ToolDefinition,
ToolInputs,
ToolOutput,
ToolRequirements,
ValueSchema,
)
from arcade.utils import (
does_function_return_value,
first_or_none,
is_string_literal,
snake_to_pascal_case,
)
class ToolMeta(BaseModel):
module: str
path: Optional[str] = None
date_added: datetime = Field(default_factory=datetime.now)
date_updated: datetime = Field(default_factory=datetime.now)
class MaterializedTool(BaseModel):
tool: Callable
definition: ToolDefinition
meta: ToolMeta
# Thought (Sam): Should generate create these from ToolDefinition?
input_model: type[BaseModel]
output_model: type[BaseModel]
@property
def name(self) -> str:
return self.definition.name
@property
def version(self) -> str:
return self.definition.version
@property
def description(self) -> str:
return self.definition.description
class ToolCatalog:
def __init__(self, tools_dir: str = settings.TOOLS_DIR):
self.tools = self.read_tools(tools_dir)
@staticmethod
def read_tools(directory: str) -> dict[str, MaterializedTool]:
toolpack = ToolPack.from_lock_file(directory)
sys.path.append(str(Path(directory).resolve() / "tools"))
tools: dict[str, MaterializedTool] = {}
for name, tool_spec in toolpack.tools.items():
module_name, versioned_tool = tool_spec.split(".", 1)
func_name, version = versioned_tool.split("@")
module = import_module(module_name)
tool_func = getattr(module, func_name)
input_model, output_model = create_func_models(tool_func)
tool_name = snake_to_pascal_case(
name
) # TODO make sure this follows create_tool_definition
tools[tool_name] = MaterializedTool(
definition=ToolCatalog.create_tool_definition(tool_func, version),
tool=tool_func,
meta=ToolMeta(module=module_name, path=module.__file__),
input_model=input_model,
output_model=output_model,
)
return tools
@staticmethod
def create_tool_definition(tool: Callable, version: str) -> ToolDefinition:
tool_name = getattr(tool, "__tool_name__", tool.__name__)
# Hard requirement: tools must have descriptions
tool_description = getattr(tool, "__tool_description__", None)
if tool_description is None:
raise ToolDefinitionError(f"Tool {tool_name} is missing a description")
# If the function returns a value, it must have a type annotation
if does_function_return_value(tool) and tool.__annotations__.get("return") is None:
raise ToolDefinitionError(f"Tool {tool_name} must have a return type annotation")
return ToolDefinition(
name=tool_name,
description=tool_description,
version=version,
inputs=create_input_definition(tool),
output=create_output_definition(tool),
requirements=ToolRequirements(
authorization=getattr(tool, "__tool_requires_auth__", None),
),
)
def __getitem__(self, name: str) -> Optional[MaterializedTool]:
# TODO error handling
for tool_name, tool in self.tools.items():
if tool_name == name:
return tool
return None
def __iter__(self) -> MaterializedTool:
yield from self.tools.values()
def get_tool(self, name: str) -> Optional[Callable]:
for _, tool in self:
if tool.definition.name == name:
return tool.tool
raise ValueError(f"Tool {name} not found.")
def list_tools(self) -> list[dict[str, str]]:
def get_tool_endpoint(t: MaterializedTool) -> str:
return f"/tool/{t.meta.module}/{t.definition.name}"
return [
{
"name": t.definition.name,
"description": t.definition.description,
"version": t.version,
"endpoint": get_tool_endpoint(t),
}
for t in self.tools.values()
]
def create_input_definition(func: Callable) -> ToolInputs:
"""
Create an input model for a function based on its parameters.
"""
input_parameters = []
for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
field_info = extract_field_info(param)
# Hard requirement: params must be described
if field_info["field_params"]["description"] is None:
raise ToolDefinitionError(
f"Parameter {field_info['field_params']['name']} is missing a description"
)
is_enum = False
enum_values: list[str] = []
# Special case: Literal["string1", "string2"] can be enumerated on the wire
if is_string_literal(field_info["field_params"]["type"]):
is_enum = True
enum_values = [str(e) for e in get_args(field_info["field_params"]["type"])]
input_parameters.append(
InputParameter(
name=field_info["field_params"]["name"],
description=field_info["field_params"]["description"],
required=field_info["field_params"]["default"] is None
and not field_info["field_params"]["optional"],
inferrable=field_info["field_params"]["inferrable"],
value_schema=ValueSchema(
val_type=field_info["field_params"]["wire_type"],
enum=enum_values if is_enum else None,
),
)
)
return ToolInputs(parameters=input_parameters)
def create_output_definition(func: Callable) -> ToolOutput:
"""
Create an output model for a function based on its return annotation.
"""
return_type = inspect.signature(func, follow_wrapped=True).return_annotation
description = "No description provided."
if return_type is inspect.Signature.empty:
return ToolOutput(
value_schema=None,
description="No description provided.",
available_modes=["null"],
)
if hasattr(return_type, "__metadata__"):
description = return_type.__metadata__[0] if return_type.__metadata__ else None
return_type = return_type.__origin__
# Unwrap Optional types
is_optional = False
if get_origin(return_type) is Union and type(None) in get_args(return_type):
return_type = next(arg for arg in get_args(return_type) if arg is not type(None))
is_optional = True
wire_type = get_wire_type(return_type)
available_modes = ["value", "error"]
if is_optional:
available_modes.append("null")
return ToolOutput(
description=description,
available_modes=available_modes,
value_schema=ValueSchema(val_type=wire_type),
)
def extract_field_info(param: inspect.Parameter) -> dict:
"""
Extract type and field parameters from a function parameter.
Args:
param (inspect.Parameter): The parameter to extract information from.
Returns:
dict: A dictionary with 'type' and 'field_params'.
"""
annotation = param.annotation
if annotation == inspect.Parameter.empty:
raise TypeError(f"Parameter {param} has no type annotation.")
metadata = getattr(annotation, "__metadata__", [])
name = param.name
description = None
str_annotations = [m for m in metadata if isinstance(m, str)]
if len(str_annotations) == 1:
description = str_annotations[0]
elif len(str_annotations) == 2:
name = str_annotations[0]
description = str_annotations[1]
else:
raise ToolDefinitionError(f"Parameter {param} has multiple descriptions")
default = param.default if param.default is not inspect.Parameter.empty else None
# If the param is Annotated[], unwrap the annotation
# Otherwise, use the literal type
original_type = annotation.__args__[0] if get_origin(annotation) is Annotated else annotation
field_type = original_type
# Unwrap Optional types
is_optional = False
if get_origin(field_type) is Union and type(None) in get_args(field_type):
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
is_optional = True
wire_type = get_wire_type(str) if is_string_literal(field_type) else get_wire_type(field_type)
# Get the Inferrable annotation, if it exists
inferrable_annotation = first_or_none(Inferrable, get_args(annotation))
field_params = {
"name": name,
"description": str(description) if description else None,
"default": default,
"optional": is_optional,
"inferrable": inferrable_annotation.value
if inferrable_annotation
else True, # Params are inferrable by default
"type": field_type,
"wire_type": wire_type,
"original_type": original_type,
}
return {"type": field_type, "field_params": field_params}
def get_wire_type(
_type: type,
) -> Literal["string", "integer", "float", "boolean", "json"]:
type_mapping = {
str: "string",
bool: "boolean",
int: "integer",
float: "float",
dict: "json",
list: "json",
BaseModel: "json",
}
wire_type = type_mapping.get(_type)
if wire_type:
return cast(Literal["string", "integer", "float", "boolean", "json"], wire_type)
elif hasattr(_type, "__origin__"):
# account for "list[str]" and "dict[str, int]" and "Optional[str]" and other typing types
origin = _type.__origin__
if origin in [list, dict]:
return "json"
elif issubclass(_type, BaseModel):
return "json"
else:
raise TypeError(f"Unsupported parameter type: {_type}")
def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]]:
"""
Analyze a function to create corresponding Pydantic models for its input and output.
Args:
func (Callable): The function to analyze.
Returns:
Tuple[Type[BaseModel], Type[BaseModel]]: A tuple containing the input and output Pydantic models.
"""
input_fields = {}
# TODO figure this out (Sam)
if asyncio.iscoroutinefunction(func) and hasattr(func, "__wrapped__"):
func = func.__wrapped__
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
# TODO make this cleaner
field_info = extract_field_info(param)
field_data = field_info["field_params"]
param_fields = {
"default": field_data["default"],
"description": field_data["description"],
# TODO more here?
}
input_fields[name] = (field_info["type"], Field(**param_fields))
input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields)
output_model = determine_output_model(func)
return input_model, output_model
def determine_output_model(func: Callable) -> type[BaseModel]:
"""
Determine the output model for a function based on its return annotation.
Args:
func (Callable): The function to analyze.
Returns:
Type[BaseModel]: A Pydantic model representing the output.
"""
return_annotation = inspect.signature(func).return_annotation
output_model_name = f"{snake_to_pascal_case(func.__name__)}Output"
if return_annotation is inspect.Signature.empty:
return create_model(output_model_name)
elif hasattr(return_annotation, "__origin__"):
if hasattr(return_annotation, "__metadata__"):
field_type = Optional[return_annotation.__args__[0]]
description = (
return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
)
if description:
return create_model(
output_model_name,
result=(field_type, Field(description=str(description))),
)
else:
return create_model(
output_model_name,
result=(
return_annotation,
Field(description="No description provided."),
),
)
else:
# Handle simple return types (like str)
return create_model(
output_model_name,
result=(return_annotation, Field(description="No description provided.")),
)
def create_response_model(name: str, output_model: type[BaseModel]) -> type[ResponseModel]:
"""
Create a response model for the given schema.
"""
# Create a new response model
response_model = create_model(
f"{snake_to_pascal_case(name)}Response",
code=(int, CustomResponseCode.HTTP_200.code),
msg=(str, CustomResponseCode.HTTP_200.msg),
data=(Optional[output_model], None),
)
return response_model

View file

@ -1,11 +1,11 @@
import json
from typing import Any, Dict, Type
from enum import Enum
from typing import Any
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from enum import Enum
from toolserve.server.core.catalog import ToolSchema
from arcade.tool.catalog import MaterializedTool
PYTHON_TO_JSON_TYPES = {
str: "string",
@ -16,7 +16,8 @@ PYTHON_TO_JSON_TYPES = {
dict: "object",
}
def python_type_to_json_type(python_type: Type) -> Dict[str, Any]:
def python_type_to_json_type(python_type: type) -> dict[str, Any]:
"""
Map Python types to JSON Schema types, including handling of complex types such as lists and dictionaries.
@ -26,23 +27,23 @@ def python_type_to_json_type(python_type: Type) -> Dict[str, Any]:
Returns:
Dict[str, Any]: A dictionary representing the JSON schema for the given Python type.
"""
if hasattr(python_type, '__origin__'):
if hasattr(python_type, "__origin__"):
origin = python_type.__origin__
if origin is list:
item_type = python_type_to_json_type(python_type.__args__[0])
return {'type': 'array', 'items': item_type}
return {"type": "array", "items": item_type}
elif origin is dict:
value_type = python_type_to_json_type(python_type.__args__[1])
return {'type': 'object', 'additionalProperties': value_type}
return {"type": "object", "additionalProperties": value_type}
elif issubclass(python_type, BaseModel):
return model_to_json_schema(python_type)
return PYTHON_TO_JSON_TYPES.get(python_type, "string")
def model_to_json_schema(model: Type[BaseModel]) -> Dict[str, Any]:
def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
"""
Convert a Pydantic model to a JSON schema.
@ -77,8 +78,9 @@ def model_to_json_schema(model: Type[BaseModel]) -> Dict[str, Any]:
"required": required,
}
def schema_to_openai_tool(tool_schema: 'ToolSchema') -> str:
"""Convert an ToolSchema object to a JSON schema string in the specified function format.
def schema_to_openai_tool(tool: "MaterializedTool") -> str:
"""Convert an ToolDefinition object to a JSON schema string in the specified function format.
Example output format:
{
@ -104,18 +106,18 @@ def schema_to_openai_tool(tool_schema: 'ToolSchema') -> str:
}
Args:
tool_schema (ToolSchema): The tool schema to convert.
tool_schema (ToolDefinition): The tool schema to convert.
Returns:
str: A JSON schema string representing the tool in the specified format.
"""
input_model_schema = model_to_json_schema(tool_schema.input_model)
input_model_schema = model_to_json_schema(tool.input_model)
function_schema = {
"type": "function",
"function": {
"name": tool_schema.name,
"description": tool_schema.description,
"name": tool.definition.name,
"description": tool.definition.description,
"parameters": input_model_schema,
}
},
}
return json.dumps(function_schema, indent=2)

View file

@ -0,0 +1,59 @@
import ast
import inspect
import re
from collections.abc import Iterable
from typing import Any, Callable, Literal, Optional, TypeVar, get_args, get_origin
T = TypeVar("T")
def first_or_none(_type: type[T], iterable: Iterable[Any]) -> Optional[T]:
"""
Returns the first item in the iterable that is an instance of the given type, or None if no such item is found.
"""
for item in iterable:
if isinstance(item, _type):
return item
return None
def pascal_to_snake_case(name: str) -> str:
"""
Converts a PascalCase name to snake_case.
"""
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def snake_to_pascal_case(name: str) -> str:
"""
Converts a snake_case name to PascalCase.
"""
return "".join(x.capitalize() or "_" for x in name.split("_"))
def is_string_literal(_type: type) -> bool:
"""
Returns True if the given type is a string literal, i.e. a Literal[str] or Literal[str, str, ...] etc.
"""
return get_origin(_type) is Literal and all(isinstance(arg, str) for arg in get_args(_type))
def does_function_return_value(func: Callable) -> bool:
"""
Returns True if the given function returns a value, i.e. if it has a return statement with a value.
"""
source = inspect.getsource(func)
tree = ast.parse(source)
class ReturnVisitor(ast.NodeVisitor):
def __init__(self):
self.returns_value = False
def visit_Return(self, node):
if node.value is not None:
self.returns_value = True
visitor = ReturnVisitor()
visitor.visit(tree)
return visitor.returns_value

9
arcade/codecov.yaml Normal file
View file

@ -0,0 +1,9 @@
coverage:
range: 70..100
round: down
precision: 1
status:
project:
default:
target: 90%
threshold: 0.5%

8
arcade/docs/index.md Normal file
View file

@ -0,0 +1,8 @@
# arcade-ai
[![Release](https://img.shields.io/github/v/release/spartee/arcade-ai)](https://img.shields.io/github/v/release/spartee/arcade-ai)
[![Build status](https://img.shields.io/github/actions/workflow/status/spartee/arcade-ai/main.yml?branch=main)](https://github.com/spartee/arcade-ai/actions/workflows/main.yml?query=branch%3Amain)
[![Commit activity](https://img.shields.io/github/commit-activity/m/spartee/arcade-ai)](https://img.shields.io/github/commit-activity/m/spartee/arcade-ai)
[![License](https://img.shields.io/github/license/spartee/arcade-ai)](https://img.shields.io/github/license/spartee/arcade-ai)
Arcade AI python

1
arcade/docs/modules.md Normal file
View file

@ -0,0 +1 @@
::: arcade.foo

54
arcade/mkdocs.yml Normal file
View file

@ -0,0 +1,54 @@
site_name: arcade-ai
repo_url: https://github.com/spartee/arcade-ai
site_url: https://spartee.github.io/arcade-ai
site_description: Arcade AI python
site_author: Arcade AI
edit_uri: edit/main/docs/
repo_name: spartee/arcade-ai
copyright: Maintained by <a href="https://spartee.com">Florian</a>.
nav:
- Home: index.md
- Modules: modules.md
plugins:
- search
- mkdocstrings:
handlers:
python:
setup_commands:
- import sys
- sys.path.append('../')
theme:
name: material
feature:
tabs: true
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: white
accent: deep orange
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
accent: deep orange
toggle:
icon: material/brightness-4
name: Switch to light mode
icon:
repo: fontawesome/brands/github
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/spartee/arcade-ai
- icon: fontawesome/brands/python
link: https://pypi.org/project/arcade-ai
markdown_extensions:
- toc:
permalink: true
- pymdownx.arithmatex:
generic: true

1826
arcade/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

2
arcade/poetry.toml Normal file
View file

@ -0,0 +1,2 @@
[virtualenvs]
in-project = true

127
arcade/pyproject.toml Normal file
View file

@ -0,0 +1,127 @@
[tool.poetry]
name = "arcade-ai"
version = "0.1.0"
description = ""
packages = [
{include="arcade", from="."}
]
authors = ["Arcade AI <sam@arcade-ai.com>"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.dependencies]
python = ">=3.10,<4.0"
pydantic = {extras = ["email"], version = "^2.7.0"}
fastapi = "^0.110.0"
redis = "^5.0.3"
uvicorn = "^0.28.0"
loguru = "^0.7.2"
pydantic-settings = "^2.2.1"
msgspec = "^0.18.6"
msgpack = "^1.0.8"
typer = "^0.9.0"
rich = "^13.7.1"
toml = "^0.10.2"
tomlkit = "^0.12.4"
stdlib-list = "^0.10.0"
requests = "^2.26.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.2.0"
pytest-cov = "^4.0.0"
deptry = "^0.12.0"
mypy = "^1.5.1"
pre-commit = "^3.4.0"
tox = "^4.11.1"
pytest-asyncio = "^0.23.7"
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.4.2"
mkdocs-material = "^9.2.7"
mkdocstrings = {extras = ["python"], version = "^0.23.0"}
[tool.poetry.scripts]
arcade = "arcade.cli.main:cli"
[tool.mypy]
files = ["arcade"]
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"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py39"
line-length = 100
fix = true
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",
]
ignore = [ # TODO work to remove these
# LineTooLong
"E501",
# DoNotAssignLambda
"E731",
# raise from (cli specific)
"TRY200",
# Depends function in arg string
"B008",
# raise from (cli specific)
"B904",
# long message exceptions
"TRY003"
]
[tool.ruff.format]
preview = true
[tool.coverage.report]
skip_empty = true
[tool.coverage.run]
branch = true
source = ["arcade"]
[tool.ruff.per-file-ignores]
"tests/*" = ["S101"]

View file

@ -0,0 +1,52 @@
import asyncio
import pytest
from arcade.sdk.schemas import OAuth2AuthorizationRequirement
from arcade.sdk.tool import tool
def test_sync_function():
"""
Ensures a function will run when decorated by @tool
"""
@tool
def sync_func(x, y):
return x + y
result = sync_func(1, 2)
assert result == 3
@pytest.mark.asyncio
async def test_async_function():
"""
Ensures an async function will run when decorated by @tool
"""
@tool
async def async_func(x, y):
await asyncio.sleep(0)
return x + y
result = await async_func(1, 2)
assert result == 3
def test_tool_decorator_with_all_options():
@tool(
name="TestTool",
desc="Test description",
requires_auth=OAuth2AuthorizationRequirement(
url="https://example.com/oauth2/auth",
scope=["test_scope", "another.scope"],
),
)
def test_tool(x, y):
return x + y
assert test_tool.__tool_name__ == "TestTool"
assert test_tool.__tool_description__ == "Test description"
assert str(test_tool.__tool_requires_auth__.url) == "https://example.com/oauth2/auth"
assert test_tool.__tool_requires_auth__.scope == ["test_scope", "another.scope"]

View file

@ -0,0 +1,456 @@
from typing import Annotated, Literal, Optional
import pytest
from arcade.sdk.annotations import Inferrable
from arcade.sdk.schemas import (
InputParameter,
OAuth2AuthorizationRequirement,
ToolInputs,
ToolOutput,
ToolRequirements,
ValueSchema,
)
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
### Tests on @tool decorator
@tool(desc="A function with a description")
def func_with_description():
pass
@tool
def func_with_docstring_description():
"""Docstring description"""
pass
@tool(name="MyCustomTool", desc="A function with a very cool description")
def func_with_name_and_description():
pass
@tool(
desc="A function that requires authentication",
requires_auth=OAuth2AuthorizationRequirement(
url="https://example.com/oauth2/auth", scope=["scope1", "scope2"]
),
)
def func_with_auth_requirement():
pass
### Tests on input params
@tool(desc="A function with an input parameter")
def func_with_param(param1: Annotated[str, "First param"]):
pass
@tool(desc="A function with a non-inferrable input parameter")
def func_with_non_inferrable_param(param1: Annotated[str, "First param", Inferrable(False)]):
pass
# Two string annotations on an input parameter is understood to be name, description
@tool(desc="A function with a renamed input parameter")
def func_with_renamed_param(param1: Annotated[str, "ParamOne", "First param"]):
pass
@tool(desc="A function with every possible input parameter")
def func_with_every_param(
param1: Annotated[str, "a string"],
param2: Annotated[int, "an integer"],
param3: Annotated[float, "a float"],
param4: Annotated[bool, "a boolean"],
):
pass
@tool(desc="A function that takes a dictionary")
def func_with_dict_param(param1: Annotated[dict, "a cool dictionary"]):
pass
@tool(desc="A function that takes a string enum")
def func_with_string_enum_param(param1: Annotated[Literal["value1", "value2"], "a few choices"]):
pass
@tool(desc="A function with an input parameter with a default value (considered optional)")
def func_with_param_with_default(param1: Annotated[str, "First param"] = "default"):
pass
@tool(desc="A function with an optional input parameter")
def func_with_optional_param(param1: Annotated[Optional[str], "First param"]):
pass
@tool(desc="A function with multiple parameters, some with default values")
def func_with_mixed_params(
param1: Annotated[str, "First param"],
param2: Annotated[int, "Second param"] = 42,
):
pass
@tool(desc="A function with a complex parameter type")
def func_with_complex_param(param1: Annotated[list[str], "A list of strings"]):
pass
### Tests on output/return values
@tool(desc="A function that performs an action without returning anything")
def func_with_no_return():
pass
@tool(desc="A function that returns a value")
def func_with_value_return() -> str:
return "output"
@tool(desc="A function with an annotated return type")
def func_with_annotated_return() -> Annotated[str, "Annotated return description"]:
return "output"
@tool(desc="A function with an optional return type")
def func_with_optional_return() -> Optional[str]:
return "maybe output"
@tool(desc="A function with a complex return type")
def func_with_complex_return() -> list[dict[str, str]]:
return [{"key": "value"}]
@pytest.mark.parametrize(
"func_under_test, expected_tool_def_fields",
[
# Tests on @tool decorator
pytest.param(
func_with_description,
{
"name": "FuncWithDescription", # Defaults to the camelCased function name
},
id="func_with_default_name",
),
pytest.param(
func_with_description,
{"description": "A function with a description"},
id="func_with_description",
),
pytest.param(
func_with_docstring_description,
{"description": "Docstring description"},
id="func_with_docstring_description",
),
pytest.param(
func_with_name_and_description,
{"name": "MyCustomTool", "description": "A function with a very cool description"},
id="func_with_description_and_name",
),
pytest.param(
func_with_name_and_description,
{"name": "MyCustomTool", "requirements": ToolRequirements(authorization=None)},
id="func_with_no_auth_requirement",
),
pytest.param(
func_with_auth_requirement,
{
"requirements": ToolRequirements(
authorization=OAuth2AuthorizationRequirement(
url="https://example.com/oauth2/auth", scope=["scope1", "scope2"]
)
)
},
id="func_with_auth_requirement",
),
# Tests on input params
pytest.param(
func_with_value_return,
{
"inputs": ToolInputs(parameters=[]),
},
id="func_with_no_params",
),
pytest.param(
func_with_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True, # Defaults to true
required=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
},
id="func_with_param",
),
pytest.param(
func_with_non_inferrable_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=False, # Set using Inferrable(False)
required=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
},
id="func_with_non_inferrable_param",
),
pytest.param(
func_with_renamed_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="ParamOne",
description="First param",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
},
id="func_with_renamed_param",
),
pytest.param(
func_with_every_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="a string",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="string", enum=None),
),
InputParameter(
name="param2",
description="an integer",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="integer", enum=None),
),
InputParameter(
name="param3",
description="a float",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="float", enum=None),
),
InputParameter(
name="param4",
description="a boolean",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="boolean", enum=None),
),
]
),
},
id="func_with_every_param",
),
pytest.param(
func_with_dict_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="a cool dictionary",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="json", enum=None),
)
]
),
},
id="func_with_dict_param",
),
pytest.param(
func_with_string_enum_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="a few choices",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="string", enum=["value1", "value2"]),
)
]
),
},
id="func_with_string_enum_param",
),
pytest.param(
func_with_param_with_default,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True,
required=False, # Because a default value is provided
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
"output": ToolOutput(
available_modes=["null"], description="No description provided."
),
},
id="func_with_param_with_default",
),
pytest.param(
func_with_optional_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True,
required=False, # Because of Optional[str]
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
"output": ToolOutput(
available_modes=["null"], description="No description provided."
),
},
id="func_with_optional_param",
),
pytest.param(
func_with_mixed_params,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="string", enum=None),
),
InputParameter(
name="param2",
description="Second param",
inferrable=True,
required=False, # Because a default value is provided
value_schema=ValueSchema(val_type="integer", enum=None),
),
]
),
},
id="func_with_mixed_params",
),
pytest.param(
func_with_complex_param,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="A list of strings",
inferrable=True,
required=True,
value_schema=ValueSchema(val_type="json", enum=None),
)
]
),
},
id="func_with_complex_param",
),
# Tests on output values
pytest.param(
func_with_no_return,
{
"output": ToolOutput(
available_modes=["null"], description="No description provided."
),
},
id="func_with_no_return",
),
pytest.param(
func_with_value_return,
{
"inputs": ToolInputs(parameters=[]),
"output": ToolOutput(
value_schema=ValueSchema(val_type="string", enum=None),
available_modes=["value", "error"],
description="No description provided.",
),
},
id="func_with_value_return",
),
pytest.param(
func_with_annotated_return,
{
"inputs": ToolInputs(parameters=[]),
"output": ToolOutput(
value_schema=ValueSchema(val_type="string", enum=None),
available_modes=["value", "error"],
description="Annotated return description",
),
},
id="func_with_annotated_return",
),
pytest.param(
func_with_optional_return,
{
"inputs": ToolInputs(parameters=[]),
"output": ToolOutput(
value_schema=ValueSchema(val_type="string", enum=None),
available_modes=["value", "error", "null"],
description="No description provided.",
),
},
id="func_with_optional_return",
),
pytest.param(
func_with_complex_return,
{
"inputs": ToolInputs(parameters=[]),
"output": ToolOutput(
value_schema=ValueSchema(val_type="json", enum=None),
available_modes=["value", "error"],
description="No description provided.",
),
},
id="func_with_complex_return",
),
],
)
def test_create_tool_def(func_under_test, expected_tool_def_fields):
tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0")
assert tool_def.version == "1.0"
for field, expected_value in expected_tool_def_fields.items():
assert getattr(tool_def, field) == expected_value
def tool_version_is_set_correctly():
tool_def = ToolCatalog.create_tool_definition(func_with_no_return, "abcd1236")
assert tool_def.version == "abcd1236"

View file

@ -0,0 +1,55 @@
import pytest
from arcade.sdk.errors import ToolDefinitionError
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
@tool
def func_with_missing_description():
pass
@tool(desc="Returning function with declared no return type (illegal)")
def func_with_missing_return_type():
return "hello world"
@tool(desc="A function with a parameter missing a description (illegal)")
def func_with_missing_param_description(param1: str):
pass
@tool(desc="A function with an unsupported parameter type (illegal)")
def func_with_unsupported_param(param1: complex):
pass
@pytest.mark.parametrize(
"func_under_test, exception_type",
[
pytest.param(
func_with_missing_description,
ToolDefinitionError,
id=func_with_missing_description.__name__,
),
pytest.param(
func_with_missing_return_type,
ToolDefinitionError,
id=func_with_missing_return_type.__name__,
),
pytest.param(
func_with_missing_param_description,
ToolDefinitionError,
id=func_with_missing_param_description.__name__,
),
pytest.param(
func_with_unsupported_param,
ToolDefinitionError,
id=func_with_unsupported_param.__name__,
),
],
)
def test_missing_info_raises_error(func_under_test, exception_type):
with pytest.raises(exception_type):
ToolCatalog.create_tool_definition(func_under_test, "1.0")

View file

@ -0,0 +1,57 @@
from typing import Annotated
import pytest
from pydantic import BaseModel, Field
from arcade.sdk.schemas import (
ToolOutput,
ValueSchema,
)
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
class ProductOutput(BaseModel):
product_name: str = Field(..., description="The name of the product")
price: int = Field(..., description="The price of the product")
stock_quantity: int = Field(..., description="The stock quantity of the product")
@tool(desc="A function that returns a Pydantic model")
def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, price, and quantity"]:
return ProductOutput(
product_name="Product 1",
price=100,
stock_quantity=1000,
)
# TODO: Function that takes a Pydantic model as an argument: break it down into components? Look at OpenAPI, do they represent nested arguments?
# TODO: Function that takes a Pydantic Field as an argument
# TODO: Pydantic Field() properties: description, default, title, default_factory, nullable
# TODO: Pydantic Field() properties stretch goal: gt, ge, lt, le, multiple_of, range, regex, max_length, min_length, max_items, min_items, unique_items, exclusive_maximum, exclusive_minimum
@pytest.mark.parametrize(
"func_under_test, expected_tool_def_fields",
[
pytest.param(
func_returns_pydantic_model,
{
"output": ToolOutput(
value_schema=ValueSchema(val_type="json", enum=None),
available_modes=["value", "error"],
description="The product, price, and quantity",
)
},
id="func_returns_pydantic_model",
),
],
)
def test_create_tool_def(func_under_test, expected_tool_def_fields):
tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0")
assert tool_def.version == "1.0"
for field, expected_value in expected_tool_def_fields.items():
assert getattr(tool_def, field) == expected_value

View file

@ -0,0 +1,26 @@
import pytest
from arcade.utils import pascal_to_snake_case, snake_to_pascal_case
@pytest.mark.parametrize(
"input_str, expected",
[
("SnakeCase", "snake_case"),
("VeryLongSnake456", "very_long_snake456"),
],
)
def test_pascal_to_snake_case(input_str: str, expected: str):
assert pascal_to_snake_case(input_str) == expected
@pytest.mark.parametrize(
"input_str, expected",
[
("snake_case", "SnakeCase"),
("very_long_snake_456", "VeryLongSnake456"),
("camelCase", "Camelcase"), # camelCase isn't explicitly supported
],
)
def test_snake_to_pascal_case(input_str: str, expected: str):
assert snake_to_pascal_case(input_str) == expected

18
arcade/tox.ini Normal file
View file

@ -0,0 +1,18 @@
[tox]
skipsdist = true
envlist = py38, py39, py310, py311
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
[testenv]
passenv = PYTHON_VERSION
allowlist_externals = poetry
commands =
poetry install -v
pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml
mypy

12
cspell.config.yaml Normal file
View file

@ -0,0 +1,12 @@
version: "0.2"
ignorePaths:
- pyproject.toml
dictionaryDefinitions: []
dictionaries: []
words:
- conlist
- pydantic
- pyproject
- toolpack
ignoreWords: []
import: []

View file

@ -2,14 +2,15 @@ import csv
import sqlite3
# Path to the CSV file
csv_file_path = './synthetic_people_data.csv'
csv_file_path = "./synthetic_people_data.csv"
# Connect to a SQLite database (will be created if it doesn't exist)
conn = sqlite3.connect('people.sqlite')
conn = sqlite3.connect("people.sqlite")
cur = conn.cursor()
# Create a table
cur.execute('''
cur.execute(
"""
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY,
Name TEXT,
@ -18,21 +19,25 @@ CREATE TABLE IF NOT EXISTS people (
Occupation TEXT,
Email TEXT
)
''')
"""
)
# Read data from the CSV file
with open(csv_file_path, 'r') as csvfile:
with open(csv_file_path, "r") as csvfile:
csvreader = csv.reader(csvfile)
next(csvreader) # Skip the header row
for row in csvreader:
# Insert each row into the database
cur.execute('''
cur.execute(
"""
INSERT INTO people (Name, Age, Location, Occupation, Email)
VALUES (?, ?, ?, ?, ?)
''', row)
""",
row,
)
# Commit changes and close the connection
conn.commit()
conn.close()
print('Data imported into SQLite database successfully.')
print("Data imported into SQLite database successfully.")

View file

@ -1,24 +0,0 @@
from sqlalchemy import create_engine, MetaData
# Replace 'your_database.db' with your actual SQLite database file
database_path = 'sqlite:///people.sqlite'
engine = create_engine(database_path)
metadata = MetaData()
# Reflect the tables in the database
metadata.reflect(bind=engine)
# Iterate over all tables and print their descriptions
for table_name in metadata.tables:
print(f"Table: {table_name}")
table = metadata.tables[table_name]
# Iterate over columns in the table and print details
for column in table.c:
print(f"Column: {column.name}")
print(f"Type: {column.type}")
print(f"Nullable: {column.nullable}")
print(f"Primary Key: {column.primary_key}")
print(f"---------------------")
print(f"{'='*20}\n")

View file

@ -8,7 +8,10 @@ email = "sam@partee.io"
[depends]
[tools]
Summarize = "llm.summarize@0.0.1"
Respond = "llm.respond@0.0.1"
TextSearch = "BM25.text_search@0.0.1"
ReadProducts = "products.read_products@0.0.1"
ReadSqlite = "read_sqlite.read_sqlite@0.0.1"
SendEmail = "gmail.send_email@0.0.1"
ReadEmail = "gmail.read_email@0.0.1"
OauthReadEmail = "gmail.oauth_read_email@0.0.1"
ListDriveFiles = "gmail.list_drive_files@0.0.1"

View file

@ -9,4 +9,6 @@ email = "sam@partee.io"
[modules]
gmail = "0.0.1"
llm = "0.0.1"
read_sqlite = "0.0.1"
BM25 = "0.0.1"
products = "0.0.1"

View file

@ -0,0 +1,158 @@
import math
import numpy as np
from typing import Annotated
from multiprocessing import Pool, cpu_count
from arcade.sdk.tool import tool
class BM25:
def __init__(self, corpus, tokenizer=None):
self.corpus_size = 0
self.avgdl = 0
self.doc_freqs = []
self.idf = {}
self.doc_len = []
self.tokenizer = tokenizer
if tokenizer:
corpus = self._tokenize_corpus(corpus)
else:
corpus = self._tokenize(corpus)
nd = self._initialize(corpus)
self._calc_idf(nd)
@staticmethod
def _tokenize(texts: list[str]) -> list[list[str]]:
return [text.split() for text in texts]
def _initialize(self, corpus):
nd = {} # word -> number of documents with word
num_doc = 0
for document in corpus:
self.doc_len.append(len(document))
num_doc += len(document)
frequencies = {}
for word in document:
if word not in frequencies:
frequencies[word] = 0
frequencies[word] += 1
self.doc_freqs.append(frequencies)
for word, freq in frequencies.items():
try:
nd[word] += 1
except KeyError:
nd[word] = 1
self.corpus_size += 1
self.avgdl = num_doc / self.corpus_size
return nd
def _tokenize_corpus(self, corpus):
pool = Pool(cpu_count())
tokenized_corpus = pool.map(self.tokenizer, corpus)
return tokenized_corpus
def _calc_idf(self, nd):
raise NotImplementedError()
def get_scores(self, query):
raise NotImplementedError()
def get_batch_scores(self, query, doc_ids):
raise NotImplementedError()
def get_top_n(self, query, documents, n=5):
assert self.corpus_size == len(
documents
), "The documents given don't match the index corpus!"
query = self._tokenize([query])[0] # tokenize the query
scores = self.get_scores(query)
top_n = np.argsort(scores)[::-1][:n]
return [documents[i] for i in top_n]
class Okapi(BM25):
def __init__(self, corpus, tokenizer=None, k1=1.5, b=0.75, epsilon=0.25):
self.k1 = k1
self.b = b
self.epsilon = epsilon
super().__init__(corpus, tokenizer)
def _calc_idf(self, nd):
"""
Calculates frequencies of terms in documents and in corpus.
This algorithm sets a floor on the idf values to eps * average_idf
"""
# collect idf sum to calculate an average idf for epsilon value
idf_sum = 0
# collect words with negative idf to set them a special epsilon value.
# idf can be negative if word is contained in more than half of documents
negative_idfs = []
for word, freq in nd.items():
idf = math.log(self.corpus_size - freq + 0.5) - math.log(freq + 0.5)
self.idf[word] = idf
idf_sum += idf
if idf < 0:
negative_idfs.append(word)
self.average_idf = idf_sum / len(self.idf)
eps = self.epsilon * self.average_idf
for word in negative_idfs:
self.idf[word] = eps
def get_scores(self, query):
"""
The ATIRE BM25 variant uses an idf function which uses a log(idf) score. To prevent negative idf scores,
this algorithm also adds a floor to the idf value of epsilon.
See [Trotman, A., X. Jia, M. Crane, Towards an Efficient and Effective Search Engine] for more info
:param query:
:return:
"""
score = np.zeros(self.corpus_size)
doc_len = np.array(self.doc_len)
for q in query:
q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])
score += (self.idf.get(q) or 0) * (
q_freq
* (self.k1 + 1)
/ (q_freq + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl))
)
return score
def get_batch_scores(self, query, doc_ids):
"""
Calculate bm25 scores between query and subset of all docs
"""
assert all(di < len(self.doc_freqs) for di in doc_ids)
score = np.zeros(len(doc_ids))
doc_len = np.array(self.doc_len)[doc_ids]
for q in query:
q_freq = np.array([(self.doc_freqs[di].get(q) or 0) for di in doc_ids])
score += (self.idf.get(q) or 0) * (
q_freq
* (self.k1 + 1)
/ (q_freq + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl))
)
return score.tolist()
@tool
async def text_search(
query: Annotated[str, "The search query"],
texts: Annotated[list[str], "The texts through which to search"],
num_results: Annotated[int, "Number of texts to return"] = 5,
) -> Annotated[list[str], "Most similar texts"]:
"""Use the BM25 algorithm to search through texts
This should only be used for smaller datasets where the number
of texts is less than 100.
"""
bm25 = Okapi(texts)
return bm25.get_top_n(query, texts, num_results)

View file

@ -0,0 +1,257 @@
import os
import re
import email
import smtplib
import imaplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from base64 import urlsafe_b64decode
from bs4 import BeautifulSoup
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.exceptions import RefreshError
from googleapiclient.discovery import build
from typing import Dict, List, Annotated
from arcade.sdk.tool import tool, get_secret
@tool
async def send_email(
recipient_email: Annotated[str, "Email address of the recipient"],
subject: Annotated[str, "Subject of the email"],
body: Annotated[str, "Body of the email"],
):
"""Send an email via gmail SMTP server"""
sender_email = get_secret("gmail_email")
sender_password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
port = get_secret("gmail_stmp_port", 587)
message = MIMEMultipart()
message["From"] = sender_email
message["To"] = recipient_email
message["Subject"] = subject
message.attach(MIMEText(body, "plain"))
server = smtplib.SMTP(server, port)
server.starttls()
server.login(sender_email, sender_password)
print(f"Logged in to SMTP server at {':'.join((server, port))}", "DEBUG")
server.send_message(message)
server.quit()
print(f"Email sent from {sender_email} to {recipient_email}", "INFO")
@tool
async def read_email(
n_emails: Annotated[int, "Number of emails to read"] = 5,
) -> Annotated[str, "emails"]:
"""Read emails from a Gmail account and extract plain text content, removing any HTML."""
email_address = get_secret("gmail_email")
password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
# Connect to the Gmail IMAP server
mail = imaplib.IMAP4_SSL(server)
mail.login(email_address, password)
mail.select("inbox") # connect to inbox.
result, data = mail.search(None, "ALL")
email_ids = data[0].split()
email_ids.reverse() # Reverse to get the most recent emails first
emails = []
for email_id in email_ids[:n_emails]:
try:
result, data = mail.fetch(email_id, "(RFC822)")
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
email_details = {"from": msg["From"], "to": msg["To"], "date": msg["Date"]}
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode("utf-8")
email_details["body"] = clean_email_body(body)
else:
body = msg.get_payload(decode=True).decode("utf-8")
email_details["body"] = clean_email_body(body)
except Exception as e:
print(f"Error reading email {email_id}: {e}", "ERROR")
continue
emails.append(email_details)
mail.close()
mail.logout()
data = "\n".join(
[f"{email['from']} - {email['date']}\n{email['body']}\n" for email in emails]
)
return data
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
SECRET_FILE = "/Users/spartee/Dropbox/Arcade/gcp/credentials.json"
@tool
async def oauth_read_email(
n_emails: Annotated[int, "Number of emails to read"] = 5,
) -> List[Dict[str, str]]:
"""Read emails from a Gmail account and extract plain text content, removing any HTML."""
creds = None
# The file token.json stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first time.
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json")
# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except RefreshError:
flow = InstalledAppFlow.from_client_secrets_file(SECRET_FILE, SCOPES)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open("token.json", "w") as token:
token.write(creds.to_json())
else:
flow = InstalledAppFlow.from_client_secrets_file(SECRET_FILE, SCOPES)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open("token.json", "w") as token:
token.write(creds.to_json())
# Call the Gmail API
service = build("gmail", "v1", credentials=creds)
# Request a list of all the messages
result = service.users().messages().list(userId="me").execute()
messages = result.get("messages")
# If there are no messages, return an empty string
if not messages:
return ""
emails = []
for msg in messages[:n_emails]:
txt = service.users().messages().get(userId="me", id=msg["id"]).execute()
try:
payload = txt["payload"]
headers = payload["headers"]
for d in headers:
if d["name"] == "From":
from_ = d["value"]
if d["name"] == "Date":
date = d["value"]
if d["name"] == "Subject":
subject = d["value"]
else:
subject = "No subject"
data = None
parts = payload.get("parts")
if parts:
part = parts[0]
body = part.get("body")
if body:
data = body.get("data")
if data:
data = urlsafe_b64decode(data).decode()
email_details = {
"from": from_,
"date": date,
"subject": subject,
"body": clean_email_body(data) if data else "",
}
emails.append(email_details)
except Exception as e:
print(f"Error reading email {msg['id']}: {e}", "ERROR")
continue
return emails
def clean_email_body(body: str) -> str:
"""Remove HTML tags and non-sentence elements from email body text."""
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=" ")
# Remove any non-sentence elements (e.g., URLs, email addresses, etc.)
text = re.sub(r"\S*@\S*\s?", "", text) # Remove emails
text = re.sub(r"http\S+", "", text) # Remove URLs
text = re.sub(r"[^.!?a-zA-Z0-9\s]", "", text) # Remove non-sentence characters
text = " ".join(text.split()) # Remove extra whitespace
return text
DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
@tool
async def list_drive_files(
n_files: Annotated[int, "Number of files to search"] = 5,
) -> list[str]:
"""List files from a Google Drive account and return their details."""
creds = None
# The file token.json stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first time.
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json")
# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except RefreshError:
flow = InstalledAppFlow.from_client_secrets_file(
SECRET_FILE, DRIVE_SCOPES
)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open("token.json", "w") as token:
token.write(creds.to_json())
else:
flow = InstalledAppFlow.from_client_secrets_file(SECRET_FILE, DRIVE_SCOPES)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open("token.json", "w") as token:
token.write(creds.to_json())
# Call the Drive v3 API
service = build("drive", "v3", credentials=creds)
# Request a list of all the files
results = (
service.files()
.list(pageSize=n_files, fields="nextPageToken, files(id, name)")
.execute()
)
items = results.get("files", [])
if not items:
print("No files found.")
else:
print("Files:")
for item in items:
print("{0} ({1})".format(item["name"], item["id"]))
return items

View file

@ -0,0 +1,76 @@
from typing import Union
from arcade.sdk.tool import tool, get_secret
import pandas as pd
from pydantic import BaseModel, Field
class ProductFilter(BaseModel):
column: str = Field(..., description="The column to filter on")
class FilterRating(ProductFilter):
greater_than: int = Field(
..., description="The rating to filter greater than", gt=0, lt=5
)
class FilterPriceGreaterThan(ProductFilter):
price: int = Field(..., description="The price to filter greater than", gt=0)
class FilterPriceLessThan(ProductFilter):
price: int = Field(..., description="The price to filter less than", gt=0)
class ProductSearch(BaseModel):
column: str = Field("Product Name", description="The column to search in")
query: str = Field(..., description="The query to search for")
filter_operation: Union[
FilterRating, FilterPriceGreaterThan, FilterPriceLessThan
] = None
class ProductOutput(BaseModel):
product_name: str = Field(..., description="The name of the product")
price: int = Field(..., description="The price of the product")
stock_quantity: int = Field(..., description="The stock quantity of the product")
@tool
def read_products(
action: ProductSearch,
cols: list[str] = [
"Product Name",
"Price",
"Stock Quantity",
],
) -> list[ProductOutput]:
"""Used to search through products by name and filter by rating or price."""
file_path = get_secret(
"PRODUCTS_PATH",
"/Users/spartee/Dropbox/Arcade/platform/toolserver/examples/data/Sample_Products_Info.csv",
)
try:
df = pd.read_csv(file_path)
df = df[cols]
if action.filter_operation:
if isinstance(action.filter_operation, FilterRating):
df = df[
df[action.filter_operation.column]
> action.filter_operation.greater_than
]
elif isinstance(action.filter_operation, FilterPriceGreaterThan):
df = df[
df[action.filter_operation.column] > action.filter_operation.price
]
elif isinstance(action.filter_operation, FilterPriceLessThan):
df = df[
df[action.filter_operation.column] < action.filter_operation.price
]
except Exception as e:
# TODO what to do here?
print(e)
return df.to_json()

View file

@ -0,0 +1,38 @@
from typing import Annotated
from arcade.sdk.tool import tool
import pandas as pd
from sqlite3 import connect
@tool
async def read_sqlite(
file_path: Annotated[str, "Path to the SQLite database file"],
table_name: Annotated[str, "Name of the table to read from"],
cols: Annotated[str, "Columns to read from the table"] = "*",
) -> str:
"""Read data from a SQLite database table and save it as a DataFrame.
Columns to choose from are:
- *: All columns
- column_name: Single column
- column_name1, column_name2, ...: Multiple columns
"""
# Connect to the SQLite database
conn = connect(file_path)
cursor = conn.cursor()
# Read the data from the table
query = f"SELECT * FROM {table_name}"
cursor.execute(query)
rows = cursor.fetchall()
# Get the column names
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [col[1] for col in cursor.fetchall()]
# Create a DataFrame from the data
df = pd.DataFrame(rows, columns=columns)
return df.json()

View file

@ -1,119 +0,0 @@
import re
import email
import smtplib
import imaplib
import pandas as pd
import plotly.express as px
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.header import decode_header
from pydantic import BaseModel
from bs4 import BeautifulSoup
from toolserve.sdk import Param, tool, get_secret
from toolserve.sdk.client import log
@tool
async def send_email(
sender_email: Param(str, "Email address of the sender"),
recipient_email: Param(str, "Email address of the recipient"),
subject: Param(str, "Subject of the email"),
body: Param(str, "Body of the email"),
):
"""Send an email via gmail SMTP server"""
email_address = get_secret("gmail_email")
sender_password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
port = get_secret("gmail_smtp_port", 587)
message = MIMEMultipart()
message['From'] = sender_email
message['To'] = recipient_email
message['Subject'] = subject
message.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(server, port)
server.starttls()
server.login(sender_email, sender_password)
log(f"Logged in to SMTP server at {':'.join((server, port))}", "DEBUG")
server.send_message(message)
server.quit()
log(f"Email sent from {sender_email} to {recipient_email}", "INFO")
@tool
async def read_email(
n_emails: Param(int, "Number of emails to read") = 5,
) -> Param(str, "emails"):
"""Read emails from a Gmail account and extract plain text content, removing any HTML."""
email_address = get_secret("gmail_email")
password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
port = get_secret("gmail_smtp_port", 587)
# Connect to the Gmail IMAP server
mail = imaplib.IMAP4_SSL(server)
mail.login(email_address, password)
mail.select("inbox") # connect to inbox.
result, data = mail.search(None, "ALL")
email_ids = data[0].split()
email_ids.reverse() # Reverse to get the most recent emails first
emails = []
for email_id in email_ids[:n_emails]:
try:
result, data = mail.fetch(email_id, "(RFC822)")
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
email_details = {
"from": msg["From"],
"to": msg["To"],
"date": msg["Date"]
}
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode('utf-8')
email_details["body"] = clean_email_body(body)
else:
body = msg.get_payload(decode=True).decode('utf-8')
email_details["body"] = clean_email_body(body)
except Exception as e:
log(f"Error reading email {email_id}: {e}", "ERROR")
continue
emails.append(email_details)
mail.close()
mail.logout()
data = "\n".join([f"{email['from']} - {email['date']}\n{email['body']}\n" for email in emails])
return data
def clean_email_body(body: str) -> str:
"""Remove HTML tags and non-sentence elements from email body text."""
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=' ')
# Remove any non-sentence elements (e.g., URLs, email addresses, etc.)
text = re.sub(r'\S*@\S*\s?', '', text) # Remove emails
text = re.sub(r'http\S+', '', text) # Remove URLs
text = re.sub(r'[^.!?a-zA-Z0-9\s]', '', text) # Remove non-sentence characters
text = ' '.join(text.split()) # Remove extra whitespace
return text

View file

@ -1,119 +0,0 @@
import re
import email
import smtplib
import imaplib
import pandas as pd
import plotly.express as px
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.header import decode_header
from pydantic import BaseModel
from bs4 import BeautifulSoup
from toolserve.sdk import Param, tool, get_secret
from toolserve.sdk.client import log
@tool
async def send_email(
sender_email: Param(str, "Email address of the sender"),
recipient_email: Param(str, "Email address of the recipient"),
subject: Param(str, "Subject of the email"),
body: Param(str, "Body of the email"),
):
"""Send an email via gmail SMTP server"""
email_address = get_secret("gmail_email")
sender_password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
port = get_secret("gmail_smtp_port", 587)
message = MIMEMultipart()
message['From'] = sender_email
message['To'] = recipient_email
message['Subject'] = subject
message.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(server, port)
server.starttls()
server.login(sender_email, sender_password)
log(f"Logged in to SMTP server at {':'.join((server, port))}", "DEBUG")
server.send_message(message)
server.quit()
log(f"Email sent from {sender_email} to {recipient_email}", "INFO")
@tool
async def read_email(
n_emails: Param(int, "Number of emails to read") = 5,
) -> Param(str, "emails"):
"""Read emails from a Gmail account and extract plain text content, removing any HTML."""
email_address = get_secret("gmail_email")
password = get_secret("gmail_password")
server = get_secret("gmail_stmp_server", "smtp.gmail.com")
port = get_secret("gmail_smtp_port", 587)
# Connect to the Gmail IMAP server
mail = imaplib.IMAP4_SSL(server)
mail.login(email_address, password)
mail.select("inbox") # connect to inbox.
result, data = mail.search(None, "ALL")
email_ids = data[0].split()
email_ids.reverse() # Reverse to get the most recent emails first
emails = []
for email_id in email_ids[:n_emails]:
try:
result, data = mail.fetch(email_id, "(RFC822)")
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
email_details = {
"from": msg["From"],
"to": msg["To"],
"date": msg["Date"]
}
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode('utf-8')
email_details["body"] = clean_email_body(body)
else:
body = msg.get_payload(decode=True).decode('utf-8')
email_details["body"] = clean_email_body(body)
except Exception as e:
log(f"Error reading email {email_id}: {e}", "ERROR")
continue
emails.append(email_details)
mail.close()
mail.logout()
data = "\n".join([f"{email['from']} - {email['date']}\n{email['body']}\n" for email in emails])
return data
def clean_email_body(body: str) -> str:
"""Remove HTML tags and non-sentence elements from email body text."""
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=' ')
# Remove any non-sentence elements (e.g., URLs, email addresses, etc.)
text = re.sub(r'\S*@\S*\s?', '', text) # Remove emails
text = re.sub(r'http\S+', '', text) # Remove URLs
text = re.sub(r'[^.!?a-zA-Z0-9\s]', '', text) # Remove non-sentence characters
text = ' '.join(text.split()) # Remove extra whitespace
return text

View file

@ -1,83 +0,0 @@
from typing import (
IO,
Union,
List,
Dict,
Optional,
Any,
Type,
)
import io
import requests
from os import PathLike
import base64
from toolserve.sdk import Param, tool, get_secret
from typing import List
import pandas as pd
import openai
@tool
async def summarize(
text: Param(str, "Text to summarize"),
system_prompt: Param(str, "System prompt to use") = "Summarize the following text",
max_tokens: Param(int, "Maximum number of tokens to generate") = 1000,
) -> Param(str, "Summarized text"):
"""Summarize a piece of text using OpenAI Language models."""
api_key = get_secret("openai_api_key", None)
model = get_secret("openai_model_summarize", "gpt-4-turbo")
# Call the OpenAI model with the tools and messages
if isinstance(text, list):
text = "\n".join(text)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
]
client = openai.AsyncClient(api_key=api_key)
completion = await openai.chat.completions.create(
model=model,
messages=messages,
)
summary = completion.choices[0].message.content
return summary
@tool
async def respond(
context: Param(str, "context of the conversation"),
system_prompt: Param(str, "System prompt to use") = "Given the following context, respond with a message in a friendly and helpful manner. Be informal and use a casual tone.",
max_tokens: Param(int, "Maximum number of tokens to generate") = 1000,
) -> Param(str, "The response to the context provided"):
"""Respond to a user given context using OpenAI Language models"""
api_key = get_secret("openai_api_key", None)
model = get_secret("openai_model_summarize", "gpt-4-turbo")
# Call the OpenAI model with the tools and messages
if isinstance(context, list):
context = "\n".join(context)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": context},
]
client = openai.AsyncClient(api_key=api_key)
completion = await openai.chat.completions.create(
model=model,
messages=messages,
)
response = completion.choices[0].message.content
return response

View file

@ -1,43 +0,0 @@
from toolserve.sdk import Param, tool, get_secret
from toolserve.sdk.dataframe import save_df
import pandas as pd
from sqlite3 import connect
@tool
async def read_sqlite(
file_path: Param(str, "Path to the SQLite database file"),
table_name: Param(str, "Name of the table to read from"),
output_name: Param(str, "Name of the output data to save"),
) -> Param(str, "Output data name"):
"""Read data from a SQLite database table and save it as a DataFrame.
Args:
file_path (str): Path to the SQLite database file.
table_name (str): Name of the table to read from.
output_name (str): Name of the output data to save.
Returns:
str: Name of the output data.
"""
# Connect to the SQLite database
conn = connect(file_path)
cursor = conn.cursor()
# Read the data from the table
query = f"SELECT * FROM {table_name}"
cursor.execute(query)
rows = cursor.fetchall()
# Get the column names
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [col[1] for col in cursor.fetchall()]
# Create a DataFrame from the data
df = pd.DataFrame(rows, columns=columns)
# Save the DataFrame
await save_df(df, output_name)
return output_name

View file

@ -1,35 +0,0 @@
import asyncio
from serpapi import GoogleSearch
from typing import List, Dict
import json
from toolserve.sdk import Param, tool, get_secret
async def google_search(
query: Param(str, "search query for google"),
num_results: Param(int, "number of results")
) -> Param(str, "Json blob of Search results"):
"""
Perform a Google search using SerpAPI and retrieve a specified number of results.
Args:
query (str): The search query.
num_results (int): The number of search results to retrieve.
Returns:
List[Dict[str, str]]: A list of dictionaries containing the link and text of each result.
"""
serpapi_key = get_secret("serp_api_key", None)
params = {
"engine": "google",
"q": query,
"num": num_results,
"api_key": serpapi_key
}
search = GoogleSearch(params)
results = search.get_dict()
json_results = json.dumps(results.get("organic_results"), indent=2)
return json_results

View file

View file

@ -0,0 +1,198 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"primitives": {
// All supported primitive data types
"type": "string",
"enum": [
"string",
"integer",
"float",
"boolean",
"json"
]
},
"value_schema": {
// Represents a value schema (e.g. function input parameter)
"type": "object",
"properties": {
"type": {
"$ref": "#/$defs/primitives"
}
},
"required": [
"type"
],
"additionalProperties": false,
"if": {
"properties": {
"type": {
"const": "string"
}
}
},
"then": {
// String values can optionally be constrained to a known list
"properties": {
"enum": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"type": "object",
"properties": {
"$schema": {
// Explicitly allow JSON-Schema to be referenced (due to additionalProperties: false)
"type": "string",
"format": "uri"
},
"name": {
"type": "string",
"description": "The tool name"
},
"description": {
"type": "string",
"description": "A human-readable description of the tool and when to use it"
},
"version": {
"type": "string",
"pattern": "^[a-f0-9]{7,40}$", // SHA version pattern (7-40 hexadecimal characters)
"description": "An identifier for this version of the tool"
},
"input": {
"type": "object",
"properties": {
"parameters": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"name": {
"description": "The human-readable name of this parameter.",
"type": "string"
},
"required": {
"description": "Whether this parameter is required (true) or optional (false).",
"type": "boolean"
},
"description": {
"description": "A descriptive, human-readable explanation of the parameter.",
"type": "string"
},
"schema": {
"$ref": "#/$defs/value_schema"
},
"inferrable": {
"type": "boolean",
"description": "Whether a value for this parameter can be inferred by a model. Defaults to `true`.",
"default": true
}
},
"required": [
"name",
"required",
"schema"
],
"additionalProperties": false
}
}
},
"required": [
"parameters"
],
"additionalProperties": false
},
"output": {
"type": "object",
"properties": {
"available_modes": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"value",
"error",
"null",
"artifact",
"requires_authorization"
]
}
},
"value": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"schema": {
"$ref": "#/$defs/value_schema"
}
},
"required": [
"schema"
],
"additionalProperties": false
}
},
"required": [
"available_modes"
],
"additionalProperties": false
},
"requirements": {
"type": "object",
"properties": {
"authorization": {
"oneOf": [
{
"type": "string",
"enum": [
"none",
"token"
]
},
{
"type": "object",
"properties": {
"oauth2": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri"
},
"scope": {
"type": "string"
}
},
"required": [
"url"
],
"additionalProperties": false
}
},
"required": [
"oauth2"
],
"additionalProperties": false
}
]
}
}
}
},
"required": [
"name",
"version",
"input",
"output"
],
"additionalProperties": false
}

View file

@ -0,0 +1,80 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
// Explicitly allow JSON-Schema to be referenced (needed due to additionalProperties: false)
"type": "string",
"format": "uri"
},
"run_id": {
"type": "string",
"description": "ID of the overall run"
},
"invocation_id": {
"type": "string",
"description": "ID of this specific tool call"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"tool": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the tool to invoke"
},
"version": {
"type": "string",
"description": "Version of the named tool to invoke"
}
},
"required": ["name", "version"],
"additionalProperties": false
},
"input": {
"type": "object",
"additionalProperties": true
},
"context": {
"type": "object",
"properties": {
"authorization": {
"type": "object",
"properties": {
"token": {
"type": "string"
},
"additionalProperties": false
}
},
"user": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "A unique ID that identifies the user"
},
"name": {
"type": "string",
"description": "The name of the user"
}
},
"required": ["id"],
"additionalProperties": false
}
}
}
},
"required": [
"run_id",
"invocation_id",
"created_at",
"tool",
"input",
"context"
],
"additionalProperties": false
}

View file

@ -0,0 +1,135 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
// Explicitly allow JSON-Schema to be referenced (needed due to additionalProperties: false)
"type": "string",
"format": "uri"
},
"invocation_id": {
"type": "string",
"description": "ID of this specific tool call"
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"success": {
"type": "boolean",
"description": "Whether the tool call was successful"
},
"output": {
// Can be null/omitted, in the case of a null-returning (void) function
"type": "object",
"oneOf": [
{
"properties": {
"value": {
"description": "The value returned from the function",
"oneOf": [
{ "type": "object", "additionalProperties": true }, // aka JSON
{ "type": "number" },
{ "type": "string" },
{ "type": "boolean" }
]
}
},
"required": ["value"],
"additionalProperties": false
},
{
"properties": {
"error": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "An error message that can be shown to the user or the AI model"
},
"developer_message": {
"type": "string",
"description": "An internal message that will be logged but will not be shown to the user or the AI model"
}
},
"required": ["message"],
"additionalProperties": false
}
},
"required": ["error"],
"additionalProperties": false
},
{
"properties": {
"requires_authorization": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "A message that can be shown to the user or AI model that explains the authorization requirement"
},
"oauth2": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri"
},
"scope": {
"type": "string"
}
},
"required": ["url"],
"additionalProperties": false
}
},
"required": ["message"],
"additionalProperties": false
}
},
"required": ["requires_authorization"],
"additionalProperties": false
},
{
"properties": {
"artifact": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "The location of the stored artifact"
},
"content_type": {
"type": "string",
"description": "The MIME Media Type of the data inside the artifact (e.g. text/csv or application/json)"
},
"size": {
"type": "integer",
"description": "The size of the artifact, in bytes"
},
"meta": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A descriptive, human-readable explanation of the data inside the artifact"
}
},
"required": ["description"],
"additionalProperties": false
}
},
"required": ["url", "content_type", "size", "meta"],
"additionalProperties": false
}
},
"required": ["artifact"],
"additionalProperties": false
}
]
}
},
"required": ["invocation_id", "finished_at", "success"],
"additionalProperties": false
}

663
toolserve/poetry.lock generated
View file

@ -1,663 +0,0 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.6.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
{file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
]
[[package]]
name = "anyio"
version = "4.3.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
{file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
sniffio = ">=1.1"
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "async-timeout"
version = "4.0.3"
description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.7"
files = [
{file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "dnspython"
version = "2.6.1"
description = "DNS toolkit"
optional = false
python-versions = ">=3.8"
files = [
{file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"},
{file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"},
]
[package.extras]
dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
dnssec = ["cryptography (>=41)"]
doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
doq = ["aioquic (>=0.9.25)"]
idna = ["idna (>=3.6)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "email-validator"
version = "2.1.1"
description = "A robust email address syntax and deliverability validation library."
optional = false
python-versions = ">=3.8"
files = [
{file = "email_validator-2.1.1-py3-none-any.whl", hash = "sha256:97d882d174e2a65732fb43bfce81a3a834cbc1bde8bf419e30ef5ea976370a05"},
{file = "email_validator-2.1.1.tar.gz", hash = "sha256:200a70680ba08904be6d1eef729205cc0d687634399a5924d842533efb824b84"},
]
[package.dependencies]
dnspython = ">=2.0.0"
idna = ">=2.0.0"
[[package]]
name = "exceptiongroup"
version = "1.2.0"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
{file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.110.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.110.1-py3-none-any.whl", hash = "sha256:5df913203c482f820d31f48e635e022f8cbfe7350e4830ef05a3163925b1addc"},
{file = "fastapi-0.110.1.tar.gz", hash = "sha256:6feac43ec359dfe4f45b2c18ec8c94edb8dc2dfc461d417d9e626590c071baad"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.37.2,<0.38.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "loguru"
version = "0.7.2"
description = "Python logging made (stupidly) simple"
optional = false
python-versions = ">=3.5"
files = [
{file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"},
{file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"},
]
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "msgpack"
version = "1.0.8"
description = "MessagePack serializer"
optional = false
python-versions = ">=3.8"
files = [
{file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"},
{file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"},
{file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"},
{file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"},
{file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"},
{file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"},
{file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"},
{file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"},
{file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"},
{file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"},
{file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"},
{file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"},
{file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"},
{file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"},
{file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"},
{file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"},
{file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"},
{file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"},
{file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"},
{file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"},
{file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"},
{file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"},
{file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"},
{file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"},
{file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"},
{file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"},
{file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"},
{file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"},
{file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"},
{file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"},
{file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"},
{file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"},
{file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"},
{file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"},
{file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"},
{file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"},
{file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"},
{file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"},
{file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"},
{file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"},
{file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"},
{file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"},
{file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"},
{file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"},
{file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"},
{file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"},
{file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"},
{file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"},
{file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"},
{file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"},
{file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"},
{file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"},
{file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"},
{file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"},
{file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"},
{file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
]
[[package]]
name = "msgspec"
version = "0.18.6"
description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML."
optional = false
python-versions = ">=3.8"
files = [
{file = "msgspec-0.18.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:77f30b0234eceeff0f651119b9821ce80949b4d667ad38f3bfed0d0ebf9d6d8f"},
{file = "msgspec-0.18.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a76b60e501b3932782a9da039bd1cd552b7d8dec54ce38332b87136c64852dd"},
{file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06acbd6edf175bee0e36295d6b0302c6de3aaf61246b46f9549ca0041a9d7177"},
{file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40a4df891676d9c28a67c2cc39947c33de516335680d1316a89e8f7218660410"},
{file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6896f4cd5b4b7d688018805520769a8446df911eb93b421c6c68155cdf9dd5a"},
{file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3ac4dd63fd5309dd42a8c8c36c1563531069152be7819518be0a9d03be9788e4"},
{file = "msgspec-0.18.6-cp310-cp310-win_amd64.whl", hash = "sha256:fda4c357145cf0b760000c4ad597e19b53adf01382b711f281720a10a0fe72b7"},
{file = "msgspec-0.18.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e77e56ffe2701e83a96e35770c6adb655ffc074d530018d1b584a8e635b4f36f"},
{file = "msgspec-0.18.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5351afb216b743df4b6b147691523697ff3a2fc5f3d54f771e91219f5c23aaa"},
{file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3232fabacef86fe8323cecbe99abbc5c02f7698e3f5f2e248e3480b66a3596b"},
{file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3b524df6ea9998bbc99ea6ee4d0276a101bcc1aa8d14887bb823914d9f60d07"},
{file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:37f67c1d81272131895bb20d388dd8d341390acd0e192a55ab02d4d6468b434c"},
{file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d0feb7a03d971c1c0353de1a8fe30bb6579c2dc5ccf29b5f7c7ab01172010492"},
{file = "msgspec-0.18.6-cp311-cp311-win_amd64.whl", hash = "sha256:41cf758d3f40428c235c0f27bc6f322d43063bc32da7b9643e3f805c21ed57b4"},
{file = "msgspec-0.18.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d86f5071fe33e19500920333c11e2267a31942d18fed4d9de5bc2fbab267d28c"},
{file = "msgspec-0.18.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce13981bfa06f5eb126a3a5a38b1976bddb49a36e4f46d8e6edecf33ccf11df1"},
{file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e97dec6932ad5e3ee1e3c14718638ba333befc45e0661caa57033cd4cc489466"},
{file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad237100393f637b297926cae1868b0d500f764ccd2f0623a380e2bcfb2809ca"},
{file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db1d8626748fa5d29bbd15da58b2d73af25b10aa98abf85aab8028119188ed57"},
{file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d70cb3d00d9f4de14d0b31d38dfe60c88ae16f3182988246a9861259c6722af6"},
{file = "msgspec-0.18.6-cp312-cp312-win_amd64.whl", hash = "sha256:1003c20bfe9c6114cc16ea5db9c5466e49fae3d7f5e2e59cb70693190ad34da0"},
{file = "msgspec-0.18.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7d9faed6dfff654a9ca7d9b0068456517f63dbc3aa704a527f493b9200b210a"},
{file = "msgspec-0.18.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9da21f804c1a1471f26d32b5d9bc0480450ea77fbb8d9db431463ab64aaac2cf"},
{file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46eb2f6b22b0e61c137e65795b97dc515860bf6ec761d8fb65fdb62aa094ba61"},
{file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8355b55c80ac3e04885d72db515817d9fbb0def3bab936bba104e99ad22cf46"},
{file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9080eb12b8f59e177bd1eb5c21e24dd2ba2fa88a1dbc9a98e05ad7779b54c681"},
{file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc001cf39becf8d2dcd3f413a4797c55009b3a3cdbf78a8bf5a7ca8fdb76032c"},
{file = "msgspec-0.18.6-cp38-cp38-win_amd64.whl", hash = "sha256:fac5834e14ac4da1fca373753e0c4ec9c8069d1fe5f534fa5208453b6065d5be"},
{file = "msgspec-0.18.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:974d3520fcc6b824a6dedbdf2b411df31a73e6e7414301abac62e6b8d03791b4"},
{file = "msgspec-0.18.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fd62e5818731a66aaa8e9b0a1e5543dc979a46278da01e85c3c9a1a4f047ef7e"},
{file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7481355a1adcf1f08dedd9311193c674ffb8bf7b79314b4314752b89a2cf7f1c"},
{file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6aa85198f8f154cf35d6f979998f6dadd3dc46a8a8c714632f53f5d65b315c07"},
{file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e24539b25c85c8f0597274f11061c102ad6b0c56af053373ba4629772b407be"},
{file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c61ee4d3be03ea9cd089f7c8e36158786cd06e51fbb62529276452bbf2d52ece"},
{file = "msgspec-0.18.6-cp39-cp39-win_amd64.whl", hash = "sha256:b5c390b0b0b7da879520d4ae26044d74aeee5144f83087eb7842ba59c02bc090"},
{file = "msgspec-0.18.6.tar.gz", hash = "sha256:a59fc3b4fcdb972d09138cb516dbde600c99d07c38fd9372a6ef500d2d031b4e"},
]
[package.extras]
dev = ["attrs", "coverage", "furo", "gcovr", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli-w"]
doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"]
test = ["attrs", "msgpack", "mypy", "pyright", "pytest", "pyyaml", "tomli", "tomli-w"]
toml = ["tomli", "tomli-w"]
yaml = ["pyyaml"]
[[package]]
name = "pydantic"
version = "2.7.0"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"},
{file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
pydantic-core = "2.18.1"
typing-extensions = ">=4.6.1"
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.18.1"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"},
{file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"},
{file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"},
{file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"},
{file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"},
{file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"},
{file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"},
{file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"},
{file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"},
{file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"},
{file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"},
{file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"},
{file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"},
{file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"},
{file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"},
{file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"},
{file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"},
{file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"},
{file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"},
{file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"},
{file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"},
{file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"},
{file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"},
{file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"},
{file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"},
{file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"},
{file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"},
{file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"},
{file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"},
{file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"},
{file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"},
{file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"},
{file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"},
{file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"},
{file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"},
{file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"},
{file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"},
{file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"},
{file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"},
{file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-settings"
version = "2.2.1"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_settings-2.2.1-py3-none-any.whl", hash = "sha256:0235391d26db4d2190cb9b31051c4b46882d28a51533f97440867f012d4da091"},
{file = "pydantic_settings-2.2.1.tar.gz", hash = "sha256:00b9f6a5e95553590434c0fa01ead0b216c3e10bc54ae02e37f359948643c5ed"},
]
[package.dependencies]
pydantic = ">=2.3.0"
python-dotenv = ">=0.21.0"
[package.extras]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pygments"
version = "2.17.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.7"
files = [
{file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
{file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
]
[package.extras]
plugins = ["importlib-metadata"]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "redis"
version = "5.0.3"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.7"
files = [
{file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"},
{file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"},
]
[package.dependencies]
async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
[package.extras]
hiredis = ["hiredis (>=1.0.0)"]
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
[[package]]
name = "rich"
version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "starlette"
version = "0.37.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
]
[package.dependencies]
anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "stdlib-list"
version = "0.10.0"
description = "A list of Python Standard Libraries (2.7 through 3.12)."
optional = false
python-versions = ">=3.7"
files = [
{file = "stdlib_list-0.10.0-py3-none-any.whl", hash = "sha256:b3a911bc441d03e0332dd1a9e7d0870ba3bb0a542a74d7524f54fb431256e214"},
{file = "stdlib_list-0.10.0.tar.gz", hash = "sha256:6519c50d645513ed287657bfe856d527f277331540691ddeaf77b25459964a14"},
]
[package.extras]
dev = ["build", "stdlib-list[doc,lint,test]"]
doc = ["furo", "sphinx"]
lint = ["black", "mypy", "ruff"]
support = ["sphobjinv"]
test = ["coverage[toml]", "pytest", "pytest-cov"]
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
[[package]]
name = "tomlkit"
version = "0.12.4"
description = "Style preserving TOML library"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomlkit-0.12.4-py3-none-any.whl", hash = "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b"},
{file = "tomlkit-0.12.4.tar.gz", hash = "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"},
]
[[package]]
name = "typer"
version = "0.9.4"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.6"
files = [
{file = "typer-0.9.4-py3-none-any.whl", hash = "sha256:aa6c4a4e2329d868b80ecbaf16f807f2b54e192209d7ac9dd42691d63f7a54eb"},
{file = "typer-0.9.4.tar.gz", hash = "sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"},
]
[package.dependencies]
click = ">=7.1.1,<9.0.0"
typing-extensions = ">=3.7.4.3"
[package.extras]
all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"]
test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.971)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
[[package]]
name = "typing-extensions"
version = "4.11.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
]
[[package]]
name = "uvicorn"
version = "0.28.1"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.28.1-py3-none-any.whl", hash = "sha256:5162f6d652f545be91b1feeaee8180774af143965ca9dc8a47ff1dc6bafa4ad5"},
{file = "uvicorn-0.28.1.tar.gz", hash = "sha256:08103e79d546b6cf20f67c7e5e434d2cf500a6e29b28773e407250c54fc4fa3c"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
description = "A small Python utility to set file creation time on Windows"
optional = false
python-versions = ">=3.5"
files = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
]
[package.extras]
dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "f034a070ec06e119c2f182dcfdcfeeb0c7c9bc4a5f9116e5c12f7f650f0678f0"

View file

@ -1,29 +0,0 @@
[tool.poetry]
name = "toolserve"
version = "0.1.0"
description = ""
authors = ["Sam Partee <Partees21@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.10"
pydantic = {extras = ["email"], version = "^2.7.0"}
fastapi = "^0.110.0"
redis = "^5.0.3"
uvicorn = "^0.28.0"
loguru = "^0.7.2"
pydantic-settings = "^2.2.1"
msgspec = "^0.18.6"
msgpack = "^1.0.8"
typer = "^0.9.0"
rich = "^13.7.1"
toml = "^0.10.2"
tomlkit = "^0.12.4"
stdlib-list = "^0.10.0"
[tool.poetry.scripts]
tool = "toolserve.cli.main:cli"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -1,6 +0,0 @@
from .tool import (
Param,
tool,
get_secret
)

View file

@ -1,201 +0,0 @@
import os
import json
import httpx
from typing import Optional, Dict, Any, List
from contextlib import asynccontextmanager
from enum import Enum
class HttpClient:
"""
A simple HTTP client class to handle requests to a specified base URL with optional authentication.
"""
def __init__(self, base_url: str, auth_token: Optional[str] = None):
"""
Initializes the HttpClient with a base URL and an optional authentication token.
Args:
base_url (str): The base URL for the HTTP requests.
auth_token (Optional[str]): Optional bearer token for authorization.
"""
self.base_url = base_url
self.auth_token = auth_token
self.client = httpx.AsyncClient()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
async def post(self, endpoint: str, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> Any:
"""
Sends a POST request to the specified endpoint with the provided data and files.
Args:
endpoint (str): The endpoint to send the POST request to.
data (Dict[str, Any]): The data to send in the POST request.
files (Optional[Dict[str, Any]]): Optional files to send with the request.
Returns:
Any: The JSON response from the server.
"""
headers = {"Authorization": f"Bearer {self.auth_token}"} if self.auth_token else {}
try:
if files:
response = await self.client.post(f"{self.base_url}{endpoint}", files=files, headers=headers)
else:
response = await self.client.post(f"{self.base_url}{endpoint}", json=data, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"HTTP error occurred: {e.response.status_code}")
except httpx.RequestError as e:
raise RuntimeError(f"Request error occurred: {e}")
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""
Sends a GET request to the specified endpoint with optional parameters.
Args:
endpoint (str): The endpoint to send the GET request to.
params (Optional[Dict[str, Any]]): Optional parameters to include in the request.
Returns:
Any: The JSON response from the server.
"""
headers = {"Authorization": f"Bearer {self.auth_token}"} if self.auth_token else {}
try:
response = await self.client.get(f"{self.base_url}{endpoint}", params=params, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"HTTP error occurred: {e.response.status_code}")
except httpx.RequestError as e:
raise RuntimeError(f"Request error occurred: {e}")
@asynccontextmanager
async def managed_http_client(base_url: str, auth_token: Optional[str] = None):
"""
Context manager to handle the lifecycle of HttpClient instances.
Args:
base_url (str): The base URL for the HTTP requests.
auth_token (Optional[str]): Optional bearer token for authorization.
"""
client = HttpClient(base_url, auth_token)
try:
yield client
finally:
await client.__aexit__(None, None, None)
def get_base_url() -> str:
return os.getenv('TOOLSERVE_URL', 'http://localhost:8000')
# ----- SDK Functions -----
class LogLevel(Enum):
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
async def log(message: str = "", level: LogLevel = LogLevel.INFO, auth_token: Optional[str] = None, endpoint: str = "/api/v1/log"):
"""
Asynchronously sends a log message to a specified endpoint.
This function constructs a log entry with a message and a log level, then sends it to the server using the provided endpoint. It uses an HTTP POST request within a managed HTTP client context.
Args:
message (str): The log message to send. Defaults to an empty string.
level (LogLevel): The severity level of the log message. Defaults to LogLevel.INFO.
auth_token (Optional[str]): An optional authorization token for the request.
endpoint (str): The API endpoint to which the log message is sent. Defaults to "/api/v1/log".
Returns:
Any: The response from the server as a result of the log message post request.
"""
base_url = get_base_url()
if isinstance(level, str):
level = LogLevel(level)
async with managed_http_client(base_url, auth_token) as client:
log_data = {"msg": message, "level": level.value}
return await client.post(endpoint, data=log_data)
async def list_data(auth_token: Optional[str] = None, endpoint: str = "/api/v1/data") -> List[Dict[str, Any]]:
"""
Retrieve a list of data objects from a specified endpoint.
Args:
auth_token (Optional[str]): Optional authorization token.
endpoint (str): API endpoint to send the request to. Defaults to "/api/v1/data".
Returns:
Dict[str, Any]: The deserialized JSON data retrieved from the server.
"""
base_url = get_base_url()
async with managed_http_client(base_url, auth_token) as client:
response = await client.get(endpoint)
return response["data"]
async def get_data(data_id: int, auth_token: Optional[str] = None, endpoint: str = "/api/v1/data/object") -> Any:
"""
Retrieve data object by its primary key from a specified endpoint.
Args:
data_id (int): The primary key of the data object to retrieve.
auth_token (Optional[str]): Optional authorization token.
endpoint (str): API endpoint to send the request to. Defaults to "/api/v1/data/object".
Returns:
Any: The deserialized JSON data retrieved from the server.
"""
base_url = get_base_url()
endpoint = f"{endpoint}/{str(data_id)}" # Append the data ID to the endpoint URL
async with managed_http_client(base_url, auth_token) as client:
response = await client.get(endpoint)
json_blob = response["data"].get('json_blob', '{}')
return json.loads(json_blob)
async def send_data(name: str, data: Dict[str, Any], auth_token: Optional[str] = None, endpoint: str = "/api/v1/data") -> Dict[str, Any]:
"""
Send data to a specified endpoint, serializing the data into JSON under the key 'json_blob'.
Args:
data (Dict[str, Any]): Data to be serialized and sent.
auth_token (Optional[str]): Optional authorization token.
endpoint (str): API endpoint to send the data to.
Returns:
Dict[str, Any]: The response from the server after sending the data.
"""
base_url = get_base_url()
json_blob = json.dumps(data)
payload = {'file_name': name, 'json_blob': json_blob}
async with managed_http_client(base_url, auth_token) as client:
response = await client.post(endpoint, data=payload)
if response["code"] != 200:
raise RuntimeError(f"Failed to send data: {response['msg']}")
else:
return {
"id": response["data"]["id"],
"file_path": response["data"]["file_path"]
}
async def save_artifact_from_file(file_path: str = "", auth_token: Optional[str] = None, endpoint: str = "/api/v1/artifact"):
base_url = get_base_url()
async with managed_http_client(base_url, auth_token) as client:
with open(file_path, 'rb') as file:
files = {'file': file}
return await client.post(endpoint, data={}, files=files)

View file

@ -1,39 +0,0 @@
try:
import pandas as pd
except ImportError:
raise ImportError("Pandas is required for this SDK component. Please install it using `pip install pandas`.")
from typing import Any, Dict
from toolserve.sdk.client import get_data, send_data
async def save_df(df: pd.DataFrame, name: str) -> Dict[str, Any]:
"""
Asynchronously saves a DataFrame to the server by converting it to a dictionary and using the SDK's send_data function.
Args:
df (pd.DataFrame): The DataFrame to save.
name (str): The name under which the DataFrame should be saved.
Returns:
Dict[str, Any]: The server's response after saving the DataFrame.
"""
data_dict = df.to_dict(orient='records')
response = await send_data(name=name, data={"data": data_dict})
return response
async def get_df(data_id: int) -> pd.DataFrame:
"""
Asynchronously retrieves a DataFrame from the server using its data ID.
Args:
data_id (int): The unique identifier for the DataFrame to retrieve.
Returns:
pd.DataFrame: The DataFrame retrieved from the server.
"""
response = await get_data(data_id=data_id)
df = pd.DataFrame(response['data'])
return df

View file

@ -1,35 +0,0 @@
from typing import Annotated, TypeVar, _AnnotatedAlias, Type, Callable, Any, Optional
import functools
import os
import asyncio
T = TypeVar('T')
class Description:
def __init__(self, description: str):
self.description = description
def __str__(self):
return self.description
def Param(type_: Type[T], description: str) -> Annotated[T, Description]:
return Annotated[type_, Description(description)]
def tool(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
loop = asyncio.get_running_loop()
partial_func = functools.partial(func, *args, **kwargs)
return await loop.run_in_executor(None, partial_func)
return wrapper
def get_secret(name: str, default: Optional[Any] = None) -> str:
secret = os.getenv(name)
if secret is None:
if default is not None:
return default
raise ValueError(f"Secret {name} is not set.")
return secret

View file

@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

View file

@ -1,68 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from decimal import Decimal
from typing import Any, Sequence, TypeVar
import msgspec
from asgiref.sync import sync_to_async
from sqlalchemy import Row, RowMapping
from starlette.responses import JSONResponse
RowData = Row | RowMapping | Any
R = TypeVar('R', bound=RowData)
@sync_to_async
def select_columns_serialize(row: R) -> dict:
"""
Serialize SQLAlchemy select table columns, does not contain relational columns
:param row:
:return:
"""
obj_dict = {}
for column in row.__table__.columns.keys():
val = getattr(row, column)
if isinstance(val, Decimal):
if val % 1 == 0:
val = int(val)
val = float(val)
obj_dict[column] = val
return obj_dict
async def select_list_serialize(row: Sequence[R]) -> list:
"""
Serialize SQLAlchemy select list
:param row:
:return:
"""
ret_list = [await select_columns_serialize(_) for _ in row]
return ret_list
@sync_to_async
def select_as_dict(row: R) -> dict:
"""
Converting SQLAlchemy select to dict, which can contain relational data,
depends on the properties of the select object itself
:param row:
:return:
"""
obj_dict = row.__dict__
if '_sa_instance_state' in obj_dict:
del obj_dict['_sa_instance_state']
return obj_dict
class MsgSpecJSONResponse(JSONResponse):
"""
JSON response using the high-performance msgspec library to serialize data to JSON.
"""
def render(self, content: Any) -> bytes:
return msgspec.json.encode(content)

View file

@ -1,220 +0,0 @@
import os
import sys
import inspect
from datetime import datetime
from typing import List, Optional, Type, Dict, Annotated, Any, Callable, Tuple
from pathlib import Path
import asyncio
from fastapi import APIRouter
from pydantic import BaseModel, ValidationError, Field, create_model
from importlib import import_module
from toolserve.server.core.conf import settings
from toolserve.server.common.response_code import CustomResponseCode
from toolserve.server.common.response import ResponseModel, response_base
from toolserve.apm.base import ToolPack
from toolserve.sdk import Param
from toolserve.utils import snake_to_camel
class ToolMeta(BaseModel):
module: str
path: str
date_added: datetime = Field(default_factory=datetime.now)
date_updated: datetime = Field(default_factory=datetime.now)
class ToolSchema(BaseModel):
name: str
description: str
version: str
tool: Callable
input_model: Type[BaseModel]
output_model: Type[BaseModel]
meta: ToolMeta
class ToolCatalog:
def __init__(self, tools_dir: str = settings.TOOLS_DIR):
self.tools = self.read_tools(tools_dir)
#self.tools.update(self.__get_builitin_tools())
@staticmethod
def read_tools(directory: str) -> List[ToolSchema]:
toolpack = ToolPack.from_lock_file(directory)
sys.path.append(str(Path(directory).resolve() / 'tools'))
tools = {}
for name, tool_spec in toolpack.tools.items():
print(name, tool_spec)
module_name, versioned_tool = tool_spec.split('.', 1)
func_name, version = versioned_tool.split('@')
module = import_module(module_name)
tool = getattr(module, func_name)
tool_meta = ToolMeta(
module=module_name,
path=module.__file__
)
input_model, output_model = create_func_models(tool)
response_model = create_response_model(name, output_model)
tool_schema = ToolSchema(
name=name,
description=tool.__doc__,
version=version,
tool=tool,
input_model=input_model,
output_model=response_model,
meta=tool_meta
)
tools[name] = tool_schema
return tools
def __get_builitin_tools(self) -> Dict[str, ToolSchema]:
tools = {}
sys.path.append(str(settings.BUILTIN_TOOLS_DIR))
for tool_spec in settings.BUILTIN_TOOLS:
print(tool_spec)
module_name, versioned_tool = tool_spec.split('.', 1)
func_name, version = versioned_tool.split('@')
module = import_module(module_name)
tool = getattr(module, func_name)
input_model, output_model = create_func_models(tool)
response_model = create_response_model(func_name, output_model)
tool_schema = ToolSchema(
name=func_name,
description=tool.__doc__,
version='builtin',
tool=tool,
input_model=input_model,
output_model=response_model,
meta=ToolMeta(module=module_name, path=module.__file__)
)
tools[func_name] = tool_schema
return tools
def __getitem__(self, name: str) -> Optional[ToolSchema]:
#TODO error handling
for tool_name, tool in self.tools.items():
if tool_name == name:
return tool
return None
def get_tool(self, name: str) -> Optional[Callable]:
for tool in self.tools:
if tool.name == name:
return tool.tool
return None
def list_tools(self) -> List[Dict[str, str]]:
def get_tool_endpoint(t: ToolSchema) -> str:
return f"/tool/{t.meta.module}/{t.name}"
return [
{'name': t.name,
'description': t.description,
'endpoint': get_tool_endpoint(t)
} for t in self.tools.values()]
def create_func_models(func: Callable) -> Tuple[Type[BaseModel], Type[BaseModel]]:
"""
Analyze a function to create corresponding Pydantic models for its input and output.
Args:
func (Callable): The function to analyze.
Returns:
Tuple[Type[BaseModel], Type[BaseModel]]: A tuple containing the input and output Pydantic models.
"""
input_fields = {}
if asyncio.iscoroutinefunction(func):
func = func.__wrapped__
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
field_info = extract_field_info(param)
input_fields[name] = (field_info['type'], Field(**field_info['field_params']))
input_model = create_model(f"{snake_to_camel(func.__name__)}Input", **input_fields)
output_model = determine_output_model(func)
return input_model, output_model
def extract_field_info(param: inspect.Parameter) -> dict:
"""
Extract type and field parameters from a function parameter.
Args:
param (inspect.Parameter): The parameter to extract information from.
Returns:
dict: A dictionary with 'type' and 'field_params'.
"""
annotation = param.annotation
default = param.default if param.default is not inspect.Parameter.empty else None
description = getattr(annotation, '__metadata__', [None])[0] if hasattr(annotation, '__metadata__') else None
field_params = {
'default': default,
'description': str(description) if description else "No description provided."
}
# Handle specific annotations like Param and Secret if needed
if hasattr(annotation, '__origin__') and annotation.__origin__ in [Param]:
field_type = annotation.__args__[0]
else:
field_type = annotation
return {'type': field_type, 'field_params': field_params}
def determine_output_model(func: Callable) -> Type[BaseModel]:
"""
Determine the output model for a function based on its return annotation.
Args:
func (Callable): The function to analyze.
Returns:
Type[BaseModel]: A Pydantic model representing the output.
"""
return_annotation = inspect.signature(func).return_annotation
if return_annotation is inspect.Signature.empty:
return create_model(f"{snake_to_camel(func.__name__)}Output")
elif hasattr(return_annotation, '__origin__'):
if hasattr(return_annotation, '__metadata__'):
field_type = Optional[return_annotation.__args__[0]]
description = return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
if description:
return create_model(f"{snake_to_camel(func.__name__)}Output", result=(field_type, Field(description=str(description))))
else:
return create_model(f"{snake_to_camel(func.__name__)}Output", result=(return_annotation, Field(description="No description provided.")))
def create_response_model(name: str, output_model: Type[BaseModel]) -> Type[ResponseModel]:
"""
Create a response model for the given schema.
"""
# Create a new response model
response_model = create_model(
f"{snake_to_camel(name)}Response",
code=(int, CustomResponseCode.HTTP_200.code),
msg=(str, CustomResponseCode.HTTP_200.msg),
data=(Optional[output_model], None)
)
return response_model

View file

@ -1,126 +0,0 @@
import os
from functools import lru_cache
from typing import Literal
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# https://docs.pydantic.dev/latest/concepts/pydantic_settings/
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env')
WORK_DIR: Path = Path.home() / '.arcade'
TOOLS_DIR: Path = os.getcwd()
ARTIFACTS_DIR: Path = WORK_DIR / 'artifacts'
DATA_DIR: Path = WORK_DIR / 'data'
BUILTIN_TOOLS_DIR: Path = Path(__file__).parent.parent.parent / 'builtin' / 'default'
BUILTIN_TOOLS: list[str] = [
"query.list_data_sources@builtin",
"query.get_data_schema@builtin",
"query.query_sql@builtin",
"data.get@builtin",
"data.select_columns@builtin",
"data.filter_rows@builtin",
"data.sort@builtin",
"data.group_by@builtin",
"data.join@builtin",
"data.search_text_columns@builtin",
"data.combine_results@builtin",
]
# Env Config
ENVIRONMENT: Literal['dev', 'pro'] = 'dev'
# Env Redis
REDIS_HOST: str = 'localhost'
REDIS_PORT: int = 6379
REDIS_PASSWORD: str = ''
REDIS_DATABASE: int = 0
# Env Token
TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32)
# Env Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str
# FastAPI
API_V1_STR: str = '/v1'
API_ACTION_STR: str = '/tool'
TITLE: str = 'Arcade AI Toolserver'
VERSION: str = '0.1.0'
DESCRIPTION: str = 'Arcade AI Toolserver API'
DOCS_URL: str | None = f'{API_V1_STR}/docs'
REDOCS_URL: str | None = f'{API_V1_STR}/redocs'
OPENAPI_URL: str | None = f'{API_V1_STR}/openapi'
# @model_validator(mode='before')
# @classmethod
# def validate_openapi_url(cls, values):
# if values['ENVIRONMENT'] == 'pro':
# values['OPENAPI_URL'] = None
# return values
# Uvicorn
UVICORN_HOST: str = '127.0.0.1'
UVICORN_PORT: int = 8000
UVICORN_RELOAD: bool = True
# Static Server
STATIC_FILES: bool = False
# DateTime
DATETIME_TIMEZONE: str = 'US/Pacific'
DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S'
# Redis
REDIS_TIMEOUT: int = 5
# Token
TOKEN_ALGORITHM: str = 'HS256' # 算法
TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1 # 过期时间,单位:秒
TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 刷新过期时间,单位:秒
TOKEN_URL_SWAGGER: str = f'{API_V1_STR}/auth/swagger_login'
TOKEN_REDIS_PREFIX: str = 'ts_token'
TOKEN_REFRESH_REDIS_PREFIX: str = 'ts_refresh_token'
TOKEN_EXCLUDE: list[str] = [ # JWT / RBAC 白名单
f'{API_V1_STR}/auth/login',
]
# Log
LOG_STDOUT_FILENAME: str = 'ts_access.log'
LOG_STDERR_FILENAME: str = 'ts_error.log'
# Middleware
MIDDLEWARE_CORS: bool = True
MIDDLEWARE_GZIP: bool = True
MIDDLEWARE_ACCESS: bool = False
# these should be set in .env
TOKEN_SECRET_KEY: str = "secret"
OPERA_LOG_ENCRYPT_SECRET_KEY: str = "secret"
# SQL Database
DB_HOST: str = "localhost"
DB_PORT: int = "3306"
DB_USER: str = "arcade"
DB_PASSWORD: str = "arcade"
DB_ECHO: bool = False
DB_DATABASE: str = 'arcade'
DB_CHARSET: str = 'utf8mb4'
@lru_cache
def get_settings():
try:
env_path = Path(os.environ["TOOLSERVE_ENV"])
except KeyError:
env_path = Path(__file__).parent.parent / '.env'
return Settings(_env_file=env_path)
settings = get_settings()

View file

@ -1,102 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any, Dict, Generic, Type, TypeVar
from pydantic import BaseModel
from sqlalchemy import and_, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from toolserve.server.models.base import MappedBase
ModelType = TypeVar('ModelType', bound=MappedBase)
CreateSchemaType = TypeVar('CreateSchemaType', bound=BaseModel)
UpdateSchemaType = TypeVar('UpdateSchemaType', bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
self.model = model
async def get_(
self,
db: AsyncSession,
*,
pk: int | None = None,
name: str | None = None,
status: int | None = None,
del_flag: int | None = None,
) -> ModelType | None:
"""
Get a record by primary key id or name
:param db:
:param pk:
:param name:
:param status:
:param del_flag:
:return:
"""
assert pk is not None or name is not None, 'Query error, pk and name parameters cannot be empty at the same time'
assert pk is None or name is None, 'Query error, pk and name parameters cannot exist at the same time'
where_list = [self.model.id == pk] if pk is not None else [self.model.name == name]
if status is not None:
assert status in (0, 1), 'Query error, status parameter can only be 0 or 1'
where_list.append(self.model.status == status)
if del_flag is not None:
assert del_flag in (0, 1), 'Query error, del_flag parameter can only be 0 or 1'
where_list.append(self.model.del_flag == del_flag)
result = await db.execute(select(self.model).where(and_(*where_list)))
return result.scalars().first()
async def create_(self, db: AsyncSession, obj_in: CreateSchemaType, user_id: int | None = None) -> None:
"""
Add a new record
:param db:
:param obj_in: Pydantic model class
:param user_id:
:return:
"""
if user_id:
create_data = self.model(**obj_in.model_dump(), create_user=user_id)
else:
create_data = self.model(**obj_in.model_dump())
db.add(create_data)
async def update_(
self, db: AsyncSession, pk: int, obj_in: UpdateSchemaType | Dict[str, Any], user_id: int | None = None
) -> int:
"""
Update a record by primary key id
:param db:
:param pk:
:param obj_in: Pydantic model class or dictionary corresponding to database fields
:param user_id:
:return:
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
if user_id:
update_data.update({'update_user': user_id})
result = await db.execute(update(self.model).where(self.model.id == pk).values(**update_data))
return result.rowcount
async def delete_(self, db: AsyncSession, pk: int, *, del_flag: int | None = None) -> int:
"""
Delete a record by primary key id
:param db:
:param pk:
:param del_flag:
:return:
"""
if del_flag is None:
result = await db.execute(delete(self.model).where(self.model.id == pk))
else:
assert del_flag == 1, 'Delete error, del_flag parameter can only be 1'
result = await db.execute(update(self.model).where(self.model.id == pk).values(del_flag=del_flag))
return result.rowcount

View file

@ -1,47 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from toolserve.server.crud.base import CRUDBase
from toolserve.server.models.sys_artifact import Artifact
from toolserve.server.schemas.artifact import CreateArtifactParam, UpdateArtifactParam
class CRUDArtifact(CRUDBase[Artifact, CreateArtifactParam, UpdateArtifactParam]):
async def get(self, db: AsyncSession, pk: int) -> Artifact | None:
return await self.get_(db, pk=pk)
async def get_list(self, name: str = None, file_path: str = None) -> Select:
se = select(self.model).order_by(desc(self.model.created_time))
where_list = []
if name:
where_list.append(self.model.name.like(f'%{name}%'))
if file_path:
where_list.append(self.model.file_path.like(f'%{file_path}%', escape='/'))
if where_list:
se = se.where(and_(*where_list))
return se
async def get_all(self, db: AsyncSession) -> Sequence[Artifact]:
artifacts = await db.execute(select(self.model))
return artifacts.scalars().all()
async def get_by_name(self, db: AsyncSession, name: str) -> Artifact | None:
artifact = await db.execute(select(self.model).where(self.model.name == name))
return artifact.scalars().first()
async def create(self, db: AsyncSession, obj_in: CreateArtifactParam) -> None:
await self.create_(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateArtifactParam) -> int:
return await self.update_(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
artifacts = await db.execute(delete(self.model).where(self.model.id.in_(pk)))
return artifacts.rowcount
artifact_dao: CRUDArtifact = CRUDArtifact(Artifact)

View file

@ -1,51 +0,0 @@
import datetime
from typing import Sequence
from sqlalchemy import Select, and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from toolserve.server.crud.base import CRUDBase
from toolserve.server.models.sys_data import Data
from toolserve.server.schemas.data import CreateDataParam, DataSchemaBase
class CRUDData(CRUDBase[Data, CreateDataParam, DataSchemaBase]):
async def get(self, db: AsyncSession, pk: int) -> Data | None:
return await self.get_(db, pk=pk)
async def get_list(self, file_name: str = None, file_path: str = None) -> Select:
query = select(self.model).order_by(desc(self.model.created_time))
conditions = []
if file_name:
conditions.append(self.model.file_name.like(f'%{file_name}%'))
if file_path:
conditions.append(self.model.file_path.like(f'%{file_path}%'))
if conditions:
query = query.where(and_(*conditions))
return query
async def get_all(self, db: AsyncSession) -> Sequence[Data]:
result = await db.execute(select(self.model))
return result.scalars().all()
async def get_by_file_name(self, db: AsyncSession, file_name: str) -> Data | None:
result = await db.execute(select(self.model).where(self.model.file_name == file_name))
return result.scalars().first()
async def create(self, db: AsyncSession, obj_in: CreateDataParam) -> Data:
existing_data = await self.get_by_file_name(db, obj_in.file_name)
if existing_data:
existing_data.updated_time = datetime.datetime.now()
db.add(existing_data)
return existing_data
else:
obj = self.model(**obj_in.dict())
db.add(obj)
return obj
async def update(self, db: AsyncSession, pk: int, obj_in: DataSchemaBase) -> int:
return await self.update_(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
result = await db.execute(delete(self.model).where(self.model.id.in_(pk)))
return result.rowcount
data_dao: CRUDData = CRUDData(Data)

View file

@ -1,45 +0,0 @@
from typing import Sequence
from sqlalchemy import Select, and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from toolserve.server.crud.base import CRUDBase
from toolserve.server.models.sys_log import Log
from toolserve.server.schemas.log import CreateLog, LogSchemaBase
class CRUDLog(CRUDBase[Log, CreateLog, LogSchemaBase]):
async def get(self, db: AsyncSession, pk: int) -> Log | None:
return await self.get_(db, pk=pk)
async def get_list(self, level: str = None, msg: str = None) -> Select:
query = select(self.model).order_by(desc(self.model.created_time))
conditions = []
if level:
conditions.append(self.model.level == level)
if msg:
conditions.append(self.model.msg.like(f'%{msg}%'))
if conditions:
query = query.where(and_(*conditions))
return query
async def get_all(self, db: AsyncSession) -> Sequence[Log]:
result = await db.execute(select(self.model))
return result.scalars().all()
async def get_by_level(self, db: AsyncSession, level: str) -> Log | None:
result = await db.execute(select(self.model).where(self.model.level == level))
return result.scalars().first()
async def create(self, db: AsyncSession, obj_in: CreateLog) -> None:
await self.create_(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: LogSchemaBase) -> int:
return await self.update_(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
result = await db.execute(delete(self.model).where(self.model.id.in_(pk)))
return result.rowcount
log_dao: CRUDLog = CRUDLog(Log)

View file

@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

View file

@ -1,68 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from typing import Annotated
from uuid import uuid4
from fastapi import Depends
from sqlalchemy import URL
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from toolserve.server.common.log import log
from toolserve.server.core.conf import settings
from toolserve.server.models import (
MappedBase,
Log,
Artifact,
Data,
)
def create_engine_and_session(url: str | URL):
try:
engine = create_async_engine(url, echo=settings.DB_ECHO, future=True, pool_pre_ping=True)
except Exception as e:
log.error('❌ Error starting db session {}', e)
sys.exit()
else:
db_session = async_sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
return engine, db_session
SQLALCHEMY_DATABASE_URL = (
f'sqlite+aiosqlite:///{settings.WORK_DIR}/{settings.DB_DATABASE}.db'
)
async_engine, async_db_session = create_engine_and_session(SQLALCHEMY_DATABASE_URL)
async def get_db() -> AsyncSession:
"""Provide a database session for a single request."""
session = async_db_session()
try:
yield session
except Exception as se:
await session.rollback()
raise se
finally:
await session.close()
# Session Annotated
CurrentSession = Annotated[AsyncSession, Depends(get_db)]
async def create_table():
"""Create the database tables if they do not exist."""
async with async_engine.begin() as conn:
try:
await conn.run_sync(MappedBase.metadata.create_all)
except Exception as e:
log.error('❌ Error creating tables {}', e)
raise e
def uuid4_str() -> str:
"""Generate a UUID4 string."""
return str(uuid4())

View file

@ -1,24 +0,0 @@
import uvicorn
from pathlib import Path
import asyncio
from toolserve.server.common.log import log
from toolserve.server.core.conf import settings
from toolserve.server.core.registrar import register_app
app = register_app()
if __name__ == '__main__':
try:
log.info(
"Arcade AI Toolserve is starting..."
)
uvicorn.run(
app=f'{Path(__file__).stem}:app',
host=settings.UVICORN_HOST,
port=settings.UVICORN_PORT,
reload=settings.UVICORN_RELOAD,
)
except Exception as e:
log.error(f'❌ FastAPI start filed: {e}')

View file

@ -1,7 +0,0 @@
from toolserve.server.models.base import MappedBase
from toolserve.server.models.sys_data import Data
from toolserve.server.models.sys_log import Log
from toolserve.server.models.sys_artifact import Artifact
__all__ = ['MappedBase', 'Data', 'Log', 'Artifact']

View file

@ -1,83 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Annotated
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
MappedAsDataclass,
declared_attr,
mapped_column,
)
from toolserve.server.utils.timezone import timezone
# Common Mapped type primary key, manual addition required, refer to the following usage
# MappedBase -> id: Mapped[id_key]
# DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False)
id_key = Annotated[
int,
mapped_column(
primary_key=True,
index=True,
autoincrement=True,
sort_order=-999,
comment="Primary key id",
),
]
# Mixin: An object-oriented programming concept, makes the structure clearer, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
class UserMixin(MappedAsDataclass):
"""User Mixin data class"""
create_user: Mapped[int] = mapped_column(sort_order=998, comment="Creator")
update_user: Mapped[int | None] = mapped_column(
init=False, default=None, sort_order=998, comment="Modifier"
)
class DateTimeMixin(MappedAsDataclass):
"""Datetime Mixin data class"""
created_time: Mapped[datetime] = mapped_column(
init=False,
default_factory=timezone.now,
sort_order=999,
comment="Creation time",
)
updated_time: Mapped[datetime | None] = mapped_column(
init=False, onupdate=timezone.now, sort_order=999, comment="Update time"
)
class MappedBase(DeclarativeBase):
"""
Declarative base class, the original DeclarativeBase class, serves as the parent class for all base or data model classes
`DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__
`mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__
"""
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower()
class DataClassBase(MappedAsDataclass, MappedBase):
"""
Declarative data class base class, integrates with data classes, allows for advanced configurations, but you must be aware of its characteristics, especially when used with DeclarativeBase
`MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__
""" # noqa: E501
__abstract__ = True
class Base(DataClassBase, DateTimeMixin):
"""
Declarative Mixin data class base class, integrates data classes, includes the Mixin data class basic table structure, you can simply understand it as a data class base class with basic table structure
""" # noqa: E501
__abstract__ = True

View file

@ -1,13 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from toolserve.server.models.base import Base, id_key
class Artifact(Base):
__tablename__ = 'sys_artifact'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(255), comment='Artifact name')
file_path: Mapped[str] = mapped_column(String(255), comment='File path')

View file

@ -1,14 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from toolserve.server.models.base import Base, id_key
class Data(Base):
__tablename__ = 'sys_data'
id: Mapped[id_key] = mapped_column(init=False)
file_name: Mapped[str] = mapped_column(String(255), comment='File name')
file_path: Mapped[str] = mapped_column(String(255), comment='File path')

View file

@ -1,15 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from toolserve.server.models.base import Base, id_key
class Log(Base):
__tablename__ = 'sys_log'
id: Mapped[id_key] = mapped_column(init=False)
level: Mapped[str] = mapped_column(String(50), comment='Log level')
msg: Mapped[str] = mapped_column(String(500), comment='Log message')

View file

@ -1,16 +0,0 @@
from fastapi import APIRouter
from toolserve.server.core.conf import settings
from toolserve.server.routes.tool import router as tool_router
from toolserve.server.routes.data import router as data_router
from toolserve.server.routes.artifact import router as artifact_router
from toolserve.server.routes.log import router as log_router
from toolserve.server.routes.slack import router as slack_router
from toolserve.server.routes.chat import router as chat_router
v1 = APIRouter(prefix=settings.API_V1_STR)
v1.include_router(tool_router, prefix="/tools", tags=["Tool Catalog"])
v1.include_router(data_router, prefix="/data", tags=["Data Management"])
v1.include_router(artifact_router, prefix="/artifact", tags=["Artifact Management"])
v1.include_router(log_router, prefix="/log", tags=["Tool Logging API"])
v1.include_router(slack_router, prefix="/slack", tags=["Slack"])
v1.include_router(chat_router, prefix="/chat", tags=["Chat"])

Some files were not shown because too many files have changed in this diff Show more