awx-zipline-ai 0.0.32__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +0 -0
- agent/__init__.py +1 -0
- agent/constants.py +15 -0
- agent/ttypes.py +1684 -0
- ai/__init__.py +0 -0
- ai/chronon/__init__.py +0 -0
- ai/chronon/airflow_helpers.py +248 -0
- ai/chronon/cli/__init__.py +0 -0
- ai/chronon/cli/compile/__init__.py +0 -0
- ai/chronon/cli/compile/column_hashing.py +336 -0
- ai/chronon/cli/compile/compile_context.py +173 -0
- ai/chronon/cli/compile/compiler.py +183 -0
- ai/chronon/cli/compile/conf_validator.py +742 -0
- ai/chronon/cli/compile/display/__init__.py +0 -0
- ai/chronon/cli/compile/display/class_tracker.py +102 -0
- ai/chronon/cli/compile/display/compile_status.py +95 -0
- ai/chronon/cli/compile/display/compiled_obj.py +12 -0
- ai/chronon/cli/compile/display/console.py +3 -0
- ai/chronon/cli/compile/display/diff_result.py +111 -0
- ai/chronon/cli/compile/fill_templates.py +35 -0
- ai/chronon/cli/compile/parse_configs.py +134 -0
- ai/chronon/cli/compile/parse_teams.py +242 -0
- ai/chronon/cli/compile/serializer.py +109 -0
- ai/chronon/cli/compile/version_utils.py +42 -0
- ai/chronon/cli/git_utils.py +145 -0
- ai/chronon/cli/logger.py +59 -0
- ai/chronon/constants.py +3 -0
- ai/chronon/group_by.py +692 -0
- ai/chronon/join.py +580 -0
- ai/chronon/logger.py +23 -0
- ai/chronon/model.py +40 -0
- ai/chronon/query.py +126 -0
- ai/chronon/repo/__init__.py +39 -0
- ai/chronon/repo/aws.py +284 -0
- ai/chronon/repo/cluster.py +136 -0
- ai/chronon/repo/compile.py +62 -0
- ai/chronon/repo/constants.py +164 -0
- ai/chronon/repo/default_runner.py +269 -0
- ai/chronon/repo/explore.py +418 -0
- ai/chronon/repo/extract_objects.py +134 -0
- ai/chronon/repo/gcp.py +586 -0
- ai/chronon/repo/gitpython_utils.py +15 -0
- ai/chronon/repo/hub_runner.py +261 -0
- ai/chronon/repo/hub_uploader.py +109 -0
- ai/chronon/repo/init.py +60 -0
- ai/chronon/repo/join_backfill.py +119 -0
- ai/chronon/repo/run.py +296 -0
- ai/chronon/repo/serializer.py +133 -0
- ai/chronon/repo/team_json_utils.py +46 -0
- ai/chronon/repo/utils.py +481 -0
- ai/chronon/repo/zipline.py +35 -0
- ai/chronon/repo/zipline_hub.py +277 -0
- ai/chronon/resources/__init__.py +0 -0
- ai/chronon/resources/gcp/__init__.py +0 -0
- ai/chronon/resources/gcp/group_bys/__init__.py +0 -0
- ai/chronon/resources/gcp/group_bys/test/__init__.py +0 -0
- ai/chronon/resources/gcp/group_bys/test/data.py +30 -0
- ai/chronon/resources/gcp/joins/__init__.py +0 -0
- ai/chronon/resources/gcp/joins/test/__init__.py +0 -0
- ai/chronon/resources/gcp/joins/test/data.py +26 -0
- ai/chronon/resources/gcp/sources/__init__.py +0 -0
- ai/chronon/resources/gcp/sources/test/__init__.py +0 -0
- ai/chronon/resources/gcp/sources/test/data.py +26 -0
- ai/chronon/resources/gcp/teams.py +58 -0
- ai/chronon/source.py +86 -0
- ai/chronon/staging_query.py +226 -0
- ai/chronon/types.py +58 -0
- ai/chronon/utils.py +510 -0
- ai/chronon/windows.py +48 -0
- awx_zipline_ai-0.0.32.dist-info/METADATA +197 -0
- awx_zipline_ai-0.0.32.dist-info/RECORD +96 -0
- awx_zipline_ai-0.0.32.dist-info/WHEEL +5 -0
- awx_zipline_ai-0.0.32.dist-info/entry_points.txt +2 -0
- awx_zipline_ai-0.0.32.dist-info/top_level.txt +4 -0
- gen_thrift/__init__.py +0 -0
- gen_thrift/api/__init__.py +1 -0
- gen_thrift/api/constants.py +15 -0
- gen_thrift/api/ttypes.py +3754 -0
- gen_thrift/common/__init__.py +1 -0
- gen_thrift/common/constants.py +15 -0
- gen_thrift/common/ttypes.py +1814 -0
- gen_thrift/eval/__init__.py +1 -0
- gen_thrift/eval/constants.py +15 -0
- gen_thrift/eval/ttypes.py +660 -0
- gen_thrift/fetcher/__init__.py +1 -0
- gen_thrift/fetcher/constants.py +15 -0
- gen_thrift/fetcher/ttypes.py +127 -0
- gen_thrift/hub/__init__.py +1 -0
- gen_thrift/hub/constants.py +15 -0
- gen_thrift/hub/ttypes.py +1109 -0
- gen_thrift/observability/__init__.py +1 -0
- gen_thrift/observability/constants.py +15 -0
- gen_thrift/observability/ttypes.py +2355 -0
- gen_thrift/planner/__init__.py +1 -0
- gen_thrift/planner/constants.py +15 -0
- gen_thrift/planner/ttypes.py +1967 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Dict, List, Optional, Type
|
|
4
|
+
|
|
5
|
+
from gen_thrift.api.ttypes import ConfType, GroupBy, Join, MetaData, Model, StagingQuery, Team
|
|
6
|
+
|
|
7
|
+
import ai.chronon.cli.compile.parse_teams as teams
|
|
8
|
+
from ai.chronon.cli.compile.conf_validator import ConfValidator
|
|
9
|
+
from ai.chronon.cli.compile.display.compile_status import CompileStatus
|
|
10
|
+
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
|
|
11
|
+
from ai.chronon.cli.compile.serializer import file2thrift
|
|
12
|
+
from ai.chronon.cli.logger import get_logger, require
|
|
13
|
+
|
|
14
|
+
logger = get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ConfigInfo:
|
|
19
|
+
folder_name: str
|
|
20
|
+
cls: Type
|
|
21
|
+
config_type: Optional[ConfType]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CompileContext:
|
|
26
|
+
def __init__(self, ignore_python_errors: bool = False):
|
|
27
|
+
self.chronon_root: str = os.getenv("CHRONON_ROOT", os.getcwd())
|
|
28
|
+
self.teams_dict: Dict[str, Team] = teams.load_teams(self.chronon_root)
|
|
29
|
+
self.compile_dir: str = "compiled"
|
|
30
|
+
self.ignore_python_errors: bool = ignore_python_errors
|
|
31
|
+
|
|
32
|
+
self.config_infos: List[ConfigInfo] = [
|
|
33
|
+
ConfigInfo(folder_name="joins", cls=Join, config_type=ConfType.JOIN),
|
|
34
|
+
ConfigInfo(
|
|
35
|
+
folder_name="group_bys",
|
|
36
|
+
cls=GroupBy,
|
|
37
|
+
config_type=ConfType.GROUP_BY,
|
|
38
|
+
),
|
|
39
|
+
ConfigInfo(
|
|
40
|
+
folder_name="staging_queries",
|
|
41
|
+
cls=StagingQuery,
|
|
42
|
+
config_type=ConfType.STAGING_QUERY,
|
|
43
|
+
),
|
|
44
|
+
ConfigInfo(folder_name="models", cls=Model, config_type=ConfType.MODEL),
|
|
45
|
+
ConfigInfo(
|
|
46
|
+
folder_name="teams_metadata", cls=MetaData, config_type=None
|
|
47
|
+
), # only for team metadata
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
self.compile_status = CompileStatus(use_live=False)
|
|
51
|
+
|
|
52
|
+
self.existing_confs: Dict[Type, Dict[str, Any]] = {}
|
|
53
|
+
for config_info in self.config_infos:
|
|
54
|
+
cls = config_info.cls
|
|
55
|
+
self.existing_confs[cls] = self._parse_existing_confs(cls)
|
|
56
|
+
|
|
57
|
+
self.validator: ConfValidator = ConfValidator(
|
|
58
|
+
input_root=self.chronon_root,
|
|
59
|
+
output_root=self.compile_dir,
|
|
60
|
+
existing_gbs=self.existing_confs[GroupBy],
|
|
61
|
+
existing_joins=self.existing_confs[Join],
|
|
62
|
+
existing_staging_queries=self.existing_confs[StagingQuery],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def input_dir(self, cls: type) -> str:
|
|
66
|
+
"""
|
|
67
|
+
- eg., input: group_by class
|
|
68
|
+
- eg., output: root/group_bys/
|
|
69
|
+
"""
|
|
70
|
+
config_info = self.config_info_for_class(cls)
|
|
71
|
+
return os.path.join(self.chronon_root, config_info.folder_name)
|
|
72
|
+
|
|
73
|
+
def staging_output_dir(self, cls: type = None) -> str:
|
|
74
|
+
"""
|
|
75
|
+
- eg., input: group_by class
|
|
76
|
+
- eg., output: root/compiled_staging/group_bys/
|
|
77
|
+
"""
|
|
78
|
+
if cls is None:
|
|
79
|
+
return os.path.join(self.chronon_root, self.compile_dir + "_staging")
|
|
80
|
+
else:
|
|
81
|
+
config_info = self.config_info_for_class(cls)
|
|
82
|
+
return os.path.join(
|
|
83
|
+
self.chronon_root,
|
|
84
|
+
self.compile_dir + "_staging",
|
|
85
|
+
config_info.folder_name,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def output_dir(self, cls: type = None) -> str:
|
|
89
|
+
"""
|
|
90
|
+
- eg., input: group_by class
|
|
91
|
+
- eg., output: root/compiled/group_bys/
|
|
92
|
+
"""
|
|
93
|
+
if cls is None:
|
|
94
|
+
return os.path.join(self.chronon_root, self.compile_dir)
|
|
95
|
+
else:
|
|
96
|
+
config_info = self.config_info_for_class(cls)
|
|
97
|
+
return os.path.join(self.chronon_root, self.compile_dir, config_info.folder_name)
|
|
98
|
+
|
|
99
|
+
def staging_output_path(self, compiled_obj: CompiledObj):
|
|
100
|
+
"""
|
|
101
|
+
- eg., input: group_by with name search.clicks.features.v1
|
|
102
|
+
- eg., output: root/compiled_staging/group_bys/search/clicks.features.v1
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
output_dir = self.staging_output_dir(compiled_obj.obj.__class__) # compiled/joins
|
|
106
|
+
|
|
107
|
+
team, rest = compiled_obj.name.split(".", 1) # search, clicks.features.v1
|
|
108
|
+
|
|
109
|
+
return os.path.join(
|
|
110
|
+
output_dir,
|
|
111
|
+
team,
|
|
112
|
+
rest,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def config_info_for_class(self, cls: type) -> ConfigInfo:
|
|
116
|
+
for info in self.config_infos:
|
|
117
|
+
if info.cls == cls:
|
|
118
|
+
return info
|
|
119
|
+
|
|
120
|
+
require(False, f"Class {cls} not found in CONFIG_INFOS")
|
|
121
|
+
|
|
122
|
+
def _parse_existing_confs(self, obj_class: type) -> Dict[str, object]:
|
|
123
|
+
result = {}
|
|
124
|
+
|
|
125
|
+
output_dir = self.output_dir(obj_class)
|
|
126
|
+
|
|
127
|
+
# Check if output_dir exists before walking
|
|
128
|
+
if not os.path.exists(output_dir):
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
for sub_root, _sub_dirs, sub_files in os.walk(output_dir):
|
|
132
|
+
for f in sub_files:
|
|
133
|
+
if f.startswith("."): # ignore hidden files - such as .DS_Store
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
full_path = os.path.join(sub_root, f)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
obj = file2thrift(full_path, obj_class)
|
|
140
|
+
|
|
141
|
+
if obj:
|
|
142
|
+
if hasattr(obj, "metaData"):
|
|
143
|
+
result[obj.metaData.name] = obj
|
|
144
|
+
compiled_obj = CompiledObj(
|
|
145
|
+
name=obj.metaData.name,
|
|
146
|
+
obj=obj,
|
|
147
|
+
file=obj.metaData.sourceFile,
|
|
148
|
+
errors=None,
|
|
149
|
+
obj_type=obj_class.__name__,
|
|
150
|
+
tjson=open(full_path).read(),
|
|
151
|
+
)
|
|
152
|
+
self.compile_status.add_existing_object_update_display(compiled_obj)
|
|
153
|
+
elif isinstance(obj, MetaData):
|
|
154
|
+
team_metadata_name = ".".join(
|
|
155
|
+
full_path.split("/")[-2:]
|
|
156
|
+
) # use the name of the file as team metadata won't have name
|
|
157
|
+
result[team_metadata_name] = obj
|
|
158
|
+
compiled_obj = CompiledObj(
|
|
159
|
+
name=team_metadata_name,
|
|
160
|
+
obj=obj,
|
|
161
|
+
file=obj.sourceFile,
|
|
162
|
+
errors=None,
|
|
163
|
+
obj_type=obj_class.__name__,
|
|
164
|
+
tjson=open(full_path).read(),
|
|
165
|
+
)
|
|
166
|
+
self.compile_status.add_existing_object_update_display(compiled_obj)
|
|
167
|
+
else:
|
|
168
|
+
logger.errors(f"Parsed object from {full_path} has no metaData attribute")
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
print(f"Failed to parse file {full_path}: {str(e)}", e)
|
|
172
|
+
|
|
173
|
+
return result
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import traceback
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from gen_thrift.api.ttypes import ConfType
|
|
8
|
+
|
|
9
|
+
import ai.chronon.cli.compile.display.compiled_obj
|
|
10
|
+
import ai.chronon.cli.compile.parse_configs as parser
|
|
11
|
+
import ai.chronon.cli.logger as logger
|
|
12
|
+
from ai.chronon.cli.compile import serializer
|
|
13
|
+
from ai.chronon.cli.compile.compile_context import CompileContext, ConfigInfo
|
|
14
|
+
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
|
|
15
|
+
from ai.chronon.cli.compile.display.console import console
|
|
16
|
+
from ai.chronon.cli.compile.parse_teams import merge_team_execution_info
|
|
17
|
+
from ai.chronon.types import MetaData
|
|
18
|
+
|
|
19
|
+
logger = logger.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CompileResult:
|
|
24
|
+
config_info: ConfigInfo
|
|
25
|
+
obj_dict: Dict[str, Any]
|
|
26
|
+
error_dict: Dict[str, List[BaseException]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Compiler:
|
|
30
|
+
def __init__(self, compile_context: CompileContext):
|
|
31
|
+
self.compile_context = compile_context
|
|
32
|
+
|
|
33
|
+
def compile(self) -> Dict[ConfType, CompileResult]:
|
|
34
|
+
# Clean staging directory at the start to ensure fresh compilation
|
|
35
|
+
staging_dir = self.compile_context.staging_output_dir()
|
|
36
|
+
if os.path.exists(staging_dir):
|
|
37
|
+
shutil.rmtree(staging_dir)
|
|
38
|
+
|
|
39
|
+
config_infos = self.compile_context.config_infos
|
|
40
|
+
|
|
41
|
+
compile_results = {}
|
|
42
|
+
all_compiled_objects = [] # Collect all compiled objects for change validation
|
|
43
|
+
|
|
44
|
+
for config_info in config_infos:
|
|
45
|
+
configs, compiled_objects = self._compile_class_configs(config_info)
|
|
46
|
+
compile_results[config_info.config_type] = configs
|
|
47
|
+
|
|
48
|
+
# Collect compiled objects for change validation
|
|
49
|
+
all_compiled_objects.extend(compiled_objects)
|
|
50
|
+
|
|
51
|
+
# Validate changes once after all classes have been processed
|
|
52
|
+
self.compile_context.validator.validate_changes(all_compiled_objects)
|
|
53
|
+
|
|
54
|
+
# Show the nice display first
|
|
55
|
+
console.print(
|
|
56
|
+
self.compile_context.compile_status.render(self.compile_context.ignore_python_errors)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Check for confirmation before finalizing files
|
|
60
|
+
self.compile_context.validator.check_pending_changes_confirmation(
|
|
61
|
+
self.compile_context.compile_status
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Only proceed with file operations if there are no compilation errors
|
|
65
|
+
if not self._has_compilation_errors() or self.compile_context.ignore_python_errors:
|
|
66
|
+
self._compile_team_metadata()
|
|
67
|
+
|
|
68
|
+
# check if staging_output_dir exists
|
|
69
|
+
staging_dir = self.compile_context.staging_output_dir()
|
|
70
|
+
if os.path.exists(staging_dir):
|
|
71
|
+
# replace staging_output_dir to output_dir
|
|
72
|
+
output_dir = self.compile_context.output_dir()
|
|
73
|
+
if os.path.exists(output_dir):
|
|
74
|
+
shutil.rmtree(output_dir)
|
|
75
|
+
shutil.move(staging_dir, output_dir)
|
|
76
|
+
else:
|
|
77
|
+
print(
|
|
78
|
+
f"Staging directory {staging_dir} does not exist. "
|
|
79
|
+
"Happens when every chronon config fails to compile or when no chronon configs exist."
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
# Clean up staging directory when there are errors (don't move to output)
|
|
83
|
+
staging_dir = self.compile_context.staging_output_dir()
|
|
84
|
+
if os.path.exists(staging_dir):
|
|
85
|
+
shutil.rmtree(staging_dir)
|
|
86
|
+
|
|
87
|
+
return compile_results
|
|
88
|
+
|
|
89
|
+
def _has_compilation_errors(self):
|
|
90
|
+
"""Check if there are any compilation errors across all class trackers."""
|
|
91
|
+
for tracker in self.compile_context.compile_status.cls_to_tracker.values():
|
|
92
|
+
if tracker.files_to_errors:
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def _compile_team_metadata(self):
|
|
97
|
+
"""
|
|
98
|
+
Compile the team metadata and return the compiled object.
|
|
99
|
+
"""
|
|
100
|
+
teams_dict = self.compile_context.teams_dict
|
|
101
|
+
for team in teams_dict:
|
|
102
|
+
m = MetaData()
|
|
103
|
+
merge_team_execution_info(m, teams_dict, team)
|
|
104
|
+
|
|
105
|
+
tjson = serializer.thrift_simple_json(m)
|
|
106
|
+
name = f"{team}.{team}_team_metadata"
|
|
107
|
+
result = CompiledObj(
|
|
108
|
+
name=name,
|
|
109
|
+
obj=m,
|
|
110
|
+
file=name,
|
|
111
|
+
errors=None,
|
|
112
|
+
obj_type=MetaData.__name__,
|
|
113
|
+
tjson=tjson,
|
|
114
|
+
)
|
|
115
|
+
self._write_object(result)
|
|
116
|
+
self.compile_context.compile_status.add_object_update_display(result, MetaData.__name__)
|
|
117
|
+
|
|
118
|
+
# Done writing team metadata, close the class
|
|
119
|
+
self.compile_context.compile_status.close_cls(MetaData.__name__)
|
|
120
|
+
|
|
121
|
+
def _compile_class_configs(
|
|
122
|
+
self, config_info: ConfigInfo
|
|
123
|
+
) -> Tuple[CompileResult, List[CompiledObj]]:
|
|
124
|
+
compile_result = CompileResult(config_info=config_info, obj_dict={}, error_dict={})
|
|
125
|
+
|
|
126
|
+
input_dir = self.compile_context.input_dir(config_info.cls)
|
|
127
|
+
|
|
128
|
+
compiled_objects = parser.from_folder(config_info.cls, input_dir, self.compile_context)
|
|
129
|
+
|
|
130
|
+
objects, errors = self._write_objects_in_folder(compiled_objects)
|
|
131
|
+
|
|
132
|
+
if objects:
|
|
133
|
+
compile_result.obj_dict.update(objects)
|
|
134
|
+
|
|
135
|
+
if errors:
|
|
136
|
+
compile_result.error_dict.update(errors)
|
|
137
|
+
|
|
138
|
+
self.compile_context.compile_status.close_cls(config_info.cls.__name__)
|
|
139
|
+
|
|
140
|
+
return compile_result, compiled_objects
|
|
141
|
+
|
|
142
|
+
def _write_objects_in_folder(
|
|
143
|
+
self,
|
|
144
|
+
compiled_objects: List[ai.chronon.cli.compile.display.compiled_obj.CompiledObj],
|
|
145
|
+
) -> Tuple[Dict[str, Any], Dict[str, List[BaseException]]]:
|
|
146
|
+
error_dict = {}
|
|
147
|
+
object_dict = {}
|
|
148
|
+
|
|
149
|
+
for co in compiled_objects:
|
|
150
|
+
if co.obj:
|
|
151
|
+
if co.errors:
|
|
152
|
+
error_dict[co.name] = co.errors
|
|
153
|
+
|
|
154
|
+
for error in co.errors:
|
|
155
|
+
self.compile_context.compile_status.print_live_console(
|
|
156
|
+
f"Error processing conf {co.name}: {error}"
|
|
157
|
+
)
|
|
158
|
+
traceback.print_exception(type(error), error, error.__traceback__)
|
|
159
|
+
|
|
160
|
+
else:
|
|
161
|
+
self._write_object(co)
|
|
162
|
+
object_dict[co.name] = co.obj
|
|
163
|
+
else:
|
|
164
|
+
error_dict[co.file] = co.errors
|
|
165
|
+
|
|
166
|
+
self.compile_context.compile_status.print_live_console(
|
|
167
|
+
f"Error processing file {co.file}: {co.errors}"
|
|
168
|
+
)
|
|
169
|
+
for error in co.errors:
|
|
170
|
+
traceback.print_exception(type(error), error, error.__traceback__)
|
|
171
|
+
|
|
172
|
+
return object_dict, error_dict
|
|
173
|
+
|
|
174
|
+
def _write_object(self, compiled_obj: CompiledObj) -> Optional[List[BaseException]]:
|
|
175
|
+
output_path = self.compile_context.staging_output_path(compiled_obj)
|
|
176
|
+
|
|
177
|
+
folder = os.path.dirname(output_path)
|
|
178
|
+
|
|
179
|
+
if not os.path.exists(folder):
|
|
180
|
+
os.makedirs(folder)
|
|
181
|
+
|
|
182
|
+
with open(output_path, "w") as f:
|
|
183
|
+
f.write(compiled_obj.tjson)
|