Skip to content

Commit

Permalink
Replace .netrc with uv environment variables
Browse files Browse the repository at this point in the history
This commit replaces .netrc with uv environment variables for installing
tools from private repositories. To store credentials, I created a new
and reusable settings file for the CLI in
`$HOME/.config/crewai/settings.json`.

The issue with .netrc files is that they are applied system-wide and are
scoped by hostname, meaning we can't differentiate tool repositories
requests from regular requests to CrewAI's API.
  • Loading branch information
vinibrsl committed Oct 31, 2024
1 parent ec2967c commit 903857e
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 19 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = [
"tomli-w>=1.1.0",
"chromadb>=0.4.24",
"tomli>=2.0.2",
"pytest-subprocess>=1.5.2",
]

[project.urls]
Expand Down
38 changes: 38 additions & 0 deletions src/crewai/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import json
from pathlib import Path
from pydantic import BaseModel, Field
from typing import Optional

DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json"

class Settings(BaseModel):
tool_repository_username: Optional[str] = Field(None, description="Username for interacting with the Tool Repository")
tool_repository_password: Optional[str] = Field(None, description="Password for interacting with the Tool Repository")
config_path: Path = Field(default=DEFAULT_CONFIG_PATH, exclude=True)

def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH, **data):
"""Load Settings from config path"""
config_path.parent.mkdir(parents=True, exist_ok=True)

file_data = {}
if config_path.is_file():
try:
with config_path.open("r") as f:
file_data = json.load(f)
except json.JSONDecodeError:
file_data = {}

merged_data = {**file_data, **data}
super().__init__(config_path=config_path, **merged_data)

def dump(self) -> None:
"""Save current settings to settings.json"""
if self.config_path.is_file():
with self.config_path.open("r") as f:
existing_data = json.load(f)
else:
existing_data = {}

updated_data = {**existing_data, **self.model_dump(exclude_unset=True)}
with self.config_path.open("w") as f:
json.dump(updated_data, f, indent=4)
40 changes: 21 additions & 19 deletions src/crewai/cli/tools/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import base64
import os
import platform
import subprocess
import tempfile
from pathlib import Path
from netrc import netrc
import stat

import click
from rich.console import Console

from crewai.cli import git
from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli.config import Settings
from crewai.cli.utils import (
get_project_description,
get_project_name,
Expand Down Expand Up @@ -153,26 +151,16 @@ def login(self):
raise SystemExit

login_response_json = login_response.json()
self._set_netrc_credentials(login_response_json["credential"])

settings = Settings()
settings.tool_repository_username = login_response_json["credential"]["username"]
settings.tool_repository_password = login_response_json["credential"]["password"]
settings.dump()

console.print(
"Successfully authenticated to the tool repository.", style="bold green"
)

def _set_netrc_credentials(self, credentials, netrc_path=None):
if not netrc_path:
netrc_filename = "_netrc" if platform.system() == "Windows" else ".netrc"
netrc_path = Path.home() / netrc_filename
netrc_path.touch(mode=stat.S_IRUSR | stat.S_IWUSR, exist_ok=True)

netrc_instance = netrc(file=netrc_path)
netrc_instance.hosts["app.crewai.com"] = (credentials["username"], "", credentials["password"])

with open(netrc_path, 'w') as file:
file.write(str(netrc_instance))

console.print(f"Added credentials to {netrc_path}", style="bold green")

def _add_package(self, tool_details):
tool_handle = tool_details["handle"]
repository_handle = tool_details["repository"]["handle"]
Expand All @@ -187,7 +175,11 @@ def _add_package(self, tool_details):
tool_handle,
]
add_package_result = subprocess.run(
add_package_command, capture_output=False, text=True, check=True
add_package_command,
capture_output=False,
env=self._build_env_with_credentials(repository_handle),
text=True,
check=True
)

if add_package_result.stderr:
Expand All @@ -206,3 +198,13 @@ def _ensure_not_in_project(self):
"[bold yellow]Tip:[/bold yellow] Navigate to a different directory and try again."
)
raise SystemExit

def _build_env_with_credentials(self, repository_handle: str):
repository_handle = repository_handle.upper().replace("-", "_")
settings = Settings()

env = os.environ.copy()
env[f"UV_INDEX_{repository_handle}_USERNAME"] = str(settings.tool_repository_username or "")
env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str(settings.tool_repository_password or "")

return env
109 changes: 109 additions & 0 deletions tests/cli/config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import unittest
import json
import tempfile
import shutil
from pathlib import Path
from crewai.cli.config import Settings

class TestSettings(unittest.TestCase):
def setUp(self):
self.test_dir = Path(tempfile.mkdtemp())
self.config_path = self.test_dir / "settings.json"

def tearDown(self):
shutil.rmtree(self.test_dir)

def test_empty_initialization(self):
settings = Settings(config_path=self.config_path)
self.assertIsNone(settings.tool_repository_username)
self.assertIsNone(settings.tool_repository_password)

def test_initialization_with_data(self):
settings = Settings(
config_path=self.config_path,
tool_repository_username="user1"
)
self.assertEqual(settings.tool_repository_username, "user1")
self.assertIsNone(settings.tool_repository_password)

def test_initialization_with_existing_file(self):
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with self.config_path.open("w") as f:
json.dump({"tool_repository_username": "file_user"}, f)

settings = Settings(config_path=self.config_path)
self.assertEqual(settings.tool_repository_username, "file_user")

def test_merge_file_and_input_data(self):
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with self.config_path.open("w") as f:
json.dump({
"tool_repository_username": "file_user",
"tool_repository_password": "file_pass"
}, f)

settings = Settings(
config_path=self.config_path,
tool_repository_username="new_user"
)
self.assertEqual(settings.tool_repository_username, "new_user")
self.assertEqual(settings.tool_repository_password, "file_pass")

def test_dump_new_settings(self):
settings = Settings(
config_path=self.config_path,
tool_repository_username="user1"
)
settings.dump()

with self.config_path.open("r") as f:
saved_data = json.load(f)

self.assertEqual(saved_data["tool_repository_username"], "user1")

def test_update_existing_settings(self):
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with self.config_path.open("w") as f:
json.dump({"existing_setting": "value"}, f)

settings = Settings(
config_path=self.config_path,
tool_repository_username="user1"
)
settings.dump()

with self.config_path.open("r") as f:
saved_data = json.load(f)

self.assertEqual(saved_data["existing_setting"], "value")
self.assertEqual(saved_data["tool_repository_username"], "user1")

def test_none_values(self):
settings = Settings(
config_path=self.config_path,
tool_repository_username=None
)
settings.dump()

with self.config_path.open("r") as f:
saved_data = json.load(f)

self.assertIsNone(saved_data.get("tool_repository_username"))

def test_invalid_json_in_config(self):
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with self.config_path.open("w") as f:
f.write("invalid json")

try:
settings = Settings(config_path=self.config_path)
self.assertIsNone(settings.tool_repository_username)
except json.JSONDecodeError:
self.fail("Settings initialization should handle invalid JSON")

def test_empty_config_file(self):
self.config_path.parent.mkdir(parents=True, exist_ok=True)
self.config_path.touch()

settings = Settings(config_path=self.config_path)
self.assertIsNone(settings.tool_repository_username)
1 change: 1 addition & 0 deletions tests/cli/tools/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_install_success(mock_get, mock_subprocess_run):
capture_output=False,
text=True,
check=True,
env=unittest.mock.ANY
)

assert "Succesfully installed sample-tool" in output
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 903857e

Please sign in to comment.