truefoundry 0.1.2__py3-none-any.whl → 0.2.0rc2__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.
Potentially problematic release.
This version of truefoundry might be problematic. Click here for more details.
- truefoundry/autodeploy/__init__.py +0 -0
- truefoundry/autodeploy/agents/__init__.py +0 -0
- truefoundry/autodeploy/agents/base.py +181 -0
- truefoundry/autodeploy/agents/developer.py +113 -0
- truefoundry/autodeploy/agents/project_identifier.py +124 -0
- truefoundry/autodeploy/agents/tester.py +75 -0
- truefoundry/autodeploy/cli.py +312 -0
- truefoundry/autodeploy/constants.py +12 -0
- truefoundry/autodeploy/exception.py +2 -0
- truefoundry/autodeploy/logger.py +13 -0
- truefoundry/autodeploy/tools/__init__.py +26 -0
- truefoundry/autodeploy/tools/ask.py +33 -0
- truefoundry/autodeploy/tools/base.py +31 -0
- truefoundry/autodeploy/tools/commit.py +139 -0
- truefoundry/autodeploy/tools/docker_build.py +109 -0
- truefoundry/autodeploy/tools/docker_run.py +150 -0
- truefoundry/autodeploy/tools/file_type_counts.py +79 -0
- truefoundry/autodeploy/tools/list_files.py +82 -0
- truefoundry/autodeploy/tools/read_file.py +66 -0
- truefoundry/autodeploy/tools/send_request.py +54 -0
- truefoundry/autodeploy/tools/write_file.py +101 -0
- truefoundry/autodeploy/utils/diff.py +157 -0
- truefoundry/autodeploy/utils/pydantic_compat.py +19 -0
- truefoundry/cli/__main__.py +29 -0
- {truefoundry-0.1.2.dist-info → truefoundry-0.2.0rc2.dist-info}/METADATA +9 -1
- truefoundry-0.2.0rc2.dist-info/RECORD +33 -0
- truefoundry-0.1.2.dist-info/RECORD +0 -10
- {truefoundry-0.1.2.dist-info → truefoundry-0.2.0rc2.dist-info}/WHEEL +0 -0
- {truefoundry-0.1.2.dist-info → truefoundry-0.2.0rc2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
from typing import Any, Dict, Generator, List, Optional
|
|
5
|
+
|
|
6
|
+
import docker
|
|
7
|
+
import requests
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
10
|
+
from rich.padding import Padding
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from truefoundry.autodeploy.tools.base import (
|
|
14
|
+
Event,
|
|
15
|
+
Message,
|
|
16
|
+
RequestEvent,
|
|
17
|
+
ResponseEvent,
|
|
18
|
+
Tool,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DockerRunLog(Event):
|
|
23
|
+
index: int
|
|
24
|
+
log: str
|
|
25
|
+
|
|
26
|
+
def render(self, console: Console):
|
|
27
|
+
# if self.index > 1:
|
|
28
|
+
# print("\r", end="")
|
|
29
|
+
console.print(Padding.indent(renderable=Text.from_ansi(self.log), level=2))
|
|
30
|
+
# console.print("Press control-c to stop waiting for more logs", end="")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DockerRun(Tool):
|
|
34
|
+
description = """
|
|
35
|
+
Run a docker image
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
class Request(RequestEvent):
|
|
39
|
+
image_tag: str
|
|
40
|
+
ports: Optional[Dict[str, int]] = Field(
|
|
41
|
+
None,
|
|
42
|
+
description="""
|
|
43
|
+
Ports to expose.
|
|
44
|
+
The keys of the dictionary are the ports to bind inside the container in 'port'.
|
|
45
|
+
The values are the ports to open on the host""",
|
|
46
|
+
)
|
|
47
|
+
command: str
|
|
48
|
+
|
|
49
|
+
def render(self, console: Console):
|
|
50
|
+
console.print(
|
|
51
|
+
f"[bold magenta]TFY-Agent[/] is executing the Docker container. Image Tag: [bold green]{self.image_tag}[/], Exposed Port: [bold green]{str(self.ports) if self.ports is not None else 'Not exposed'}[/], Command: [bold green]{self.command}[/]"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
class Response(ResponseEvent):
|
|
55
|
+
logs: Optional[List[str]] = Field(
|
|
56
|
+
None, description="Logs of the container. Only last 50 chars."
|
|
57
|
+
)
|
|
58
|
+
exit_code: Optional[int] = Field(
|
|
59
|
+
None,
|
|
60
|
+
description="""
|
|
61
|
+
Exit code of the process if the container stops.
|
|
62
|
+
This will not be passed if the container is still running.
|
|
63
|
+
""",
|
|
64
|
+
)
|
|
65
|
+
client_error: Optional[str] = Field(None, description="Docker client error.")
|
|
66
|
+
|
|
67
|
+
def __rich_console__(
|
|
68
|
+
self, console: Console, options: ConsoleOptions
|
|
69
|
+
) -> RenderResult:
|
|
70
|
+
none_text = "[italic magenta]None[/]"
|
|
71
|
+
error_text = (
|
|
72
|
+
f"[green]'{self.client_error}'[/]"
|
|
73
|
+
if self.client_error is not None
|
|
74
|
+
else none_text
|
|
75
|
+
)
|
|
76
|
+
exit_code_text = (
|
|
77
|
+
"[green]'0'[/]"
|
|
78
|
+
if self.exit_code == 0
|
|
79
|
+
else (
|
|
80
|
+
none_text if self.exit_code is None else f"[red]{self.exit_code}[/]"
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
yield Text.from_markup("[bold magenta]Response[/](")
|
|
85
|
+
if self.logs is not None:
|
|
86
|
+
yield Text.from_markup(' [yellow]logs=[/]"')
|
|
87
|
+
yield Text.from_ansi("".join(self.logs))
|
|
88
|
+
yield Text.from_markup('"')
|
|
89
|
+
else:
|
|
90
|
+
yield Text.from_markup(f" [yellow]logs[/]={none_text}")
|
|
91
|
+
|
|
92
|
+
yield Text.from_markup(f" [yellow]exit_code[/]={exit_code_text}")
|
|
93
|
+
yield Text.from_markup(f" [yellow]client_error[/]={error_text} \n)")
|
|
94
|
+
|
|
95
|
+
def __init__(self, docker_client: docker.DockerClient, environment: Dict):
|
|
96
|
+
self.containers = []
|
|
97
|
+
self.docker_client = docker_client
|
|
98
|
+
self.environment = environment
|
|
99
|
+
atexit.register(self._kill_running_containers)
|
|
100
|
+
|
|
101
|
+
def _kill_running_containers(self):
|
|
102
|
+
if self.containers:
|
|
103
|
+
container = self.containers.pop()
|
|
104
|
+
try:
|
|
105
|
+
container.remove(force=True)
|
|
106
|
+
except docker.errors.APIError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def run(self, request: DockerRun.Request) -> Generator[Event, Any, ResponseEvent]:
|
|
110
|
+
self._kill_running_containers()
|
|
111
|
+
yield Message(message="[bold cyan]Testing:[/] Running Docker container...")
|
|
112
|
+
try:
|
|
113
|
+
container = self.docker_client.containers.run(
|
|
114
|
+
request.image_tag,
|
|
115
|
+
detach=True,
|
|
116
|
+
remove=False,
|
|
117
|
+
stderr=True,
|
|
118
|
+
ports=request.ports,
|
|
119
|
+
environment=self.environment,
|
|
120
|
+
command=request.command,
|
|
121
|
+
)
|
|
122
|
+
except docker.errors.APIError as ex:
|
|
123
|
+
if ex.is_client_error():
|
|
124
|
+
return DockerRun.Response(client_error=str(ex))
|
|
125
|
+
raise
|
|
126
|
+
yield Message(message="[bold yellow]Docker logs:[/]")
|
|
127
|
+
self.containers.append(container)
|
|
128
|
+
exit_code = None
|
|
129
|
+
|
|
130
|
+
all_logs = []
|
|
131
|
+
logs = container.logs(stream=True)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
for i, log in enumerate(logs):
|
|
135
|
+
log = log.decode()
|
|
136
|
+
all_logs.append(log)
|
|
137
|
+
yield DockerRunLog(index=i, log=log)
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
pass
|
|
140
|
+
else:
|
|
141
|
+
yield Message(message="\n[bold yellow]There are no more logs.[/]")
|
|
142
|
+
exit_code = None
|
|
143
|
+
try:
|
|
144
|
+
exit_code = container.wait(timeout=1).get("StatusCode")
|
|
145
|
+
except (
|
|
146
|
+
requests.exceptions.ReadTimeout,
|
|
147
|
+
requests.exceptions.ConnectionError,
|
|
148
|
+
):
|
|
149
|
+
...
|
|
150
|
+
return DockerRun.Response(logs=all_logs, exit_code=exit_code)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Any, Dict, Generator
|
|
6
|
+
|
|
7
|
+
import gitignorefile
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from truefoundry.autodeploy.tools.base import (
|
|
13
|
+
Event,
|
|
14
|
+
Message,
|
|
15
|
+
RequestEvent,
|
|
16
|
+
ResponseEvent,
|
|
17
|
+
Tool,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ShowFileCount(Event):
|
|
22
|
+
file_types: Dict[str, int]
|
|
23
|
+
|
|
24
|
+
def render(self, console: Console):
|
|
25
|
+
total_files = sum(self.file_types.values())
|
|
26
|
+
console.print(f"Found {total_files} files.")
|
|
27
|
+
table = Table()
|
|
28
|
+
table.add_column("File Type", style="cyan")
|
|
29
|
+
table.add_column("Count", justify="right", style="green")
|
|
30
|
+
for file_type, count in self.file_types.items():
|
|
31
|
+
table.add_row(file_type, str(count))
|
|
32
|
+
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FileTypeCounts(Tool):
|
|
37
|
+
description = """
|
|
38
|
+
Get counts of different types of file present.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
class Request(RequestEvent): ...
|
|
42
|
+
|
|
43
|
+
class Response(ResponseEvent):
|
|
44
|
+
file_types: Dict[str, int] = Field(
|
|
45
|
+
...,
|
|
46
|
+
description='Counts of different types of files. Ex: {"py": 1} or {"c": 1}',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_root_path: str):
|
|
50
|
+
self.project_root_path = project_root_path
|
|
51
|
+
|
|
52
|
+
def run(
|
|
53
|
+
self, request: FileTypeCounts.Request
|
|
54
|
+
) -> Generator[Event, Any, ResponseEvent]:
|
|
55
|
+
counter = Counter()
|
|
56
|
+
|
|
57
|
+
yield Message(
|
|
58
|
+
message="[bold cyan]Processing:[/] Scanning for various file types..."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def gitignore(_):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
gitignore_path = os.path.join(self.project_root_path, ".gitignore")
|
|
65
|
+
if os.path.exists(gitignore_path):
|
|
66
|
+
gitignore = gitignorefile.parse(path=gitignore_path)
|
|
67
|
+
for root, dirs, ps in os.walk(
|
|
68
|
+
self.project_root_path,
|
|
69
|
+
):
|
|
70
|
+
root = root[len(self.project_root_path) :]
|
|
71
|
+
if ".git" in dirs:
|
|
72
|
+
dirs.remove(".git")
|
|
73
|
+
counter.update(
|
|
74
|
+
p.split(".")[-1] if len(p) and p[0] != "." else p
|
|
75
|
+
for p in ps
|
|
76
|
+
if not gitignore(os.path.join(root, p).strip(os.path.sep))
|
|
77
|
+
)
|
|
78
|
+
yield ShowFileCount(file_types=counter)
|
|
79
|
+
return FileTypeCounts.Response(file_types=counter)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
from typing import Any, Generator, List, Optional
|
|
6
|
+
|
|
7
|
+
import gitignorefile
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from truefoundry.autodeploy.tools.base import (
|
|
11
|
+
Event,
|
|
12
|
+
Message,
|
|
13
|
+
RequestEvent,
|
|
14
|
+
ResponseEvent,
|
|
15
|
+
Tool,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ListFiles(Tool):
|
|
20
|
+
description = """
|
|
21
|
+
List all files.
|
|
22
|
+
If you want to find all json files recuresively under directory a/b
|
|
23
|
+
the subdir should be a/b and pattern will be *.json
|
|
24
|
+
|
|
25
|
+
If you want to find all json files recuresively under current directory
|
|
26
|
+
the subdir should be . and pattern will be *.json
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
class Request(RequestEvent):
|
|
30
|
+
sub_dir: str
|
|
31
|
+
pattern: str = Field(..., description="Glob pattern. Avoid passing '*'")
|
|
32
|
+
|
|
33
|
+
class Response(ResponseEvent):
|
|
34
|
+
paths: List[str] = Field(
|
|
35
|
+
...,
|
|
36
|
+
description="File paths under the given directory",
|
|
37
|
+
)
|
|
38
|
+
error: Optional[str] = Field(None)
|
|
39
|
+
|
|
40
|
+
def __init__(self, project_root_path: str):
|
|
41
|
+
self.project_root_path = project_root_path
|
|
42
|
+
|
|
43
|
+
def run(self, request: ListFiles.Request) -> Generator[Event, Any, ResponseEvent]:
|
|
44
|
+
yield Message(
|
|
45
|
+
message=f"[bold cyan]Searching:[/] 🔍 Looking for files matching the pattern [magenta]{request.pattern}[/]"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
paths: List[str] = []
|
|
49
|
+
|
|
50
|
+
def gitignore(_):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
gitignore_path = os.path.join(self.project_root_path, ".gitignore")
|
|
54
|
+
if os.path.exists(gitignore_path):
|
|
55
|
+
gitignore = gitignorefile.parse(path=gitignore_path)
|
|
56
|
+
|
|
57
|
+
path = os.path.join(self.project_root_path, request.sub_dir.strip(os.path.sep))
|
|
58
|
+
if not os.path.exists(path):
|
|
59
|
+
return ListFiles.Response(
|
|
60
|
+
paths=None,
|
|
61
|
+
error=f"Incorrect sub_dir {request.sub_dir}. Does not exist",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for root, dirs, ps in os.walk(
|
|
65
|
+
path,
|
|
66
|
+
):
|
|
67
|
+
root = root[len(path) :]
|
|
68
|
+
if ".git" in dirs:
|
|
69
|
+
dirs.remove(".git")
|
|
70
|
+
paths.extend(
|
|
71
|
+
os.path.join(root, p).lstrip(os.path.sep)
|
|
72
|
+
for p in ps
|
|
73
|
+
if fnmatch(p, request.pattern)
|
|
74
|
+
and not gitignore(os.path.join(root, p).strip(os.path.sep))
|
|
75
|
+
)
|
|
76
|
+
if len(paths) > 0:
|
|
77
|
+
yield Message(message=f"[bold green]Success:[/] Found {len(paths)} files.")
|
|
78
|
+
else:
|
|
79
|
+
yield Message(
|
|
80
|
+
message=f"[red]Alert:[/] No files found matching the pattern {request.pattern}."
|
|
81
|
+
)
|
|
82
|
+
return ListFiles.Response(paths=paths)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Generator, List, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from truefoundry.autodeploy.tools.base import (
|
|
9
|
+
Event,
|
|
10
|
+
Message,
|
|
11
|
+
RequestEvent,
|
|
12
|
+
ResponseEvent,
|
|
13
|
+
Tool,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Line(BaseModel):
|
|
18
|
+
line_number: int
|
|
19
|
+
content: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReadFile(Tool):
|
|
23
|
+
description = """
|
|
24
|
+
Read contents of a file.
|
|
25
|
+
Avoid reading *.lock type files as they tend to be large
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
class Request(RequestEvent):
|
|
29
|
+
path: str = Field(
|
|
30
|
+
...,
|
|
31
|
+
pattern=r"^[a-zA-Z0-9\.]{1}.*$",
|
|
32
|
+
description="File path to open. ",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class Response(ResponseEvent):
|
|
36
|
+
data: Optional[List[Line]] = Field(
|
|
37
|
+
None,
|
|
38
|
+
description="Content of the file.",
|
|
39
|
+
)
|
|
40
|
+
error: Optional[str] = Field(
|
|
41
|
+
None,
|
|
42
|
+
description="Error while opening a file.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
project_root_path: str,
|
|
48
|
+
):
|
|
49
|
+
self.project_root_path = project_root_path
|
|
50
|
+
|
|
51
|
+
def run(self, request: ReadFile.Request) -> Generator[Event, Any, ResponseEvent]:
|
|
52
|
+
yield Message(
|
|
53
|
+
message=f"[bold cyan]Processing:[/] Reading file at [magenta]{request.path}[/] and extracting details..."
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
with open(
|
|
57
|
+
os.path.join(self.project_root_path, request.path),
|
|
58
|
+
"r",
|
|
59
|
+
encoding="utf8",
|
|
60
|
+
) as f:
|
|
61
|
+
response = ReadFile.Response(data=[])
|
|
62
|
+
for i, line in enumerate(f):
|
|
63
|
+
response.data.append(Line(line_number=i + 1, content=line))
|
|
64
|
+
return response
|
|
65
|
+
except FileNotFoundError as ex:
|
|
66
|
+
return ReadFile.Response(error=str(ex))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generator, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
from truefoundry.autodeploy.tools.base import (
|
|
9
|
+
Event,
|
|
10
|
+
Message,
|
|
11
|
+
RequestEvent,
|
|
12
|
+
ResponseEvent,
|
|
13
|
+
Tool,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SendRequest(Tool):
|
|
18
|
+
description = """
|
|
19
|
+
Send an HTTP request.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.call_count = 0
|
|
24
|
+
|
|
25
|
+
class Request(RequestEvent):
|
|
26
|
+
method: str
|
|
27
|
+
url: str
|
|
28
|
+
|
|
29
|
+
class Response(ResponseEvent):
|
|
30
|
+
response_code: Optional[int] = Field(None, description="Response Code")
|
|
31
|
+
response_body: Optional[str] = None
|
|
32
|
+
error: Optional[str] = Field(None, description="Error.")
|
|
33
|
+
|
|
34
|
+
def run(self, request: SendRequest.Request) -> Generator[Event, Any, ResponseEvent]:
|
|
35
|
+
self.call_count += 1
|
|
36
|
+
yield Message(
|
|
37
|
+
message=f"[bold cyan]Testing:[/] Sending a [magenta]{request.method.upper()}[/] request to [magenta]{request.url}[/]..."
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
response = requests.request(request.method.lower(), url=request.url)
|
|
41
|
+
yield Message(
|
|
42
|
+
message=f"[bold green]Success:[/] Received response with status code [magenta]{response.status_code}[/]"
|
|
43
|
+
)
|
|
44
|
+
return SendRequest.Response(
|
|
45
|
+
response_code=response.status_code,
|
|
46
|
+
response_body=response.text[-50:],
|
|
47
|
+
)
|
|
48
|
+
except Exception as ex:
|
|
49
|
+
yield Message(
|
|
50
|
+
message="[red]Alert:[/] Request could not be completed successfully."
|
|
51
|
+
)
|
|
52
|
+
return SendRequest.Response(
|
|
53
|
+
error=str(ex),
|
|
54
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# deprecated
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.prompt import Confirm
|
|
10
|
+
|
|
11
|
+
from truefoundry.autodeploy.tools.base import Tool
|
|
12
|
+
from truefoundry.autodeploy.utils.diff import Diff
|
|
13
|
+
|
|
14
|
+
INTERACTIVE_SESSION = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WriteFile(Tool):
|
|
18
|
+
description = """
|
|
19
|
+
Write contents to a file.
|
|
20
|
+
Read first before write.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
class Request(BaseModel):
|
|
24
|
+
path: str = Field(
|
|
25
|
+
...,
|
|
26
|
+
pattern=r"^[a-zA-Z0-9\.]{1}.*$",
|
|
27
|
+
description="File path to write.",
|
|
28
|
+
)
|
|
29
|
+
content: str = Field(..., description="Content of the file.")
|
|
30
|
+
justification: str = Field(
|
|
31
|
+
...,
|
|
32
|
+
description="Justification of why the new content is required. Ensure the justification, justifies the content.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class Response(BaseModel):
|
|
36
|
+
cancellation_reason: Optional[str] = Field(
|
|
37
|
+
None, description="Operation cancelled by user"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
error: Optional[str] = Field(
|
|
41
|
+
None,
|
|
42
|
+
description="Error while opening a file.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(self, project_root_path: str):
|
|
46
|
+
self.project_root_path = project_root_path
|
|
47
|
+
|
|
48
|
+
def _user_interaction(
|
|
49
|
+
self, request: WriteFile.Request, console: Console
|
|
50
|
+
) -> Optional[WriteFile.Response]:
|
|
51
|
+
console.log("You are about to write or edit a file.")
|
|
52
|
+
|
|
53
|
+
console.print(f"Displaying changes in {request.path}")
|
|
54
|
+
prev_content = ""
|
|
55
|
+
|
|
56
|
+
if os.path.exists(os.path.join(self.project_root_path, request.path)):
|
|
57
|
+
with open(
|
|
58
|
+
os.path.join(self.project_root_path, request.path),
|
|
59
|
+
"r",
|
|
60
|
+
encoding="utf8",
|
|
61
|
+
) as f:
|
|
62
|
+
prev_content = f.read()
|
|
63
|
+
|
|
64
|
+
console.print(
|
|
65
|
+
Diff(
|
|
66
|
+
lhs=prev_content,
|
|
67
|
+
rhs=request.content,
|
|
68
|
+
width=os.get_terminal_size().columns,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
response = Confirm.ask(
|
|
72
|
+
f"Writing file at {request.path}?",
|
|
73
|
+
)
|
|
74
|
+
if not response:
|
|
75
|
+
description = console.input(
|
|
76
|
+
"You chose to cancel. Can you provide a reason why? [green]>> "
|
|
77
|
+
)
|
|
78
|
+
return WriteFile.Response(
|
|
79
|
+
cancellation_reason=description, error="Operation cancelled by user."
|
|
80
|
+
)
|
|
81
|
+
console.log(f"Writing file to {request.path}")
|
|
82
|
+
|
|
83
|
+
def run(
|
|
84
|
+
self,
|
|
85
|
+
request: WriteFile.Request,
|
|
86
|
+
console: Console,
|
|
87
|
+
) -> WriteFile.Response:
|
|
88
|
+
if INTERACTIVE_SESSION:
|
|
89
|
+
interaction_response = self._user_interaction(request, console)
|
|
90
|
+
if isinstance(interaction_response, WriteFile.Response):
|
|
91
|
+
return interaction_response
|
|
92
|
+
try:
|
|
93
|
+
with open(
|
|
94
|
+
os.path.join(self.project_root_path, request.path),
|
|
95
|
+
"w",
|
|
96
|
+
encoding="utf8",
|
|
97
|
+
) as f:
|
|
98
|
+
f.write(request.content)
|
|
99
|
+
return WriteFile.Response()
|
|
100
|
+
except FileNotFoundError as ex:
|
|
101
|
+
return WriteFile.Response(error=str(ex))
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import pprint
|
|
3
|
+
from typing import Iterator, List
|
|
4
|
+
|
|
5
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rewrite_line(line, line_to_rewrite, prev_marker):
|
|
10
|
+
marker_style_map = {
|
|
11
|
+
"+": {
|
|
12
|
+
" ": "green",
|
|
13
|
+
"+": "white on green",
|
|
14
|
+
"^": "white on green",
|
|
15
|
+
},
|
|
16
|
+
"-": {
|
|
17
|
+
" ": "red",
|
|
18
|
+
"-": "white on red",
|
|
19
|
+
"^": "white on red",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
new_line = Text("")
|
|
23
|
+
current_span = []
|
|
24
|
+
# Differ lines start with a 2 letter code, so skip past that
|
|
25
|
+
prev_char = line[2]
|
|
26
|
+
for idx, char in enumerate(line[2:], start=2):
|
|
27
|
+
if prev_marker in ("+", "-"):
|
|
28
|
+
if char != prev_char:
|
|
29
|
+
style = marker_style_map.get(prev_marker, {}).get(prev_char, None)
|
|
30
|
+
if style is not None:
|
|
31
|
+
new_line.append_text(Text("".join(current_span), style=style))
|
|
32
|
+
current_span = []
|
|
33
|
+
if idx - 2 < len(line_to_rewrite):
|
|
34
|
+
current_span.append(line_to_rewrite[idx - 2])
|
|
35
|
+
prev_char = char
|
|
36
|
+
|
|
37
|
+
# Lines starting with ? aren't guaranteed to be the same length as the lines before them
|
|
38
|
+
# so some characters may be left over. Add any leftover characters to the output.
|
|
39
|
+
# subtract 2 for code at start
|
|
40
|
+
remaining_index = idx - 2
|
|
41
|
+
if prev_marker == "-":
|
|
42
|
+
new_line.append_text(Text(line_to_rewrite[remaining_index:], style="red"))
|
|
43
|
+
elif prev_marker == "+":
|
|
44
|
+
new_line.append_text(Text(line_to_rewrite[remaining_index:], style="green"))
|
|
45
|
+
return new_line
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_symbolic_unified_diff(diff: List[str]) -> RenderResult:
|
|
49
|
+
output_lines = []
|
|
50
|
+
style = "grey"
|
|
51
|
+
last_style = style
|
|
52
|
+
for line in diff:
|
|
53
|
+
if line.startswith("+ "):
|
|
54
|
+
style = "green"
|
|
55
|
+
output_line = f"+ {line[2:]}"
|
|
56
|
+
elif line.startswith("- "):
|
|
57
|
+
style = "red"
|
|
58
|
+
output_line = f"- {line[2:]}"
|
|
59
|
+
elif line.startswith("? "):
|
|
60
|
+
if last_style == "red":
|
|
61
|
+
output_line = line[:-1].replace("+", "-")
|
|
62
|
+
elif last_style == "green":
|
|
63
|
+
output_line = line[:-1].replace("-", "+")
|
|
64
|
+
else:
|
|
65
|
+
output_line = line
|
|
66
|
+
style = "gray"
|
|
67
|
+
output_lines.append(Text(output_line, style=style))
|
|
68
|
+
last_style = style if style != "gray" else last_style
|
|
69
|
+
return output_lines
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_unified_diff(diff: List[str]) -> RenderResult:
|
|
73
|
+
prev_marker = ""
|
|
74
|
+
output_lines: List[Text] = []
|
|
75
|
+
for line in diff:
|
|
76
|
+
if line.startswith("- "):
|
|
77
|
+
output_lines.append(Text(line[2:], style="red"))
|
|
78
|
+
elif line.startswith("+ "):
|
|
79
|
+
output_lines.append(Text(line[2:], style="green"))
|
|
80
|
+
elif line.startswith("? "):
|
|
81
|
+
line_to_rewrite = output_lines[-1].plain
|
|
82
|
+
output_lines[-1] = rewrite_line(line, line_to_rewrite, prev_marker)
|
|
83
|
+
else:
|
|
84
|
+
output_lines.append(Text(line[2:], style="#949494"))
|
|
85
|
+
prev_marker = line[0]
|
|
86
|
+
return output_lines
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def llm_unified_diff(diff: List[str]) -> RenderResult:
|
|
90
|
+
prev_marker = ""
|
|
91
|
+
output_lines: List[Text] = []
|
|
92
|
+
for line in diff:
|
|
93
|
+
if len(line) < 1:
|
|
94
|
+
continue
|
|
95
|
+
if line.startswith("---"):
|
|
96
|
+
output_lines.append(Text(line, style="dim"))
|
|
97
|
+
elif line.startswith("+++"):
|
|
98
|
+
output_lines.append(Text(line, style="dim"))
|
|
99
|
+
elif line.startswith("@@"):
|
|
100
|
+
output_lines.append(Text(line, style="bright_blue"))
|
|
101
|
+
elif line.startswith("-"):
|
|
102
|
+
output_lines.append(Text(line[1:], style="red"))
|
|
103
|
+
elif line.startswith("+"):
|
|
104
|
+
output_lines.append(Text(line[1:], style="green"))
|
|
105
|
+
elif line.startswith("?"):
|
|
106
|
+
line_to_rewrite = output_lines[-1].plain
|
|
107
|
+
output_lines[-1] = rewrite_line(line, line_to_rewrite, prev_marker)
|
|
108
|
+
else:
|
|
109
|
+
output_lines.append(Text(line[1:], style="#949494"))
|
|
110
|
+
prev_marker = line[0]
|
|
111
|
+
return output_lines
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class LLMDiff:
|
|
115
|
+
def __init__(self, llm_diff: str) -> None:
|
|
116
|
+
self.diff = llm_diff.splitlines()
|
|
117
|
+
|
|
118
|
+
def __rich_console__(
|
|
119
|
+
self, console: Console, options: ConsoleOptions
|
|
120
|
+
) -> RenderResult:
|
|
121
|
+
return llm_unified_diff(diff=self.diff)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Diff:
|
|
125
|
+
"""Constructs a Diff object to render diff-highlighted code."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
lhs: object,
|
|
130
|
+
rhs: object,
|
|
131
|
+
width: int,
|
|
132
|
+
show_symbols: bool = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.width = width
|
|
135
|
+
self.lhs = lhs if isinstance(lhs, str) else pprint.pformat(lhs, width=width)
|
|
136
|
+
self.rhs = rhs if isinstance(rhs, str) else pprint.pformat(rhs, width=width)
|
|
137
|
+
self.show_symbols = show_symbols
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def sides_are_different(self) -> bool:
|
|
141
|
+
return self.lhs != self.rhs
|
|
142
|
+
|
|
143
|
+
def raw_unified_diff(self) -> Iterator[str]:
|
|
144
|
+
differ = difflib.Differ()
|
|
145
|
+
lines_lhs = self.lhs.splitlines()
|
|
146
|
+
lines_rhs = self.rhs.splitlines()
|
|
147
|
+
return differ.compare(lines_lhs, lines_rhs)
|
|
148
|
+
|
|
149
|
+
def __rich_console__(
|
|
150
|
+
self, console: Console, options: ConsoleOptions
|
|
151
|
+
) -> RenderResult:
|
|
152
|
+
diff = self.raw_unified_diff()
|
|
153
|
+
if self.show_symbols:
|
|
154
|
+
result = build_symbolic_unified_diff(diff)
|
|
155
|
+
else:
|
|
156
|
+
result = build_unified_diff(diff)
|
|
157
|
+
yield from result
|