pytrilogy 0.3.148__cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.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 (206) hide show
  1. LICENSE.md +19 -0
  2. _preql_import_resolver/__init__.py +5 -0
  3. _preql_import_resolver/_preql_import_resolver.cpython-312-aarch64-linux-gnu.so +0 -0
  4. pytrilogy-0.3.148.dist-info/METADATA +555 -0
  5. pytrilogy-0.3.148.dist-info/RECORD +206 -0
  6. pytrilogy-0.3.148.dist-info/WHEEL +5 -0
  7. pytrilogy-0.3.148.dist-info/entry_points.txt +2 -0
  8. pytrilogy-0.3.148.dist-info/licenses/LICENSE.md +19 -0
  9. trilogy/__init__.py +27 -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 +100 -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 +148 -0
  26. trilogy/constants.py +119 -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 +454 -0
  31. trilogy/core/env_processor.py +239 -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 +1240 -0
  36. trilogy/core/graph_models.py +142 -0
  37. trilogy/core/internal.py +85 -0
  38. trilogy/core/models/__init__.py +0 -0
  39. trilogy/core/models/author.py +2662 -0
  40. trilogy/core/models/build.py +2603 -0
  41. trilogy/core/models/build_environment.py +165 -0
  42. trilogy/core/models/core.py +506 -0
  43. trilogy/core/models/datasource.py +434 -0
  44. trilogy/core/models/environment.py +756 -0
  45. trilogy/core/models/execute.py +1213 -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 +548 -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 +270 -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 +207 -0
  71. trilogy/core/processing/node_generators/node_merge_node.py +695 -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 +786 -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 +522 -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 +604 -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 +256 -0
  110. trilogy/dialect/__init__.py +32 -0
  111. trilogy/dialect/base.py +1431 -0
  112. trilogy/dialect/bigquery.py +314 -0
  113. trilogy/dialect/common.py +147 -0
  114. trilogy/dialect/config.py +159 -0
  115. trilogy/dialect/dataframe.py +50 -0
  116. trilogy/dialect/duckdb.py +376 -0
  117. trilogy/dialect/enums.py +149 -0
  118. trilogy/dialect/metadata.py +173 -0
  119. trilogy/dialect/mock.py +190 -0
  120. trilogy/dialect/postgres.py +117 -0
  121. trilogy/dialect/presto.py +110 -0
  122. trilogy/dialect/results.py +89 -0
  123. trilogy/dialect/snowflake.py +129 -0
  124. trilogy/dialect/sql_server.py +137 -0
  125. trilogy/engine.py +48 -0
  126. trilogy/execution/__init__.py +17 -0
  127. trilogy/execution/config.py +119 -0
  128. trilogy/execution/state/__init__.py +0 -0
  129. trilogy/execution/state/file_state_store.py +0 -0
  130. trilogy/execution/state/sqllite_state_store.py +0 -0
  131. trilogy/execution/state/state_store.py +301 -0
  132. trilogy/executor.py +656 -0
  133. trilogy/hooks/__init__.py +4 -0
  134. trilogy/hooks/base_hook.py +40 -0
  135. trilogy/hooks/graph_hook.py +135 -0
  136. trilogy/hooks/query_debugger.py +166 -0
  137. trilogy/metadata/__init__.py +0 -0
  138. trilogy/parser.py +10 -0
  139. trilogy/parsing/README.md +21 -0
  140. trilogy/parsing/__init__.py +0 -0
  141. trilogy/parsing/common.py +1069 -0
  142. trilogy/parsing/config.py +5 -0
  143. trilogy/parsing/exceptions.py +8 -0
  144. trilogy/parsing/helpers.py +1 -0
  145. trilogy/parsing/parse_engine.py +2863 -0
  146. trilogy/parsing/render.py +773 -0
  147. trilogy/parsing/trilogy.lark +544 -0
  148. trilogy/py.typed +0 -0
  149. trilogy/render.py +45 -0
  150. trilogy/scripts/README.md +9 -0
  151. trilogy/scripts/__init__.py +0 -0
  152. trilogy/scripts/agent.py +41 -0
  153. trilogy/scripts/agent_info.py +306 -0
  154. trilogy/scripts/common.py +430 -0
  155. trilogy/scripts/dependency/Cargo.lock +617 -0
  156. trilogy/scripts/dependency/Cargo.toml +39 -0
  157. trilogy/scripts/dependency/README.md +131 -0
  158. trilogy/scripts/dependency/build.sh +25 -0
  159. trilogy/scripts/dependency/src/directory_resolver.rs +387 -0
  160. trilogy/scripts/dependency/src/lib.rs +16 -0
  161. trilogy/scripts/dependency/src/main.rs +770 -0
  162. trilogy/scripts/dependency/src/parser.rs +435 -0
  163. trilogy/scripts/dependency/src/preql.pest +208 -0
  164. trilogy/scripts/dependency/src/python_bindings.rs +311 -0
  165. trilogy/scripts/dependency/src/resolver.rs +716 -0
  166. trilogy/scripts/dependency/tests/base.preql +3 -0
  167. trilogy/scripts/dependency/tests/cli_integration.rs +377 -0
  168. trilogy/scripts/dependency/tests/customer.preql +6 -0
  169. trilogy/scripts/dependency/tests/main.preql +9 -0
  170. trilogy/scripts/dependency/tests/orders.preql +7 -0
  171. trilogy/scripts/dependency/tests/test_data/base.preql +9 -0
  172. trilogy/scripts/dependency/tests/test_data/consumer.preql +1 -0
  173. trilogy/scripts/dependency.py +323 -0
  174. trilogy/scripts/display.py +555 -0
  175. trilogy/scripts/environment.py +59 -0
  176. trilogy/scripts/fmt.py +32 -0
  177. trilogy/scripts/ingest.py +472 -0
  178. trilogy/scripts/ingest_helpers/__init__.py +1 -0
  179. trilogy/scripts/ingest_helpers/foreign_keys.py +123 -0
  180. trilogy/scripts/ingest_helpers/formatting.py +93 -0
  181. trilogy/scripts/ingest_helpers/typing.py +161 -0
  182. trilogy/scripts/init.py +105 -0
  183. trilogy/scripts/parallel_execution.py +748 -0
  184. trilogy/scripts/plan.py +189 -0
  185. trilogy/scripts/refresh.py +106 -0
  186. trilogy/scripts/run.py +79 -0
  187. trilogy/scripts/serve.py +202 -0
  188. trilogy/scripts/serve_helpers/__init__.py +41 -0
  189. trilogy/scripts/serve_helpers/file_discovery.py +142 -0
  190. trilogy/scripts/serve_helpers/index_generation.py +206 -0
  191. trilogy/scripts/serve_helpers/models.py +38 -0
  192. trilogy/scripts/single_execution.py +131 -0
  193. trilogy/scripts/testing.py +129 -0
  194. trilogy/scripts/trilogy.py +75 -0
  195. trilogy/std/__init__.py +0 -0
  196. trilogy/std/color.preql +3 -0
  197. trilogy/std/date.preql +13 -0
  198. trilogy/std/display.preql +18 -0
  199. trilogy/std/geography.preql +22 -0
  200. trilogy/std/metric.preql +15 -0
  201. trilogy/std/money.preql +67 -0
  202. trilogy/std/net.preql +14 -0
  203. trilogy/std/ranking.preql +7 -0
  204. trilogy/std/report.preql +5 -0
  205. trilogy/std/semantic.preql +6 -0
  206. trilogy/utility.py +34 -0
