ctxgraph 0.1.0__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.
ctxgraph/cli/main.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ctxgraph.capsule.renderer import render_capsule, render_project_overview
12
+ from ctxgraph.clients.models import ModelMode, get_mode_config
13
+ from ctxgraph.graph.builder import build_graph, get_storage
14
+ from ctxgraph.graph.query import search_relevant_nodes
15
+
16
+ app = typer.Typer(name="ctx", help="Context graph engine for AI coding assistants")
17
+ console = Console()
18
+
19
+
20
+ @app.callback()
21
+ def callback():
22
+ pass
23
+
24
+
25
+ @app.command()
26
+ def build(
27
+ repo_path: Optional[str] = typer.Argument(
28
+ None, help="Path to repository (default: current directory)"
29
+ ),
30
+ repo: Optional[str] = typer.Option(
31
+ None, "--repo", "-r", help="Repository path (synonym for positional)"
32
+ ),
33
+ exclude: Optional[list[str]] = typer.Option(
34
+ None, "--exclude", "-e", help="Additional exclude patterns"
35
+ ),
36
+ db_path: Optional[str] = typer.Option(
37
+ None, "--db", "-d", help="Custom database path"
38
+ ),
39
+ ):
40
+ """Build the knowledge graph for a repository."""
41
+ effective = repo or repo_path
42
+ path = Path(effective).resolve() if effective else Path.cwd()
43
+
44
+ if not (path / ".ctxgraph").exists():
45
+ (path / ".ctxgraph").mkdir(parents=True, exist_ok=True)
46
+
47
+ with console.status(f"Analyzing {path}..."):
48
+ stats = build_graph(path, db_path, exclude)
49
+
50
+ table = Table(title="Graph Build Complete")
51
+ table.add_column("Metric", style="cyan")
52
+ table.add_column("Value", style="green")
53
+
54
+ table.add_row("Files Analyzed", str(stats["files_analyzed"]))
55
+ table.add_row("Files Skipped", str(stats.get("files_skipped", 0)))
56
+ table.add_row("Errors", str(stats.get("errors", 0)))
57
+ table.add_row("Total Nodes", str(stats.get("total_nodes", 0)))
58
+ table.add_row("Total Edges", str(stats.get("total_edges", 0)))
59
+ table.add_row("Time", f"{stats.get('elapsed_seconds', 0)}s")
60
+
61
+ console.print(table)
62
+ console.print(f"\nGraph stored in: [bold]{path / '.ctxgraph' / 'graph.db'}[/bold]")
63
+
64
+
65
+ @app.command()
66
+ def capsule(
67
+ query: str = typer.Argument(..., help="Task description"),
68
+ repo_path: Optional[str] = typer.Option(
69
+ None, "--repo", "-r", help="Repository path"
70
+ ),
71
+ max_nodes: Optional[int] = typer.Option(
72
+ None, "--max-nodes", "-n", help="Maximum nodes in capsule"
73
+ ),
74
+ mode: str = typer.Option(
75
+ "balanced", "--mode", "-m", help="Model mode: fast, balanced, deep"
76
+ ),
77
+ overview: bool = typer.Option(
78
+ False, "--overview", "-o", help="Generate project overview instead"
79
+ ),
80
+ ):
81
+ """Generate a context capsule for Claude."""
82
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
83
+ model_mode = ModelMode.from_str(mode)
84
+ mode_cfg = get_mode_config(model_mode)
85
+
86
+ storage = get_storage(path)
87
+ if storage is None:
88
+ console.print(
89
+ "[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
90
+ )
91
+ raise typer.Exit(1)
92
+
93
+ if overview:
94
+ result = render_project_overview(storage)
95
+ else:
96
+ result = render_capsule(
97
+ storage,
98
+ query,
99
+ max_nodes=max_nodes or mode_cfg["max_nodes"],
100
+ )
101
+
102
+ console.print(result)
103
+
104
+
105
+ @app.command()
106
+ def query(
107
+ query: str = typer.Argument(..., help="Search query"),
108
+ repo_path: Optional[str] = typer.Option(
109
+ None, "--repo", "-r", help="Repository path"
110
+ ),
111
+ mode: str = typer.Option(
112
+ "balanced", "--mode", "-m", help="Model mode: fast, balanced, deep"
113
+ ),
114
+ ):
115
+ """Search the knowledge graph."""
116
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
117
+ model_mode = ModelMode.from_str(mode)
118
+ mode_cfg = get_mode_config(model_mode)
119
+
120
+ storage = get_storage(path)
121
+ if storage is None:
122
+ console.print(
123
+ "[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
124
+ )
125
+ raise typer.Exit(1)
126
+
127
+ results = search_relevant_nodes(
128
+ storage,
129
+ query,
130
+ max_nodes=mode_cfg["max_nodes"],
131
+ max_depth=mode_cfg["max_depth"],
132
+ )
133
+
134
+ if not results:
135
+ console.print("[yellow]No matches found.[/yellow]")
136
+ return
137
+
138
+ table = Table(title=f"Search Results: {query}")
139
+ table.add_column("Type", style="cyan")
140
+ table.add_column("Name", style="green")
141
+ table.add_column("Path", style="blue")
142
+ table.add_column("Relevance", style="yellow")
143
+
144
+ for node, score in results:
145
+ type_tag = {"file": "F", "class": "C", "function": "M", "module": "M"}
146
+ table.add_row(
147
+ type_tag.get(node.type, "?"),
148
+ node.name,
149
+ node.path or "-",
150
+ str(score),
151
+ )
152
+
153
+ console.print(table)
154
+
155
+
156
+ @app.command()
157
+ def view(
158
+ repo_path: Optional[str] = typer.Option(
159
+ None, "--repo", "-r", help="Repository path"
160
+ ),
161
+ port: Optional[int] = typer.Option(None, "--port", "-p", help="Port for server"),
162
+ output: Optional[str] = typer.Option(
163
+ None, "--output", "-o", help="Save HTML to file"
164
+ ),
165
+ open_browser: bool = typer.Option(
166
+ True, "--open/--no-open", help="Open in browser automatically"
167
+ ),
168
+ ):
169
+ """Visualize the dependency graph in a browser."""
170
+ from ctxgraph.view.visualizer import render_view
171
+
172
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
173
+ storage = get_storage(path)
174
+ if storage is None:
175
+ console.print(
176
+ "[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
177
+ )
178
+ raise typer.Exit(1)
179
+
180
+ html = render_view(storage)
181
+
182
+ if output:
183
+ out_path = Path(output)
184
+ out_path.write_text(html, encoding="utf-8")
185
+ console.print(f"Saved to [bold]{out_path}[/bold]")
186
+ else:
187
+ out_path = path / ".ctxgraph" / "graph.html"
188
+ out_path.parent.mkdir(parents=True, exist_ok=True)
189
+ out_path.write_text(html, encoding="utf-8")
190
+ console.print(f"Saved to [bold]{out_path}[/bold]")
191
+
192
+ if open_browser:
193
+ import webbrowser
194
+
195
+ webbrowser.open(f"file://{out_path.absolute()}")
196
+ console.print("Opened in browser.")
197
+
198
+
199
+ @app.command()
200
+ def serve(
201
+ repo_path: Optional[str] = typer.Option(
202
+ None, "--repo", "-r", help="Repository path"
203
+ ),
204
+ port: Optional[int] = typer.Option(
205
+ None, "--port", "-p", help="Port for SSE mode (default: stdio mode)"
206
+ ),
207
+ ):
208
+ """Start MCP server for dynamic graph queries (MCP protocol)."""
209
+ from ctxgraph.mcp.server import run_server
210
+
211
+ if port:
212
+ console.print(f"[yellow]SSE mode on port {port} - not yet supported, falling back to stdio[/yellow]")
213
+ run_server(repo_path)
214
+
215
+
216
+ @app.command()
217
+ def info(
218
+ repo_path: Optional[str] = typer.Option(
219
+ None, "--repo", "-r", help="Repository path"
220
+ ),
221
+ ):
222
+ """Show graph statistics."""
223
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
224
+ storage = get_storage(path)
225
+ if storage is None:
226
+ console.print(
227
+ "[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
228
+ )
229
+ raise typer.Exit(1)
230
+
231
+ stats = storage.stats()
232
+ build_time = storage.get_metadata("build_time")
233
+
234
+ table = Table(title="Graph Info")
235
+ table.add_column("Metric", style="cyan")
236
+ table.add_column("Value", style="green")
237
+
238
+ table.add_row("Total Nodes", str(stats["nodes"]))
239
+ table.add_row("Total Edges", str(stats["edges"]))
240
+
241
+ plural_map = {"file": "files", "class": "classes", "function": "functions", "module": "modules"}
242
+ for t, cnt in stats.get("types", {}).items():
243
+ label = plural_map.get(t, t + "s")
244
+ table.add_row(f" {label}", str(cnt))
245
+
246
+ if build_time:
247
+ table.add_row("Last Build", build_time)
248
+
249
+ console.print(table)
250
+
251
+
252
+ if __name__ == "__main__":
253
+ app()
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+
7
+ class ModelMode(str, Enum):
8
+ FAST = "fast"
9
+ BALANCED = "balanced"
10
+ DEEP = "deep"
11
+
12
+ @classmethod
13
+ def from_str(cls, value: str) -> "ModelMode":
14
+ value = value.lower().strip()
15
+ for mode in cls:
16
+ if mode.value == value:
17
+ return mode
18
+ return cls.BALANCED
19
+
20
+
21
+ MODE_CONFIG = {
22
+ ModelMode.FAST: {
23
+ "description": "Quick responses, minimal analysis (Sonnet-class)",
24
+ "max_nodes": 10,
25
+ "max_depth": 1,
26
+ "summary_length": 100,
27
+ },
28
+ ModelMode.BALANCED: {
29
+ "description": "Balanced speed and depth (default)",
30
+ "max_nodes": 20,
31
+ "max_depth": 2,
32
+ "summary_length": 200,
33
+ },
34
+ ModelMode.DEEP: {
35
+ "description": "Deep analysis, full context (Opus-class)",
36
+ "max_nodes": 40,
37
+ "max_depth": 3,
38
+ "summary_length": 500,
39
+ },
40
+ }
41
+
42
+
43
+ def get_mode_config(mode: ModelMode) -> dict:
44
+ return MODE_CONFIG[mode]
File without changes
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+ from typing import Optional
7
+
8
+ from ctxgraph.config.settings import Settings
9
+
10
+
11
+ def chat_completion(
12
+ settings: Settings,
13
+ system_prompt: str,
14
+ user_prompt: str,
15
+ ) -> Optional[str]:
16
+ provider = settings.provider
17
+
18
+ if provider == "ollama":
19
+ return _ollama_chat(settings, system_prompt, user_prompt)
20
+ elif provider == "claude":
21
+ return _claude_chat(settings, system_prompt, user_prompt)
22
+ elif provider == "openai":
23
+ return _openai_chat(settings, system_prompt, user_prompt)
24
+ else:
25
+ return _custom_chat(settings, system_prompt, user_prompt)
26
+
27
+
28
+ def generate_summary(
29
+ settings: Settings,
30
+ code: str,
31
+ context: str = "",
32
+ ) -> Optional[str]:
33
+ system = "You are a code analysis assistant. Summarize the following code concisely in 1-2 sentences."
34
+ user = f"Context: {context}\n\nCode:\n```python\n{code[:2000]}\n```"
35
+
36
+ return chat_completion(settings, system, user)
37
+
38
+
39
+ def _ollama_chat(settings: Settings, system: str, user: str) -> Optional[str]:
40
+ url = settings.get_chat_url()
41
+ payload = {
42
+ "model": settings.model,
43
+ "messages": [
44
+ {"role": "system", "content": system},
45
+ {"role": "user", "content": user},
46
+ ],
47
+ "stream": False,
48
+ "temperature": settings.temperature,
49
+ }
50
+
51
+ try:
52
+ data = json.dumps(payload).encode()
53
+ req = urllib.request.Request(
54
+ url, data=data, headers={"Content-Type": "application/json"}
55
+ )
56
+ with urllib.request.urlopen(req, timeout=60) as resp:
57
+ result = json.loads(resp.read())
58
+ return result.get("message", {}).get("content", "")
59
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
60
+ return None
61
+
62
+
63
+ def _claude_chat(settings: Settings, system: str, user: str) -> Optional[str]:
64
+ api_key = settings.api_key
65
+ if not api_key:
66
+ return None
67
+
68
+ url = settings.get_chat_url()
69
+ payload = {
70
+ "model": settings.model,
71
+ "system": system,
72
+ "messages": [{"role": "user", "content": user}],
73
+ "max_tokens": settings.max_tokens,
74
+ "temperature": settings.temperature,
75
+ }
76
+
77
+ try:
78
+ data = json.dumps(payload).encode()
79
+ req = urllib.request.Request(
80
+ url,
81
+ data=data,
82
+ headers={
83
+ "Content-Type": "application/json",
84
+ "x-api-key": api_key,
85
+ "anthropic-version": "2023-06-01",
86
+ },
87
+ )
88
+ with urllib.request.urlopen(req, timeout=60) as resp:
89
+ result = json.loads(resp.read())
90
+ return result.get("content", [{}])[0].get("text", "")
91
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
92
+ return None
93
+
94
+
95
+ def _openai_chat(settings: Settings, system: str, user: str) -> Optional[str]:
96
+ api_key = settings.api_key
97
+ if not api_key:
98
+ return None
99
+
100
+ url = settings.get_chat_url()
101
+ payload = {
102
+ "model": settings.model,
103
+ "messages": [
104
+ {"role": "system", "content": system},
105
+ {"role": "user", "content": user},
106
+ ],
107
+ "max_tokens": settings.max_tokens,
108
+ "temperature": settings.temperature,
109
+ }
110
+
111
+ try:
112
+ data = json.dumps(payload).encode()
113
+ req = urllib.request.Request(
114
+ url,
115
+ data=data,
116
+ headers={
117
+ "Content-Type": "application/json",
118
+ "Authorization": f"Bearer {api_key}",
119
+ },
120
+ )
121
+ with urllib.request.urlopen(req, timeout=60) as resp:
122
+ result = json.loads(resp.read())
123
+ return result.get("choices", [{}])[0].get("message", {}).get("content", "")
124
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
125
+ return None
126
+
127
+
128
+ def _custom_chat(settings: Settings, system: str, user: str) -> Optional[str]:
129
+ url = settings.get_chat_url()
130
+ payload = {
131
+ "model": settings.model,
132
+ "messages": [
133
+ {"role": "system", "content": system},
134
+ {"role": "user", "content": user},
135
+ ],
136
+ "temperature": settings.temperature,
137
+ }
138
+
139
+ headers = {"Content-Type": "application/json"}
140
+ if settings.api_key:
141
+ headers["Authorization"] = f"Bearer {settings.api_key}"
142
+
143
+ try:
144
+ data = json.dumps(payload).encode()
145
+ req = urllib.request.Request(url, data=data, headers=headers)
146
+ with urllib.request.urlopen(req, timeout=60) as resp:
147
+ result = json.loads(resp.read())
148
+ return json.dumps(result)
149
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
150
+ return None
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ DEFAULT_CONFIG = {
10
+ "graph": {
11
+ "exclude": [],
12
+ "follow_symlinks": False,
13
+ "max_file_size_mb": 5,
14
+ },
15
+ "ai": {
16
+ "provider": "ollama",
17
+ "model": "qwen2.5-coder:7b",
18
+ "endpoint": "http://localhost:11434",
19
+ "api_key": None,
20
+ "temperature": 0.1,
21
+ "max_tokens": 4096,
22
+ },
23
+ "context": {
24
+ "mode": "balanced",
25
+ "max_nodes": 20,
26
+ "max_depth": 2,
27
+ "max_tokens": 4000,
28
+ },
29
+ }
30
+
31
+ PROVIDER_CONFIGS = {
32
+ "ollama": {
33
+ "endpoint_default": "http://localhost:11434",
34
+ "api_key_required": False,
35
+ "models": ["qwen2.5-coder:7b", "llama3.2", "llama3.1", "mistral", "codellama", "deepseek-coder"],
36
+ "chat_endpoint": "/api/chat",
37
+ "generate_endpoint": "/api/generate",
38
+ },
39
+ "claude": {
40
+ "endpoint_default": "https://api.anthropic.com/v1",
41
+ "api_key_required": True,
42
+ "models": ["claude-sonnet-4-20250514", "claude-haiku-3-5-20241022"],
43
+ "chat_endpoint": "/messages",
44
+ "api_key_env": "ANTHROPIC_API_KEY",
45
+ },
46
+ "openai": {
47
+ "endpoint_default": "https://api.openai.com/v1",
48
+ "api_key_required": True,
49
+ "models": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
50
+ "chat_endpoint": "/chat/completions",
51
+ "api_key_env": "OPENAI_API_KEY",
52
+ },
53
+ "custom": {
54
+ "endpoint_default": None,
55
+ "api_key_required": False,
56
+ "models": [],
57
+ "chat_endpoint": "/v1/chat/completions",
58
+ "api_key_env": None,
59
+ },
60
+ }
61
+
62
+
63
+ class Settings:
64
+ def __init__(self, repo_path: Optional[Path] = None):
65
+ self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
66
+ self._data = dict(DEFAULT_CONFIG)
67
+ self._load()
68
+
69
+ def _load(self):
70
+ config_paths = [
71
+ self.repo_path / ".ctxgraph" / "config.toml",
72
+ self.repo_path / ".ctxgraph" / "config.json",
73
+ self.repo_path / "ctxgraph.toml",
74
+ self.repo_path / "ctxgraph.json",
75
+ ]
76
+
77
+ for path in config_paths:
78
+ if path.exists():
79
+ self._load_file(path)
80
+ break
81
+
82
+ self._apply_env_overrides()
83
+
84
+ def _load_file(self, path: Path):
85
+ text = path.read_text(encoding="utf-8")
86
+ if path.suffix == ".json":
87
+ parsed = json.loads(text)
88
+ self._deep_merge(self._data, parsed)
89
+ elif path.suffix == ".toml":
90
+ parsed = self._parse_toml(text)
91
+ self._deep_merge(self._data, parsed)
92
+
93
+ def _apply_env_overrides(self):
94
+ provider = self._data["ai"]["provider"]
95
+ pconfig = PROVIDER_CONFIGS.get(provider, {})
96
+
97
+ api_key_env = pconfig.get("api_key_env")
98
+ if api_key_env:
99
+ env_key = os.environ.get(api_key_env)
100
+ if env_key:
101
+ self._data["ai"]["api_key"] = env_key
102
+
103
+ env_endpoint = os.environ.get("CTXGRAPH_ENDPOINT")
104
+ if env_endpoint:
105
+ self._data["ai"]["endpoint"] = env_endpoint
106
+
107
+ env_model = os.environ.get("CTXGRAPH_MODEL")
108
+ if env_model:
109
+ self._data["ai"]["model"] = env_model
110
+
111
+ env_provider = os.environ.get("CTXGRAPH_PROVIDER")
112
+ if env_provider:
113
+ self._data["ai"]["provider"] = env_provider
114
+
115
+ @property
116
+ def provider(self) -> str:
117
+ return self._data["ai"]["provider"]
118
+
119
+ @property
120
+ def model(self) -> str:
121
+ return self._data["ai"]["model"]
122
+
123
+ @property
124
+ def endpoint(self) -> str:
125
+ return self._data["ai"]["endpoint"]
126
+
127
+ @property
128
+ def api_key(self) -> Optional[str]:
129
+ return self._data["ai"].get("api_key")
130
+
131
+ @property
132
+ def temperature(self) -> float:
133
+ return self._data["ai"].get("temperature", 0.1)
134
+
135
+ @property
136
+ def max_tokens(self) -> int:
137
+ return self._data["ai"].get("max_tokens", 4096)
138
+
139
+ @property
140
+ def exclude_patterns(self) -> list[str]:
141
+ return self._data["graph"].get("exclude", [])
142
+
143
+ @property
144
+ def context_mode(self) -> str:
145
+ return self._data["context"].get("mode", "balanced")
146
+
147
+ @property
148
+ def max_nodes(self) -> int:
149
+ return self._data["context"].get("max_nodes", 20)
150
+
151
+ @property
152
+ def max_depth(self) -> int:
153
+ return self._data["context"].get("max_depth", 2)
154
+
155
+ def get_provider_config(self) -> dict:
156
+ return PROVIDER_CONFIGS.get(self.provider, PROVIDER_CONFIGS["custom"])
157
+
158
+ def get_chat_url(self) -> str:
159
+ pconfig = self.get_provider_config()
160
+ endpoint = self.endpoint.rstrip("/")
161
+ return f"{endpoint}{pconfig.get('chat_endpoint', '/v1/chat/completions')}"
162
+
163
+ def to_dict(self) -> dict:
164
+ return dict(self._data)
165
+
166
+ @staticmethod
167
+ def _parse_toml(text: str) -> dict:
168
+ result = {}
169
+ current_section = result
170
+
171
+ for line in text.split("\n"):
172
+ line = line.strip()
173
+ if not line or line.startswith("#"):
174
+ continue
175
+ if line.startswith("[") and line.endswith("]"):
176
+ section_name = line[1:-1].strip()
177
+ current_section = result.setdefault(section_name, {})
178
+ elif "=" in line:
179
+ key, _, value = line.partition("=")
180
+ key = key.strip()
181
+ value = value.strip()
182
+ value = value.strip('"').strip("'")
183
+ current_section[key] = value
184
+
185
+ return result
186
+
187
+ @staticmethod
188
+ def _deep_merge(base: dict, override: dict):
189
+ for key, value in override.items():
190
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
191
+ Settings._deep_merge(base[key], value)
192
+ else:
193
+ base[key] = value
194
+
195
+
196
+ def create_default_config(repo_path: Path):
197
+ config_dir = repo_path / ".ctxgraph"
198
+ config_dir.mkdir(parents=True, exist_ok=True)
199
+
200
+ config_path = config_dir / "config.toml"
201
+ if config_path.exists():
202
+ return
203
+
204
+ config_path.write_text(
205
+ """# ctxgraph configuration
206
+ # https://github.com/anomalyco/ctxgraph
207
+
208
+ [graph]
209
+ # Additional exclude patterns (gitignore is used automatically)
210
+ exclude = []
211
+
212
+ [ai]
213
+ # Provider: ollama, claude, openai, or custom
214
+ provider = "ollama"
215
+ # Model name
216
+ model = "qwen2.5-coder:7b"
217
+ # API endpoint
218
+ endpoint = "http://localhost:11434"
219
+ # API key (or set env var: ANTHROPIC_API_KEY, OPENAI_API_KEY)
220
+ api_key = ""
221
+
222
+ [context]
223
+ # Mode: fast, balanced, deep
224
+ mode = "balanced"
225
+ # Max nodes to include in context capsule
226
+ max_nodes = 20
227
+ # Max BFS depth for dependency traversal
228
+ max_depth = 2
229
+ """,
230
+ encoding="utf-8",
231
+ )
232
+ return config_path
File without changes