Skip to content

Commit ef9881e

Browse files
caroljung-cgtkucar
authored and
tkucar
committed
CG-10470: Add config CLI commands (#391)
# Motivation <!-- Why is this change necessary? --> # Content <!-- Please include a summary of the change --> # Testing <!-- How was the change tested? --> # Please check the following before marking your PR as ready for review - [x] I have added tests for my changes - [x] I have updated the documentation or added new documentation as needed
1 parent 3524117 commit ef9881e

File tree

17 files changed

+667
-4
lines changed

17 files changed

+667
-4
lines changed

.codegen/config.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,25 @@
1+
[secrets]
2+
github_token = ""
3+
openai_api_key = ""
4+
5+
[repository]
16
organization_name = "codegen-sh"
27
repo_name = "codegen-sdk"
8+
9+
[feature_flags.codebase]
10+
debug = false
11+
verify_graph = false
12+
track_graph = false
13+
method_usages = true
14+
sync_enabled = true
15+
full_range_index = false
16+
ignore_process_errors = true
17+
disable_graph = false
18+
generics = true
19+
20+
[feature_flags.codebase.import_resolution_overrides]
21+
22+
[feature_flags.codebase.typescript]
23+
ts_dependency_manager = false
24+
ts_language_engine = false
25+
v8_ts_engine = false

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"watchfiles<1.1.0,>=1.0.0",
2727
"rich<14.0.0,>=13.7.1",
2828
"pydantic<3.0.0,>=2.9.2",
29+
"pydantic-settings>=2.0.0",
2930
"docstring-parser<1.0,>=0.16",
3031
"plotly>=5.24.0,<7.0.0",
3132
"humanize<5.0.0,>=4.10.0",

src/codegen/cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import rich_click as click
22
from rich.traceback import install
33

4+
from codegen.cli.commands.config.main import config_command
45
from codegen.cli.commands.create.main import create_command
56
from codegen.cli.commands.deploy.main import deploy_command
67
from codegen.cli.commands.expert.main import expert_command
@@ -39,6 +40,7 @@ def main():
3940
main.add_command(run_on_pr_command)
4041
main.add_command(notebook_command)
4142
main.add_command(reset_command)
43+
main.add_command(config_command)
4244

4345

4446
if __name__ == "__main__":
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
from itertools import groupby
3+
4+
import rich
5+
import rich_click as click
6+
from rich.table import Table
7+
8+
from codegen.shared.configs.config import config
9+
10+
11+
@click.group(name="config")
12+
def config_command():
13+
"""Manage codegen configuration."""
14+
pass
15+
16+
17+
@config_command.command(name="list")
18+
def list_command():
19+
"""List current configuration values."""
20+
table = Table(title="Configuration Values", border_style="blue", show_header=True)
21+
table.add_column("Key", style="cyan", no_wrap=True)
22+
table.add_column("Value", style="magenta")
23+
24+
def flatten_dict(data: dict, prefix: str = "") -> dict:
25+
items = {}
26+
for key, value in data.items():
27+
full_key = f"{prefix}{key}" if prefix else key
28+
if isinstance(value, dict):
29+
# Always include dictionary fields, even if empty
30+
if not value:
31+
items[full_key] = "{}"
32+
items.update(flatten_dict(value, f"{full_key}."))
33+
else:
34+
items[full_key] = value
35+
return items
36+
37+
# Get flattened config and sort by keys
38+
flat_config = flatten_dict(config.model_dump())
39+
sorted_items = sorted(flat_config.items(), key=lambda x: x[0])
40+
41+
# Group by top-level prefix
42+
def get_prefix(item):
43+
return item[0].split(".")[0]
44+
45+
for prefix, group in groupby(sorted_items, key=get_prefix):
46+
table.add_section()
47+
table.add_row(f"[bold yellow]{prefix}[/bold yellow]", "")
48+
for key, value in group:
49+
# Remove the prefix from the displayed key
50+
display_key = key[len(prefix) + 1 :] if "." in key else key
51+
table.add_row(f" {display_key}", str(value))
52+
53+
rich.print(table)
54+
55+
56+
@config_command.command(name="get")
57+
@click.argument("key")
58+
def get_command(key: str):
59+
"""Get a configuration value."""
60+
value = config.get(key)
61+
if value is None:
62+
rich.print(f"[red]Error: Configuration key '{key}' not found[/red]")
63+
return
64+
65+
rich.print(f"[cyan]{key}[/cyan] = [magenta]{value}[/magenta]")
66+
67+
68+
@config_command.command(name="set")
69+
@click.argument("key")
70+
@click.argument("value")
71+
def set_command(key: str, value: str):
72+
"""Set a configuration value and write to config.toml."""
73+
cur_value = config.get(key)
74+
if cur_value is None:
75+
rich.print(f"[red]Error: Configuration key '{key}' not found[/red]")
76+
return
77+
78+
if cur_value.lower() != value.lower():
79+
try:
80+
config.set(key, value)
81+
except Exception as e:
82+
logging.exception(e)
83+
rich.print(f"[red]{e}[/red]")
84+
return
85+
86+
rich.print(f"[green]Successfully set {key}=[magenta]{value}[/magenta] and saved to config.toml[/green]")

src/codegen/git/configs/constants.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,3 @@
33
CODEGEN_BOT_NAME = "codegen-bot"
44
CODEGEN_BOT_EMAIL = "[email protected]"
55
CODEOWNERS_FILEPATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]
6-
HIGHSIDE_REMOTE_NAME = "highside"
7-
LOWSIDE_REMOTE_NAME = "lowside"

