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.
Files changed (50) hide show
  1. agno/agent/agent.py +5 -24
  2. agno/app/agui/async_router.py +5 -5
  3. agno/app/agui/sync_router.py +5 -5
  4. agno/app/agui/utils.py +84 -14
  5. agno/app/playground/app.py +3 -2
  6. agno/document/chunking/row.py +39 -0
  7. agno/document/reader/base.py +0 -7
  8. agno/embedder/jina.py +73 -0
  9. agno/embedder/openai.py +5 -1
  10. agno/memory/agent.py +2 -2
  11. agno/memory/team.py +2 -2
  12. agno/models/anthropic/claude.py +9 -1
  13. agno/models/aws/bedrock.py +311 -15
  14. agno/models/google/gemini.py +26 -6
  15. agno/models/litellm/chat.py +38 -7
  16. agno/models/message.py +1 -0
  17. agno/models/openai/chat.py +1 -22
  18. agno/models/openai/responses.py +5 -5
  19. agno/models/portkey/__init__.py +3 -0
  20. agno/models/portkey/portkey.py +88 -0
  21. agno/models/xai/xai.py +54 -0
  22. agno/run/v2/workflow.py +4 -0
  23. agno/storage/mysql.py +2 -0
  24. agno/storage/postgres.py +5 -3
  25. agno/storage/session/v2/workflow.py +29 -5
  26. agno/storage/singlestore.py +4 -1
  27. agno/storage/sqlite.py +0 -1
  28. agno/team/team.py +38 -36
  29. agno/tools/bitbucket.py +292 -0
  30. agno/tools/daytona.py +411 -63
  31. agno/tools/evm.py +123 -0
  32. agno/tools/jina.py +13 -6
  33. agno/tools/linkup.py +54 -0
  34. agno/tools/mcp.py +170 -26
  35. agno/tools/mem0.py +15 -2
  36. agno/tools/models/morph.py +186 -0
  37. agno/tools/postgres.py +186 -168
  38. agno/tools/zep.py +21 -32
  39. agno/utils/log.py +16 -0
  40. agno/utils/models/claude.py +1 -0
  41. agno/utils/string.py +14 -0
  42. agno/vectordb/pgvector/pgvector.py +4 -5
  43. agno/workflow/v2/workflow.py +152 -25
  44. agno/workflow/workflow.py +90 -63
  45. {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/METADATA +20 -3
  46. {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/RECORD +50 -42
  47. {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/WHEEL +0 -0
  48. {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/entry_points.txt +0 -0
  49. {agno-1.7.5.dist-info → agno-1.7.7.dist-info}/licenses/LICENSE +0 -0
  50. {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 psycopg2
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, log_info
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[psycopg2.extensions.connection] = None,
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[psycopg2.extensions.connection] = connection
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
- tools.append(self.show_tables)
42
- tools.append(self.describe_table)
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) -> psycopg2.extensions.connection:
56
+ def connection(self) -> PgConnection[DictRow]:
56
57
  """
57
- Returns the Postgres psycopg2 connection.
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
- connection_kwargs: Dict[str, Any] = {}
63
- if self.db_name is not None:
64
- connection_kwargs["database"] = self.db_name
65
- if self.user is not None:
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 is not None:
68
+ if self.password:
68
69
  connection_kwargs["password"] = self.password
69
- if self.host is not None:
70
+ if self.host:
70
71
  connection_kwargs["host"] = self.host
71
- if self.port is not None:
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
- self._connection = psycopg2.connect(**connection_kwargs)
77
- self._connection.set_session(readonly=True)
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
- """Function to show tables in the database
124
+ """Lists all tables in the configured schema."""
83
125
 
84
- :return: List of tables in the database
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
- stmt = f"SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_name = '{table}' AND table_schema = '{self.table_schema}';"
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
- def summarize_table(self, table: str) -> str:
104
- """Function to compute a number of aggregates over a table.
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
- :param table: Table to summarize
109
- :return: Summary of the table
136
+ Returns:
137
+ A string describing the table's columns and data types.
110
138
  """
111
- stmt = f"""WITH column_stats AS (
112
- SELECT
113
- column_name,
114
- data_type
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
- table_summary = self.run_query(stmt)
144
+ return self._execute_query(stmt, (self.table_schema, table))
168
145
 
169
- log_debug(f"Table summary: {table_summary}")
170
- return table_summary
146
+ def summarize_table(self, table: str) -> str:
147
+ """
148
+ Computes and returns key summary statistics for a table's columns.
171
149
 
172
- def inspect_query(self, query: str) -> str:
173
- """Function to inspect a query and return the query plan. Always inspect your query before running them.
150
+ Args:
151
+ table: The name of the table to summarize.
174
152
 
175
- :param query: Query to inspect
176
- :return: Query plan
153
+ Returns:
154
+ A string containing a summary of the table.
177
155
  """
178
- stmt = f"EXPLAIN {query};"
179
- explain_plan = self.run_query(stmt)
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
- log_debug(f"Explain plan: {explain_plan}")
182
- return explain_plan
213
+ return "\n".join(summary_parts)
183
214
 
184
- def export_table_to_path(self, table: str, path: Optional[str] = None) -> str:
185
- """Save a table in CSV format.
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
- :param table: Table to export
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
- log_debug(f"Exporting Table {table} as CSV to path {path}")
196
- if path is None:
197
- path = f"{table}.csv"
198
- else:
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 run_query(self, query: str) -> str:
208
- """Function that runs a query and returns the result.
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 query: SQL query to run
211
- :return: Result of the query
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
- # -*- Format the SQL Query
215
- # Remove backticks
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
- log_info(f"Running: {formatted_sql}")
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
- result_data = "\n".join(result_rows)
239
- result_output = ",".join(query_result.columns) + "\n" + result_data
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
- log_debug(f"Query result: {result_output}")
247
+ columns = [desc[0] for desc in cursor.description]
244
248
 
245
- return result_output
246
- except Exception as e:
247
- return str(e)
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)