agno 1.7.5__py3-none-any.whl → 1.7.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno/agent/agent.py +5 -24
- agno/app/agui/async_router.py +5 -5
- agno/app/agui/sync_router.py +5 -5
- agno/app/agui/utils.py +84 -14
- agno/app/playground/app.py +3 -2
- agno/document/chunking/row.py +39 -0
- agno/document/reader/base.py +0 -7
- agno/embedder/jina.py +73 -0
- agno/embedder/openai.py +5 -1
- agno/memory/agent.py +2 -2
- agno/memory/team.py +2 -2
- agno/models/anthropic/claude.py +9 -1
- agno/models/aws/bedrock.py +311 -15
- agno/models/google/gemini.py +26 -6
- agno/models/litellm/chat.py +38 -7
- agno/models/message.py +1 -0
- agno/models/openai/chat.py +1 -22
- agno/models/openai/responses.py +5 -5
- agno/models/portkey/__init__.py +3 -0
- agno/models/portkey/portkey.py +88 -0
- agno/models/xai/xai.py +54 -0
- agno/run/v2/workflow.py +4 -0
- agno/storage/mysql.py +2 -0
- agno/storage/postgres.py +5 -3
- agno/storage/session/v2/workflow.py +29 -5
- agno/storage/singlestore.py +4 -1
- agno/storage/sqlite.py +0 -1
- agno/team/team.py +38 -36
- agno/tools/bitbucket.py +292 -0
- agno/tools/daytona.py +411 -63
- agno/tools/evm.py +123 -0
- agno/tools/jina.py +13 -6
- agno/tools/linkup.py +54 -0
- agno/tools/mcp.py +170 -26
- agno/tools/mem0.py +15 -2
- agno/tools/models/morph.py +186 -0
- agno/tools/postgres.py +186 -168
- agno/tools/zep.py +21 -32
- agno/utils/log.py +16 -0
- agno/utils/models/claude.py +1 -0
- agno/utils/string.py +14 -0
- agno/vectordb/pgvector/pgvector.py +4 -5
- agno/workflow/v2/workflow.py +152 -25
- agno/workflow/workflow.py +90 -63
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/METADATA +20 -3
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/RECORD +50 -42
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/WHEEL +0 -0
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/entry_points.txt +0 -0
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/licenses/LICENSE +0 -0
- {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from os import getenv
|
|
3
|
+
from textwrap import dedent
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from agno.tools import Toolkit
|
|
7
|
+
from agno.utils.log import log_debug, log_error
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from openai import OpenAI
|
|
11
|
+
except ImportError:
|
|
12
|
+
raise ImportError("`openai` not installed. Please install using `pip install openai`")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MorphTools(Toolkit):
|
|
16
|
+
"""Tools for interacting with Morph's Fast Apply API for code editing"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_key: Optional[str] = None,
|
|
21
|
+
base_url: str = "https://api.morphllm.com/v1",
|
|
22
|
+
instructions: Optional[str] = None,
|
|
23
|
+
add_instructions: bool = True,
|
|
24
|
+
model: str = "morph-v3-large",
|
|
25
|
+
**kwargs,
|
|
26
|
+
):
|
|
27
|
+
"""Initialize Morph Fast Apply tools.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
api_key: Morph API key. If not provided, will look for MORPH_API_KEY environment variable.
|
|
31
|
+
base_url: The base URL for the Morph API.
|
|
32
|
+
model: The Morph model to use. Options:
|
|
33
|
+
- "morph-v3-fast" (4500+ tok/sec, 96% accuracy)
|
|
34
|
+
- "morph-v3-large" (2500+ tok/sec, 98% accuracy)
|
|
35
|
+
- "auto" (automatic selection)
|
|
36
|
+
**kwargs: Additional arguments to pass to Toolkit.
|
|
37
|
+
"""
|
|
38
|
+
# Set up instructions
|
|
39
|
+
if instructions is None:
|
|
40
|
+
self.instructions = self.DEFAULT_INSTRUCTIONS
|
|
41
|
+
else:
|
|
42
|
+
self.instructions = instructions
|
|
43
|
+
|
|
44
|
+
super().__init__(
|
|
45
|
+
name="morph_tools",
|
|
46
|
+
tools=[self.edit_file],
|
|
47
|
+
instructions=self.instructions,
|
|
48
|
+
add_instructions=add_instructions,
|
|
49
|
+
**kwargs,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.api_key = api_key or getenv("MORPH_API_KEY")
|
|
53
|
+
if not self.api_key:
|
|
54
|
+
raise ValueError("MORPH_API_KEY not set. Please set the MORPH_API_KEY environment variable.")
|
|
55
|
+
|
|
56
|
+
self.base_url = base_url
|
|
57
|
+
self.model = model
|
|
58
|
+
self._morph_client: Optional[OpenAI] = None
|
|
59
|
+
|
|
60
|
+
def _get_client(self):
|
|
61
|
+
"""Get or create the Morph OpenAI client."""
|
|
62
|
+
if self._morph_client is None:
|
|
63
|
+
self._morph_client = OpenAI(
|
|
64
|
+
api_key=self.api_key,
|
|
65
|
+
base_url=self.base_url,
|
|
66
|
+
)
|
|
67
|
+
return self._morph_client
|
|
68
|
+
|
|
69
|
+
def edit_file(
|
|
70
|
+
self,
|
|
71
|
+
target_file: str,
|
|
72
|
+
instructions: str,
|
|
73
|
+
code_edit: str,
|
|
74
|
+
original_code: Optional[str] = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Apply code edits to a target file using Morph's Fast Apply API.
|
|
78
|
+
|
|
79
|
+
This function reads the specified file, sends its content along with
|
|
80
|
+
editing instructions and code edits to Morph's API, and writes the
|
|
81
|
+
resulting code back to the file. A backup of the original file is
|
|
82
|
+
created before writing changes.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
target_file (str): Path to the file to be edited.
|
|
86
|
+
instructions (str): High-level instructions describing the intended change.
|
|
87
|
+
code_edit (str): Specific code edit or change to apply.
|
|
88
|
+
original_code (Optional[str], optional): Original content of the file.
|
|
89
|
+
If not provided, the function reads from target_file.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
str: Result message indicating success or failure, and details about
|
|
93
|
+
the backup and any errors encountered.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
# Always read the actual file content for backup purposes
|
|
97
|
+
actual_file_content = None
|
|
98
|
+
if os.path.exists(target_file):
|
|
99
|
+
try:
|
|
100
|
+
with open(target_file, "r", encoding="utf-8") as f:
|
|
101
|
+
actual_file_content = f.read()
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return f"Error reading {target_file} for backup: {e}"
|
|
104
|
+
else:
|
|
105
|
+
return f"Error: File {target_file} does not exist."
|
|
106
|
+
|
|
107
|
+
# Use provided original_code or fall back to file content
|
|
108
|
+
code_to_process = original_code if original_code is not None else actual_file_content
|
|
109
|
+
|
|
110
|
+
# Format the message for Morph's Fast Apply API
|
|
111
|
+
content = f"<instruction>{instructions}</instruction>\n<code>{code_to_process}</code>\n<update>{code_edit}</update>"
|
|
112
|
+
|
|
113
|
+
log_debug(f"Input to Morph: {content}")
|
|
114
|
+
|
|
115
|
+
client = self._get_client()
|
|
116
|
+
|
|
117
|
+
response = client.chat.completions.create(
|
|
118
|
+
model=self.model,
|
|
119
|
+
messages=[
|
|
120
|
+
{
|
|
121
|
+
"role": "user",
|
|
122
|
+
"content": content,
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if response.choices and response.choices[0].message.content:
|
|
128
|
+
final_code = response.choices[0].message.content
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
backup_file = f"{target_file}.backup"
|
|
132
|
+
with open(backup_file, "w", encoding="utf-8") as f:
|
|
133
|
+
f.write(actual_file_content)
|
|
134
|
+
|
|
135
|
+
# Write the new code
|
|
136
|
+
with open(target_file, "w", encoding="utf-8") as f:
|
|
137
|
+
f.write(final_code)
|
|
138
|
+
return f"Successfully applied edit to {target_file} using Morph Fast Apply! Original content backed up as {backup_file}"
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
return f"Successfully applied edit but failed to write back to {target_file}: {e}"
|
|
142
|
+
|
|
143
|
+
else:
|
|
144
|
+
return f"Failed to apply edit to {target_file}: No response from Morph API"
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
log_error(f"Failed to apply edit using Morph Fast Apply: {e}")
|
|
148
|
+
return f"Failed to apply edit to {target_file}: {e}"
|
|
149
|
+
|
|
150
|
+
DEFAULT_INSTRUCTIONS = dedent("""\
|
|
151
|
+
You have access to Morph Fast Apply for ultra-fast code editing with 98% accuracy at 2500+ tokens/second.
|
|
152
|
+
|
|
153
|
+
## How to use the edit_file tool:
|
|
154
|
+
|
|
155
|
+
**Critical Requirements:**
|
|
156
|
+
1. **Instructions Parameter**: Generate clear first-person instructions describing what you're doing
|
|
157
|
+
- Example: "I am adding type hints to all functions and methods"
|
|
158
|
+
- Example: "I am refactoring the error handling to use try-catch blocks"
|
|
159
|
+
|
|
160
|
+
2. **Code Edit Parameter**: Specify ONLY the lines you want to change
|
|
161
|
+
- Use `# ... existing code ...` (or `// ... existing code ...` for JS/Java) to represent unchanged sections
|
|
162
|
+
- NEVER write out unchanged code in the code_edit parameter
|
|
163
|
+
- Include sufficient context around changes to resolve ambiguity
|
|
164
|
+
|
|
165
|
+
3. **Single Edit Call**: Make ALL edits to a file in a single edit_file call. The apply model can handle many distinct edits at once.
|
|
166
|
+
|
|
167
|
+
**Example Format:**
|
|
168
|
+
```
|
|
169
|
+
# ... existing code ...
|
|
170
|
+
def add(a: int, b: int) -> int:
|
|
171
|
+
\"\"\"Add two numbers together.\"\"\"
|
|
172
|
+
return a + b
|
|
173
|
+
# ... existing code ...
|
|
174
|
+
def multiply(x: int, y: int) -> int:
|
|
175
|
+
\"\"\"Multiply two numbers.\"\"\"
|
|
176
|
+
return x * y
|
|
177
|
+
# ... existing code ...
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Important Guidelines:**
|
|
181
|
+
- Bias towards repeating as few lines as possible while conveying the change clearly
|
|
182
|
+
- Each edit should contain sufficient context of unchanged lines around the code you're editing
|
|
183
|
+
- DO NOT omit spans of pre-existing code without using the `# ... existing code ...` comment
|
|
184
|
+
- If deleting a section, provide context before and after to clearly indicate the deletion
|
|
185
|
+
- The tool automatically creates backup files before applying changes\
|
|
186
|
+
""")
|
agno/tools/postgres.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
+
import csv
|
|
1
2
|
from typing import Any, Dict, List, Optional
|
|
2
3
|
|
|
3
4
|
try:
|
|
4
|
-
import
|
|
5
|
+
import psycopg
|
|
6
|
+
from psycopg import sql
|
|
7
|
+
from psycopg.connection import Connection as PgConnection
|
|
8
|
+
from psycopg.rows import DictRow, dict_row
|
|
5
9
|
except ImportError:
|
|
6
|
-
raise ImportError(
|
|
7
|
-
"`psycopg2` not installed. Please install using `pip install psycopg2`. If you face issues, try `pip install psycopg2-binary`."
|
|
8
|
-
)
|
|
10
|
+
raise ImportError("`psycopg` not installed. Please install using `pip install 'psycopg-binary'`.")
|
|
9
11
|
|
|
10
12
|
from agno.tools import Toolkit
|
|
11
|
-
from agno.utils.log import log_debug,
|
|
13
|
+
from agno.utils.log import log_debug, log_error
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class PostgresTools(Toolkit):
|
|
15
|
-
"""A basic tool to connect to a PostgreSQL database and perform read-only operations on it."""
|
|
16
|
-
|
|
17
17
|
def __init__(
|
|
18
18
|
self,
|
|
19
|
-
connection: Optional[
|
|
19
|
+
connection: Optional[PgConnection[DictRow]] = None,
|
|
20
20
|
db_name: Optional[str] = None,
|
|
21
21
|
user: Optional[str] = None,
|
|
22
22
|
password: Optional[str] = None,
|
|
@@ -29,7 +29,7 @@ class PostgresTools(Toolkit):
|
|
|
29
29
|
table_schema: str = "public",
|
|
30
30
|
**kwargs,
|
|
31
31
|
):
|
|
32
|
-
self._connection: Optional[
|
|
32
|
+
self._connection: Optional[PgConnection[DictRow]] = connection
|
|
33
33
|
self.db_name: Optional[str] = db_name
|
|
34
34
|
self.user: Optional[str] = user
|
|
35
35
|
self.password: Optional[str] = password
|
|
@@ -37,9 +37,10 @@ class PostgresTools(Toolkit):
|
|
|
37
37
|
self.port: Optional[int] = port
|
|
38
38
|
self.table_schema: str = table_schema
|
|
39
39
|
|
|
40
|
-
tools: List[Any] = [
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
tools: List[Any] = [
|
|
41
|
+
self.show_tables,
|
|
42
|
+
self.describe_table,
|
|
43
|
+
]
|
|
43
44
|
if inspect_queries:
|
|
44
45
|
tools.append(self.inspect_query)
|
|
45
46
|
if run_queries:
|
|
@@ -52,196 +53,213 @@ class PostgresTools(Toolkit):
|
|
|
52
53
|
super().__init__(name="postgres_tools", tools=tools, **kwargs)
|
|
53
54
|
|
|
54
55
|
@property
|
|
55
|
-
def connection(self) ->
|
|
56
|
+
def connection(self) -> PgConnection[DictRow]:
|
|
56
57
|
"""
|
|
57
|
-
Returns the Postgres
|
|
58
|
-
|
|
59
|
-
:return psycopg2.extensions.connection: psycopg2 connection
|
|
58
|
+
Returns the Postgres psycopg connection.
|
|
59
|
+
:return psycopg.connection.Connection: psycopg connection
|
|
60
60
|
"""
|
|
61
|
-
if self._connection is None:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
if self._connection is None or self._connection.closed:
|
|
62
|
+
log_debug("Establishing new PostgreSQL connection.")
|
|
63
|
+
connection_kwargs: Dict[str, Any] = {"row_factory": dict_row}
|
|
64
|
+
if self.db_name:
|
|
65
|
+
connection_kwargs["dbname"] = self.db_name
|
|
66
|
+
if self.user:
|
|
66
67
|
connection_kwargs["user"] = self.user
|
|
67
|
-
if self.password
|
|
68
|
+
if self.password:
|
|
68
69
|
connection_kwargs["password"] = self.password
|
|
69
|
-
if self.host
|
|
70
|
+
if self.host:
|
|
70
71
|
connection_kwargs["host"] = self.host
|
|
71
|
-
if self.port
|
|
72
|
+
if self.port:
|
|
72
73
|
connection_kwargs["port"] = self.port
|
|
73
|
-
if self.table_schema is not None:
|
|
74
|
-
connection_kwargs["options"] = f"-c search_path={self.table_schema}"
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
connection_kwargs["options"] = f"-c search_path={self.table_schema}"
|
|
76
|
+
|
|
77
|
+
self._connection = psycopg.connect(**connection_kwargs)
|
|
78
|
+
self._connection.read_only = True
|
|
78
79
|
|
|
79
80
|
return self._connection
|
|
80
81
|
|
|
82
|
+
def __enter__(self):
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
def close(self):
|
|
89
|
+
"""Closes the database connection if it's open."""
|
|
90
|
+
if self._connection and not self._connection.closed:
|
|
91
|
+
log_debug("Closing PostgreSQL connection.")
|
|
92
|
+
self._connection.close()
|
|
93
|
+
self._connection = None
|
|
94
|
+
|
|
95
|
+
def _execute_query(self, query: str, params: Optional[tuple] = None) -> str:
|
|
96
|
+
try:
|
|
97
|
+
with self.connection.cursor() as cursor:
|
|
98
|
+
log_debug(f"Running PostgreSQL Query: {query} with Params: {params}")
|
|
99
|
+
cursor.execute(query, params)
|
|
100
|
+
|
|
101
|
+
if cursor.description is None:
|
|
102
|
+
return cursor.statusmessage or "Query executed successfully with no output."
|
|
103
|
+
|
|
104
|
+
columns = [desc[0] for desc in cursor.description]
|
|
105
|
+
rows = cursor.fetchall()
|
|
106
|
+
|
|
107
|
+
if not rows:
|
|
108
|
+
return f"Query returned no results.\nColumns: {', '.join(columns)}"
|
|
109
|
+
|
|
110
|
+
header = ",".join(columns)
|
|
111
|
+
data_rows = [",".join(map(str, row.values())) for row in rows]
|
|
112
|
+
return f"{header}\n" + "\n".join(data_rows)
|
|
113
|
+
|
|
114
|
+
except psycopg.Error as e:
|
|
115
|
+
log_error(f"Database error: {e}")
|
|
116
|
+
if self.connection and not self.connection.closed:
|
|
117
|
+
self.connection.rollback()
|
|
118
|
+
return f"Error executing query: {e}"
|
|
119
|
+
except Exception as e:
|
|
120
|
+
log_error(f"An unexpected error occurred: {e}")
|
|
121
|
+
return f"An unexpected error occurred: {e}"
|
|
122
|
+
|
|
81
123
|
def show_tables(self) -> str:
|
|
82
|
-
"""
|
|
124
|
+
"""Lists all tables in the configured schema."""
|
|
83
125
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
stmt = f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{self.table_schema}';"
|
|
87
|
-
tables = self.run_query(stmt)
|
|
88
|
-
log_debug(f"Tables: {tables}")
|
|
89
|
-
return tables
|
|
126
|
+
stmt = "SELECT table_name FROM information_schema.tables WHERE table_schema = %s;"
|
|
127
|
+
return self._execute_query(stmt, (self.table_schema,))
|
|
90
128
|
|
|
91
129
|
def describe_table(self, table: str) -> str:
|
|
92
|
-
"""Function to describe a table
|
|
93
|
-
|
|
94
|
-
:param table: Table to describe
|
|
95
|
-
:return: Description of the table
|
|
96
130
|
"""
|
|
97
|
-
|
|
98
|
-
table_description = self.run_query(stmt)
|
|
99
|
-
|
|
100
|
-
log_debug(f"Table description: {table_description}")
|
|
101
|
-
return f"{table}\n{table_description}"
|
|
131
|
+
Provides the schema (column name, data type, is nullable) for a given table.
|
|
102
132
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
The function launches a query that computes a number of aggregates over all columns,
|
|
106
|
-
including min, max, avg, std and approx_unique.
|
|
133
|
+
Args:
|
|
134
|
+
table: The name of the table to describe.
|
|
107
135
|
|
|
108
|
-
:
|
|
109
|
-
|
|
136
|
+
Returns:
|
|
137
|
+
A string describing the table's columns and data types.
|
|
110
138
|
"""
|
|
111
|
-
stmt =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
FROM
|
|
116
|
-
information_schema.columns
|
|
117
|
-
WHERE
|
|
118
|
-
table_name = '{table}'
|
|
119
|
-
AND table_schema = '{self.table_schema}'
|
|
120
|
-
)
|
|
121
|
-
SELECT
|
|
122
|
-
column_name,
|
|
123
|
-
data_type,
|
|
124
|
-
COUNT(COALESCE(column_name::text, '')) AS non_null_count,
|
|
125
|
-
COUNT(*) - COUNT(COALESCE(column_name::text, '')) AS null_count,
|
|
126
|
-
SUM(COALESCE(column_name::numeric, 0)) AS sum,
|
|
127
|
-
AVG(COALESCE(column_name::numeric, 0)) AS mean,
|
|
128
|
-
MIN(column_name::numeric) AS min,
|
|
129
|
-
MAX(column_name::numeric) AS max,
|
|
130
|
-
STDDEV(COALESCE(column_name::numeric, 0)) AS stddev
|
|
131
|
-
FROM
|
|
132
|
-
column_stats,
|
|
133
|
-
LATERAL (
|
|
134
|
-
SELECT
|
|
135
|
-
*
|
|
136
|
-
FROM
|
|
137
|
-
{table}
|
|
138
|
-
) AS tbl
|
|
139
|
-
WHERE
|
|
140
|
-
data_type IN ('integer', 'numeric', 'real', 'double precision')
|
|
141
|
-
GROUP BY
|
|
142
|
-
column_name, data_type
|
|
143
|
-
UNION ALL
|
|
144
|
-
SELECT
|
|
145
|
-
column_name,
|
|
146
|
-
data_type,
|
|
147
|
-
COUNT(COALESCE(column_name::text, '')) AS non_null_count,
|
|
148
|
-
COUNT(*) - COUNT(COALESCE(column_name::text, '')) AS null_count,
|
|
149
|
-
NULL AS sum,
|
|
150
|
-
NULL AS mean,
|
|
151
|
-
NULL AS min,
|
|
152
|
-
NULL AS max,
|
|
153
|
-
NULL AS stddev
|
|
154
|
-
FROM
|
|
155
|
-
column_stats,
|
|
156
|
-
LATERAL (
|
|
157
|
-
SELECT
|
|
158
|
-
*
|
|
159
|
-
FROM
|
|
160
|
-
{table}
|
|
161
|
-
) AS tbl
|
|
162
|
-
WHERE
|
|
163
|
-
data_type NOT IN ('integer', 'numeric', 'real', 'double precision')
|
|
164
|
-
GROUP BY
|
|
165
|
-
column_name, data_type;
|
|
139
|
+
stmt = """
|
|
140
|
+
SELECT column_name, data_type, is_nullable
|
|
141
|
+
FROM information_schema.columns
|
|
142
|
+
WHERE table_schema = %s AND table_name = %s;
|
|
166
143
|
"""
|
|
167
|
-
|
|
144
|
+
return self._execute_query(stmt, (self.table_schema, table))
|
|
168
145
|
|
|
169
|
-
|
|
170
|
-
|
|
146
|
+
def summarize_table(self, table: str) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Computes and returns key summary statistics for a table's columns.
|
|
171
149
|
|
|
172
|
-
|
|
173
|
-
|
|
150
|
+
Args:
|
|
151
|
+
table: The name of the table to summarize.
|
|
174
152
|
|
|
175
|
-
:
|
|
176
|
-
|
|
153
|
+
Returns:
|
|
154
|
+
A string containing a summary of the table.
|
|
177
155
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
156
|
+
try:
|
|
157
|
+
with self.connection.cursor() as cursor:
|
|
158
|
+
# First, get column information using a parameterized query
|
|
159
|
+
schema_query = """
|
|
160
|
+
SELECT column_name, data_type
|
|
161
|
+
FROM information_schema.columns
|
|
162
|
+
WHERE table_schema = %s AND table_name = %s;
|
|
163
|
+
"""
|
|
164
|
+
cursor.execute(schema_query, (self.table_schema, table))
|
|
165
|
+
columns = cursor.fetchall()
|
|
166
|
+
if not columns:
|
|
167
|
+
return f"Error: Table '{table}' not found in schema '{self.table_schema}'."
|
|
168
|
+
|
|
169
|
+
summary_parts = [f"Summary for table: {table}\n"]
|
|
170
|
+
table_identifier = sql.Identifier(self.table_schema, table)
|
|
171
|
+
|
|
172
|
+
for col in columns:
|
|
173
|
+
col_name, data_type = col["column_name"], col["data_type"]
|
|
174
|
+
col_identifier = sql.Identifier(col_name)
|
|
175
|
+
|
|
176
|
+
query = None
|
|
177
|
+
if any(
|
|
178
|
+
t in data_type for t in ["integer", "numeric", "real", "double precision", "bigint", "smallint"]
|
|
179
|
+
):
|
|
180
|
+
query = sql.SQL("""
|
|
181
|
+
SELECT
|
|
182
|
+
COUNT(*) AS total_rows,
|
|
183
|
+
COUNT({col}) AS non_null_rows,
|
|
184
|
+
MIN({col}) AS min,
|
|
185
|
+
MAX({col}) AS max,
|
|
186
|
+
AVG({col}) AS average,
|
|
187
|
+
STDDEV({col}) AS std_deviation
|
|
188
|
+
FROM {tbl};
|
|
189
|
+
""").format(col=col_identifier, tbl=table_identifier)
|
|
190
|
+
elif any(t in data_type for t in ["char", "text", "uuid"]):
|
|
191
|
+
query = sql.SQL("""
|
|
192
|
+
SELECT
|
|
193
|
+
COUNT(*) AS total_rows,
|
|
194
|
+
COUNT({col}) AS non_null_rows,
|
|
195
|
+
COUNT(DISTINCT {col}) AS unique_values,
|
|
196
|
+
AVG(LENGTH({col}::text)) as avg_length
|
|
197
|
+
FROM {tbl};
|
|
198
|
+
""").format(col=col_identifier, tbl=table_identifier)
|
|
199
|
+
|
|
200
|
+
if query:
|
|
201
|
+
cursor.execute(query)
|
|
202
|
+
stats = cursor.fetchone()
|
|
203
|
+
summary_parts.append(f"\n--- Column: {col_name} (Type: {data_type}) ---")
|
|
204
|
+
if stats is not None:
|
|
205
|
+
for key, value in stats.items():
|
|
206
|
+
val_str = (
|
|
207
|
+
f"{value:.2f}" if isinstance(value, float) and value is not None else str(value)
|
|
208
|
+
)
|
|
209
|
+
summary_parts.append(f" {key}: {val_str}")
|
|
210
|
+
else:
|
|
211
|
+
summary_parts.append(" No statistics available")
|
|
180
212
|
|
|
181
|
-
|
|
182
|
-
return explain_plan
|
|
213
|
+
return "\n".join(summary_parts)
|
|
183
214
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
If the path is provided, the table will be saved under that path.
|
|
187
|
-
Eg: If path is /tmp, the table will be saved as /tmp/table.csv
|
|
188
|
-
Otherwise it will be saved in the current directory
|
|
215
|
+
except psycopg.Error as e:
|
|
216
|
+
return f"Error summarizing table: {e}"
|
|
189
217
|
|
|
190
|
-
|
|
191
|
-
:param path: Path to export to
|
|
192
|
-
:return: None
|
|
218
|
+
def inspect_query(self, query: str) -> str:
|
|
193
219
|
"""
|
|
220
|
+
Shows the execution plan for a SQL query (using EXPLAIN).
|
|
194
221
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
path = f"{path}/{table}.csv"
|
|
200
|
-
|
|
201
|
-
export_statement = f"COPY {self.table_schema}.{table} TO '{path}' DELIMITER ',' CSV HEADER;"
|
|
202
|
-
result = self.run_query(export_statement)
|
|
203
|
-
log_debug(f"Exported {table} to {path}/{table}")
|
|
204
|
-
|
|
205
|
-
return result
|
|
222
|
+
:param query: The SQL query to inspect.
|
|
223
|
+
:return: The query's execution plan.
|
|
224
|
+
"""
|
|
225
|
+
return self._execute_query(f"EXPLAIN {query}")
|
|
206
226
|
|
|
207
|
-
def
|
|
208
|
-
"""
|
|
227
|
+
def export_table_to_path(self, table: str, path: str) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Exports a table's data to a local CSV file.
|
|
209
230
|
|
|
210
|
-
:param
|
|
211
|
-
:
|
|
231
|
+
:param table: The name of the table to export.
|
|
232
|
+
:param path: The local file path to save the file.
|
|
233
|
+
:return: A confirmation message with the file path.
|
|
212
234
|
"""
|
|
235
|
+
log_debug(f"Exporting Table {table} as CSV to local path {path}")
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
formatted_sql = query.replace("`", "")
|
|
217
|
-
# If there are multiple statements, only run the first one
|
|
218
|
-
formatted_sql = formatted_sql.split(";")[0]
|
|
237
|
+
table_identifier = sql.Identifier(self.table_schema, table)
|
|
238
|
+
stmt = sql.SQL("SELECT * FROM {tbl};").format(tbl=table_identifier)
|
|
219
239
|
|
|
220
240
|
try:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
cursor = self.connection.cursor()
|
|
224
|
-
cursor.execute(query)
|
|
225
|
-
query_result = cursor.fetchall()
|
|
226
|
-
|
|
227
|
-
result_output = "No output"
|
|
228
|
-
if query_result is not None:
|
|
229
|
-
try:
|
|
230
|
-
results_as_python_objects = query_result
|
|
231
|
-
result_rows = []
|
|
232
|
-
for row in results_as_python_objects:
|
|
233
|
-
if len(row) == 1:
|
|
234
|
-
result_rows.append(str(row[0]))
|
|
235
|
-
else:
|
|
236
|
-
result_rows.append(",".join(str(x) for x in row))
|
|
241
|
+
with self.connection.cursor() as cursor:
|
|
242
|
+
cursor.execute(stmt)
|
|
237
243
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
except AttributeError:
|
|
241
|
-
result_output = str(query_result)
|
|
244
|
+
if cursor.description is None:
|
|
245
|
+
return f"Error: Query returned no description for table '{table}'."
|
|
242
246
|
|
|
243
|
-
|
|
247
|
+
columns = [desc[0] for desc in cursor.description]
|
|
244
248
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
250
|
+
writer = csv.writer(f)
|
|
251
|
+
writer.writerow(columns)
|
|
252
|
+
writer.writerows(row.values() for row in cursor)
|
|
253
|
+
|
|
254
|
+
return f"Successfully exported table '{table}' to '{path}'."
|
|
255
|
+
except (psycopg.Error, IOError) as e:
|
|
256
|
+
return f"Error exporting table: {e}"
|
|
257
|
+
|
|
258
|
+
def run_query(self, query: str) -> str:
|
|
259
|
+
"""
|
|
260
|
+
Runs a read-only SQL query and returns the result.
|
|
261
|
+
|
|
262
|
+
:param query: The SQL query to run.
|
|
263
|
+
:return: The query result as a formatted string.
|
|
264
|
+
"""
|
|
265
|
+
return self._execute_query(query)
|