Tool SDK, Schemas (#2)
Co-authored-by: Nate Barbettini <nathanaelb@gmail.com>
This commit is contained in:
parent
a5decd4483
commit
7f3abfd1f9
114 changed files with 5276 additions and 3342 deletions
5
.editorconfig
Normal file
5
.editorconfig
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
max_line_length = 120
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
183
.gitignore
vendored
183
.gitignore
vendored
|
|
@ -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
22
.pre-commit-config.yaml
Normal 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
133
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024, Arcade AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
12
README.md
12
README.md
|
|
@ -1,4 +1,10 @@
|
|||
# ToolServe
|
||||
[](https://img.shields.io/github/v/release/spartee/arcade-ai)
|
||||
[](https://github.com/spartee/arcade-ai/actions/workflows/main.yml?query=branch%3Amain)
|
||||
[](https://codecov.io/gh/spartee/arcade-ai)
|
||||
[](https://img.shields.io/github/commit-activity/m/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
56
arcade/Makefile
Normal 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
|
||||
1
arcade/arcade/actor/common/exception/__init__.py
Normal file
1
arcade/arcade/actor/common/exception/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
#!/usr/bin/env python3
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"""
|
||||
|
||||
13
arcade/arcade/actor/common/serializers.py
Normal file
13
arcade/arcade/actor/common/serializers.py
Normal 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)
|
||||
71
arcade/arcade/actor/core/conf.py
Normal file
71
arcade/arcade/actor/core/conf.py
Normal 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()
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from starlette.requests import Request
|
||||
|
||||
|
||||
def get_catalog(request: Request):
|
||||
return request.app.state.catalog
|
||||
return request.app.state.catalog
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
21
arcade/arcade/actor/main.py
Normal file
21
arcade/arcade/actor/main.py
Normal 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}")
|
||||
7
arcade/arcade/actor/routes/__init__.py
Normal file
7
arcade/arcade/actor/routes/__init__.py
Normal 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"])
|
||||
59
arcade/arcade/actor/routes/tool.py
Normal file
59
arcade/arcade/actor/routes/tool.py
Normal 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))
|
||||
145
arcade/arcade/actor/schemas/base.py
Normal file
145
arcade/arcade/actor/schemas/base.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
8
arcade/arcade/sdk/annotations.py
Normal file
8
arcade/arcade/sdk/annotations.py
Normal 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
|
||||
14
arcade/arcade/sdk/errors.py
Normal file
14
arcade/arcade/sdk/errors.py
Normal 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
|
||||
71
arcade/arcade/sdk/schemas.py
Normal file
71
arcade/arcade/sdk/schemas.py
Normal 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
34
arcade/arcade/sdk/tool.py
Normal 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
|
||||
407
arcade/arcade/tool/catalog.py
Normal file
407
arcade/arcade/tool/catalog.py
Normal 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
|
||||
|
|
@ -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)
|
||||
59
arcade/arcade/utils/__init__.py
Normal file
59
arcade/arcade/utils/__init__.py
Normal 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
9
arcade/codecov.yaml
Normal 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
8
arcade/docs/index.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# arcade-ai
|
||||
|
||||
[](https://img.shields.io/github/v/release/spartee/arcade-ai)
|
||||
[](https://github.com/spartee/arcade-ai/actions/workflows/main.yml?query=branch%3Amain)
|
||||
[](https://img.shields.io/github/commit-activity/m/spartee/arcade-ai)
|
||||
[](https://img.shields.io/github/license/spartee/arcade-ai)
|
||||
|
||||
Arcade AI python
|
||||
1
arcade/docs/modules.md
Normal file
1
arcade/docs/modules.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
::: arcade.foo
|
||||
54
arcade/mkdocs.yml
Normal file
54
arcade/mkdocs.yml
Normal 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
1826
arcade/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
2
arcade/poetry.toml
Normal file
2
arcade/poetry.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[virtualenvs]
|
||||
in-project = true
|
||||
127
arcade/pyproject.toml
Normal file
127
arcade/pyproject.toml
Normal 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"]
|
||||
52
arcade/tests/sdk/test_tool_decorator.py
Normal file
52
arcade/tests/sdk/test_tool_decorator.py
Normal 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"]
|
||||
456
arcade/tests/tool/test_create_tool_definition.py
Normal file
456
arcade/tests/tool/test_create_tool_definition.py
Normal 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"
|
||||
55
arcade/tests/tool/test_create_tool_definition_errors.py
Normal file
55
arcade/tests/tool/test_create_tool_definition_errors.py
Normal 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")
|
||||
57
arcade/tests/tool/test_create_tool_definition_pydantic.py
Normal file
57
arcade/tests/tool/test_create_tool_definition_pydantic.py
Normal 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
|
||||
26
arcade/tests/utils/test_utils_casing.py
Normal file
26
arcade/tests/utils/test_utils_casing.py
Normal 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
18
arcade/tox.ini
Normal 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
12
cspell.config.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
version: "0.2"
|
||||
ignorePaths:
|
||||
- pyproject.toml
|
||||
dictionaryDefinitions: []
|
||||
dictionaries: []
|
||||
words:
|
||||
- conlist
|
||||
- pydantic
|
||||
- pyproject
|
||||
- toolpack
|
||||
ignoreWords: []
|
||||
import: []
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
158
examples/generic/tools/BM25.py
Normal file
158
examples/generic/tools/BM25.py
Normal 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)
|
||||
257
examples/generic/tools/gmail.py
Normal file
257
examples/generic/tools/gmail.py
Normal 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
|
||||
76
examples/generic/tools/products.py
Normal file
76
examples/generic/tools/products.py
Normal 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()
|
||||
38
examples/generic/tools/read_sqlite.py
Normal file
38
examples/generic/tools/read_sqlite.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
198
schemas/preview/tool_definition.schema.jsonc
Normal file
198
schemas/preview/tool_definition.schema.jsonc
Normal 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
|
||||
}
|
||||
80
schemas/preview/tool_request.schema.jsonc
Normal file
80
schemas/preview/tool_request.schema.jsonc
Normal 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
|
||||
}
|
||||
135
schemas/preview/tool_response.schema.jsonc
Normal file
135
schemas/preview/tool_response.schema.jsonc
Normal 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
663
toolserve/poetry.lock
generated
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
from .tool import (
|
||||
Param,
|
||||
tool,
|
||||
get_secret
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
|
@ -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())
|
||||
|
|
@ -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}')
|
||||
|
|
@ -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']
|
||||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
Loading…
Reference in a new issue