sageql 0.2.1__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 (51) hide show
  1. sageql/__init__.py +108 -0
  2. sageql/catalog_input.py +138 -0
  3. sageql/chat_cli.py +229 -0
  4. sageql/chat_provider.py +527 -0
  5. sageql/cli.py +43 -0
  6. sageql/context.py +136 -0
  7. sageql/conversation.py +92 -0
  8. sageql/core.py +47 -0
  9. sageql/discovery.py +188 -0
  10. sageql/errors.py +9 -0
  11. sageql/execution.py +139 -0
  12. sageql/localization.py +98 -0
  13. sageql/odbc_catalog.py +172 -0
  14. sageql/openai_provider.py +79 -0
  15. sageql/planning.py +415 -0
  16. sageql/report_validation.py +233 -0
  17. sageql/schema.py +86 -0
  18. sageql/sdk/__init__.py +36 -0
  19. sageql/sdk/adapters.py +709 -0
  20. sageql/sdk/agents.py +1000 -0
  21. sageql/sdk/business.py +158 -0
  22. sageql/sdk/calendars.py +133 -0
  23. sageql/sdk/comparisons.py +185 -0
  24. sageql/sdk/consistency.py +268 -0
  25. sageql/sdk/day_counts.py +88 -0
  26. sageql/sdk/decreases.py +88 -0
  27. sageql/sdk/engine.py +781 -0
  28. sageql/sdk/evidence.py +1098 -0
  29. sageql/sdk/grounding.py +568 -0
  30. sageql/sdk/intent.py +570 -0
  31. sageql/sdk/labels.py +77 -0
  32. sageql/sdk/lookups.py +130 -0
  33. sageql/sdk/models.py +492 -0
  34. sageql/sdk/names.py +10 -0
  35. sageql/sdk/periods.py +115 -0
  36. sageql/sdk/phrases.py +372 -0
  37. sageql/sdk/provider.py +835 -0
  38. sageql/sdk/relationships.py +238 -0
  39. sageql/sdk/saved.py +133 -0
  40. sageql/sdk/semantics.py +751 -0
  41. sageql/sdk/sessions.py +240 -0
  42. sageql/sdk/vocabulary.py +171 -0
  43. sageql/sql_generation.py +220 -0
  44. sageql/tsql.py +301 -0
  45. sageql/understanding.py +74 -0
  46. sageql/validation.py +50 -0
  47. sageql-0.2.1.dist-info/METADATA +415 -0
  48. sageql-0.2.1.dist-info/RECORD +51 -0
  49. sageql-0.2.1.dist-info/WHEEL +4 -0
  50. sageql-0.2.1.dist-info/entry_points.txt +2 -0
  51. sageql-0.2.1.dist-info/licenses/LICENSE +21 -0
