Skip to content

Commit 91663f3

Browse files
authored
Merge pull request #71 from tcdent/file-abstractions
File abstractions for project config and env
2 parents 3df0935 + d264c04 commit 91663f3

File tree

7 files changed

+247
-40
lines changed

7 files changed

+247
-40
lines changed

agentstack/generation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .agent_generation import generate_agent
22
from .task_generation import generate_task
33
from .tool_generation import add_tool, remove_tool
4+
from .files import ConfigFile, EnvFile, CONFIG_FILENAME

agentstack/generation/files.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from typing import Optional, Union
2+
import os
3+
import json
4+
from pathlib import Path
5+
from pydantic import BaseModel
6+
7+
8+
DEFAULT_FRAMEWORK = "crewai"
9+
CONFIG_FILENAME = "agentstack.json"
10+
ENV_FILEMANE = ".env"
11+
12+
class ConfigFile(BaseModel):
13+
"""
14+
Interface for interacting with the agentstack.json file inside a project directory.
15+
Handles both data validation and file I/O.
16+
17+
`path` is the directory where the agentstack.json file is located. Defaults
18+
to the current working directory.
19+
20+
Use it as a context manager to make and save edits:
21+
```python
22+
with ConfigFile() as config:
23+
config.tools.append('tool_name')
24+
```
25+
26+
Config Schema
27+
-------------
28+
framework: str
29+
The framework used in the project. Defaults to 'crewai'.
30+
tools: list[str]
31+
A list of tools that are currently installed in the project.
32+
telemetry_opt_out: Optional[bool]
33+
Whether the user has opted out of telemetry.
34+
"""
35+
framework: Optional[str] = DEFAULT_FRAMEWORK
36+
tools: list[str] = []
37+
telemetry_opt_out: Optional[bool] = None
38+
39+
def __init__(self, path: Union[str, Path, None] = None):
40+
path = Path(path) if path else Path.cwd()
41+
if os.path.exists(path / CONFIG_FILENAME):
42+
with open(path / CONFIG_FILENAME, 'r') as f:
43+
super().__init__(**json.loads(f.read()))
44+
else:
45+
raise FileNotFoundError(f"File {path / CONFIG_FILENAME} does not exist.")
46+
self._path = path # attribute needs to be set after init
47+
48+
def model_dump(self, *args, **kwargs) -> dict:
49+
# Ignore None values
50+
dump = super().model_dump(*args, **kwargs)
51+
return {key: value for key, value in dump.items() if value is not None}
52+
53+
def write(self):
54+
with open(self._path / CONFIG_FILENAME, 'w') as f:
55+
f.write(json.dumps(self.model_dump(), indent=4))
56+
57+
def __enter__(self) -> 'ConfigFile': return self
58+
def __exit__(self, *args): self.write()
59+
60+
61+
class EnvFile:
62+
"""
63+
Interface for interacting with the .env file inside a project directory.
64+
Unlike the ConfigFile, we do not re-write the entire file on every change,
65+
and instead just append new lines to the end of the file. This preseres
66+
comments and other formatting that the user may have added and prevents
67+
opportunities for data loss.
68+
69+
`path` is the directory where the .env file is located. Defaults to the
70+
current working directory.
71+
`filename` is the name of the .env file, defaults to '.env'.
72+
73+
Use it as a context manager to make and save edits:
74+
```python
75+
with EnvFile() as env:
76+
env.append_if_new('ENV_VAR', 'value')
77+
```
78+
"""
79+
variables: dict[str, str]
80+
81+
def __init__(self, path: Union[str, Path, None] = None, filename: str = ENV_FILEMANE):
82+
self._path = Path(path) if path else Path.cwd()
83+
self._filename = filename
84+
self.read()
85+
86+
def __getitem__(self, key):
87+
return self.variables[key]
88+
89+
def __setitem__(self, key, value):
90+
if key in self.variables:
91+
raise ValueError("EnvFile does not allow overwriting values.")
92+
self.append_if_new(key, value)
93+
94+
def __contains__(self, key) -> bool:
95+
return key in self.variables
96+
97+
def append_if_new(self, key, value):
98+
if not key in self.variables:
99+
self.variables[key] = value
100+
self._new_variables[key] = value
101+
102+
def read(self):
103+
def parse_line(line):
104+
key, value = line.split('=')
105+
return key.strip(), value.strip()
106+
107+
if os.path.exists(self._path / self._filename):
108+
with open(self._path / self._filename, 'r') as f:
109+
self.variables = dict([parse_line(line) for line in f.readlines() if '=' in line])
110+
else:
111+
self.variables = {}
112+
self._new_variables = {}
113+
114+
def write(self):
115+
with open(self._path / self._filename, 'a') as f:
116+
for key, value in self._new_variables.items():
117+
f.write(f"\n{key}={value}")
118+
119+
def __enter__(self) -> 'EnvFile': return self
120+
def __exit__(self, *args): self.write()
121+