src/codegen/sdk/codebase/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class GSFeatureFlags(BaseModel):
3535
model_config = ConfigDict(frozen=True)
3636
debug: bool = False
3737
verify_graph: bool = False
38-
track_graph: bool = True # Track the initial graph state
38+
track_graph: bool = False # Track the initial graph state
3939
method_usages: bool = True
4040
sync_enabled: bool = True
4141
ts_dependency_manager: bool = False # Enable Typescript Dependency Manager
@@ -50,7 +50,7 @@ class GSFeatureFlags(BaseModel):
5050

5151
DefaultFlags = GSFeatureFlags(sync_enabled=False)
5252

53-
TestFlags = GSFeatureFlags(debug=True, verify_graph=True, full_range_index=True)
53+
TestFlags = GSFeatureFlags(debug=True, track_graph=True, verify_graph=True, full_range_index=True)
5454
LintFlags = GSFeatureFlags(method_usages=False)
5555
ParseTestFlags = GSFeatureFlags(debug=False, track_graph=False)
5656

src/codegen/shared/configs/config.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pathlib import Path
2+
3+
import tomllib
4+
5+
from codegen.shared.configs.constants import CONFIG_PATH
6+
from codegen.shared.configs.models import Config
7+
8+
9+
def load(config_path: Path | None = None) -> Config:
10+
"""Loads configuration from various sources."""
11+
# Load from .env file
12+
env_config = _load_from_env()
13+
14+
# Load from .codegen/config.toml file
15+
toml_config = _load_from_toml(config_path or CONFIG_PATH)
16+
17+
# Merge configurations recursively
18+
config_dict = _merge_configs(env_config.model_dump(), toml_config.model_dump())
19+
20+
return Config(**config_dict)
21+
22+
23+
def _load_from_env() -> Config:
24+
"""Load configuration from the environment variables."""
25+
return Config()
26+
27+
28+
def _load_from_toml(config_path: Path) -> Config:
29+
"""Load configuration from the TOML file."""
30+
if config_path.exists():
31+
with open(config_path, "rb") as f:
32+
toml_config = tomllib.load(f)
33+
return Config.model_validate(toml_config, strict=False)
34+
35+
return Config()
36+
37+
38+
def _merge_configs(base: dict, override: dict) -> dict:
39+
"""Recursively merge two dictionaries, with override taking precedence for non-null values."""
40+
merged = base.copy()
41+
for key, override_value in override.items():
42+
if isinstance(override_value, dict) and key in base and isinstance(base[key], dict):
43+
# Recursively merge nested dictionaries
44+
merged[key] = _merge_configs(base[key], override_value)
45+
elif override_value is not None and override_value != "":
46+
# Override only if value is non-null and non-empty
47+
merged[key] = override_value
48+
return merged
49+
50+
51+
config = load()
52+
53+
if __name__ == "__main__":
54+
print(config)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pathlib import Path
2+
3+
# Config file
4+
CODEGEN_REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent
5+
CODEGEN_DIR_NAME = ".codegen"
6+
CONFIG_FILENAME = "config.toml"
7+
CONFIG_PATH = CODEGEN_REPO_ROOT / CODEGEN_DIR_NAME / CONFIG_FILENAME
8+
9+
# Environment variables
10+
ENV_FILENAME = ".env"
11+
ENV_PATH = CODEGEN_REPO_ROOT / "src" / "codegen" / ENV_FILENAME

