Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions agentstack/generation/files.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Optional, Union
import os, sys
import string
from pathlib import Path

if sys.version_info >= (3, 11):
Expand All @@ -9,18 +10,23 @@
from agentstack import conf


ENV_FILEMANE = ".env"
ENV_FILENAME = ".env"
PYPROJECT_FILENAME = "pyproject.toml"


class EnvFile:
"""
Interface for interacting with the .env file inside a project directory.
Unlike the ConfigFile, we do not re-write the entire file on every change,
and instead just append new lines to the end of the file. This preseres
and instead just append new lines to the end of the file. This preserves
comments and other formatting that the user may have added and prevents
opportunities for data loss.

If the value of a variable is None, it will be commented out when it is written
to the file. This gives the user a suggestion, but doesn't override values that
may have been set by the user via other means (for example, but the user's shell).
Commented variable are not re-parsed when the file is read.

`path` is the directory where the .env file is located. Defaults to the
current working directory.
`filename` is the name of the .env file, defaults to '.env'.
Expand All @@ -34,47 +40,59 @@ class EnvFile:

variables: dict[str, str]

def __init__(self, filename: str = ENV_FILEMANE):
def __init__(self, filename: str = ENV_FILENAME):
self._filename = filename
self.read()

def __getitem__(self, key):
def __getitem__(self, key) -> str:
return self.variables[key]

def __setitem__(self, key, value):
def __setitem__(self, key, value) -> None:
if key in self.variables:
raise ValueError("EnvFile does not allow overwriting values.")
self.append_if_new(key, value)

def __contains__(self, key) -> bool:
return key in self.variables

def append_if_new(self, key, value):
def append_if_new(self, key, value) -> None:
"""Setting a non-existent key will append it to the end of the file."""
if key not in self.variables:
self.variables[key] = value
self._new_variables[key] = value

def read(self):
def parse_line(line):
def read(self) -> None:
def parse_line(line) -> tuple[str, str]:
"""
Parse a line from the .env file.
Pairs are split on the first '=' character, and stripped of whitespace & quotes.
Only the last occurrence of a variable is stored.
"""
key, value = line.split('=')
return key.strip(), value.strip()
return key.strip(), value.strip(string.whitespace + '"')

if os.path.exists(conf.PATH / self._filename):
with open(conf.PATH / self._filename, 'r') as f:
self.variables = dict([parse_line(line) for line in f.readlines() if '=' in line])
self.variables = dict(
[parse_line(line) for line in f.readlines() if '=' in line and not line.startswith('#')]
)
else:
self.variables = {}
self._new_variables = {}

def write(self):
def write(self) -> None:
"""Append new variables to the end of the file."""
with open(conf.PATH / self._filename, 'a') as f:
for key, value in self._new_variables.items():
f.write(f"\n{key}={value}")
if value is None:
f.write(f'\n#{key}=""') # comment-out empty values
else:
f.write(f'\n{key}={value}')

def __enter__(self) -> 'EnvFile':
return self

def __exit__(self, *args):
def __exit__(self, *args) -> None:
self.write()


Expand Down
8 changes: 4 additions & 4 deletions agentstack/tools/agent_connect.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
"category": "network-protocols",
"packages": ["agent-connect"],
"env": {
"HOST_DOMAIN": "...",
"HOST_DOMAIN": null,
"HOST_PORT": 80,
"HOST_WS_PATH": "/ws",
"DID_DOCUMENT_PATH": "...",
"SSL_CERT_PATH": "...",
"SSL_KEY_PATH": "..."
"DID_DOCUMENT_PATH": null,
"SSL_CERT_PATH": null,
"SSL_KEY_PATH": null
},
"tools": ["send_message", "receive_message"]
}
4 changes: 2 additions & 2 deletions agentstack/tools/browserbase.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"category": "browsing",
"packages": ["browserbase", "playwright"],
"env": {
"BROWSERBASE_API_KEY": "...",
"BROWSERBASE_PROJECT_ID": "..."
"BROWSERBASE_API_KEY": null,
"BROWSERBASE_PROJECT_ID": null
},
"tools": ["browserbase"],
"cta": "Create an API key at https://www.browserbase.com/"
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/composio.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"category": "unified-apis",
"packages": ["composio-crewai"],
"env": {
"COMPOSIO_API_KEY": "..."
"COMPOSIO_API_KEY": null
},
"tools": ["composio_tools"],
"tools_bundled": true,
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/exa.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"category": "web-retrieval",
"packages": ["exa_py"],
"env": {
"EXA_API_KEY": "..."
"EXA_API_KEY": null
},
"tools": ["search_and_contents"],
"cta": "Get your Exa API key at https://dashboard.exa.ai/api-keys"
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/firecrawl.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"category": "browsing",
"packages": ["firecrawl-py"],
"env": {
"FIRECRAWL_API_KEY": "..."
"FIRECRAWL_API_KEY": null
},
"tools": ["web_scrape", "web_crawl", "retrieve_web_crawl"],
"cta": "Create an API key at https://www.firecrawl.dev/"
Expand Down
6 changes: 3 additions & 3 deletions agentstack/tools/ftp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"category": "computer-control",
"packages": [],
"env": {
"FTP_HOST": "...",
"FTP_USER": "...",
"FTP_PASSWORD": "..."
"FTP_HOST": null,
"FTP_USER": null,
"FTP_PASSWORD": null
},
"tools": ["upload_files"],
"cta": "Be sure to add your FTP credentials to .env"
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/mem0.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"category": "storage",
"packages": ["mem0ai"],
"env": {
"MEM0_API_KEY": "..."
"MEM0_API_KEY": null
},
"tools": ["write_to_memory", "read_from_memory"],
"cta": "Create your mem0 API key at https://mem0.ai/"
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/neon.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"url": "https://github.com/neondatabase/neon",
"packages": ["neon-api", "psycopg2-binary"],
"env": {
"NEON_API_KEY": "..."
"NEON_API_KEY": null
},
"tools": ["create_database", "execute_sql_ddl", "run_sql_query"],
"cta": "Create an API key at https://www.neon.tech"
Expand Down
2 changes: 1 addition & 1 deletion agentstack/tools/perplexity.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"url": "https://perplexity.ai",
"category": "search",
"env": {
"PERPLEXITY_API_KEY": "..."
"PERPLEXITY_API_KEY": null
},
"tools": ["query_perplexity"]
}
2 changes: 1 addition & 1 deletion agentstack/tools/stripe.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"category": "application-specific",
"packages": ["stripe-agent-toolkit", "stripe"],
"env": {
"STRIPE_SECRET_KEY": "sk-..."
"STRIPE_SECRET_KEY": null
},
"tools_bundled": true,
"tools": ["stripe_tools"],
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

ENV_VAR1=value1
ENV_VAR2=value2
ENV_VAR2=value_ignored
ENV_VAR2=value2
#ENV_VAR3=""
33 changes: 31 additions & 2 deletions tests/test_generation_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_read_env(self):
assert env["ENV_VAR1"] == "value1"
assert env["ENV_VAR2"] == "value2"
with self.assertRaises(KeyError) as _:
env["ENV_VAR3"]
env["ENV_VAR100"]

def test_write_env(self):
shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env")
Expand All @@ -103,4 +103,33 @@ def test_write_env(self):
env.append_if_new("ENV_VAR100", "value2") # Should be added

tmp_data = open(self.project_dir / ".env").read()
assert tmp_data == """\nENV_VAR1=value1\nENV_VAR2=value2\nENV_VAR100=value2"""
assert (
tmp_data
== """\nENV_VAR1=value1\nENV_VAR2=value_ignored\nENV_VAR2=value2\n#ENV_VAR3=""\nENV_VAR100=value2"""
)

def test_write_env_numeric_that_can_be_boolean(self):
shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env")

with EnvFile() as env:
env.append_if_new("ENV_VAR100", 0)
env.append_if_new("ENV_VAR101", 1)

env = EnvFile() # re-read the file
assert env.variables == {"ENV_VAR1": "value1", "ENV_VAR2": "value2", "ENV_VAR100": "0", "ENV_VAR101": "1"}

def test_write_env_commented(self):
"""We should be able to write a commented-out value."""
shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env")

with EnvFile() as env:
env.append_if_new("ENV_VAR3", "value3")

env = EnvFile() # re-read the file
assert env.variables == {"ENV_VAR1": "value1", "ENV_VAR2": "value2", "ENV_VAR3": "value3"}

tmp_file = open(self.project_dir / ".env").read()
assert (
tmp_file
== """\nENV_VAR1=value1\nENV_VAR2=value_ignored\nENV_VAR2=value2\n#ENV_VAR3=""\nENV_VAR3=value3"""
)
Loading