pytrilogy 0.3.138__cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.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 (182) hide show
  1. LICENSE.md +19 -0
  2. _preql_import_resolver/__init__.py +5 -0
  3. _preql_import_resolver/_preql_import_resolver.cpython-311-x86_64-linux-gnu.so +0 -0
  4. pytrilogy-0.3.138.dist-info/METADATA +525 -0
  5. pytrilogy-0.3.138.dist-info/RECORD +182 -0
  6. pytrilogy-0.3.138.dist-info/WHEEL +5 -0
  7. pytrilogy-0.3.138.dist-info/entry_points.txt +2 -0
  8. pytrilogy-0.3.138.dist-info/licenses/LICENSE.md +19 -0
  9. trilogy/__init__.py +9 -0
  10. trilogy/ai/README.md +10 -0
  11. trilogy/ai/__init__.py +19 -0
  12. trilogy/ai/constants.py +92 -0
  13. trilogy/ai/conversation.py +107 -0
  14. trilogy/ai/enums.py +7 -0
  15. trilogy/ai/execute.py +50 -0
  16. trilogy/ai/models.py +34 -0
  17. trilogy/ai/prompts.py +87 -0
  18. trilogy/ai/providers/__init__.py +0 -0
  19. trilogy/ai/providers/anthropic.py +106 -0
  20. trilogy/ai/providers/base.py +24 -0
  21. trilogy/ai/providers/google.py +146 -0
  22. trilogy/ai/providers/openai.py +89 -0
  23. trilogy/ai/providers/utils.py +68 -0
  24. trilogy/authoring/README.md +3 -0
  25. trilogy/authoring/__init__.py +143 -0
  26. trilogy/constants.py +113 -0
  27. trilogy/core/README.md +52 -0
  28. trilogy/core/__init__.py +0 -0
  29. trilogy/core/constants.py +6 -0
  30. trilogy/core/enums.py +443 -0
  31. trilogy/core/env_processor.py +120 -0
  32. trilogy/core/environment_helpers.py +320 -0
  33. trilogy/core/ergonomics.py +193 -0
  34. trilogy/core/exceptions.py +123 -0
  35. trilogy/core/functions.py +1227 -0
  36. trilogy/core/graph_models.py +139 -0
  37. trilogy/core/internal.py +85 -0
  38. trilogy/core/models/__init__.py +0 -0
  39. trilogy/core/models/author.py +2672 -0
  40. trilogy/core/models/build.py +2521 -0
  41. trilogy/core/models/build_environment.py +180 -0
  42. trilogy/core/models/core.py +494 -0
  43. trilogy/core/models/datasource.py +322 -0
  44. trilogy/core/models/environment.py +748 -0
  45. trilogy/core/models/execute.py +1177 -0
  46. trilogy/core/optimization.py +251 -0
  47. trilogy/core/optimizations/__init__.py +12 -0
  48. trilogy/core/optimizations/base_optimization.py +17 -0
  49. trilogy/core/optimizations/hide_unused_concept.py +47 -0
  50. trilogy/core/optimizations/inline_datasource.py +102 -0
  51. trilogy/core/optimizations/predicate_pushdown.py +245 -0
  52. trilogy/core/processing/README.md +94 -0
  53. trilogy/core/processing/READMEv2.md +121 -0
  54. trilogy/core/processing/VIRTUAL_UNNEST.md +30 -0
  55. trilogy/core/processing/__init__.py +0 -0
  56. trilogy/core/processing/concept_strategies_v3.py +508 -0
  57. trilogy/core/processing/constants.py +15 -0
  58. trilogy/core/processing/discovery_node_factory.py +451 -0
  59. trilogy/core/processing/discovery_utility.py +517 -0
  60. trilogy/core/processing/discovery_validation.py +167 -0
  61. trilogy/core/processing/graph_utils.py +43 -0
  62. trilogy/core/processing/node_generators/README.md +9 -0
  63. trilogy/core/processing/node_generators/__init__.py +31 -0
  64. trilogy/core/processing/node_generators/basic_node.py +160 -0
  65. trilogy/core/processing/node_generators/common.py +268 -0
  66. trilogy/core/processing/node_generators/constant_node.py +38 -0
  67. trilogy/core/processing/node_generators/filter_node.py +315 -0
  68. trilogy/core/processing/node_generators/group_node.py +213 -0
  69. trilogy/core/processing/node_generators/group_to_node.py +117 -0
  70. trilogy/core/processing/node_generators/multiselect_node.py +205 -0
  71. trilogy/core/processing/node_generators/node_merge_node.py +653 -0
  72. trilogy/core/processing/node_generators/recursive_node.py +88 -0
  73. trilogy/core/processing/node_generators/rowset_node.py +165 -0
  74. trilogy/core/processing/node_generators/select_helpers/__init__.py +0 -0
  75. trilogy/core/processing/node_generators/select_helpers/datasource_injection.py +261 -0
  76. trilogy/core/processing/node_generators/select_merge_node.py +748 -0
  77. trilogy/core/processing/node_generators/select_node.py +95 -0
  78. trilogy/core/processing/node_generators/synonym_node.py +98 -0
  79. trilogy/core/processing/node_generators/union_node.py +91 -0
  80. trilogy/core/processing/node_generators/unnest_node.py +182 -0
  81. trilogy/core/processing/node_generators/window_node.py +201 -0
  82. trilogy/core/processing/nodes/README.md +28 -0
  83. trilogy/core/processing/nodes/__init__.py +179 -0
  84. trilogy/core/processing/nodes/base_node.py +519 -0
  85. trilogy/core/processing/nodes/filter_node.py +75 -0
  86. trilogy/core/processing/nodes/group_node.py +194 -0
  87. trilogy/core/processing/nodes/merge_node.py +420 -0
  88. trilogy/core/processing/nodes/recursive_node.py +46 -0
  89. trilogy/core/processing/nodes/select_node_v2.py +242 -0
  90. trilogy/core/processing/nodes/union_node.py +53 -0
  91. trilogy/core/processing/nodes/unnest_node.py +62 -0
  92. trilogy/core/processing/nodes/window_node.py +56 -0
  93. trilogy/core/processing/utility.py +823 -0
  94. trilogy/core/query_processor.py +596 -0
  95. trilogy/core/statements/README.md +35 -0
  96. trilogy/core/statements/__init__.py +0 -0
  97. trilogy/core/statements/author.py +536 -0
  98. trilogy/core/statements/build.py +0 -0
  99. trilogy/core/statements/common.py +20 -0
  100. trilogy/core/statements/execute.py +155 -0
  101. trilogy/core/table_processor.py +66 -0
  102. trilogy/core/utility.py +8 -0
  103. trilogy/core/validation/README.md +46 -0
  104. trilogy/core/validation/__init__.py +0 -0
  105. trilogy/core/validation/common.py +161 -0
  106. trilogy/core/validation/concept.py +146 -0
  107. trilogy/core/validation/datasource.py +227 -0
  108. trilogy/core/validation/environment.py +73 -0
  109. trilogy/core/validation/fix.py +106 -0
  110. trilogy/dialect/__init__.py +32 -0
  111. trilogy/dialect/base.py +1359 -0
  112. trilogy/dialect/bigquery.py +256 -0
  113. trilogy/dialect/common.py +147 -0
  114. trilogy/dialect/config.py +144 -0
  115. trilogy/dialect/dataframe.py +50 -0
  116. trilogy/dialect/duckdb.py +177 -0
  117. trilogy/dialect/enums.py +147 -0
  118. trilogy/dialect/metadata.py +173 -0
  119. trilogy/dialect/mock.py +190 -0
  120. trilogy/dialect/postgres.py +91 -0
  121. trilogy/dialect/presto.py +104 -0
  122. trilogy/dialect/results.py +89 -0
  123. trilogy/dialect/snowflake.py +90 -0
  124. trilogy/dialect/sql_server.py +92 -0
  125. trilogy/engine.py +48 -0
  126. trilogy/execution/config.py +75 -0
  127. trilogy/executor.py +568 -0
  128. trilogy/hooks/__init__.py +4 -0
  129. trilogy/hooks/base_hook.py +40 -0
  130. trilogy/hooks/graph_hook.py +139 -0
  131. trilogy/hooks/query_debugger.py +166 -0
  132. trilogy/metadata/__init__.py +0 -0
  133. trilogy/parser.py +10 -0
  134. trilogy/parsing/README.md +21 -0
  135. trilogy/parsing/__init__.py +0 -0
  136. trilogy/parsing/common.py +1069 -0
  137. trilogy/parsing/config.py +5 -0
  138. trilogy/parsing/exceptions.py +8 -0
  139. trilogy/parsing/helpers.py +1 -0
  140. trilogy/parsing/parse_engine.py +2813 -0
  141. trilogy/parsing/render.py +750 -0
  142. trilogy/parsing/trilogy.lark +540 -0
  143. trilogy/py.typed +0 -0
  144. trilogy/render.py +42 -0
  145. trilogy/scripts/README.md +7 -0
  146. trilogy/scripts/__init__.py +0 -0
  147. trilogy/scripts/dependency/Cargo.lock +617 -0
  148. trilogy/scripts/dependency/Cargo.toml +39 -0
  149. trilogy/scripts/dependency/README.md +131 -0
  150. trilogy/scripts/dependency/build.sh +25 -0
  151. trilogy/scripts/dependency/src/directory_resolver.rs +162 -0
  152. trilogy/scripts/dependency/src/lib.rs +16 -0
  153. trilogy/scripts/dependency/src/main.rs +770 -0
  154. trilogy/scripts/dependency/src/parser.rs +435 -0
  155. trilogy/scripts/dependency/src/preql.pest +208 -0
  156. trilogy/scripts/dependency/src/python_bindings.rs +289 -0
  157. trilogy/scripts/dependency/src/resolver.rs +716 -0
  158. trilogy/scripts/dependency/tests/base.preql +3 -0
  159. trilogy/scripts/dependency/tests/cli_integration.rs +377 -0
  160. trilogy/scripts/dependency/tests/customer.preql +6 -0
  161. trilogy/scripts/dependency/tests/main.preql +9 -0
  162. trilogy/scripts/dependency/tests/orders.preql +7 -0
  163. trilogy/scripts/dependency/tests/test_data/base.preql +9 -0
  164. trilogy/scripts/dependency/tests/test_data/consumer.preql +1 -0
  165. trilogy/scripts/dependency.py +323 -0
  166. trilogy/scripts/display.py +460 -0
  167. trilogy/scripts/environment.py +46 -0
  168. trilogy/scripts/parallel_execution.py +483 -0
  169. trilogy/scripts/single_execution.py +131 -0
  170. trilogy/scripts/trilogy.py +772 -0
  171. trilogy/std/__init__.py +0 -0
  172. trilogy/std/color.preql +3 -0
  173. trilogy/std/date.preql +13 -0
  174. trilogy/std/display.preql +18 -0
  175. trilogy/std/geography.preql +22 -0
  176. trilogy/std/metric.preql +15 -0
  177. trilogy/std/money.preql +67 -0
  178. trilogy/std/net.preql +14 -0
  179. trilogy/std/ranking.preql +7 -0
  180. trilogy/std/report.preql +5 -0
  181. trilogy/std/semantic.preql +6 -0
  182. trilogy/utility.py +34 -0
