Skip to content

Commit 938b346

Browse files
feat: --format and --force flag for CLI (#1310)
## Summary For the VS Code plugin we are currently doing parsing and cleaning on text in order to understand what to prompt to user or what the status is post execution (errors, etc). Adding two things to simplify life: --force flag -> forcefully compiles ignoring prompt... aka demo mode. --format [JSON| TEXT] --> Only output a json that can be parsed and operated on rather than user friendly text. To achieve this: * added a format_print(... ,format=...) method to wrap our prints and skip them when json mode. * added a format enum, decorator to wrap exceptions. * added format mostly everywhere. TODO: Still need to cover more test of the cli to make sure it works as intended. ## Checklist - [ ] Added Unit Tests - [ ] Covered by existing CI - [ ] Integration tested - [ ] Documentation update <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added TEXT/JSON CLI output modes with unified formatter, structured JSON errors/prompts, and a --force option; formatting applied across compile, hub, and uploader flows. * **Tests** * Added a JSON-format compile test and an autouse fixture to improve test import/path isolation. * **Chores** * Switched many test/sample artifacts to sample_sources, added numerous compiled sample/canary data files, and minor build/test config updates. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 170b4a6 commit 938b346

File tree

71 files changed

+5047
-191
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+5047
-191
lines changed

python/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Chronon Python API for materializing configs to be run by the Chronon Engine. Co
1313
Most fields are self explanatory. Time columns are expected to be in milliseconds (unixtime).
1414

1515
```python
16-
# File <repo>/sources/test_sources.py
16+
# File <repo>/sources/sample_sources.py
1717
from ai.chronon.query import (
1818
Query,
1919
select,
@@ -65,7 +65,7 @@ from ai.chronon.group_by import (
6565
Aggregation,
6666
DefaultAggregation,
6767
)
68-
from sources import test_sources
68+
from sources import sample_sources
6969

7070
sum_cols = [f"active_{x}_days" for x in [30, 90, 120]]
7171

@@ -114,11 +114,11 @@ A Join is a collection of feature values for the keys and (times if applicable)
114114
```python
115115
# File <repo>/joins/example_team/example_join.py
116116
from ai.chronon.join import Join, JoinPart
117-
from sources import test_sources
117+
from sources import sample_sources
118118
from group_bys.example_team import example_group_by
119119

120120
v1 = Join(
121-
left=test_sources.website__views,
121+
left=sample_sources.website__views,
122122
right_parts=[
123123
JoinPart(group_by=example_group_by.v0),
124124
],

python/package.mill

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,14 @@ object `package` extends PythonModule with RuffModule with PublishModule {
6363

6464
override def sources = Task.Sources(moduleDir)
6565

66-
// Override forkEnv to set PYTHONPATH
66+
// Override forkEnv to set PYTHONPATH for test isolation
6767
override def forkEnv: T[Map[String, String]] = Task {
6868

6969
val generatedPath = build.python.generatedSources().head.path.toString
7070
val sourcePath = (moduleDir / os.up / "src").toString
71-
val samplePath = (moduleDir / "sample").toString
72-
71+
// Note: samplePath removed from global PYTHONPATH to avoid conflicts with canary/
7372
super.forkEnv() ++ Map(
74-
"PYTHONPATH" -> s"$generatedPath:$sourcePath:$samplePath"
73+
"PYTHONPATH" -> s"$generatedPath:$sourcePath",
7574
)
7675

7776
}

python/src/ai/chronon/cli/compile/compile_context.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ai.chronon.cli.compile.display.compile_status import CompileStatus
1818
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
1919
from ai.chronon.cli.compile.serializer import file2thrift
20+
from ai.chronon.cli.formatter import Format
2021
from ai.chronon.cli.logger import get_logger, require
2122

2223
logger = get_logger()
@@ -31,11 +32,13 @@ class ConfigInfo:
3132

3233
@dataclass
3334
class CompileContext:
34-
def __init__(self, ignore_python_errors: bool = False):
35+
def __init__(self, ignore_python_errors: bool = False, format: Format = Format.TEXT, force: bool = False):
3536
self.chronon_root: str = os.getenv("CHRONON_ROOT", os.getcwd())
36-
self.teams_dict: Dict[str, Team] = teams.load_teams(self.chronon_root)
37+
self.teams_dict: Dict[str, Team] = teams.load_teams(self.chronon_root, print=format != Format.JSON)
3738
self.compile_dir: str = "compiled"
3839
self.ignore_python_errors: bool = ignore_python_errors
40+
self.format: Format = format
41+
self.force: bool = force
3942

4043
self.config_infos: List[ConfigInfo] = [
4144
ConfigInfo(folder_name="joins", cls=Join, config_type=ConfType.JOIN),
@@ -55,7 +58,7 @@ def __init__(self, ignore_python_errors: bool = False):
5558
), # only for team metadata
5659
]
5760

58-
self.compile_status = CompileStatus(use_live=False)
61+
self.compile_status = CompileStatus(use_live=False, format=format)
5962

6063
self.existing_confs: Dict[Type, Dict[str, Any]] = {}
6164
for config_info in self.config_infos:

python/src/ai/chronon/cli/compile/compiler.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
1515
from ai.chronon.cli.compile.display.console import console
1616
from ai.chronon.cli.compile.parse_teams import merge_team_execution_info
17+
from ai.chronon.cli.formatter import Format, PromptException
1718
from ai.chronon.types import MetaData
1819

1920
logger = logger.get_logger()
@@ -52,14 +53,26 @@ def compile(self) -> Dict[ConfType, CompileResult]:
5253
self.compile_context.validator.validate_changes(all_compiled_objects)
5354

5455
# Show the nice display first
55-
console.print(
56-
self.compile_context.compile_status.render(self.compile_context.ignore_python_errors)
57-
)
56+
if self.compile_context.format != Format.JSON:
57+
console.print(
58+
self.compile_context.compile_status.render(self.compile_context.ignore_python_errors)
59+
)
5860

5961
# Check for confirmation before finalizing files
60-
self.compile_context.validator.check_pending_changes_confirmation(
61-
self.compile_context.compile_status
62-
)
62+
if not self.compile_context.force:
63+
if self.compile_context.format != Format.JSON:
64+
self.compile_context.validator.check_pending_changes_confirmation(
65+
self.compile_context.compile_status
66+
)
67+
else:
68+
# In case of JSON format we need to prompt the user for confirmation if changes are not versioned.
69+
non_version_changes = self.compile_context.validator._non_version_changes()
70+
if non_version_changes:
71+
raise PromptException(prompt=f"The following configs are changing in-place (changing semantics without changing the version)."
72+
f" {', '.join([v.name for v in non_version_changes])} Do you want to proceed?",
73+
options= ["yes", "no"],
74+
instructions="If 'yes' run with --force to proceed with the compilation."
75+
)
6376

6477
# Only proceed with file operations if there are no compilation errors
6578
if not self._has_compilation_errors() or self.compile_context.ignore_python_errors:
@@ -151,23 +164,25 @@ def _write_objects_in_folder(
151164
if co.errors:
152165
error_dict[co.name] = co.errors
153166

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__)
167+
if self.compile_context.format != Format.JSON:
168+
for error in co.errors:
169+
self.compile_context.compile_status.print_live_console(
170+
f"Error processing conf {co.name}: {error}"
171+
)
172+
traceback.print_exception(type(error), error, error.__traceback__)
159173

160174
else:
161175
self._write_object(co)
162176
object_dict[co.name] = co.obj
163177
else:
164178
error_dict[co.file] = co.errors
165179

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__)
180+
if self.compile_context.format != Format.JSON:
181+
self.compile_context.compile_status.print_live_console(
182+
f"Error processing file {co.file}: {co.errors}"
183+
)
184+
for error in co.errors:
185+
traceback.print_exception(type(error), error, error.__traceback__)
171186

172187
return object_dict, error_dict
173188

python/src/ai/chronon/cli/compile/conf_validator.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -672,16 +672,18 @@ def check_pending_changes_confirmation(self, compile_status):
672672
return # No pending changes
673673

674674
# Check if we need user confirmation (only for non-version-bump changes)
675-
non_version_changes = self._filter_non_version_changes(
676-
self._pending_changes["changed"] + self._pending_changes["deleted"],
677-
self._pending_changes["added"],
678-
)
679-
680-
if non_version_changes:
675+
if self._non_version_changes():
681676
if not self._prompt_user_confirmation():
682677
console.print("❌ Compilation cancelled by user.")
683678
sys.exit(1)
684679

680+
def _non_version_changes(self):
681+
"""Return list of changes that are NOT version bumps and require confirmation."""
682+
return self._filter_non_version_changes(
683+
self._pending_changes["changed"] + self._pending_changes["deleted"],
684+
self._pending_changes["added"],
685+
)
686+
685687
def _has_compilation_errors(self, compile_status):
686688
"""Check if there are any compilation errors across all class trackers."""
687689
for tracker in compile_status.cls_to_tracker.values():

python/src/ai/chronon/cli/compile/display/class_tracker.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,22 @@
55

66
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
77
from ai.chronon.cli.compile.display.diff_result import DiffResult
8+
from ai.chronon.cli.formatter import Format, format_print
89

910

1011
class ClassTracker:
1112
"""
1213
Tracker object per class - Join, StagingQuery, GroupBy etc
1314
"""
1415

15-
def __init__(self):
16+
def __init__(self, format: Format = Format.TEXT):
1617
self.existing_objs: Dict[str, CompiledObj] = {} # name to obj
1718
self.files_to_obj: Dict[str, List[Any]] = {}
1819
self.files_to_errors: Dict[str, List[Exception]] = {}
1920
self.new_objs: Dict[str, CompiledObj] = {} # name to obj
2021
self.diff_result = DiffResult()
2122
self.deleted_names: List[str] = []
23+
self.format = format
2224

2325
def add_existing(self, obj: CompiledObj) -> None:
2426
self.existing_objs[obj.name] = obj
@@ -51,9 +53,9 @@ def _update_diff(self, compiled: CompiledObj) -> None:
5153
n=2,
5254
)
5355