sageql/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """Public API for SageQL."""
2
+
3
+ from sageql.core import Candidate, GeneratedQuery, SQLProvider, generate_sql
4
+ from sageql.conversation import (
5
+ ChatConfig,
6
+ ChatError,
7
+ ChatProvider,
8
+ DatabaseConfig,
9
+ LLMConfig,
10
+ Message,
11
+ ReportConversation,
12
+ )
13
+ from sageql.context import ContextProvider, ContextResolution, ContextResolutionSession, ResolvedContext
14
+ from sageql.catalog_input import catalog_from_dict, load_catalog_file
15
+ from sageql.discovery import (
16
+ CatalogSource, DiscoveryCandidates, DiscoveryError, DiscoveryProvider,
17
+ DiscoverySelection, QuerySpace, discover_query_space,
18
+ )
19
+ from sageql.odbc_catalog import ODBCCatalogSource
20
+ from sageql.planning import (
21
+ PlanCandidates, PlanProposal, PlanQuality, PlanningError, PlanningProvider,
22
+ ProposedFilter, ProposedJoin, ProposedMeasure, QueryPlan, create_query_plan,
23
+ review_query_plan,
24
+ )
25
+ from sageql.schema import Column, Definition, Relation, SchemaCatalog, Table
26
+ from sageql.sql_generation import SQLGenerationError, SQLQuery, generate_sql_from_plan
27
+ from sageql.report_validation import (
28
+ PreparedQuery, QueryValidationError, RequiredFilter, ValidationResult,
29
+ prepare_report_query, validate_report_query,
30
+ )
31
+ from sageql.execution import QueryExecutionError, QueryResult, execute_sqlite_report
32
+ from sageql.errors import GenerationError, InvalidSQL
33
+ from sageql.validation import validate_sql
34
+ from sageql.tsql import (
35
+ TSQLQuery, TSQLResult, TSQLReportError, render_tsql_report,
36
+ validate_tsql_report, execute_tsql_report,
37
+ )
38
+ from sageql.understanding import RequestAssessment, RequestUnderstandingSession, UnderstandingProvider
39
+ from sageql.sdk import SageQL
40
+
41
+ __all__ = [
42
+ "Candidate",
43
+ "SageQL",
44
+ "ChatConfig",
45
+ "ChatError",
46
+ "ChatProvider",
47
+ "ContextProvider",
48
+ "ContextResolution",
49
+ "ContextResolutionSession",
50
+ "CatalogSource",
51
+ "catalog_from_dict",
52
+ "Column",
53
+ "DatabaseConfig",
54
+ "Definition",
55
+ "DiscoveryCandidates",
56
+ "DiscoveryError",
57
+ "DiscoveryProvider",
58
+ "DiscoverySelection",
59
+ "GeneratedQuery",
60
+ "GenerationError",
61
+ "InvalidSQL",
62
+ "LLMConfig",
63
+ "load_catalog_file",
64
+ "Message",
65
+ "ODBCCatalogSource",
66
+ "PlanCandidates",
67
+ "PlanProposal",
68
+ "PlanQuality",
69
+ "PlanningError",
70
+ "PlanningProvider",
71
+ "ProposedFilter",
72
+ "ProposedJoin",
73
+ "ProposedMeasure",
74
+ "QueryPlan",
75
+ "QueryExecutionError",
76
+ "QueryResult",
77
+ "QueryValidationError",
78
+ "QuerySpace",
79
+ "Relation",
80
+ "ReportConversation",
81
+ "ResolvedContext",
82
+ "RequestAssessment",
83
+ "RequestUnderstandingSession",
84
+ "RequiredFilter",
85
+ "SQLProvider",
86
+ "SQLGenerationError",
87
+ "SQLQuery",
88
+ "PreparedQuery",
89
+ "ValidationResult",
90
+ "SchemaCatalog",
91
+ "Table",
92
+ "UnderstandingProvider",
93
+ "generate_sql",
94
+ "generate_sql_from_plan",
95
+ "prepare_report_query",
96
+ "validate_report_query",
97
+ "execute_sqlite_report",
98
+ "discover_query_space",
99
+ "create_query_plan",
100
+ "review_query_plan",
101
+ "validate_sql",
102
+ "TSQLQuery",
103
+ "TSQLResult",
104
+ "TSQLReportError",
105
+ "render_tsql_report",
106
+ "validate_tsql_report",
107
+ "execute_tsql_report",
108
+ ]
@@ -0,0 +1,138 @@
1
+ """Parse schema metadata supplied by the package user."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Mapping
6
+
7
+ from sageql.schema import Column, Definition, Relation, SchemaCatalog, Table
8
+
9
+
10
+ _MAX_FILE_BYTES = 1_000_000
11
+
12
+
13
+ def _object(value: Any, required: set[str], optional: set[str], label: str) -> Mapping[str, Any]:
14
+ if not isinstance(value, dict) or not required <= value.keys() or value.keys() - required - optional:
15
+ raise ValueError(f"{label} has missing or unexpected fields")
16
+ return value
17
+
18
+
19
+ def _array(value: Any, label: str, *, maximum: int, nonempty: bool = False) -> list[Any]:
20
+ if not isinstance(value, list) or len(value) > maximum or (nonempty and not value):
21
+ raise ValueError(f"{label} must be a JSON array with 1 to {maximum} items" if nonempty
22
+ else f"{label} must be a JSON array with at most {maximum} items")
23
+ return value
24
+
25
+
26
+ def _text(value: Any, label: str, *, allow_empty: bool = False) -> str:
27
+ if not isinstance(value, str) or len(value) > 2000 or (not allow_empty and not value.strip()):
28
+ raise ValueError(f"{label} must be text")
29
+ if any(ord(character) < 32 for character in value if character not in "\n\t"):
30
+ raise ValueError(f"{label} contains invalid control characters")
31
+ return value.strip()
32
+
33
+
34
+ def _identifier(value: Any, label: str, *, allow_empty: bool = False) -> str:
35
+ result = _text(value, label, allow_empty=allow_empty)
36
+ if len(result) > 256 or "\n" in result or "\t" in result:
37
+ raise ValueError(f"{label} must be a single-line identifier")
38
+ return result
39
+
40
+
41
+ def catalog_from_dict(data: Mapping[str, Any]) -> SchemaCatalog:
42
+ """Validate a JSON-shaped catalog with tables, columns, relations, and definitions."""
43
+ root = _object(data, {"tables"}, {"relations", "definitions"}, "catalog")
44
+ tables: list[Table] = []
45
+ columns: list[Column] = []
46
+ definitions: list[Definition] = []
47
+ for index, raw_table in enumerate(_array(root["tables"], "tables", maximum=200, nonempty=True)):
48
+ label = f"tables[{index}]"
49
+ item = _object(raw_table, {"name", "columns"}, {"schema", "kind", "description"}, label)
50
+ table = Table(
51
+ name=_identifier(item["name"], label + ".name"),
52
+ schema=_identifier(item.get("schema", ""), label + ".schema", allow_empty=True),
53
+ kind=_identifier(item.get("kind", "TABLE"), label + ".kind").upper(),
54
+ )
55
+ tables.append(table)
56
+ if "description" in item:
57
+ definitions.append(Definition(
58
+ table.key, _text(item["description"], label + ".description"),
59
+ "user table description",
60
+ ))
61
+ for col_index, raw_column in enumerate(
62
+ _array(item["columns"], label + ".columns", maximum=500, nonempty=True)
63
+ ):
64
+ col_label = f"{label}.columns[{col_index}]"
65
+ col = _object(raw_column, {"name"}, {"data_type", "nullable", "description"}, col_label)
66
+ nullable = col.get("nullable", True)
67
+ if type(nullable) is not bool:
68
+ raise ValueError(f"{col_label}.nullable must be a boolean")
69
+ column = Column(
70
+ table=table.key,
71
+ name=_identifier(col["name"], col_label + ".name"),
72
+ data_type=_identifier(col.get("data_type", "unknown"), col_label + ".data_type"),
73
+ nullable=nullable,
74
+ )
75
+ columns.append(column)
76
+ if "description" in col:
77
+ definitions.append(Definition(
78
+ column.key, _text(col["description"], col_label + ".description"),
79
+ "user column description",
80
+ ))
81
+ if len(columns) > 5000:
82
+ raise ValueError("catalog has too many columns")
83
+
84
+ table_names = {table.key.casefold(): table.key for table in tables}
85
+ column_names = {(column.table.casefold(), column.name.casefold()): column.name
86
+ for column in columns}
87
+ relations: list[Relation] = []
88
+ for index, raw_relation in enumerate(_array(root.get("relations", []), "relations", maximum=500)):
89
+ label = f"relations[{index}]"
90
+ item = _object(raw_relation,
91
+ {"name", "child_table", "child_columns", "parent_table", "parent_columns"},
92
+ set(), label)
93
+ child_raw = _identifier(item["child_table"], label + ".child_table")
94
+ parent_raw = _identifier(item["parent_table"], label + ".parent_table")
95
+ child = table_names.get(child_raw.casefold(), child_raw)
96
+ parent = table_names.get(parent_raw.casefold(), parent_raw)
97
+ def relation_columns(field: str, table: str) -> tuple[str, ...]:
98
+ values = _array(item[field], label + "." + field, maximum=32, nonempty=True)
99
+ return tuple(
100
+ column_names.get((table.casefold(), name.casefold()), name)
101
+ for name in (_identifier(value, label + "." + field) for value in values)
102
+ )
103
+ relations.append(Relation(
104
+ name=_identifier(item["name"], label + ".name"),
105
+ child_table=child,
106
+ child_columns=relation_columns("child_columns", child),
107
+ parent_table=parent,
108
+ parent_columns=relation_columns("parent_columns", parent),
109
+ ))
110
+
111
+ for index, raw_definition in enumerate(_array(root.get("definitions", []), "definitions", maximum=500)):
112
+ label = f"definitions[{index}]"
113
+ item = _object(raw_definition, {"term", "meaning"}, set(), label)
114
+ definitions.append(Definition(
115
+ _identifier(item["term"], label + ".term"),
116
+ _text(item["meaning"], label + ".meaning"),
117
+ "user glossary",
118
+ ))
119
+ try:
120
+ return SchemaCatalog(tuple(tables), tuple(columns), tuple(relations), tuple(definitions))
121
+ except ValueError as exc:
122
+ raise ValueError(f"invalid catalog structure: {exc}") from exc
123
+
124
+
125
+ def load_catalog_file(path: str | Path) -> SchemaCatalog:
126
+ """Load a UTF-8 JSON catalog without echoing file contents in errors."""
127
+ try:
128
+ with Path(path).open("rb") as handle:
129
+ content = handle.read(_MAX_FILE_BYTES + 1)
130
+ except OSError as exc:
131
+ raise ValueError("could not read schema catalog file") from exc
132
+ if len(content) > _MAX_FILE_BYTES:
133
+ raise ValueError("schema catalog file is too large")
134
+ try:
135
+ data = json.loads(content.decode("utf-8"))
136
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
137
+ raise ValueError("schema catalog must be valid UTF-8 JSON") from exc
138
+ return catalog_from_dict(data)
sageql/chat_cli.py ADDED
@@ -0,0 +1,229 @@
1
+ """Interactive report-planning chat CLI."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from sageql.chat_provider import OpenAIChatProvider
10
+ from sageql.catalog_input import load_catalog_file
11
+ from sageql.conversation import ChatConfig, ChatError, DatabaseConfig, LLMConfig
12
+ from sageql.context import ContextResolutionSession, ResolvedContext
13
+ from sageql.discovery import DiscoveryError, QuerySpace, discover_query_space
14
+ from sageql.planning import PlanQuality, PlanningError, QueryPlan, create_query_plan, review_query_plan
15
+ from sageql.report_validation import QueryValidationError, validate_report_query
16
+ from sageql.sql_generation import SQLGenerationError, SQLQuery, generate_sql_from_plan
17
+ from sageql.execution import QueryExecutionError, execute_sqlite_report
18
+ from sageql.understanding import RequestUnderstandingSession
19
+
20
+
21
+ def _required_input(label: str) -> str:
22
+ while True:
23
+ value = input(label).strip()
24
+ if value:
25
+ return value
26
+ print("Please enter a value.", file=sys.stderr)
27
+
28
+
29
+ def _configured_or_input(name: str, label: str) -> str:
30
+ return os.environ.get(name, "").strip() or _required_input(label)
31
+
32
+
33
+ def _next_message() -> str:
34
+ return _required_input("You> ")
35
+
36
+
37
+ def _print_context(context: ResolvedContext) -> None:
38
+ print("Resolved context:")
39
+ print(f" Time period: {context.time_period or 'Not specified'}")
40
+ print(f" Entities: {', '.join(context.entities) or 'Not specified'}")
41
+ print(f" Metrics: {', '.join(context.metrics) or 'Not specified'}")
42
+ print(f" Filters: {', '.join(context.filters) or 'Not specified'}")
43
+ print(f" Comparison period: {context.comparison_period or 'Not specified'}")
44
+
45
+
46
+ def _print_query_space(space: QuerySpace) -> None:
47
+ print("Query space discovery:")
48
+ print(" Tables/views:")
49
+ for table in space.tables:
50
+ print(f" {table.key} ({table.kind.lower()})")
51
+ print(" Columns:")
52
+ for column in space.columns:
53
+ print(f" {column.key} ({column.data_type})")
54
+ print(" Relations:")
55
+ for relation in space.relations:
56
+ child = ", ".join(f"{relation.child_table}.{item}" for item in relation.child_columns)
57
+ parent = ", ".join(f"{relation.parent_table}.{item}" for item in relation.parent_columns)
58
+ print(f" {child} -> {parent} [{relation.name}]")
59
+ print(" Definitions:")
60
+ for definition in space.definitions:
61
+ print(f" {definition.term}: {definition.meaning} ({definition.source})")
62
+ if not space.relations:
63
+ print(" No relevant supplied relations selected.")
64
+ if not space.definitions:
65
+ print(" No relevant definitions selected or available.")
66
+
67
+
68
+ def _print_plan(plan: QueryPlan, quality: PlanQuality) -> None:
69
+ print("\nQuery plan (logical operations, no SQL):")
70
+ print(json.dumps(plan.operations(), indent=2, ensure_ascii=False))
71
+ print("Plan quality:")
72
+ for check in quality.checks_passed:
73
+ print(f" PASS: {check}")
74
+ for item in quality.review_items:
75
+ print(f" REVIEW: {item}")
76
+ print(" Execution: Not performed.")
77
+
78
+
79
+ def _print_sql(query: SQLQuery) -> None:
80
+ print("\nGenerated SQLite SQL (review before execution):")
81
+ print(query.sql)
82
+ print("Parameters:", dict(query.parameters))
83
+ print("Required date bindings:", ", ".join(query.required_parameters) or "None")
84
+
85
+
86
+ def _schema_paths(values: list[str]) -> dict[str, str]:
87
+ paths = {}
88
+ for value in values:
89
+ name, separator, path = value.partition("=")
90
+ if not separator or not name or not path or name in paths:
91
+ raise ValueError("--sqlite-schema must be a unique NAME=PATH entry")
92
+ paths[name] = path
93
+ return paths
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ parser = argparse.ArgumentParser(
98
+ prog="sageql chat", description="Discuss a report request with an AI assistant"
99
+ )
100
+ parser.add_argument("--no-discovery", action="store_true", help="stop after context resolution")
101
+ parser.add_argument("--no-planning", action="store_true", help="stop after query-space discovery")
102
+ parser.add_argument("--no-sql", action="store_true", help="stop after logical query planning")
103
+ parser.add_argument("--catalog", help="path to a user-supplied schema catalog JSON file")
104
+ parser.add_argument("--period-start", help="inclusive ISO date for the report period")
105
+ parser.add_argument("--period-end", help="exclusive ISO date for the report period")
106
+ parser.add_argument("--comparison-start", help="inclusive ISO date for the comparison period")
107
+ parser.add_argument("--comparison-end", help="exclusive ISO date for the comparison period")
108
+ parser.add_argument("--execute-sqlite", metavar="PATH", help="execute on this SQLite file read-only")
109
+ parser.add_argument("--sqlite-schema", action="append", default=[], metavar="NAME=PATH",
110
+ help="attach a named SQLite schema file read-only; repeat as needed")
111
+ parser.add_argument("--max-rows", type=int, default=50, help="maximum rows returned when executing")
112
+ args = parser.parse_args(argv)
113
+ if args.execute_sqlite and (args.no_discovery or args.no_planning or args.no_sql):
114
+ parser.error("--execute-sqlite requires discovery, planning, and SQL generation")
115
+ if bool(args.period_start) != bool(args.period_end):
116
+ parser.error("provide both --period-start and --period-end")
117
+ if bool(args.comparison_start) != bool(args.comparison_end):
118
+ parser.error("provide both --comparison-start and --comparison-end")
119
+
120
+ try:
121
+ first_question = _required_input("What report would you like to create? ")
122
+ print("\nDatabase configuration")
123
+ database = DatabaseConfig(
124
+ server_host=_configured_or_input("DB_SERVER_HOST", "Server / Host: "),
125
+ database=_configured_or_input("DB_NAME", "Database: "),
126
+ authentication=_configured_or_input("DB_AUTHENTICATION", "Authentication method: "),
127
+ odbc_driver=_configured_or_input("DB_ODBC_DRIVER", "ODBC Driver: "),
128
+ )
129
+
130
+ print("\nLLM configuration")
131
+ api_key = (
132
+ os.environ.get("LLM_API_KEY")
133
+ or os.environ.get("OPENAI_API_KEY")
134
+ or getpass.getpass("API Key: ")
135
+ )
136
+ base_url = os.environ.get("LLM_BASE_URL", "").strip()
137
+ model = os.environ.get("LLM_MODEL", "").strip()
138
+ if not base_url:
139
+ base_url = input("Base URL [https://api.openai.com/v1]: ").strip()
140
+ if not model:
141
+ model = input("Model [gpt-5-nano]: ").strip()
142
+ llm = LLMConfig(
143
+ api_key=api_key,
144
+ base_url=base_url or "https://api.openai.com/v1",
145
+ model=model or "gpt-5-nano",
146
+ )
147
+
148
+ config = ChatConfig(database=database, llm=llm)
149
+ provider = OpenAIChatProvider(llm)
150
+ conversation = RequestUnderstandingSession(config, provider)
151
+ print("\nRequest understanding started. Type /exit to finish.\n")
152
+ pending = first_question
153
+ while True:
154
+ try:
155
+ assessment = conversation.submit(pending)
156
+ if assessment.enough_information:
157
+ print(f"Request understanding:\n{assessment.request_understanding.strip()}")
158
+ break
159
+ print(f"AI> {assessment.clarification_question.strip()}\n")
160
+ except ChatError as exc:
161
+ print(f"sageql: {exc}", file=sys.stderr)
162
+
163
+ pending = _next_message()
164
+ if pending.lower() in {"/exit", "/quit"}:
165
+ return 0
166
+
167
+ context_session = ContextResolutionSession(
168
+ config, provider, conversation.history, assessment.request_understanding
169
+ )
170
+ resolution = context_session.start()
171
+ while not resolution.ready:
172
+ print(f"AI> {resolution.clarification_question.strip()}\n")
173
+ answer = _next_message()
174
+ if answer.lower() in {"/exit", "/quit"}:
175
+ return 0
176
+ resolution = context_session.submit(answer)
177
+ _print_context(resolution.context)
178
+ if not args.no_discovery:
179
+ catalog_path = args.catalog or os.getenv("SCHEMA_CATALOG_PATH", "")
180
+ if not catalog_path:
181
+ catalog_path = _required_input("Schema catalog JSON path: ")
182
+ catalog = load_catalog_file(catalog_path)
183
+ print("\nDiscovering query space from supplied schema...")
184
+ space = discover_query_space(catalog, provider, assessment.request_understanding,
185
+ resolution.context)
186
+ _print_query_space(space)
187
+ if not args.no_planning:
188
+ plan = create_query_plan(
189
+ assessment.request_understanding, resolution.context, space, provider
190
+ )
191
+ _print_plan(plan, review_query_plan(plan, resolution.context, space))
192
+ if not args.no_sql:
193
+ period_bounds = ((args.period_start, args.period_end)
194
+ if args.period_start else None)
195
+ comparison_bounds = ((args.comparison_start, args.comparison_end)
196
+ if args.comparison_start else None)
197
+ query = generate_sql_from_plan(
198
+ plan, period_bounds=period_bounds, comparison_bounds=comparison_bounds
199
+ )
200
+ _print_sql(query)
201
+ validation = validate_report_query(query, plan, resolution.context, space)
202
+ print("Validation:")
203
+ for check in validation.checks_passed:
204
+ print(f" PASS: {check}")
205
+ for issue in validation.issues:
206
+ print(f" BLOCK: {issue}")
207
+ if args.execute_sqlite:
208
+ if not validation.valid:
209
+ raise QueryValidationError("query must pass validation before execution")
210
+ result = execute_sqlite_report(
211
+ args.execute_sqlite, query, plan, resolution.context, space,
212
+ attached_schemas=_schema_paths(args.sqlite_schema),
213
+ max_rows=args.max_rows,
214
+ )
215
+ print("Read-only SQLite result:")
216
+ print(" Columns:", result.columns)
217
+ for row in result.rows:
218
+ print(" Row:", row)
219
+ print(" Truncated:", result.truncated)
220
+ else:
221
+ print("Execution: Not performed.")
222
+ return 0
223
+ except (EOFError, KeyboardInterrupt):
224
+ print("\nChat ended.")
225
+ return 0
226
+ except (ValueError, ChatError, DiscoveryError, PlanningError, SQLGenerationError,
227
+ QueryValidationError, QueryExecutionError) as exc:
228
+ print(f"sageql: {exc}", file=sys.stderr)
229
+ return 1