@@ -0,0 +1,772 @@
1
+ import traceback
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from io import StringIO
6
+ from pathlib import Path as PathlibPath
7
+ from typing import Any, Iterable, Union
8
+
9
+ from click import UNPROCESSED, Path, argument, group, option, pass_context
10
+ from click.exceptions import Exit
11
+
12
+ from trilogy import Executor, parse
13
+ from trilogy.constants import DEFAULT_NAMESPACE
14
+ from trilogy.core.exceptions import (
15
+ ConfigurationException,
16
+ ModelValidationError,
17
+ )
18
+ from trilogy.core.models.environment import Environment
19
+ from trilogy.dialect.enums import Dialects
20
+ from trilogy.execution.config import RuntimeConfig, load_config_file
21
+ from trilogy.hooks.query_debugger import DebuggingHook
22
+ from trilogy.parsing.render import Renderer
23
+ from trilogy.scripts.dependency import (
24
+ ETLDependencyStrategy,
25
+ ScriptNode,
26
+ )
27
+ from trilogy.scripts.display import (
28
+ RICH_AVAILABLE,
29
+ create_progress_context,
30
+ print_error,
31
+ print_info,
32
+ print_success,
33
+ set_rich_mode,
34
+ show_debug_mode,
35
+ show_environment_params,
36
+ show_execution_info,
37
+ show_execution_start,
38
+ show_execution_summary,
39
+ show_formatting_result,
40
+ show_parallel_execution_start,
41
+ show_parallel_execution_summary,
42
+ show_script_result,
43
+ with_status,
44
+ )
45
+ from trilogy.scripts.environment import extra_to_kwargs, parse_env_params
46
+ from trilogy.scripts.parallel_execution import (
47
+ EagerBFSStrategy,
48
+ ParallelExecutor,
49
+ )
50
+ from trilogy.scripts.single_execution import (
51
+ execute_queries_simple,
52
+ execute_queries_with_progress,
53
+ )
54
+
55
+ set_rich_mode = set_rich_mode
56
+
57
+ # Default parallelism level
58
+ DEFAULT_PARALLELISM = 2
59
+
60
+
61
+ @dataclass
62
+ class CLIRuntimeParams:
63
+ """Parameters provided via CLI for execution."""
64
+
65
+ input: str
66
+ dialect: Dialects | None = None
67
+ parallelism: int | None = None
68
+ param: tuple[str, ...] = ()
69
+ conn_args: tuple[str, ...] = ()
70
+ debug: bool = False
71
+ config_path: PathlibPath | None = None
72
+ execution_strategy: str = "eager_bfs"
73
+
74
+
75
+ def merge_runtime_config(
76
+ cli_params: CLIRuntimeParams, file_config: RuntimeConfig
77
+ ) -> tuple[Dialects, int]:
78
+ """
79
+ Merge CLI parameters with config file settings.
80
+ CLI parameters take precedence over config file.
81
+
82
+ Returns:
83
+ tuple of (dialect, parallelism)
84
+
85
+ Raises:
86
+ Exit: If no dialect is specified in either CLI or config
87
+ """
88
+ # Resolve dialect: CLI argument takes precedence over config
89
+ if cli_params.dialect:
90
+ dialect = cli_params.dialect
91
+ elif file_config.engine_dialect:
92
+ dialect = file_config.engine_dialect
93
+ else:
94
+ print_error(
95
+ "No dialect specified. Provide dialect as argument or set engine.dialect in config file."
96
+ )
97
+ raise Exit(1)
98
+
99
+ # Resolve parallelism: CLI argument takes precedence over config
100
+ parallelism = (
101
+ cli_params.parallelism
102
+ if cli_params.parallelism is not None
103
+ else file_config.parallelism
104
+ )
105
+
106
+ return dialect, parallelism
107
+
108
+
109
+ class RunMode(Enum):
110
+ RUN = "run"
111
+ INTEGRATION = "integration"
112
+ UNIT = "unit"
113
+
114
+
115
+ def resolve_input(path: PathlibPath) -> list[PathlibPath]:
116
+
117
+ # Directory
118
+ if path.is_dir():
119
+ pattern = "**/*.preql"
120
+ return sorted(path.glob(pattern))
121
+ # Single file
122
+ if path.exists() and path.is_file():
123
+ return [path]
124
+
125
+ raise FileNotFoundError(f"Input path '{path}' does not exist.")
126
+
127
+
128
+ def get_runtime_config(
129
+ path: PathlibPath, config_override: PathlibPath | None = None
130
+ ) -> RuntimeConfig:
131
+ if config_override:
132
+ config_path = config_override
133
+ elif path.is_dir():
134
+ config_path = path / "trilogy.toml"
135
+ else:
136
+ config_path = path.parent / "trilogy.toml"
137
+ if config_path.exists():
138
+ try:
139
+ return load_config_file(config_path)
140
+ except Exception as e:
141
+ print_error(f"Failed to load configuration file {config_path}: {e}")
142
+ handle_execution_exception(e)
143
+ return RuntimeConfig(startup_trilogy=[], startup_sql=[])
144
+
145
+
146
+ def resolve_input_information(
147
+ input: str, config_path_input: PathlibPath | None = None
148
+ ) -> tuple[Iterable[PathlibPath | StringIO], PathlibPath, str, str, RuntimeConfig]:
149
+ input_as_path = PathlibPath(input)
150
+ files: Iterable[StringIO | PathlibPath]
151
+ if input_as_path.exists():
152
+ pathlib_path = input_as_path
153
+ files = resolve_input(pathlib_path)
154
+
155
+ if pathlib_path.is_dir():
156
+ directory = pathlib_path
157
+ input_type = "directory"
158
+ config = get_runtime_config(pathlib_path, config_path_input)
159
+
160
+ else:
161
+ directory = pathlib_path.parent
162
+ input_type = "file"
163
+ config = get_runtime_config(pathlib_path, config_path_input)
164
+
165
+ input_name = pathlib_path.name
166
+ else:
167
+ script = input
168
+ files = [StringIO(script)]
169
+ directory = PathlibPath.cwd()
170
+ input_type = "query"
171
+ input_name = "inline"
172
+ config = RuntimeConfig(startup_trilogy=[], startup_sql=[])
173
+ return files, directory, input_type, input_name, config
174
+
175
+
176
+ def validate_required_connection_params(
177
+ conn_dict: dict[str, Any],
178
+ required_keys: list[str],
179
+ optional_keys: list[str],
180
+ dialect_name: str,
181
+ ) -> dict:
182
+ missing = [key for key in required_keys if key not in conn_dict]
183
+ extra = [
184
+ key
185
+ for key in conn_dict
186
+ if key not in required_keys and key not in optional_keys
187
+ ]
188
+ if missing:
189
+ raise ConfigurationException(
190
+ f"Missing required {dialect_name} connection parameters: {', '.join(missing)}"
191
+ )
192
+ if extra:
193
+ print(
194
+ f"Warning: Extra {dialect_name} connection parameters provided: {', '.join(extra)}"
195
+ )
196
+ return {
197
+ k: v for k, v in conn_dict.items() if k in required_keys or k in optional_keys
198
+ }
199
+
200
+
201
+ def get_dialect_config(
202
+ edialect: Dialects, conn_dict: dict[str, Any], runtime_config: RuntimeConfig
203
+ ) -> Any:
204
+ """Get dialect configuration based on dialect type."""
205
+ conf: Union[Any, None] = None
206
+
207
+ if edialect == Dialects.DUCK_DB:
208
+ from trilogy.dialect.config import DuckDBConfig
209
+
210
+ conn_dict = validate_required_connection_params(
211
+ conn_dict, [], ["path"], "DuckDB"
212
+ )
213
+ conf = DuckDBConfig(**conn_dict)
214
+ elif edialect == Dialects.SNOWFLAKE:
215
+ from trilogy.dialect.config import SnowflakeConfig
216
+
217
+ conn_dict = validate_required_connection_params(
218
+ conn_dict, ["username", "password", "account"], [], "Snowflake"
219
+ )
220
+ conf = SnowflakeConfig(**conn_dict)
221
+ elif edialect == Dialects.SQL_SERVER:
222
+ from trilogy.dialect.config import SQLServerConfig
223
+
224
+ conn_dict = validate_required_connection_params(
225
+ conn_dict,
226
+ ["host", "port", "username", "password", "database"],
227
+ [],
228
+ "SQL Server",
229
+ )
230
+ conf = SQLServerConfig(**conn_dict)
231
+ elif edialect == Dialects.POSTGRES:
232
+ from trilogy.dialect.config import PostgresConfig
233
+
234
+ conn_dict = validate_required_connection_params(
235
+ conn_dict,
236
+ ["host", "port", "username", "password", "database"],
237
+ [],
238
+ "Postgres",
239
+ )
240
+ conf = PostgresConfig(**conn_dict)
241
+ elif edialect == Dialects.BIGQUERY:
242
+ from trilogy.dialect.config import BigQueryConfig
243
+
244
+ conn_dict = validate_required_connection_params(
245
+ conn_dict, [], ["project"], "BigQuery"
246
+ )
247
+ conf = BigQueryConfig(**conn_dict)
248
+ elif edialect == Dialects.PRESTO:
249
+ from trilogy.dialect.config import PrestoConfig
250
+
251
+ conn_dict = validate_required_connection_params(
252
+ conn_dict,
253
+ ["host", "port", "username", "password", "catalog"],
254
+ [],
255
+ "Presto",
256
+ )
257
+ conf = PrestoConfig(**conn_dict)
258
+ if conf and runtime_config.engine_config:
259
+ conf = runtime_config.engine_config.merge_config(conf)
260
+ return conf
261
+
262
+
263
+ def create_executor(
264
+ param: tuple[str, ...],
265
+ directory: PathlibPath,
266
+ conn_args: Iterable[str],
267
+ edialect: Dialects,
268
+ debug: bool,
269
+ config: RuntimeConfig,
270
+ ) -> Executor:
271
+ # Parse environment parameters from dedicated flag
272
+ namespace = DEFAULT_NAMESPACE
273
+ try:
274
+ env_params = parse_env_params(param)
275
+ show_environment_params(env_params)
276
+ except ValueError as e:
277
+ print_error(str(e))
278
+ raise Exit(1) from e
279
+
280
+ # Parse connection arguments from remaining args
281
+ conn_dict = extra_to_kwargs(conn_args)
282
+
283
+ # Configure dialect
284
+ try:
285
+ conf = get_dialect_config(edialect, conn_dict, runtime_config=config)
286
+ except Exception as e:
287
+ handle_execution_exception(e)
288
+
289
+ # Create environment and set additional parameters if any exist
290
+ environment = Environment(working_path=str(directory), namespace=namespace)
291
+ if env_params:
292
+ environment.set_parameters(**env_params)
293
+
294
+ exec = Executor(
295
+ dialect=edialect,
296
+ engine=edialect.default_engine(conf=conf),
297
+ environment=environment,
298
+ hooks=[DebuggingHook()] if debug else [],
299
+ )
300
+ if config.startup_sql:
301
+ for script in config.startup_sql:
302
+ print_info(f"Executing startup SQL script: {script}")
303
+ exec.execute_file(script)
304
+ if config.startup_trilogy:
305
+ for script in config.startup_trilogy:
306
+ print_info(f"Executing startup Trilogy script: {script}")
307
+ exec.execute_file(script)
308
+ return exec
309
+
310
+
311
+ def create_executor_for_script(
312
+ node: ScriptNode,
313
+ param: tuple[str, ...],
314
+ conn_args: Iterable[str],
315
+ edialect: Dialects,
316
+ debug: bool,
317
+ config: RuntimeConfig,
318
+ ) -> Executor:
319
+ """
320
+ Create an executor for a specific script node.
321
+
322
+ Each script gets its own executor with its own environment,
323
+ using the script's parent directory as the working path.
324
+ """
325
+ directory = node.path.parent
326
+ return create_executor(param, directory, conn_args, edialect, debug, config)
327
+
328
+
329
+ def validate_datasources(
330
+ exec: Executor, mock: bool = False, quiet: bool = False
331
+ ) -> None:
332
+ """Validate datasources with consistent error handling.
333
+
334
+ Args:
335
+ exec: The executor instance
336
+ mock: If True, mock datasources before validation (for unit tests)
337
+ quiet: If True, suppress informational messages (for parallel execution)
338
+
339
+ Raises:
340
+ Exit: If validation fails
341
+ """
342
+ datasources = exec.environment.datasources.keys()
343
+ if not datasources:
344
+ if not quiet:
345
+ message = "unit" if mock else "integration"
346
+ print_success(f"No datasources found to {message} test.")
347
+ return
348
+
349
+ if mock:
350
+ exec.execute_text("mock datasources {};".format(", ".join(datasources)))
351
+
352
+ try:
353
+ exec.execute_text("validate datasources {};".format(", ".join(datasources)))
354
+ except ModelValidationError as e:
355
+ if not e.children:
356
+ print_error(f"Datasource validation failed: {e.message}")
357
+ for idx, child in enumerate(e.children or []):
358
+ print_error(f"Error {idx + 1}: {child.message}")
359
+ raise Exit(1) from e
360
+
361
+
362
+ def execute_script_for_run(
363
+ exec: Executor, node: ScriptNode, quiet: bool = False
364
+ ) -> None:
365
+ """Execute a script for the 'run' command (parallel execution mode)."""
366
+ with open(node.path, "r") as f:
367
+ queries = exec.parse_text(f.read())
368
+ for query in queries:
369
+ exec.execute_query(query)
370
+
371
+
372
+ def execute_script_for_integration(
373
+ exec: Executor, node: ScriptNode, quiet: bool = False
374
+ ) -> None:
375
+ """Execute a script for the 'integration' command (parse + validate)."""
376
+ with open(node.path, "r") as f:
377
+ exec.parse_text(f.read())
378
+ validate_datasources(exec, mock=False, quiet=quiet)
379
+
380
+
381
+ def execute_script_for_unit(
382
+ exec: Executor, node: ScriptNode, quiet: bool = False
383
+ ) -> None:
384
+ """Execute a script for the 'unit' command (parse + mock validate)."""
385
+ with open(node.path, "r") as f:
386
+ exec.parse_text(f.read())
387
+ validate_datasources(exec, mock=True, quiet=quiet)
388
+
389
+
390
+ def get_execution_strategy(strategy_name: str):
391
+ """Get execution strategy by name."""
392
+ strategies = {
393
+ "eager_bfs": EagerBFSStrategy,
394
+ }
395
+ if strategy_name not in strategies:
396
+ raise ValueError(
397
+ f"Unknown execution strategy: {strategy_name}. "
398
+ f"Available: {', '.join(strategies.keys())}"
399
+ )
400
+ return strategies[strategy_name]()
401
+
402
+
403
+ def handle_execution_exception(e: Exception, debug: bool = False) -> None:
404
+ print_error(f"Unexpected error: {e}")
405
+ if debug:
406
+ print_error(f"Full traceback:\n{traceback.format_exc()}")
407
+ raise Exit(1) from e
408
+
409
+
410
+ def run_single_script_execution(
411
+ files: list[StringIO | PathlibPath],
412
+ directory: PathlibPath,
413
+ input_type: str,
414
+ input_name: str,
415
+ edialect: Dialects,
416
+ param: tuple[str, ...],
417
+ conn_args: Iterable[str],
418
+ debug: bool,
419
+ execution_mode: str,
420
+ config: RuntimeConfig,
421
+ ) -> None:
422
+ """
423
+ Run single script execution with polished multi-statement progress display.
424
+
425
+ Args:
426
+ text: List of script contents
427
+ directory: Working directory
428
+ input_type: Type of input (file, query, etc.)
429
+ input_name: Name of the input
430
+ edialect: Dialect to use
431
+ param: Environment parameters
432
+ conn_args: Connection arguments
433
+ debug: Debug mode flag
434
+ execution_mode: One of 'run', 'integration', or 'unit'
435
+ """
436
+ show_execution_info(input_type, input_name, edialect.value, debug)
437
+
438
+ exec = create_executor(param, directory, conn_args, edialect, debug, config)
439
+ base = files[0]
440
+ if isinstance(base, StringIO):
441
+ text = [base.getvalue()]
442
+ else:
443
+ with open(base, "r") as raw:
444
+ text = [raw.read()]
445
+
446
+ if execution_mode == "run":
447
+ # Parse all scripts and collect queries
448
+ queries = []
449
+ try:
450
+ for script in text:
451
+ queries += exec.parse_text(script)
452
+ except Exception as e:
453
+ handle_execution_exception(e, debug=debug)
454
+
455
+ start = datetime.now()
456
+ show_execution_start(len(queries))
457
+
458
+ # Execute with progress tracking for multiple statements
459
+ if len(queries) > 1 and RICH_AVAILABLE:
460
+ progress = create_progress_context()
461
+ else:
462
+ progress = None
463
+
464
+ try:
465
+ if progress:
466
+ exception = execute_queries_with_progress(exec, queries)
467
+ else:
468
+ exception = execute_queries_simple(exec, queries)
469
+
470
+ total_duration = datetime.now() - start
471
+ show_execution_summary(len(queries), total_duration, exception is None)
472
+
473
+ if exception:
474
+ raise Exit(1) from exception
475
+ except Exit:
476
+ raise
477
+ except Exception as e:
478
+ handle_execution_exception(e, debug=debug)
479
+
480
+ elif execution_mode == "integration":
481
+ for script in text:
482
+ exec.parse_text(script)
483
+ validate_datasources(exec, mock=False, quiet=False)
484
+ print_success("Integration tests passed successfully!")
485
+
486
+ elif execution_mode == "unit":
487
+ for script in text:
488
+ exec.parse_text(script)
489
+ validate_datasources(exec, mock=True, quiet=False)
490
+ print_success("Unit tests passed successfully!")
491
+
492
+
493
+ def run_parallel_execution(
494
+ cli_params: CLIRuntimeParams,
495
+ execution_fn,
496
+ execution_mode: str = "run",
497
+ ) -> None:
498
+ """
499
+ Run parallel execution for directory inputs, or single-script execution
500
+ with polished progress display for single files/inline queries.
501
+
502
+ Args:
503
+ cli_params: CLI runtime parameters containing all execution settings
504
+ execution_fn: Function to execute each script (exec, node, quiet) -> None
505
+ execution_mode: One of 'run', 'integration', or 'unit'
506
+ """
507
+ # Check if input is a directory (parallel execution)
508
+ pathlib_input = PathlibPath(cli_params.input)
509
+ files_iter, directory, input_type, input_name, config = resolve_input_information(
510
+ cli_params.input, cli_params.config_path
511
+ )
512
+ files = list(files_iter)
513
+
514
+ # Merge CLI params with config file
515
+ edialect, parallelism = merge_runtime_config(cli_params, config)
516
+ if not pathlib_input.exists() or len(files) == 1:
517
+ # Inline query - use polished single-script execution
518
+
519
+ run_single_script_execution(
520
+ files=files,
521
+ directory=directory,
522
+ input_type=input_type,
523
+ input_name=input_name,
524
+ edialect=edialect,
525
+ param=cli_params.param,
526
+ conn_args=cli_params.conn_args,
527
+ debug=cli_params.debug,
528
+ execution_mode=execution_mode,
529
+ config=config,
530
+ )
531
+ return
532
+ # Multiple files - use parallel execution
533
+ show_execution_info(input_type, input_name, edialect.value, cli_params.debug)
534
+
535
+ # Get execution strategy
536
+ strategy = get_execution_strategy(cli_params.execution_strategy)
537
+
538
+ # Set up parallel executor
539
+ parallel_exec = ParallelExecutor(
540
+ max_workers=parallelism,
541
+ dependency_strategy=ETLDependencyStrategy(),
542
+ execution_strategy=strategy,
543
+ )
544
+
545
+ # Get execution plan for display
546
+ if pathlib_input.is_dir():
547
+ execution_plan = parallel_exec.get_folder_execution_plan(pathlib_input)
548
+ elif pathlib_input.is_file():
549
+ execution_plan = parallel_exec.get_execution_plan([pathlib_input])
550
+ else:
551
+ raise FileNotFoundError(f"Input path '{pathlib_input}' does not exist.")
552
+
553
+ num_edges = execution_plan.number_of_edges()
554
+ num_nodes = execution_plan.number_of_nodes()
555
+
556
+ show_parallel_execution_start(
557
+ num_nodes, num_edges, parallelism, cli_params.execution_strategy
558
+ )
559
+
560
+ # Factory to create executor for each script
561
+ def executor_factory(node: ScriptNode) -> Executor:
562
+ return create_executor_for_script(
563
+ node,
564
+ cli_params.param,
565
+ cli_params.conn_args,
566
+ edialect,
567
+ cli_params.debug,
568
+ config,
569
+ )
570
+
571
+ # Wrap execution_fn to pass quiet=True for parallel execution
572
+ def quiet_execution_fn(exec: Executor, node: ScriptNode) -> None:
573
+ execution_fn(exec, node, quiet=True)
574
+
575
+ # Run parallel execution
576
+ summary = parallel_exec.execute(
577
+ root=pathlib_input,
578
+ executor_factory=executor_factory,
579
+ execution_fn=quiet_execution_fn,
580
+ on_script_complete=show_script_result,
581
+ )
582
+
583
+ show_parallel_execution_summary(summary)
584
+
585
+ if not summary.all_succeeded:
586
+ print_error("Some scripts failed during execution.")
587
+ raise Exit(1)
588
+
589
+ print_success("All scripts executed successfully!")
590
+
591
+
592
+ @group()
593
+ @option("--debug", default=False, help="Enable debug mode")
594
+ @pass_context
595
+ def cli(ctx, debug: bool):
596
+ """Trilogy CLI - A beautiful query execution tool."""
597
+ ctx.ensure_object(dict)
598
+ ctx.obj["DEBUG"] = debug
599
+
600
+ if debug:
601
+ show_debug_mode()
602
+
603
+
604
+ @cli.command("fmt")
605
+ @argument("input", type=Path(exists=True))
606
+ @pass_context
607
+ def fmt(ctx, input):
608
+ """Format a Trilogy script file."""
609
+ with with_status("Formatting script"):
610
+ start = datetime.now()
611
+ try:
612
+ with open(input, "r") as f:
613
+ script = f.read()
614
+ _, queries = parse(script)
615
+ r = Renderer()
616
+ with open(input, "w") as f:
617
+ f.write("\n".join([r.to_string(x) for x in queries]))
618
+ duration = datetime.now() - start
619
+
620
+ print_success("Script formatted successfully")
621
+ show_formatting_result(input, len(queries), duration)
622
+
623
+ except Exception as e:
624
+ handle_execution_exception(e, debug=ctx.obj["DEBUG"])
625
+
626
+
627
+ @cli.command(
628
+ "integration",
629
+ context_settings=dict(
630
+ ignore_unknown_options=True,
631
+ ),
632
+ )
633
+ @argument("input", type=Path())
634
+ @argument("dialect", type=str, required=False)
635
+ @option("--param", multiple=True, help="Environment parameters as key=value pairs")
636
+ @option(
637
+ "--parallelism",
638
+ "-p",
639
+ default=None,
640
+ help="Maximum parallel workers for directory execution",
641
+ )
642
+ @option(
643
+ "--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
644
+ )
645
+ @argument("conn_args", nargs=-1, type=UNPROCESSED)
646
+ @pass_context
647
+ def integration(
648
+ ctx, input, dialect: str | None, param, parallelism: int | None, config, conn_args
649
+ ):
650
+ """Run integration tests on Trilogy scripts."""
651
+ cli_params = CLIRuntimeParams(
652
+ input=input,
653
+ dialect=Dialects(dialect) if dialect else None,
654
+ parallelism=parallelism,
655
+ param=param,
656
+ conn_args=conn_args,
657
+ debug=ctx.obj["DEBUG"],
658
+ config_path=PathlibPath(config) if config else None,
659
+ execution_strategy="eager_bfs",
660
+ )
661
+
662
+ try:
663
+ run_parallel_execution(
664
+ cli_params=cli_params,
665
+ execution_fn=execute_script_for_integration,
666
+ execution_mode="integration",
667
+ )
668
+ except Exit:
669
+ raise
670
+ except Exception as e:
671
+ handle_execution_exception(e, debug=cli_params.debug)
672
+
673
+
674
+ @cli.command(
675
+ "unit",
676
+ context_settings=dict(
677
+ ignore_unknown_options=True,
678
+ ),
679
+ )
680
+ @argument("input", type=Path())
681
+ @option("--param", multiple=True, help="Environment parameters as key=value pairs")
682
+ @option(
683
+ "--parallelism",
684
+ "-p",
685
+ default=None,
686
+ help="Maximum parallel workers for directory execution",
687
+ )
688
+ @option(
689
+ "--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
690
+ )
691
+ @pass_context
692
+ def unit(
693
+ ctx,
694
+ input,
695
+ param,
696
+ parallelism: int | None,
697
+ config,
698
+ ):
699
+ """Run unit tests on Trilogy scripts with mocked datasources."""
700
+ # Build CLI runtime params (unit tests always use DuckDB)
701
+ cli_params = CLIRuntimeParams(
702
+ input=input,
703
+ dialect=Dialects.DUCK_DB,
704
+ parallelism=parallelism,
705
+ param=param,
706
+ conn_args=(),
707
+ debug=ctx.obj["DEBUG"],
708
+ config_path=PathlibPath(config) if config else None,
709
+ execution_strategy="eager_bfs",
710
+ )
711
+
712
+ try:
713
+ run_parallel_execution(
714
+ cli_params=cli_params,
715
+ execution_fn=execute_script_for_unit,
716
+ execution_mode="unit",
717
+ )
718
+ except Exit:
719
+ raise
720
+ except Exception as e:
721
+ handle_execution_exception(e, debug=cli_params.debug)
722
+
723
+
724
+ @cli.command(
725
+ "run",
726
+ context_settings=dict(
727
+ ignore_unknown_options=True,
728
+ ),
729
+ )
730
+ @argument("input", type=Path())
731
+ @argument("dialect", type=str, required=False)
732
+ @option("--param", multiple=True, help="Environment parameters as key=value pairs")
733
+ @option(
734
+ "--parallelism",
735
+ "-p",
736
+ default=None,
737
+ help="Maximum parallel workers for directory execution",
738
+ )
739
+ @option(
740
+ "--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
741
+ )
742
+ @argument("conn_args", nargs=-1, type=UNPROCESSED)
743
+ @pass_context
744
+ def run(
745
+ ctx, input, dialect: str | None, param, parallelism: int | None, config, conn_args
746
+ ):
747
+ """Execute a Trilogy script or query."""
748
+ cli_params = CLIRuntimeParams(
749
+ input=input,
750
+ dialect=Dialects(dialect) if dialect else None,
751
+ parallelism=parallelism,
752
+ param=param,
753
+ conn_args=conn_args,
754
+ debug=ctx.obj["DEBUG"],
755
+ config_path=PathlibPath(config) if config else None,
756
+ execution_strategy="eager_bfs",
757
+ )
758
+
759
+ try:
760
+ run_parallel_execution(
761
+ cli_params=cli_params,
762
+ execution_fn=execute_script_for_run,
763
+ execution_mode="run",
764
+ )
765
+ except Exit:
766
+ raise
767
+ except Exception as e:
768
+ handle_execution_exception(e, debug=cli_params.debug)
769
+
770
+
771
+ if __name__ == "__main__":
772
+ cli()