54-
print(f"Updated object: {compiled.name} in file {compiled.file}")
55-
print("".join(diff))
56-
print("\n")
56+
format_print(f"Updated object: {compiled.name} in file {compiled.file}", format=self.format)
57+
format_print("".join(diff), format=self.format)
58+
format_print("\n", format=self.format)
5759

5860
self.diff_result.updated.append(compiled.name)
5961

python/src/ai/chronon/cli/compile/display/compile_status.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,23 @@
66

77
from ai.chronon.cli.compile.display.class_tracker import ClassTracker
88
from ai.chronon.cli.compile.display.compiled_obj import CompiledObj
9+
from ai.chronon.cli.formatter import Format
910

1011

1112
class CompileStatus:
1213
"""
1314
Uses rich ui - to consolidate and sink the overview of the compile process to the bottom.
1415
"""
1516

16-
def __init__(self, use_live: bool = False):
17+
def __init__(self, use_live: bool = False, format: Format = Format.TEXT):
1718
self.cls_to_tracker: Dict[str, ClassTracker] = OrderedDict()
1819
self.use_live = use_live
1920
# we need vertical_overflow to be visible as the output gets cufoff when our output goes past the termianal window
2021
# but then we start seeing duplicates: https://github.com/Textualize/rich/issues/3263
2122
if self.use_live:
2223
self.live = Live(refresh_per_second=50, vertical_overflow="visible")
2324
self.live.start()
25+
self.format = format
2426

