pytrilogy 0.3.142__cp313-cp313-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.
- LICENSE.md +19 -0
- _preql_import_resolver/__init__.py +5 -0
- _preql_import_resolver/_preql_import_resolver.cpython-313-x86_64-linux-gnu.so +0 -0
- pytrilogy-0.3.142.dist-info/METADATA +555 -0
- pytrilogy-0.3.142.dist-info/RECORD +200 -0
- pytrilogy-0.3.142.dist-info/WHEEL +5 -0
- pytrilogy-0.3.142.dist-info/entry_points.txt +2 -0
- pytrilogy-0.3.142.dist-info/licenses/LICENSE.md +19 -0
- trilogy/__init__.py +16 -0
- trilogy/ai/README.md +10 -0
- trilogy/ai/__init__.py +19 -0
- trilogy/ai/constants.py +92 -0
- trilogy/ai/conversation.py +107 -0
- trilogy/ai/enums.py +7 -0
- trilogy/ai/execute.py +50 -0
- trilogy/ai/models.py +34 -0
- trilogy/ai/prompts.py +100 -0
- trilogy/ai/providers/__init__.py +0 -0
- trilogy/ai/providers/anthropic.py +106 -0
- trilogy/ai/providers/base.py +24 -0
- trilogy/ai/providers/google.py +146 -0
- trilogy/ai/providers/openai.py +89 -0
- trilogy/ai/providers/utils.py +68 -0
- trilogy/authoring/README.md +3 -0
- trilogy/authoring/__init__.py +148 -0
- trilogy/constants.py +113 -0
- trilogy/core/README.md +52 -0
- trilogy/core/__init__.py +0 -0
- trilogy/core/constants.py +6 -0
- trilogy/core/enums.py +443 -0
- trilogy/core/env_processor.py +120 -0
- trilogy/core/environment_helpers.py +320 -0
- trilogy/core/ergonomics.py +193 -0
- trilogy/core/exceptions.py +123 -0
- trilogy/core/functions.py +1227 -0
- trilogy/core/graph_models.py +139 -0
- trilogy/core/internal.py +85 -0
- trilogy/core/models/__init__.py +0 -0
- trilogy/core/models/author.py +2669 -0
- trilogy/core/models/build.py +2521 -0
- trilogy/core/models/build_environment.py +180 -0
- trilogy/core/models/core.py +501 -0
- trilogy/core/models/datasource.py +322 -0
- trilogy/core/models/environment.py +751 -0
- trilogy/core/models/execute.py +1177 -0
- trilogy/core/optimization.py +251 -0
- trilogy/core/optimizations/__init__.py +12 -0
- trilogy/core/optimizations/base_optimization.py +17 -0
- trilogy/core/optimizations/hide_unused_concept.py +47 -0
- trilogy/core/optimizations/inline_datasource.py +102 -0
- trilogy/core/optimizations/predicate_pushdown.py +245 -0
- trilogy/core/processing/README.md +94 -0
- trilogy/core/processing/READMEv2.md +121 -0
- trilogy/core/processing/VIRTUAL_UNNEST.md +30 -0
- trilogy/core/processing/__init__.py +0 -0
- trilogy/core/processing/concept_strategies_v3.py +508 -0
- trilogy/core/processing/constants.py +15 -0
- trilogy/core/processing/discovery_node_factory.py +451 -0
- trilogy/core/processing/discovery_utility.py +548 -0
- trilogy/core/processing/discovery_validation.py +167 -0
- trilogy/core/processing/graph_utils.py +43 -0
- trilogy/core/processing/node_generators/README.md +9 -0
- trilogy/core/processing/node_generators/__init__.py +31 -0
- trilogy/core/processing/node_generators/basic_node.py +160 -0
- trilogy/core/processing/node_generators/common.py +268 -0
- trilogy/core/processing/node_generators/constant_node.py +38 -0
- trilogy/core/processing/node_generators/filter_node.py +315 -0
- trilogy/core/processing/node_generators/group_node.py +213 -0
- trilogy/core/processing/node_generators/group_to_node.py +117 -0
- trilogy/core/processing/node_generators/multiselect_node.py +205 -0
- trilogy/core/processing/node_generators/node_merge_node.py +653 -0
- trilogy/core/processing/node_generators/recursive_node.py +88 -0
- trilogy/core/processing/node_generators/rowset_node.py +165 -0
- trilogy/core/processing/node_generators/select_helpers/__init__.py +0 -0
- trilogy/core/processing/node_generators/select_helpers/datasource_injection.py +261 -0
- trilogy/core/processing/node_generators/select_merge_node.py +748 -0
- trilogy/core/processing/node_generators/select_node.py +95 -0
- trilogy/core/processing/node_generators/synonym_node.py +98 -0
- trilogy/core/processing/node_generators/union_node.py +91 -0
- trilogy/core/processing/node_generators/unnest_node.py +182 -0
- trilogy/core/processing/node_generators/window_node.py +201 -0
- trilogy/core/processing/nodes/README.md +28 -0
- trilogy/core/processing/nodes/__init__.py +179 -0
- trilogy/core/processing/nodes/base_node.py +519 -0
- trilogy/core/processing/nodes/filter_node.py +75 -0
- trilogy/core/processing/nodes/group_node.py +194 -0
- trilogy/core/processing/nodes/merge_node.py +420 -0
- trilogy/core/processing/nodes/recursive_node.py +46 -0
- trilogy/core/processing/nodes/select_node_v2.py +242 -0
- trilogy/core/processing/nodes/union_node.py +53 -0
- trilogy/core/processing/nodes/unnest_node.py +62 -0
- trilogy/core/processing/nodes/window_node.py +56 -0
- trilogy/core/processing/utility.py +823 -0
- trilogy/core/query_processor.py +596 -0
- trilogy/core/statements/README.md +35 -0
- trilogy/core/statements/__init__.py +0 -0
- trilogy/core/statements/author.py +536 -0
- trilogy/core/statements/build.py +0 -0
- trilogy/core/statements/common.py +20 -0
- trilogy/core/statements/execute.py +155 -0
- trilogy/core/table_processor.py +66 -0
- trilogy/core/utility.py +8 -0
- trilogy/core/validation/README.md +46 -0
- trilogy/core/validation/__init__.py +0 -0
- trilogy/core/validation/common.py +161 -0
- trilogy/core/validation/concept.py +146 -0
- trilogy/core/validation/datasource.py +227 -0
- trilogy/core/validation/environment.py +73 -0
- trilogy/core/validation/fix.py +256 -0
- trilogy/dialect/__init__.py +32 -0
- trilogy/dialect/base.py +1392 -0
- trilogy/dialect/bigquery.py +308 -0
- trilogy/dialect/common.py +147 -0
- trilogy/dialect/config.py +144 -0
- trilogy/dialect/dataframe.py +50 -0
- trilogy/dialect/duckdb.py +231 -0
- trilogy/dialect/enums.py +147 -0
- trilogy/dialect/metadata.py +173 -0
- trilogy/dialect/mock.py +190 -0
- trilogy/dialect/postgres.py +117 -0
- trilogy/dialect/presto.py +110 -0
- trilogy/dialect/results.py +89 -0
- trilogy/dialect/snowflake.py +129 -0
- trilogy/dialect/sql_server.py +137 -0
- trilogy/engine.py +48 -0
- trilogy/execution/config.py +75 -0
- trilogy/executor.py +568 -0
- trilogy/hooks/__init__.py +4 -0
- trilogy/hooks/base_hook.py +40 -0
- trilogy/hooks/graph_hook.py +139 -0
- trilogy/hooks/query_debugger.py +166 -0
- trilogy/metadata/__init__.py +0 -0
- trilogy/parser.py +10 -0
- trilogy/parsing/README.md +21 -0
- trilogy/parsing/__init__.py +0 -0
- trilogy/parsing/common.py +1069 -0
- trilogy/parsing/config.py +5 -0
- trilogy/parsing/exceptions.py +8 -0
- trilogy/parsing/helpers.py +1 -0
- trilogy/parsing/parse_engine.py +2813 -0
- trilogy/parsing/render.py +769 -0
- trilogy/parsing/trilogy.lark +540 -0
- trilogy/py.typed +0 -0
- trilogy/render.py +42 -0
- trilogy/scripts/README.md +9 -0
- trilogy/scripts/__init__.py +0 -0
- trilogy/scripts/agent.py +41 -0
- trilogy/scripts/agent_info.py +303 -0
- trilogy/scripts/common.py +355 -0
- trilogy/scripts/dependency/Cargo.lock +617 -0
- trilogy/scripts/dependency/Cargo.toml +39 -0
- trilogy/scripts/dependency/README.md +131 -0
- trilogy/scripts/dependency/build.sh +25 -0
- trilogy/scripts/dependency/src/directory_resolver.rs +177 -0
- trilogy/scripts/dependency/src/lib.rs +16 -0
- trilogy/scripts/dependency/src/main.rs +770 -0
- trilogy/scripts/dependency/src/parser.rs +435 -0
- trilogy/scripts/dependency/src/preql.pest +208 -0
- trilogy/scripts/dependency/src/python_bindings.rs +303 -0
- trilogy/scripts/dependency/src/resolver.rs +716 -0
- trilogy/scripts/dependency/tests/base.preql +3 -0
- trilogy/scripts/dependency/tests/cli_integration.rs +377 -0
- trilogy/scripts/dependency/tests/customer.preql +6 -0
- trilogy/scripts/dependency/tests/main.preql +9 -0
- trilogy/scripts/dependency/tests/orders.preql +7 -0
- trilogy/scripts/dependency/tests/test_data/base.preql +9 -0
- trilogy/scripts/dependency/tests/test_data/consumer.preql +1 -0
- trilogy/scripts/dependency.py +323 -0
- trilogy/scripts/display.py +512 -0
- trilogy/scripts/environment.py +46 -0
- trilogy/scripts/fmt.py +32 -0
- trilogy/scripts/ingest.py +471 -0
- trilogy/scripts/ingest_helpers/__init__.py +1 -0
- trilogy/scripts/ingest_helpers/foreign_keys.py +123 -0
- trilogy/scripts/ingest_helpers/formatting.py +93 -0
- trilogy/scripts/ingest_helpers/typing.py +161 -0
- trilogy/scripts/init.py +105 -0
- trilogy/scripts/parallel_execution.py +713 -0
- trilogy/scripts/plan.py +189 -0
- trilogy/scripts/run.py +63 -0
- trilogy/scripts/serve.py +140 -0
- trilogy/scripts/serve_helpers/__init__.py +41 -0
- trilogy/scripts/serve_helpers/file_discovery.py +142 -0
- trilogy/scripts/serve_helpers/index_generation.py +206 -0
- trilogy/scripts/serve_helpers/models.py +38 -0
- trilogy/scripts/single_execution.py +131 -0
- trilogy/scripts/testing.py +119 -0
- trilogy/scripts/trilogy.py +68 -0
- trilogy/std/__init__.py +0 -0
- trilogy/std/color.preql +3 -0
- trilogy/std/date.preql +13 -0
- trilogy/std/display.preql +18 -0
- trilogy/std/geography.preql +22 -0
- trilogy/std/metric.preql +15 -0
- trilogy/std/money.preql +67 -0
- trilogy/std/net.preql +14 -0
- trilogy/std/ranking.preql +7 -0
- trilogy/std/report.preql +5 -0
- trilogy/std/semantic.preql +6 -0
- trilogy/utility.py +34 -0
trilogy/scripts/plan.py
ADDED
|
@@ -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))
|
trilogy/scripts/run.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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 CLIRuntimeParams, handle_execution_exception
|
|
11
|
+
from trilogy.scripts.dependency import ScriptNode
|
|
12
|
+
from trilogy.scripts.parallel_execution import run_parallel_execution
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def execute_script_for_run(
|
|
16
|
+
exec: Executor, node: ScriptNode, quiet: bool = False
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Execute a script for the 'run' command (parallel execution mode)."""
|
|
19
|
+
with open(node.path, "r") as f:
|
|
20
|
+
queries = exec.parse_text(f.read())
|
|
21
|
+
for query in queries:
|
|
22
|
+
exec.execute_query(query)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@argument("input", type=Path())
|
|
26
|
+
@argument("dialect", type=str, required=False)
|
|
27
|
+
@option("--param", multiple=True, help="Environment parameters as key=value pairs")
|
|
28
|
+
@option(
|
|
29
|
+
"--parallelism",
|
|
30
|
+
"-p",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Maximum parallel workers for directory execution",
|
|
33
|
+
)
|
|
34
|
+
@option(
|
|
35
|
+
"--config", type=Path(exists=True), help="Path to trilogy.toml configuration file"
|
|
36
|
+
)
|
|
37
|
+
@argument("conn_args", nargs=-1, type=UNPROCESSED)
|
|
38
|
+
@pass_context
|
|
39
|
+
def run(
|
|
40
|
+
ctx, input, dialect: str | None, param, parallelism: int | None, config, conn_args
|
|
41
|
+
):
|
|
42
|
+
"""Execute a Trilogy script or query."""
|
|
43
|
+
cli_params = CLIRuntimeParams(
|
|
44
|
+
input=input,
|
|
45
|
+
dialect=Dialects(dialect) if dialect else None,
|
|
46
|
+
parallelism=parallelism,
|
|
47
|
+
param=param,
|
|
48
|
+
conn_args=conn_args,
|
|
49
|
+
debug=ctx.obj["DEBUG"],
|
|
50
|
+
config_path=PathlibPath(config) if config else None,
|
|
51
|
+
execution_strategy="eager_bfs",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
run_parallel_execution(
|
|
56
|
+
cli_params=cli_params,
|
|
57
|
+
execution_fn=execute_script_for_run,
|
|
58
|
+
execution_mode="run",
|
|
59
|
+
)
|
|
60
|
+
except Exit:
|
|
61
|
+
raise
|
|
62
|
+
except Exception as e:
|
|
63
|
+
handle_execution_exception(e, debug=cli_params.debug)
|
trilogy/scripts/serve.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Serve command for Trilogy CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path as PathlibPath
|
|
5
|
+
|
|
6
|
+
from click import Path, argument, option, pass_context
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_fastapi_available() -> bool:
|
|
10
|
+
"""Check if FastAPI and uvicorn are available."""
|
|
11
|
+
try:
|
|
12
|
+
import fastapi # noqa: F401
|
|
13
|
+
import uvicorn # noqa: F401
|
|
14
|
+
|
|
15
|
+
return True
|
|
16
|
+
except ImportError:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(app, engine: str, directory_path: PathlibPath, host: str, port: int):
|
|
21
|
+
from fastapi import HTTPException
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import PlainTextResponse
|
|
24
|
+
|
|
25
|
+
from trilogy.scripts.serve_helpers import (
|
|
26
|
+
ModelImport,
|
|
27
|
+
StoreIndex,
|
|
28
|
+
find_all_model_files,
|
|
29
|
+
find_file_content_by_name,
|
|
30
|
+
find_model_by_name,
|
|
31
|
+
generate_model_index,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
url_host = "localhost" if host == "0.0.0.0" else host
|
|
35
|
+
if port in (80, 443):
|
|
36
|
+
base_url = f"http://{url_host}"
|
|
37
|
+
else:
|
|
38
|
+
base_url = f"http://{url_host}:{port}"
|
|
39
|
+
app.add_middleware(
|
|
40
|
+
CORSMiddleware,
|
|
41
|
+
allow_origins=["*"],
|
|
42
|
+
allow_credentials=True,
|
|
43
|
+
allow_methods=["*"],
|
|
44
|
+
allow_headers=["*"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@app.get("/")
|
|
48
|
+
async def root():
|
|
49
|
+
"""Root endpoint with server information."""
|
|
50
|
+
file_count = len(find_all_model_files(directory_path))
|
|
51
|
+
return {
|
|
52
|
+
"message": "Trilogy Model Server",
|
|
53
|
+
"description": f"Serving model '{directory_path.name}' with {file_count} files from {directory_path}",
|
|
54
|
+
"endpoints": {
|
|
55
|
+
"/index.json": "Get list of available models",
|
|
56
|
+
"/models/<model-name>.json": "Get specific model details",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@app.get("/index.json", response_model=StoreIndex)
|
|
61
|
+
async def get_index() -> StoreIndex:
|
|
62
|
+
"""Return the store index with list of available models."""
|
|
63
|
+
return StoreIndex(
|
|
64
|
+
name=f"Trilogy Models - {directory_path.name}",
|
|
65
|
+
models=generate_model_index(directory_path, base_url, engine),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@app.get("/models/{model_name}.json", response_model=ModelImport)
|
|
69
|
+
async def get_model(model_name: str) -> ModelImport:
|
|
70
|
+
"""Return a specific model by name."""
|
|
71
|
+
model = find_model_by_name(model_name, directory_path, base_url, engine)
|
|
72
|
+
if model is None:
|
|
73
|
+
raise HTTPException(status_code=404, detail="Model not found")
|
|
74
|
+
return model
|
|
75
|
+
|
|
76
|
+
@app.get("/files/{file_name}")
|
|
77
|
+
async def get_file(file_name: str):
|
|
78
|
+
"""Return the raw .preql or .sql file content."""
|
|
79
|
+
content = find_file_content_by_name(file_name, directory_path)
|
|
80
|
+
if content is None:
|
|
81
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
82
|
+
return PlainTextResponse(content=content)
|
|
83
|
+
|
|
84
|
+
print(f"Starting Trilogy Model Server on http://{host}:{port}")
|
|
85
|
+
print(f"Serving model '{directory_path.name}' from: {directory_path}")
|
|
86
|
+
print(f"Engine: {engine}")
|
|
87
|
+
print(f"Access the index at: http://{host}:{port}/index.json")
|
|
88
|
+
print(
|
|
89
|
+
f"Found {len(find_all_model_files(directory_path))} model files (.preql, .sql, .csv)"
|
|
90
|
+
)
|
|
91
|
+
return app
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@argument("directory", type=Path(exists=True, file_okay=False, dir_okay=True))
|
|
95
|
+
@argument("engine", type=str, required=False, default="generic")
|
|
96
|
+
@option("--port", "-p", default=8100, help="Port to run the server on")
|
|
97
|
+
@option("--host", "-h", default="0.0.0.0", help="Host to bind the server to")
|
|
98
|
+
@option("--timeout", "-t", default=None, type=float, help="Shutdown after N seconds")
|
|
99
|
+
@pass_context
|
|
100
|
+
def serve(
|
|
101
|
+
ctx, directory: str, engine: str, port: int, host: str, timeout: float | None
|
|
102
|
+
):
|
|
103
|
+
"""Start a FastAPI server to expose Trilogy models from a directory."""
|
|
104
|
+
if not check_fastapi_available():
|
|
105
|
+
print(
|
|
106
|
+
"Error: FastAPI and uvicorn are required for the serve command.\n"
|
|
107
|
+
"Please install with: pip install pytrilogy[serve]",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
import uvicorn
|
|
113
|
+
from fastapi import FastAPI
|
|
114
|
+
|
|
115
|
+
from trilogy import __version__
|
|
116
|
+
|
|
117
|
+
directory_path = PathlibPath(directory).resolve()
|
|
118
|
+
# Use localhost instead of 0.0.0.0 in URLs so they resolve properly
|
|
119
|
+
|
|
120
|
+
app = FastAPI(title="Trilogy Model Server", version=__version__)
|
|
121
|
+
|
|
122
|
+
create_app(app, engine, directory_path, host, port)
|
|
123
|
+
|
|
124
|
+
if timeout is not None:
|
|
125
|
+
import threading
|
|
126
|
+
|
|
127
|
+
config = uvicorn.Config(app, host=host, port=port)
|
|
128
|
+
server = uvicorn.Server(config)
|
|
129
|
+
|
|
130
|
+
def shutdown_after_timeout():
|
|
131
|
+
import time
|
|
132
|
+
|
|
133
|
+
time.sleep(timeout)
|
|
134
|
+
server.should_exit = True
|
|
135
|
+
|
|
136
|
+
timer = threading.Thread(target=shutdown_after_timeout, daemon=True)
|
|
137
|
+
timer.start()
|
|
138
|
+
server.run()
|
|
139
|
+
else:
|
|
140
|
+
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
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""File discovery and processing utilities for the serve command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_preql_files(directory_path: Path) -> list[Path]:
|
|
7
|
+
"""Find all .preql files in the directory recursively.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
directory_path: The root directory to search
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
List of Path objects for all .preql files found
|
|
14
|
+
"""
|
|
15
|
+
return list(directory_path.rglob("*.preql"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_sql_files(directory_path: Path) -> list[Path]:
|
|
19
|
+
"""Find all .sql files in the directory recursively.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
directory_path: The root directory to search
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of Path objects for all .sql files found
|
|
26
|
+
"""
|
|
27
|
+
return list(directory_path.rglob("*.sql"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_csv_files(directory_path: Path) -> list[Path]:
|
|
31
|
+
"""Find all .csv files in the directory recursively.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
directory_path: The root directory to search
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of Path objects for all .csv files found
|
|
38
|
+
"""
|
|
39
|
+
return list(directory_path.rglob("*.csv"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_trilogy_files(directory_path: Path) -> list[Path]:
|
|
43
|
+
"""Find all .preql and .sql files in the directory recursively.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
directory_path: The root directory to search
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of Path objects for all .preql and .sql files found, sorted by path
|
|
50
|
+
"""
|
|
51
|
+
preql_files = find_preql_files(directory_path)
|
|
52
|
+
sql_files = find_sql_files(directory_path)
|
|
53
|
+
return sorted(preql_files + sql_files)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_all_model_files(directory_path: Path) -> list[Path]:
|
|
57
|
+
"""Find all model files (.preql, .sql, .csv) in the directory recursively.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
directory_path: The root directory to search
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of Path objects for all model files found, sorted by path
|
|
64
|
+
"""
|
|
65
|
+
preql_files = find_preql_files(directory_path)
|
|
66
|
+
sql_files = find_sql_files(directory_path)
|
|
67
|
+
csv_files = find_csv_files(directory_path)
|
|
68
|
+
return sorted(preql_files + sql_files + csv_files)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_relative_model_name(preql_file: Path, directory_path: Path) -> str:
|
|
72
|
+
"""Get the relative model name from a model file path.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
preql_file: Path to the .preql, .sql, or .csv file
|
|
76
|
+
directory_path: Root directory path
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Relative model name with forward slashes and no extension
|
|
80
|
+
"""
|
|
81
|
+
relative_path = preql_file.relative_to(directory_path)
|
|
82
|
+
return (
|
|
83
|
+
str(relative_path)
|
|
84
|
+
.replace("\\", "/")
|
|
85
|
+
.replace(".preql", "")
|
|
86
|
+
.replace(".sql", "")
|
|
87
|
+
.replace(".csv", "")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_safe_model_name(model_name: str) -> str:
|
|
92
|
+
"""Convert a model name to a URL-safe format.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
model_name: The model name (may contain slashes)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
URL-safe model name with slashes replaced by hyphens
|
|
99
|
+
"""
|
|
100
|
+
return model_name.replace("/", "-")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_description_from_file(file_path: Path) -> str:
|
|
104
|
+
"""Extract description from a preql or sql file's comments.
|
|
105
|
+
|
|
106
|
+
Looks for the first comment line (starting with # or --) in the first 5 lines
|
|
107
|
+
of the file and uses it as the description.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
file_path: Path to the .preql or .sql file
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Description extracted from comments or a default description
|
|
114
|
+
"""
|
|
115
|
+
with open(file_path, "r") as f:
|
|
116
|
+
content = f.read()
|
|
117
|
+
|
|
118
|
+
model_name = file_path.stem
|
|
119
|
+
default_description = f"Trilogy model: {model_name}"
|
|
120
|
+
|
|
121
|
+
first_lines = content.split("\n")[:5]
|
|
122
|
+
for line in first_lines:
|
|
123
|
+
stripped = line.strip()
|
|
124
|
+
if stripped.startswith("#"):
|
|
125
|
+
return stripped.lstrip("#").strip()
|
|
126
|
+
if stripped.startswith("--"):
|
|
127
|
+
return stripped.lstrip("-").strip()
|
|
128
|
+
|
|
129
|
+
return default_description
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_file_content(file_path: Path) -> str:
|
|
133
|
+
"""Read and return the content of a file.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
file_path: Path to the file
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
File content as string
|
|
140
|
+
"""
|
|
141
|
+
with open(file_path, "r") as f:
|
|
142
|
+
return f.read()
|