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.

@@ -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