agentstack/generation/tool_generation.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from pydantic import BaseModel, ValidationError
99

1010
from agentstack.utils import get_package_path
11+
from agentstack.generation.files import ConfigFile, EnvFile
1112
from .gen_utils import insert_code_after_tag, string_in_file
1213
from ..utils import open_json_file, get_framework, term_color
1314

1415

1516
TOOL_INIT_FILENAME = "src/tools/__init__.py"
16-
AGENTSTACK_JSON_FILENAME = "agentstack.json"
17+
1718
FRAMEWORK_FILENAMES: dict[str, str] = {
1819
'crewai': 'src/crew.py',
1920
}
@@ -87,9 +88,9 @@ def add_tool(tool_name: str, path: Optional[str] = None):
8788
path = './'
8889

8990
framework = get_framework(path)
90-
agentstack_json = open_json_file(f'{path}{AGENTSTACK_JSON_FILENAME}')
91+
agentstack_config = ConfigFile(path)
9192

92-
if tool_name in agentstack_json.get('tools', []):
93+
if tool_name in agentstack_config.tools:
9394
print(term_color(f'Tool {tool_name} is already installed', 'red'))
9495
sys.exit(1)
9596

@@ -100,27 +101,20 @@ def add_tool(tool_name: str, path: Optional[str] = None):
100101
shutil.copy(tool_file_path, f'{path}src/tools/{tool_name}_tool.py') # Move tool from package to project
101102
add_tool_to_tools_init(tool_data, path) # Export tool from tools dir
102103
add_tool_to_agent_definition(framework, tool_data, path) # Add tool to agent definition
103-
if tool_data.env: # if the env vars aren't in the .env files, add them
104-
# tool_data.env is a dict, key is the env var name, value is the value
105-
for var, value in tool_data.env.items():
106-
env_var = f'{var}={value}'
107-
if not string_in_file(f'{path}.env', env_var):
108-
insert_code_after_tag(f'{path}.env', '# Tools', [env_var, ])
109-
if not string_in_file(f'{path}.env.example', env_var):
110-
insert_code_after_tag(f'{path}.env.example', '# Tools', [env_var, ])
111104

112-
if tool_data.post_install:
113-
os.system(tool_data.post_install)
105+
if tool_data.env: # add environment variables which don't exist
106+
with EnvFile(path) as env:
107+
for var, value in tool_data.env.items():
108+
env.append_if_new(var, value)
109+
with EnvFile(path, filename=".env.example") as env:
110+
for var, value in tool_data.env.items():
111+
env.append_if_new(var, value)
114112

115113
if tool_data.post_install:
116114
os.system(tool_data.post_install)
117-
118-
if not agentstack_json.get('tools'):
119-
agentstack_json['tools'] = []
120-
agentstack_json['tools'].append(tool_name)
121115

122-
with open(f'{path}{AGENTSTACK_JSON_FILENAME}', 'w') as f:
123-
json.dump(agentstack_json, f, indent=4)
116+
with agentstack_config as config:
117+
config.tools.append(tool_name)
124118