@@ -0,0 +1,189 @@
1
+ """Plan command for Trilogy CLI - shows execution order without running."""
2
+
3
+ import json
4
+ from pathlib import Path as PathlibPath
5
+ from typing import Any
6
+
7
+ import networkx as nx
8
+ from click import Path, argument, option, pass_context
9
+ from click.exceptions import Exit
10
+
11
+ from trilogy.scripts.common import (
12
+ handle_execution_exception,
13
+ resolve_input_information,
14
+ )
15
+ from trilogy.scripts.dependency import ETLDependencyStrategy, ScriptNode
16
+ from trilogy.scripts.display import print_error, print_info, show_execution_plan
17
+ from trilogy.scripts.parallel_execution import ParallelExecutor
18
+
19
+
20
+ def safe_relative_path(path: PathlibPath, root: PathlibPath) -> str:
21
+ """Get path relative to root, falling back to absolute if not a subpath.
22
+
23
+ This handles the case where imports pull in files from outside the root
24
+ directory (e.g., `import ../shared/utils.preql`).
25
+ """
26
+ try:
27
+ return str(path.relative_to(root))
28
+ except ValueError:
29
+ return str(path)
30
+
31
+
32
+ def graph_to_json(graph: nx.DiGraph, root: PathlibPath) -> dict[str, Any]:
33
+ """Convert dependency graph to JSON-serializable dict."""
34
+ nodes = [
35
+ {"id": safe_relative_path(node.path, root), "path": str(node.path)}
36
+ for node in graph.nodes()
37
+ ]
38
+ edges = [
39
+ {
40
+ "from": safe_relative_path(from_node.path, root),
41
+ "to": safe_relative_path(to_node.path, root),
42
+ }
43
+ for from_node, to_node in graph.edges()
44
+ ]
45
+ return {"nodes": nodes, "edges": edges}
46
+
47
+
48
+ def get_execution_levels(graph: nx.DiGraph) -> list[list[ScriptNode]]:
49
+ """Get execution levels (BFS layers) from dependency graph."""
50
+ if not graph.nodes():
51
+ return []
52
+
53
+ levels: list[list[ScriptNode]] = []
54
+ remaining_deps = {node: graph.in_degree(node) for node in graph.nodes()}
55
+ completed: set[ScriptNode] = set()
56
+
57
+ while len(completed) < len(graph.nodes()):
58
+ ready = [
59
+ node
60
+ for node in graph.nodes()
61
+ if remaining_deps[node] == 0 and node not in completed
62
+ ]
63
+ if not ready:
64
+ break
65
+ levels.append(ready)
66
+ for node in ready:
67
+ completed.add(node)
68
+ for dependent in graph.successors(node):
69
+ remaining_deps[dependent] -= 1
70
+
71
+ return levels
72
+
73
+
74
+ def format_plan_text(
75
+ graph: nx.DiGraph, root: PathlibPath
76
+ ) -> tuple[list[str], list[tuple[str, str]], list[list[str]]]:
77
+ """Format plan for text display."""
78
+ nodes = [safe_relative_path(node.path, root) for node in graph.nodes()]
79
+ edges = [
80
+ (
81
+ safe_relative_path(from_node.path, root),
82
+ safe_relative_path(to_node.path, root),
83
+ )
84
+ for from_node, to_node in graph.edges()
85
+ ]
86
+ levels = get_execution_levels(graph)
87
+ execution_order = [
88
+ [safe_relative_path(node.path, root) for node in level] for level in levels
89
+ ]
90
+ return nodes, edges, execution_order
91
+
92
+
93
+ @argument("input", type=Path())
94
+ @option(
95
+ "--output",
96
+ "-o",
97
+ type=Path(),
98
+ default=None,
99
+ help="Output file path for the plan",
100
+ )
101
+ @option(
102
+ "--json",
103
+ "json_format",
104
+ is_flag=True,
105
+ default=False,
106
+ help="Output plan as JSON graph with nodes and edges",
107
+ )
108
+ @option(
109
+ "--config",
110
+ type=Path(exists=True),
111
+ help="Path to trilogy.toml configuration file",
112
+ )
113
+ @pass_context
114
+ def plan(ctx, input: str, output: str | None, json_format: bool, config: str | None):
115
+ """Show execution plan without running scripts."""
116
+ try:
117
+ pathlib_input = PathlibPath(input)
118
+ config_path = PathlibPath(config) if config else None
119
+
120
+ # Resolve input to validate it exists
121
+ _ = list(resolve_input_information(input, config_path)[0])
122
+
123
+ if not pathlib_input.exists():
124
+ print_error(f"Input path '{input}' does not exist.")
125
+ raise Exit(1)
126
+
127
+ # Set up parallel executor with ETL strategy
128
+ parallel_exec = ParallelExecutor(
129
+ max_workers=1,
130
+ dependency_strategy=ETLDependencyStrategy(),
131
+ )
132
+
133
+ # Get execution plan
134
+ if pathlib_input.is_dir():
135
+ root = pathlib_input
136
+ graph = parallel_exec.get_folder_execution_plan(pathlib_input)
137
+ elif pathlib_input.is_file():
138
+ root = pathlib_input.parent
139
+ graph = parallel_exec.get_execution_plan([pathlib_input])
140
+ else:
141
+ print_error(f"Input path '{input}' is not a file or directory.")
142
+ raise Exit(1)
143
+
144
+ if json_format:
145
+ plan_data = graph_to_json(graph, root)
146
+ levels = get_execution_levels(graph)
147
+ plan_data["execution_order"] = [
148
+ [safe_relative_path(node.path, root) for node in level]
149
+ for level in levels
150
+ ]
151
+ json_output = json.dumps(plan_data, indent=2)
152
+
153
+ if output:
154
+ output_path = PathlibPath(output)
155
+ output_path.write_text(json_output)
156
+ print_info(f"Plan written to {output_path}")
157
+ else:
158
+ print(json_output)
159
+ else:
160
+ nodes, edges, execution_order = format_plan_text(graph, root)
161
+
162
+ if output:
163
+ output_path = PathlibPath(output)
164
+ lines = []
165
+ lines.append("Execution Plan")
166
+ lines.append(f"Scripts: {len(nodes)}")
167
+ lines.append(f"Dependencies: {len(edges)}")
168
+ lines.append(f"Execution Levels: {len(execution_order)}")
169
+ lines.append("")
170
+ if execution_order:
171
+ lines.append("Execution Order:")
172
+ for level_idx, level_scripts in enumerate(execution_order):
173
+ lines.append(
174
+ f" Level {level_idx + 1}: {', '.join(level_scripts)}"
175
+ )
176
+ lines.append("")
177
+ if edges:
178
+ lines.append("Dependencies:")
179
+ for from_node, to_node in edges:
180
+ lines.append(f" {from_node} -> {to_node}")
181
+ output_path.write_text("\n".join(lines))
182
+ print_info(f"Plan written to {output_path}")
183
+ else:
184
+ show_execution_plan(nodes, edges, execution_order)
185
+
186
+ except Exit:
187
+ raise
188
+ except Exception as e:
189
+ handle_execution_exception(e, debug=ctx.obj.get("DEBUG", False))
@@ -0,0 +1,106 @@
1
+ """Refresh command for Trilogy CLI - refreshes stale assets."""
2
+
3
+ from pathlib import Path as PathlibPath
4
+
5
+ from click import UNPROCESSED, Path, argument, option, pass_context
6
+ from click.exceptions import Exit
7
+
8
+ from trilogy import Executor
9
+ from trilogy.core.statements.execute import ProcessedValidateStatement
10
+ from trilogy.dialect.enums import Dialects
11
+ from trilogy.execution.state.state_store import BaseStateStore
12
+ from trilogy.scripts.common import (
13
+ CLIRuntimeParams,
14
+ ExecutionStats,
15
+ count_statement_stats,
16
+ handle_execution_exception,
17
+ )
18
+ from trilogy.scripts.dependency import ScriptNode
19
+ from trilogy.scripts.parallel_execution import ExecutionMode, run_parallel_execution
20
+
21
+
22
+ def execute_script_for_refresh(
23
+ exec: Executor, node: ScriptNode, quiet: bool = False
24
+ ) -> ExecutionStats:
25
+ """Execute a script for the 'refresh' command - parse and refresh stale assets."""
26
+ from trilogy.scripts.display import print_info, print_success, print_warning
27
+
28
+ validation = []
29
+ with open(node.path, "r") as f:
30
+ statements = exec.parse_text(f.read())
31
+
32
+ for x in statements:
33
+ if isinstance(x, ProcessedValidateStatement):
34
+ validation.append(x)
35
+
36
+ stats = count_statement_stats([])
37
+
38
+ state_store = BaseStateStore()
39
+ stale_assets = state_store.get_stale_assets(exec.environment, exec)
40
+
41
+ if not stale_assets:
42
+ if not quiet:
43
+ print_info(f"No stale assets found in {node.path.name}")
44
+ return stats
45
+
46
+ if not quiet:
47
+ print_warning(f"Found {len(stale_assets)} stale asset(s) in {node.path.name}")
48
+
49
+ for asset in stale_assets:
50
+ if not quiet:
51
+ print_info(f" Refreshing {asset.datasource_id}: {asset.reason}")
52
+ datasource = exec.environment.datasources[asset.datasource_id]
53
+ exec.update_datasource(datasource)
54
+ stats.update_count += 1
55
+ for x in validation:
56
+ exec.execute_statement(x)
57
+ stats = count_statement_stats([x])
58
+ if not quiet:
59
+ print_success(f"Refreshed {len(stale_assets)} asset(s) in {node.path.name}")
60
+
61
+ return stats
62
+
63
+
64
+ @argument("input", type=Path())
65
+ @argument("dialect", type=str, required=False)
66
+ @option("--param", multiple=True, help="Environment parameters as key=value pairs")
67
+ @option(
68
+ "--parallelism",
69
+ "-p",
70
+ default=None,
71
+ help="Maximum parallel workers for directory execution",
72
+ )
73
+ @option(
74
+ "--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
75
+ )
76
+ @argument("conn_args", nargs=-1, type=UNPROCESSED)
77
+ @pass_context
78
+ def refresh(
79
+ ctx, input, dialect: str | None, param, parallelism: int | None, config, conn_args
80
+ ):
81
+ """Refresh stale assets in Trilogy scripts.
82
+
83
+ Parses each script, identifies datasources marked as 'root' (source of truth),
84
+ compares watermarks to find stale derived assets, and refreshes them.
85
+ """
86
+ cli_params = CLIRuntimeParams(
87
+ input=input,
88
+ dialect=Dialects(dialect) if dialect else None,
89
+ parallelism=parallelism,
90
+ param=param,
91
+ conn_args=conn_args,
92
+ debug=ctx.obj["DEBUG"],
93
+ config_path=PathlibPath(config) if config else None,
94
+ execution_strategy="eager_bfs",
95
+ )
96
+
97
+ try:
98
+ run_parallel_execution(
99
+ cli_params=cli_params,
100
+ execution_fn=execute_script_for_refresh,
101
+ execution_mode=ExecutionMode.REFRESH,
102
+ )
103
+ except Exit:
104
+ raise
105
+ except Exception as e:
106
+ handle_execution_exception(e, debug=cli_params.debug)
trilogy/scripts/run.py ADDED
@@ -0,0 +1,79 @@
1
+ """Run command for Trilogy CLI."""
2
+
3
+ from pathlib import Path as PathlibPath
4
+
5
+ from click import UNPROCESSED, Path, argument, option, pass_context
6
+ from click.exceptions import Exit
7
+
8
+ from trilogy import Executor
9
+ from trilogy.dialect.enums import Dialects
10
+ from trilogy.scripts.common import (
11
+ CLIRuntimeParams,
12
+ ExecutionStats,
13
+ execute_script_with_stats,
14
+ handle_execution_exception,
15
+ )
16
+ from trilogy.scripts.dependency import ScriptNode
17
+ from trilogy.scripts.parallel_execution import ExecutionMode, run_parallel_execution
18
+
19
+
20
+ def execute_script_for_run(
21
+ exec: Executor, node: ScriptNode, quiet: bool = False
22
+ ) -> ExecutionStats:
23
+ """Execute a script for the 'run' command (parallel execution mode)."""
24
+ return execute_script_with_stats(exec, node.path, run_statements=True)
25
+
26
+
27
+ @argument("input", type=Path())
28
+ @argument("dialect", type=str, required=False)
29
+ @option("--param", multiple=True, help="Environment parameters as key=value pairs")
30
+ @option(
31
+ "--parallelism",
32
+ "-p",
33
+ default=None,
34
+ help="Maximum parallel workers for directory execution",
35
+ )
36
+ @option(
37
+ "--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
38
+ )
39
+ @option(
40
+ "--env",
41
+ "-e",
42
+ multiple=True,
43
+ help="Set environment variables as KEY=VALUE pairs",
44
+ )
45
+ @argument("conn_args", nargs=-1, type=UNPROCESSED)
46
+ @pass_context
47
+ def run(
48
+ ctx,
49
+ input,
50
+ dialect: str | None,
51
+ param,
52
+ parallelism: int | None,
53
+ config,
54
+ env,
55
+ conn_args,
56
+ ):
57
+ """Execute a Trilogy script or query."""
58
+ cli_params = CLIRuntimeParams(
59
+ input=input,
60
+ dialect=Dialects(dialect) if dialect else None,
61
+ parallelism=parallelism,
62
+ param=param,
63
+ conn_args=conn_args,
64
+ debug=ctx.obj["DEBUG"],
65
+ config_path=PathlibPath(config) if config else None,
66
+ execution_strategy="eager_bfs",
67
+ env=env,
68
+ )
69
+
70
+ try:
71
+ run_parallel_execution(
72
+ cli_params=cli_params,
73
+ execution_fn=execute_script_for_run,
74
+ execution_mode=ExecutionMode.RUN,
75
+ )
76
+ except Exit:
77
+ raise
78
+ except Exception as e:
79
+ handle_execution_exception(e, debug=cli_params.debug)
@@ -0,0 +1,202 @@
1
+ """Serve command for Trilogy CLI."""
2
+
3
+ import sys
4
+ from pathlib import Path as PathlibPath
5
+ from urllib.parse import quote
6
+
7
+ from click import Path, argument, option, pass_context
8
+
9
+ from trilogy.execution.config import load_config_file
10
+ from trilogy.scripts.common import find_trilogy_config
11
+ from trilogy.scripts.serve_helpers import (
12
+ find_trilogy_files,
13
+ get_relative_model_name,
14
+ get_safe_model_name,
15
+ )
16
+
17
+
18
+ def check_fastapi_available() -> bool:
19
+ """Check if FastAPI and uvicorn are available."""
20
+ try:
21
+ import fastapi # noqa: F401
22
+ import uvicorn # noqa: F401
23
+
24
+ return True
25
+ except ImportError:
26
+ return False
27
+
28
+
29
+ def create_app(app, engine: str, directory_path: PathlibPath, host: str, port: int):
30
+ from fastapi import HTTPException
31
+ from fastapi.middleware.cors import CORSMiddleware
32
+ from fastapi.responses import PlainTextResponse
33
+
34
+ from trilogy.scripts.serve_helpers import (
35
+ ModelImport,
36
+ StoreIndex,
37
+ find_all_model_files,
38
+ find_file_content_by_name,
39
+ find_model_by_name,
40
+ generate_model_index,
41
+ )
42
+
43
+ url_host = "localhost" if host == "0.0.0.0" else host
44
+ if port in (80, 443):
45
+ base_url = f"http://{url_host}"
46
+ else:
47
+ base_url = f"http://{url_host}:{port}"
48
+ app.add_middleware(
49
+ CORSMiddleware,
50
+ allow_origins=["*"],
51
+ allow_credentials=True,
52
+ allow_methods=["*"],
53
+ allow_headers=["*"],
54
+ )
55
+
56
+ @app.get("/")
57
+ async def root():
58
+ """Root endpoint with server information."""
59
+ file_count = len(find_all_model_files(directory_path))
60
+ return {
61
+ "message": "Trilogy Model Server",
62
+ "description": f"Serving model '{directory_path.name}' with {file_count} files from {directory_path}",
63
+ "endpoints": {
64
+ "/index.json": "Get list of available models",
65
+ "/models/<model-name>.json": "Get specific model details",
66
+ },
67
+ }
68
+
69
+ @app.get("/index.json", response_model=StoreIndex)
70
+ async def get_index() -> StoreIndex:
71
+ """Return the store index with list of available models."""
72
+ return StoreIndex(
73
+ name=f"Trilogy Models - {directory_path.name}",
74
+ models=generate_model_index(directory_path, base_url, engine),
75
+ )
76
+
77
+ @app.get("/models/{model_name}.json", response_model=ModelImport)
78
+ async def get_model(model_name: str) -> ModelImport:
79
+ """Return a specific model by name."""
80
+ model = find_model_by_name(model_name, directory_path, base_url, engine)
81
+ if model is None:
82
+ raise HTTPException(status_code=404, detail="Model not found")
83
+ return model
84
+
85
+ @app.get("/files/{file_name}")
86
+ async def get_file(file_name: str):
87
+ """Return the raw .preql or .sql file content."""
88
+ content = find_file_content_by_name(file_name, directory_path)
89
+ if content is None:
90
+ raise HTTPException(status_code=404, detail="File not found")
91
+ return PlainTextResponse(content=content)
92
+
93
+ print(f"Starting Trilogy Model Server on http://{host}:{port}")
94
+ print(f"Serving model '{directory_path.name}' from: {directory_path}")
95
+ print(f"Engine: {engine}")
96
+ print(f"Access the index at: http://{host}:{port}/index.json")
97
+ print(
98
+ f"Found {len(find_all_model_files(directory_path))} model files (.preql, .sql, .csv)"
99
+ )
100
+ return app
101
+
102
+
103
+ @argument("path", type=Path(exists=True, file_okay=True, dir_okay=True))
104
+ @argument("engine", type=str, required=False, default="generic")
105
+ @option("--port", "-p", default=8100, help="Port to run the server on")
106
+ @option("--host", "-h", default="0.0.0.0", help="Host to bind the server to")
107
+ @option("--timeout", "-t", default=None, type=float, help="Shutdown after N seconds")
108
+ @pass_context
109
+ def serve(ctx, path: str, engine: str, port: int, host: str, timeout: float | None):
110
+ """Start a FastAPI server to expose Trilogy models from a directory or file."""
111
+ if not check_fastapi_available():
112
+ print(
113
+ "Error: FastAPI and uvicorn are required for the serve command.\n"
114
+ "Please install with: pip install pytrilogy[serve]",
115
+ file=sys.stderr,
116
+ )
117
+ sys.exit(1)
118
+
119
+ import uvicorn
120
+ from fastapi import FastAPI
121
+
122
+ from trilogy import __version__
123
+
124
+ path_obj = PathlibPath(path).resolve()
125
+
126
+ # Determine directory and target file
127
+ if path_obj.is_file():
128
+ directory_path = path_obj.parent
129
+ target_file = path_obj
130
+ else:
131
+ directory_path = path_obj
132
+ target_file = None
133
+
134
+ # Load trilogy.toml to get engine dialect if not explicitly provided
135
+ config_path = find_trilogy_config(directory_path)
136
+ if config_path and engine == "generic":
137
+ try:
138
+ runtime_config = load_config_file(config_path)
139
+ if runtime_config.engine_dialect:
140
+ engine = runtime_config.engine_dialect.value
141
+ except Exception:
142
+ pass
143
+
144
+ # Use localhost instead of 0.0.0.0 in URLs so they resolve properly
145
+ app = FastAPI(title="Trilogy Model Server", version=__version__)
146
+
147
+ create_app(app, engine, directory_path, host, port)
148
+
149
+ # Generate Trilogy Studio URL
150
+ url_host = "localhost" if host == "0.0.0.0" else host
151
+ base_url = (
152
+ f"http://{url_host}:{port}" if port not in (80, 443) else f"http://{url_host}"
153
+ )
154
+
155
+ # Find target file if not specified
156
+ if target_file is None:
157
+ trilogy_files = find_trilogy_files(directory_path)
158
+ if trilogy_files:
159
+ target_file = trilogy_files[0]
160
+
161
+ if target_file:
162
+ # The model URL uses the directory name (not the file name)
163
+ model_safe_name = get_safe_model_name(directory_path.name)
164
+ model_url = f"{base_url}/models/{model_safe_name}.json"
165
+ store_url = base_url
166
+
167
+ # The asset name is the specific file within the model
168
+ asset_name = get_relative_model_name(target_file, directory_path)
169
+
170
+ # URL-encode the parameters
171
+ studio_url = (
172
+ f"https://trilogydata.dev/trilogy-studio-core/#"
173
+ f"import={quote(model_url)}&"
174
+ f"assetType=trilogy&"
175
+ f"assetName={quote(asset_name)}&"
176
+ f"modelName={quote(directory_path.name)}&"
177
+ f"connection={quote(engine)}&"
178
+ f"store={quote(store_url)}"
179
+ )
180
+
181
+ print("\n" + "=" * 80)
182
+ print("Trilogy Studio Link:")
183
+ print(studio_url)
184
+ print("=" * 80 + "\n")
185
+
186
+ if timeout is not None:
187
+ import threading
188
+
189
+ config = uvicorn.Config(app, host=host, port=port)
190
+ server = uvicorn.Server(config)
191
+
192
+ def shutdown_after_timeout():
193
+ import time
194
+
195
+ time.sleep(timeout)
196
+ server.should_exit = True
197
+
198
+ timer = threading.Thread(target=shutdown_after_timeout, daemon=True)
199
+ timer.start()
200
+ server.run()
201
+ else:
202
+ uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,41 @@
1
+ """Helpers for the serve command."""
2
+
3
+ from trilogy.scripts.serve_helpers.file_discovery import (
4
+ extract_description_from_file,
5
+ find_all_model_files,
6
+ find_csv_files,
7
+ find_preql_files,
8
+ find_sql_files,
9
+ find_trilogy_files,
10
+ get_relative_model_name,
11
+ get_safe_model_name,
12
+ )
13
+ from trilogy.scripts.serve_helpers.index_generation import (
14
+ find_file_content_by_name,
15
+ find_model_by_name,
16
+ generate_model_index,
17
+ )
18
+ from trilogy.scripts.serve_helpers.models import (
19
+ ImportFile,
20
+ ModelImport,
21
+ StoreIndex,
22
+ StoreModelIndex,
23
+ )
24
+
25
+ __all__ = [
26
+ "ImportFile",
27
+ "ModelImport",
28
+ "StoreIndex",
29
+ "StoreModelIndex",
30
+ "find_preql_files",
31
+ "find_sql_files",
32
+ "find_csv_files",
33
+ "find_trilogy_files",
34
+ "find_all_model_files",
35
+ "extract_description_from_file",
36
+ "get_relative_model_name",
37
+ "get_safe_model_name",
38
+ "generate_model_index",
39
+ "find_model_by_name",
40
+ "find_file_content_by_name",
41
+ ]