mirror of
https://github.com/hwchase17/langchain
synced 2024-11-06 03:20:49 +00:00
3a2eb6e12b
Added noqa for existing prints. Can slowly remove / will prevent more being intro'd
624 lines
19 KiB
Python
624 lines
19 KiB
Python
"""Util that calls clickup."""
|
|
|
|
import json
|
|
import warnings
|
|
from dataclasses import asdict, dataclass, fields
|
|
from typing import Any, Dict, List, Mapping, Optional, Tuple, Type, Union
|
|
|
|
import requests
|
|
from langchain_core.pydantic_v1 import BaseModel, Extra, root_validator
|
|
from langchain_core.utils import get_from_dict_or_env
|
|
|
|
DEFAULT_URL = "https://api.clickup.com/api/v2"
|
|
|
|
|
|
@dataclass
|
|
class Component:
|
|
"""Base class for all components."""
|
|
|
|
@classmethod
|
|
def from_data(cls, data: Dict[str, Any]) -> "Component":
|
|
raise NotImplementedError()
|
|
|
|
|
|
@dataclass
|
|
class Task(Component):
|
|
"""Class for a task."""
|
|
|
|
id: int
|
|
name: str
|
|
text_content: str
|
|
description: str
|
|
status: str
|
|
creator_id: int
|
|
creator_username: str
|
|
creator_email: str
|
|
assignees: List[Dict[str, Any]]
|
|
watchers: List[Dict[str, Any]]
|
|
priority: Optional[str]
|
|
due_date: Optional[str]
|
|
start_date: Optional[str]
|
|
points: int
|
|
team_id: int
|
|
project_id: int
|
|
|
|
@classmethod
|
|
def from_data(cls, data: Dict[str, Any]) -> "Task":
|
|
priority = None if data["priority"] is None else data["priority"]["priority"]
|
|
return cls(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
text_content=data["text_content"],
|
|
description=data["description"],
|
|
status=data["status"]["status"],
|
|
creator_id=data["creator"]["id"],
|
|
creator_username=data["creator"]["username"],
|
|
creator_email=data["creator"]["email"],
|
|
assignees=data["assignees"],
|
|
watchers=data["watchers"],
|
|
priority=priority,
|
|
due_date=data["due_date"],
|
|
start_date=data["start_date"],
|
|
points=data["points"],
|
|
team_id=data["team_id"],
|
|
project_id=data["project"]["id"],
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CUList(Component):
|
|
"""Component class for a list."""
|
|
|
|
folder_id: float
|
|
name: str
|
|
content: Optional[str] = None
|
|
due_date: Optional[int] = None
|
|
due_date_time: Optional[bool] = None
|
|
priority: Optional[int] = None
|
|
assignee: Optional[int] = None
|
|
status: Optional[str] = None
|
|
|
|
@classmethod
|
|
def from_data(cls, data: dict) -> "CUList":
|
|
return cls(
|
|
folder_id=data["folder_id"],
|
|
name=data["name"],
|
|
content=data.get("content"),
|
|
due_date=data.get("due_date"),
|
|
due_date_time=data.get("due_date_time"),
|
|
priority=data.get("priority"),
|
|
assignee=data.get("assignee"),
|
|
status=data.get("status"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Member(Component):
|
|
"""Component class for a member."""
|
|
|
|
id: int
|
|
username: str
|
|
email: str
|
|
initials: str
|
|
|
|
@classmethod
|
|
def from_data(cls, data: Dict) -> "Member":
|
|
return cls(
|
|
id=data["user"]["id"],
|
|
username=data["user"]["username"],
|
|
email=data["user"]["email"],
|
|
initials=data["user"]["initials"],
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Team(Component):
|
|
"""Component class for a team."""
|
|
|
|
id: int
|
|
name: str
|
|
members: List[Member]
|
|
|
|
@classmethod
|
|
def from_data(cls, data: Dict) -> "Team":
|
|
members = [Member.from_data(member_data) for member_data in data["members"]]
|
|
return cls(id=data["id"], name=data["name"], members=members)
|
|
|
|
|
|
@dataclass
|
|
class Space(Component):
|
|
"""Component class for a space."""
|
|
|
|
id: int
|
|
name: str
|
|
private: bool
|
|
enabled_features: Dict[str, Any]
|
|
|
|
@classmethod
|
|
def from_data(cls, data: Dict[str, Any]) -> "Space":
|
|
space_data = data["spaces"][0]
|
|
enabled_features = {
|
|
feature: value
|
|
for feature, value in space_data["features"].items()
|
|
if value["enabled"]
|
|
}
|
|
return cls(
|
|
id=space_data["id"],
|
|
name=space_data["name"],
|
|
private=space_data["private"],
|
|
enabled_features=enabled_features,
|
|
)
|
|
|
|
|
|
def parse_dict_through_component(
|
|
data: dict, component: Type[Component], fault_tolerant: bool = False
|
|
) -> Dict:
|
|
"""Parse a dictionary by creating
|
|
a component and then turning it back into a dictionary.
|
|
|
|
This helps with two things
|
|
1. Extract and format data from a dictionary according to schema
|
|
2. Provide a central place to do this in a fault-tolerant way
|
|
|
|
"""
|
|
try:
|
|
return asdict(component.from_data(data))
|
|
except Exception as e:
|
|
if fault_tolerant:
|
|
warning_str = f"""Error encountered while trying to parse
|
|
{str(data)}: {str(e)}\n Falling back to returning input data."""
|
|
warnings.warn(warning_str)
|
|
return data
|
|
else:
|
|
raise e
|
|
|
|
|
|
def extract_dict_elements_from_component_fields(
|
|
data: dict, component: Type[Component]
|
|
) -> dict:
|
|
"""Extract elements from a dictionary.
|
|
|
|
Args:
|
|
data: The dictionary to extract elements from.
|
|
component: The component to extract elements from.
|
|
|
|
Returns:
|
|
A dictionary containing the elements from the input dictionary that are also
|
|
in the component.
|
|
"""
|
|
output = {}
|
|
for attribute in fields(component):
|
|
if attribute.name in data:
|
|
output[attribute.name] = data[attribute.name]
|
|
return output
|
|
|
|
|
|
def load_query(
|
|
query: str, fault_tolerant: bool = False
|
|
) -> Tuple[Optional[Dict], Optional[str]]:
|
|
"""Attempts to parse a JSON string and return the parsed object.
|
|
|
|
If parsing fails, returns an error message.
|
|
|
|
:param query: The JSON string to parse.
|
|
:return: A tuple containing the parsed object or None and an error message or None.
|
|
"""
|
|
try:
|
|
return json.loads(query), None
|
|
except json.JSONDecodeError as e:
|
|
if fault_tolerant:
|
|
return (
|
|
None,
|
|
f"""Input must be a valid JSON. Got the following error: {str(e)}.
|
|
"Please reformat and try again.""",
|
|
)
|
|
else:
|
|
raise e
|
|
|
|
|
|
def fetch_first_id(data: dict, key: str) -> Optional[int]:
|
|
"""Fetch the first id from a dictionary."""
|
|
if key in data and len(data[key]) > 0:
|
|
if len(data[key]) > 1:
|
|
warnings.warn(f"Found multiple {key}: {data[key]}. Defaulting to first.")
|
|
return data[key][0]["id"]
|
|
return None
|
|
|
|
|
|
def fetch_data(url: str, access_token: str, query: Optional[dict] = None) -> dict:
|
|
"""Fetch data from a URL."""
|
|
headers = {"Authorization": access_token}
|
|
response = requests.get(url, headers=headers, params=query)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def fetch_team_id(access_token: str) -> Optional[int]:
|
|
"""Fetch the team id."""
|
|
url = f"{DEFAULT_URL}/team"
|
|
data = fetch_data(url, access_token)
|
|
return fetch_first_id(data, "teams")
|
|
|
|
|
|
def fetch_space_id(team_id: int, access_token: str) -> Optional[int]:
|
|
"""Fetch the space id."""
|
|
url = f"{DEFAULT_URL}/team/{team_id}/space"
|
|
data = fetch_data(url, access_token, query={"archived": "false"})
|
|
return fetch_first_id(data, "spaces")
|
|
|
|
|
|
def fetch_folder_id(space_id: int, access_token: str) -> Optional[int]:
|
|
"""Fetch the folder id."""
|
|
url = f"{DEFAULT_URL}/space/{space_id}/folder"
|
|
data = fetch_data(url, access_token, query={"archived": "false"})
|
|
return fetch_first_id(data, "folders")
|
|
|
|
|
|
def fetch_list_id(space_id: int, folder_id: int, access_token: str) -> Optional[int]:
|
|
"""Fetch the list id."""
|
|
if folder_id:
|
|
url = f"{DEFAULT_URL}/folder/{folder_id}/list"
|
|
else:
|
|
url = f"{DEFAULT_URL}/space/{space_id}/list"
|
|
|
|
data = fetch_data(url, access_token, query={"archived": "false"})
|
|
|
|
# The structure to fetch list id differs based if its folderless
|
|
if folder_id and "id" in data:
|
|
return data["id"]
|
|
else:
|
|
return fetch_first_id(data, "lists")
|
|
|
|
|
|
class ClickupAPIWrapper(BaseModel):
|
|
"""Wrapper for Clickup API."""
|
|
|
|
access_token: Optional[str] = None
|
|
team_id: Optional[str] = None
|
|
space_id: Optional[str] = None
|
|
folder_id: Optional[str] = None
|
|
list_id: Optional[str] = None
|
|
|
|
class Config:
|
|
"""Configuration for this pydantic object."""
|
|
|
|
extra = Extra.forbid
|
|
|
|
@classmethod
|
|
def get_access_code_url(
|
|
cls, oauth_client_id: str, redirect_uri: str = "https://google.com"
|
|
) -> str:
|
|
"""Get the URL to get an access code."""
|
|
url = f"https://app.clickup.com/api?client_id={oauth_client_id}"
|
|
return f"{url}&redirect_uri={redirect_uri}"
|
|
|
|
@classmethod
|
|
def get_access_token(
|
|
cls, oauth_client_id: str, oauth_client_secret: str, code: str
|
|
) -> Optional[str]:
|
|
"""Get the access token."""
|
|
url = f"{DEFAULT_URL}/oauth/token"
|
|
|
|
params = {
|
|
"client_id": oauth_client_id,
|
|
"client_secret": oauth_client_secret,
|
|
"code": code,
|
|
}
|
|
|
|
response = requests.post(url, params=params)
|
|
data = response.json()
|
|
|
|
if "access_token" not in data:
|
|
print(f"Error: {data}") # noqa: T201
|
|
if "ECODE" in data and data["ECODE"] == "OAUTH_014":
|
|
url = ClickupAPIWrapper.get_access_code_url(oauth_client_id)
|
|
print( # noqa: T201
|
|
"You already used this code once. Generate a new one.",
|
|
f"Our best guess for the url to get a new code is:\n{url}",
|
|
)
|
|
return None
|
|
|
|
return data["access_token"]
|
|
|
|
@root_validator()
|
|
def validate_environment(cls, values: Dict) -> Dict:
|
|
"""Validate that api key and python package exists in environment."""
|
|
values["access_token"] = get_from_dict_or_env(
|
|
values, "access_token", "CLICKUP_ACCESS_TOKEN"
|
|
)
|
|
values["team_id"] = fetch_team_id(values["access_token"])
|
|
values["space_id"] = fetch_space_id(values["team_id"], values["access_token"])
|
|
values["folder_id"] = fetch_folder_id(
|
|
values["space_id"], values["access_token"]
|
|
)
|
|
values["list_id"] = fetch_list_id(
|
|
values["space_id"], values["folder_id"], values["access_token"]
|
|
)
|
|
|
|
return values
|
|
|
|
def attempt_parse_teams(self, input_dict: dict) -> Dict[str, List[dict]]:
|
|
"""Parse appropriate content from the list of teams."""
|
|
parsed_teams: Dict[str, List[dict]] = {"teams": []}
|
|
for team in input_dict["teams"]:
|
|
try:
|
|
team = parse_dict_through_component(team, Team, fault_tolerant=False)
|
|
parsed_teams["teams"].append(team)
|
|
except Exception as e:
|
|
warnings.warn(f"Error parsing a team {e}")
|
|
|
|
return parsed_teams
|
|
|
|
def get_headers(
|
|
self,
|
|
) -> Mapping[str, Union[str, bytes]]:
|
|
"""Get the headers for the request."""
|
|
if not isinstance(self.access_token, str):
|
|
raise TypeError(f"Access Token: {self.access_token}, must be str.")
|
|
|
|
headers = {
|
|
"Authorization": str(self.access_token),
|
|
"Content-Type": "application/json",
|
|
}
|
|
return headers
|
|
|
|
def get_default_params(self) -> Dict:
|
|
return {"archived": "false"}
|
|
|
|
def get_authorized_teams(self) -> Dict[Any, Any]:
|
|
"""Get all teams for the user."""
|
|
url = f"{DEFAULT_URL}/team"
|
|
|
|
response = requests.get(url, headers=self.get_headers())
|
|
|
|
data = response.json()
|
|
parsed_teams = self.attempt_parse_teams(data)
|
|
|
|
return parsed_teams
|
|
|
|
def get_folders(self) -> Dict:
|
|
"""
|
|
Get all the folders for the team.
|
|
"""
|
|
url = f"{DEFAULT_URL}/team/" + str(self.team_id) + "/space"
|
|
params = self.get_default_params()
|
|
response = requests.get(url, headers=self.get_headers(), params=params)
|
|
return {"response": response}
|
|
|
|
def get_task(self, query: str, fault_tolerant: bool = True) -> Dict:
|
|
"""
|
|
Retrieve a specific task.
|
|
"""
|
|
|
|
params, error = load_query(query, fault_tolerant=True)
|
|
if params is None:
|
|
return {"Error": error}
|
|
|
|
url = f"{DEFAULT_URL}/task/{params['task_id']}"
|
|
params = {
|
|
"custom_task_ids": "true",
|
|
"team_id": self.team_id,
|
|
"include_subtasks": "true",
|
|
}
|
|
response = requests.get(url, headers=self.get_headers(), params=params)
|
|
data = response.json()
|
|
parsed_task = parse_dict_through_component(
|
|
data, Task, fault_tolerant=fault_tolerant
|
|
)
|
|
|
|
return parsed_task
|
|
|
|
def get_lists(self) -> Dict:
|
|
"""
|
|
Get all available lists.
|
|
"""
|
|
|
|
url = f"{DEFAULT_URL}/folder/{self.folder_id}/list"
|
|
params = self.get_default_params()
|
|
response = requests.get(url, headers=self.get_headers(), params=params)
|
|
return {"response": response}
|
|
|
|
def query_tasks(self, query: str) -> Dict:
|
|
"""
|
|
Query tasks that match certain fields
|
|
"""
|
|
params, error = load_query(query, fault_tolerant=True)
|
|
if params is None:
|
|
return {"Error": error}
|
|
|
|
url = f"{DEFAULT_URL}/list/{params['list_id']}/task"
|
|
|
|
params = self.get_default_params()
|
|
response = requests.get(url, headers=self.get_headers(), params=params)
|
|
|
|
return {"response": response}
|
|
|
|
def get_spaces(self) -> Dict:
|
|
"""
|
|
Get all spaces for the team.
|
|
"""
|
|
url = f"{DEFAULT_URL}/team/{self.team_id}/space"
|
|
response = requests.get(
|
|
url, headers=self.get_headers(), params=self.get_default_params()
|
|
)
|
|
data = response.json()
|
|
parsed_spaces = parse_dict_through_component(data, Space, fault_tolerant=True)
|
|
return parsed_spaces
|
|
|
|
def get_task_attribute(self, query: str) -> Dict:
|
|
"""
|
|
Update an attribute of a specified task.
|
|
"""
|
|
|
|
task = self.get_task(query, fault_tolerant=True)
|
|
params, error = load_query(query, fault_tolerant=True)
|
|
if not isinstance(params, dict):
|
|
return {"Error": error}
|
|
|
|
if params["attribute_name"] not in task:
|
|
return {
|
|
"Error": f"""attribute_name = {params['attribute_name']} was not
|
|
found in task keys {task.keys()}. Please call again with one of the key names."""
|
|
}
|
|
|
|
return {params["attribute_name"]: task[params["attribute_name"]]}
|
|
|
|
def update_task(self, query: str) -> Dict:
|
|
"""
|
|
Update an attribute of a specified task.
|
|
"""
|
|
query_dict, error = load_query(query, fault_tolerant=True)
|
|
if query_dict is None:
|
|
return {"Error": error}
|
|
|
|
url = f"{DEFAULT_URL}/task/{query_dict['task_id']}"
|
|
params = {
|
|
"custom_task_ids": "true",
|
|
"team_id": self.team_id,
|
|
"include_subtasks": "true",
|
|
}
|
|
headers = self.get_headers()
|
|
payload = {query_dict["attribute_name"]: query_dict["value"]}
|
|
|
|
response = requests.put(url, headers=headers, params=params, json=payload)
|
|
|
|
return {"response": response}
|
|
|
|
def update_task_assignees(self, query: str) -> Dict:
|
|
"""
|
|
Add or remove assignees of a specified task.
|
|
"""
|
|
query_dict, error = load_query(query, fault_tolerant=True)
|
|
if query_dict is None:
|
|
return {"Error": error}
|
|
|
|
for user in query_dict["users"]:
|
|
if not isinstance(user, int):
|
|
return {
|
|
"Error": f"""All users must be integers, not strings!
|
|
"Got user {user} if type {type(user)}"""
|
|
}
|
|
|
|
url = f"{DEFAULT_URL}/task/{query_dict['task_id']}"
|
|
|
|
headers = self.get_headers()
|
|
|
|
if query_dict["operation"] == "add":
|
|
assigne_payload = {"add": query_dict["users"], "rem": []}
|
|
elif query_dict["operation"] == "rem":
|
|
assigne_payload = {"add": [], "rem": query_dict["users"]}
|
|
else:
|
|
raise ValueError(
|
|
f"Invalid operation ({query_dict['operation']}). ",
|
|
"Valid options ['add', 'rem'].",
|
|
)
|
|
|
|
params = {
|
|
"custom_task_ids": "true",
|
|
"team_id": self.team_id,
|
|
"include_subtasks": "true",
|
|
}
|
|
|
|
payload = {"assignees": assigne_payload}
|
|
response = requests.put(url, headers=headers, params=params, json=payload)
|
|
return {"response": response}
|
|
|
|
def create_task(self, query: str) -> Dict:
|
|
"""
|
|
Creates a new task.
|
|
"""
|
|
query_dict, error = load_query(query, fault_tolerant=True)
|
|
if query_dict is None:
|
|
return {"Error": error}
|
|
|
|
list_id = self.list_id
|
|
url = f"{DEFAULT_URL}/list/{list_id}/task"
|
|
params = {"custom_task_ids": "true", "team_id": self.team_id}
|
|
|
|
payload = extract_dict_elements_from_component_fields(query_dict, Task)
|
|
headers = self.get_headers()
|
|
|
|
response = requests.post(url, json=payload, headers=headers, params=params)
|
|
data: Dict = response.json()
|
|
return parse_dict_through_component(data, Task, fault_tolerant=True)
|
|
|
|
def create_list(self, query: str) -> Dict:
|
|
"""
|
|
Creates a new list.
|
|
"""
|
|
query_dict, error = load_query(query, fault_tolerant=True)
|
|
if query_dict is None:
|
|
return {"Error": error}
|
|
|
|
# Default to using folder as location if it exists.
|
|
# If not, fall back to using the space.
|
|
location = self.folder_id if self.folder_id else self.space_id
|
|
url = f"{DEFAULT_URL}/folder/{location}/list"
|
|
|
|
payload = extract_dict_elements_from_component_fields(query_dict, Task)
|
|
headers = self.get_headers()
|
|
|
|
response = requests.post(url, json=payload, headers=headers)
|
|
data = response.json()
|
|
parsed_list = parse_dict_through_component(data, CUList, fault_tolerant=True)
|
|
# set list id to new list
|
|
if "id" in parsed_list:
|
|
self.list_id = parsed_list["id"]
|
|
return parsed_list
|
|
|
|
def create_folder(self, query: str) -> Dict:
|
|
"""
|
|
Creates a new folder.
|
|
"""
|
|
|
|
query_dict, error = load_query(query, fault_tolerant=True)
|
|
if query_dict is None:
|
|
return {"Error": error}
|
|
|
|
space_id = self.space_id
|
|
url = f"{DEFAULT_URL}/space/{space_id}/folder"
|
|
payload = {
|
|
"name": query_dict["name"],
|
|
}
|
|
|
|
headers = self.get_headers()
|
|
|
|
response = requests.post(url, json=payload, headers=headers)
|
|
data = response.json()
|
|
|
|
if "id" in data:
|
|
self.list_id = data["id"]
|
|
return data
|
|
|
|
def run(self, mode: str, query: str) -> str:
|
|
"""Run the API."""
|
|
if mode == "get_task":
|
|
output = self.get_task(query)
|
|
elif mode == "get_task_attribute":
|
|
output = self.get_task_attribute(query)
|
|
elif mode == "get_teams":
|
|
output = self.get_authorized_teams()
|
|
elif mode == "create_task":
|
|
output = self.create_task(query)
|
|
elif mode == "create_list":
|
|
output = self.create_list(query)
|
|
elif mode == "create_folder":
|
|
output = self.create_folder(query)
|
|
elif mode == "get_lists":
|
|
output = self.get_lists()
|
|
elif mode == "get_folders":
|
|
output = self.get_folders()
|
|
elif mode == "get_spaces":
|
|
output = self.get_spaces()
|
|
elif mode == "update_task":
|
|
output = self.update_task(query)
|
|
elif mode == "update_task_assignees":
|
|
output = self.update_task_assignees(query)
|
|
else:
|
|
output = {"ModeError": f"Got unexpected mode {mode}."}
|
|
|
|
try:
|
|
return json.dumps(output)
|
|
except Exception:
|
|
return str(output)
|