125119
print(term_color(f'🔨 Tool {tool_name} added to agentstack project successfully', 'green'))
126120
if tool_data.cta:
@@ -133,9 +127,9 @@ def remove_tool(tool_name: str, path: Optional[str] = None):
133127
path = './'
134128

135129
framework = get_framework()
136-
agentstack_json = open_json_file(f'{path}{AGENTSTACK_JSON_FILENAME}')
130+
agentstack_config = ConfigFile(path)
137131

138-
if not tool_name in agentstack_json.get('tools', []):
132+
if not tool_name in agentstack_config.tools:
139133
print(term_color(f'Tool {tool_name} is not installed', 'red'))
140134
sys.exit(1)
141135

@@ -152,9 +146,8 @@ def remove_tool(tool_name: str, path: Optional[str] = None):
152146
os.system(tool_data.post_remove)
153147
# We don't remove the .env variables to preserve user data.
154148

155-
agentstack_json['tools'].remove(tool_name)
156-
with open(f'{path}{AGENTSTACK_JSON_FILENAME}', 'w') as f:
157-
json.dump(agentstack_json, f, indent=4)
149+
with agentstack_config as config:
150+
config.tools.remove(tool_name)
158151

159152
print(term_color(f'🔨 Tool {tool_name}', 'green'), term_color('removed', 'red'), term_color('from agentstack project successfully', 'green'))
160153

agentstack/utils.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from pathlib import Path
99
import importlib.resources
1010

11-
1211
def get_version():
1312
try:
1413
return version('agentstack')
@@ -17,8 +16,11 @@ def get_version():
1716
return "Unknown version"
1817

1918

20-
def verify_agentstack_project():
21-
if not os.path.isfile('agentstack.json'):
19+
def verify_agentstack_project(path: Optional[str] = None):
20+
from agentstack.generation import ConfigFile
21+
try:
22+
agentstack_config = ConfigFile(path)
23+
except FileNotFoundError:
2224
print("\033[31mAgentStack Error: This does not appear to be an AgentStack project."
2325
"\nPlease ensure you're at the root directory of your project and a file named agentstack.json exists. "
2426
"If you're starting a new project, run `agentstack init`\033[0m")
@@ -33,13 +35,10 @@ def get_package_path() -> Path:
3335

3436

3537
def get_framework(path: Optional[str] = None) -> str:
38+
from agentstack.generation import ConfigFile
3639
try:
37-
file_path = 'agentstack.json'
38-
if path is not None:
39-
file_path = path + '/' + file_path
40-
41-
agentstack_data = open_json_file(file_path)
42-
framework = agentstack_data.get('framework')
40+
agentstack_config = ConfigFile(path)
41+
framework = agentstack_config.framework
4342

4443
if framework.lower() not in ['crewai', 'autogen', 'litellm']:
4544
print(term_color("agentstack.json contains an invalid framework", "red"))
@@ -51,14 +50,10 @@ def get_framework(path: Optional[str] = None) -> str:
5150

5251

5352
def get_telemetry_opt_out(path: Optional[str] = None) -> str:
53+
from agentstack.generation import ConfigFile
5454
try:
55-
file_path = 'agentstack.json'
56-
if path is not None:
57-
file_path = path + '/' + file_path
58-
59-
agentstack_data = open_json_file(file_path)
60-
opt_out = agentstack_data.get('telemetry_opt_out', False)
61-
return opt_out
55+
agentstack_config = ConfigFile(path)
56+
return bool(agentstack_config.telemetry_opt_out)
6257
except FileNotFoundError:
6358
print("\033[31mFile agentstack.json does not exist. Are you in the right directory?\033[0m")
6459
sys.exit(1)

tests/fixtures/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
ENV_VAR1=value1
3+
ENV_VAR2=value2

tests/fixtures/agentstack.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"framework": "crewai",
3+
"tools": ["tool1", "tool2"]
4+
}