src/codegen/shared/configs/models.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import json
2+
from pathlib import Path
3+
4+
import toml
5+
from pydantic import BaseModel, Field
6+
from pydantic_settings import BaseSettings, SettingsConfigDict
7+
8+
from codegen.shared.configs.constants import CONFIG_PATH, ENV_PATH
9+
10+
11+
class TypescriptConfig(BaseModel):
12+
ts_dependency_manager: bool | None = None
13+
ts_language_engine: bool | None = None
14+
v8_ts_engine: bool | None = None
15+
16+
17+
class CodebaseFeatureFlags(BaseModel):
18+
debug: bool | None = None
19+
verify_graph: bool | None = None
20+
track_graph: bool | None = None
21+
method_usages: bool | None = None
22+
sync_enabled: bool | None = None
23+
full_range_index: bool | None = None
24+
ignore_process_errors: bool | None = None
25+
disable_graph: bool | None = None
26+
generics: bool | None = None
27+
import_resolution_overrides: dict[str, str] = Field(default_factory=lambda: {})
28+
typescript: TypescriptConfig = Field(default_factory=TypescriptConfig)
29+
30+
31+
class RepositoryConfig(BaseModel):
32+
organization_name: str | None = None
33+
repo_name: str | None = None
34+
35+
36+
class SecretsConfig(BaseSettings):
37+
model_config = SettingsConfigDict(
38+
env_prefix="CODEGEN_SECRETS__",
39+
env_file=ENV_PATH,
40+
case_sensitive=False,
41+
)
42+
github_token: str | None = None
43+
openai_api_key: str | None = None
44+
45+
46+
class FeatureFlagsConfig(BaseModel):
47+
codebase: CodebaseFeatureFlags = Field(default_factory=CodebaseFeatureFlags)
48+
49+
50+
class Config(BaseSettings):
51+
model_config = SettingsConfigDict(
52+
extra="ignore",
53+
exclude_defaults=False,
54+
)
55+
secrets: SecretsConfig = Field(default_factory=SecretsConfig)
56+
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
57+
feature_flags: FeatureFlagsConfig = Field(default_factory=FeatureFlagsConfig)
58+
59+
def save(self, config_path: Path | None = None) -> None:
60+
"""Save configuration to the config file."""
61+
path = config_path or CONFIG_PATH
62+
63+
path.parent.mkdir(parents=True, exist_ok=True)
64+
65+
with open(path, "w") as f:
66+
toml.dump(self.model_dump(exclude_none=True), f)
67+
68+
def get(self, full_key: str) -> str | None:
69+
"""Get a configuration value as a JSON string."""
70+
data = self.model_dump()
71+
keys = full_key.split(".")
72+
current = data
73+
for k in keys:
74+
if not isinstance(current, dict) or k not in current:
75+
return None
76+
current = current[k]
77+
return json.dumps(current)
78+
79+
def set(self, full_key: str, value: str) -> None:
80+
"""Update a configuration value and save it to the config file.
81+
82+
Args:
83+
full_key: Dot-separated path to the config value (e.g. "feature_flags.codebase.debug")
84+
value: string representing the new value
85+
"""
86+
data = self.model_dump()
87+
keys = full_key.split(".")
88+
current = data
89+
current_attr = self
90+
91+
# Traverse through the key path and validate
92+
for k in keys[:-1]:
93+
if not isinstance(current, dict) or k not in current:
94+
msg = f"Invalid configuration path: {full_key}"
95+
raise KeyError(msg)
96+
current = current[k]
97+
current_attr = current_attr.__getattribute__(k)
98+
99+
if not isinstance(current, dict) or keys[-1] not in current:
100+
msg = f"Invalid configuration path: {full_key}"
101+
raise KeyError(msg)
102+
103+
# Validate the value type at key
104+
field_info = current_attr.model_fields[keys[-1]].annotation
105+
if isinstance(field_info, BaseModel):
106+
try:
107+
Config.model_validate(value, strict=False)
108+
except Exception as e:
109+
msg = f"Value does not match the expected type for key: {full_key}\n\nError:{e}"
110+
raise ValueError(msg)
111+
112+
# Set the key value
113+
if isinstance(current[keys[-1]], dict):
114+
try:
115+
current[keys[-1]] = json.loads(value)
116+
except json.JSONDecodeError as e:
117+
msg = f"Value must be a valid JSON object for key: {full_key}\n\nError:{e}"
118+
raise ValueError(msg)
119+
else:
120+
current[keys[-1]] = value
121+
122+
# Update the Config object with the new data
123+
self.__dict__.update(self.__class__.model_validate(data).__dict__)
124+
125+
# Save to config file
126+
self.save()
127+
128+
def __str__(self) -> str:
129+
"""Return a pretty-printed string representation of the config."""
130+
return json.dumps(self.model_dump(exclude_none=False), indent=2)

0 commit comments

Comments
 (0)