2527
def print_live_console(self, msg: str):
2628
if self.use_live:
@@ -33,7 +35,7 @@ def add_object_update_display(self, compiled: CompiledObj, obj_type: str = None)
3335
)
3436

3537
if obj_type not in self.cls_to_tracker:
36-
self.cls_to_tracker[obj_type] = ClassTracker()
38+
self.cls_to_tracker[obj_type] = ClassTracker(format=self.format)
3739

3840
self.cls_to_tracker[obj_type].add(compiled)
3941

@@ -43,7 +45,7 @@ def add_existing_object_update_display(self, existing_obj: CompiledObj) -> None:
4345
obj_type = existing_obj.obj_type
4446

4547
if obj_type not in self.cls_to_tracker:
46-
self.cls_to_tracker[obj_type] = ClassTracker()
48+
self.cls_to_tracker[obj_type] = ClassTracker(format=self.format)
4749

4850
self.cls_to_tracker[obj_type].add_existing(existing_obj)
4951

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Formatter for CLI responses.
3+
4+
In the interest of making our CLI MCP friendly as well as VSCode plugin friendly, we need more structured responses to avoid parsing the output manually.
5+
6+
For this purpose we add a new kind of exception that can be raised to prompt the user for a confirmation.
7+
Any exception is caught by the jsonify_exceptions_if_json_format decorator and turned into a JSON response.
8+
"""
9+
10+
import functools
11+
import json
12+
import sys
13+
import traceback
14+
from enum import Enum
15+
16+
17+
class PromptException(Exception):
18+
"""
19+
Exception to raise when user prompt is needed.
20+
Args:
21+
prompt: The prompt to display to the user.
22+
options: The options to display to the user.
23+
instructions: The instructions for implementation.
24+
"""
25+
def __init__(self, prompt, options, instructions):
26+
super().__init__(prompt)
27+
self.prompt = prompt
28+
self.options = options
29+
self.instructions = instructions
30+
31+
class Format(Enum):
32+
JSON = "json"
33+
TEXT = "text"
34+
35+
def format_print(lines, format: Format = Format.TEXT):
36+
""" Format aware print. """
37+
if format != Format.JSON:
38+
print(lines)
39+
40+
41+
def jsonify_exceptions_if_json_format(func):
42+
""" Turn exceptions into JSON if the format is JSON. If a prompt exception is raised, return the prompt and options."""
43+
@functools.wraps(func)
44+
def wrapper(*args, **kwargs):
45+
use_json = kwargs.get("format") == Format.JSON
46+
try:
47+
return func(*args, **kwargs)
48+
except PromptException as e:
49+
if use_json:
50+
print(json.dumps({
51+
"prompt": e.prompt,
52+
"options": e.options,
53+
"instructions": e.instructions,
54+
}))
55+
sys.exit(0)
56+
except Exception as e:
57+
if use_json:
58+
print(json.dumps({
59+
"error": type(e).__name__,
60+
"message": str(e),
61+
"traceback": traceback.format_exc(),
62+
}))
63+
sys.exit(1)
64+
raise e
65+
return wrapper

python/src/ai/chronon/click_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def handle_compile(func):
1717
def wrapper(*args, **kwargs):
1818
if not kwargs.get("skip_compile"):
1919
sys.path.append(kwargs.get("repo"))
20-
__compile(kwargs.get("repo"))
20+
__compile(kwargs.get("repo"), force=kwargs.get("force"))
2121
return func(*args, **kwargs)
2222
return wrapper
2323

0 commit comments

Comments
 (0)