tests/test_generation_files.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import os, sys
2+
import unittest
3+
import importlib.resources
4+
from pathlib import Path
5+
import shutil
6+
from agentstack.generation.files import ConfigFile, EnvFile
7+
from agentstack.utils import verify_agentstack_project, get_framework, get_telemetry_opt_out
8+
9+
BASE_PATH = Path(__file__).parent
10+
11+
class GenerationFilesTest(unittest.TestCase):
12+
def test_read_config(self):
13+
config = ConfigFile(BASE_PATH / "fixtures") # + agentstack.json
14+
assert config.framework == "crewai"
15+
assert config.tools == ["tool1", "tool2"]
16+
assert config.telemetry_opt_out is None
17+
18+
def test_write_config(self):
19+
try:
20+
os.makedirs(BASE_PATH/"tmp", exist_ok=True)
21+
shutil.copy(BASE_PATH/"fixtures/agentstack.json",
22+
BASE_PATH/"tmp/agentstack.json")
23+
24+
with ConfigFile(BASE_PATH/"tmp") as config:
25+
config.framework = "crewai"
26+
config.tools = ["tool1", "tool2"]
27+
config.telemetry_opt_out = True
28+
29+
tmp_data = open(BASE_PATH/"tmp/agentstack.json").read()
30+
assert tmp_data == """{
31+
"framework": "crewai",
32+
"tools": [
33+
"tool1",
34+
"tool2"
35+
],
36+
"telemetry_opt_out": true
37+
}"""
38+
except Exception as e:
39+
raise e
40+
finally:
41+
os.remove(BASE_PATH / "tmp/agentstack.json")
42+
#os.rmdir(BASE_PATH / "tmp")
43+
44+
def test_read_missing_config(self):
45+
with self.assertRaises(FileNotFoundError) as context:
46+
config = ConfigFile(BASE_PATH / "missing")
47+
48+
def test_verify_agentstack_project_valid(self):
49+
verify_agentstack_project(BASE_PATH / "fixtures")
50+
51+
def test_verify_agentstack_project_invalid(self):
52+
with self.assertRaises(SystemExit) as context:
53+
verify_agentstack_project(BASE_PATH / "missing")
54+
55+
def test_get_framework(self):
56+
assert get_framework(BASE_PATH / "fixtures") == "crewai"
57+
with self.assertRaises(SystemExit) as context:
58+
get_framework(BASE_PATH / "missing")
59+
60+
def test_get_telemetry_opt_out(self):
61+
assert get_telemetry_opt_out(BASE_PATH / "fixtures") is False
62+
with self.assertRaises(SystemExit) as context:
63+
get_telemetry_opt_out(BASE_PATH / "missing")
64+
65+
def test_read_env(self):
66+
env = EnvFile(BASE_PATH / "fixtures")
67+
assert env.variables == {"ENV_VAR1": "value1", "ENV_VAR2": "value2"}
68+
assert env["ENV_VAR1"] == "value1"
69+
assert env["ENV_VAR2"] == "value2"
70+
with self.assertRaises(KeyError) as context:
71+
env["ENV_VAR3"]
72+
73+
def test_write_env(self):
74+
try:
75+
os.makedirs(BASE_PATH/"tmp", exist_ok=True)
76+
shutil.copy(BASE_PATH/"fixtures/.env",
77+
BASE_PATH/"tmp/.env")
78+
79+
with EnvFile(BASE_PATH/"tmp") as env:
80+
env.append_if_new("ENV_VAR1", "value100") # Should not be updated
81+
env.append_if_new("ENV_VAR100", "value2") # Should be added
82+
83+
tmp_data = open(BASE_PATH/"tmp/.env").read()
84+
assert tmp_data == """\nENV_VAR1=value1\nENV_VAR2=value2\nENV_VAR100=value2"""
85+
except Exception as e:
86+
raise e
87+
finally:
88+
os.remove(BASE_PATH / "tmp/.env")
89+
#os.rmdir(BASE_PATH / "tmp")
90+

0 commit comments

Comments
 (0)