diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 243aa474..fba3c0c2 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,7 +1,9 @@ -from .cli import configure_default_model, welcome_message, get_validated_input +from .cli import configure_default_model, welcome_message, get_validated_input, parse_insertion_point from .init import init_project from .wizard import run_wizard from .run import run_project -from .tools import list_tools, add_tool +from .tools import list_tools, add_tool, remove_tool +from .tasks import add_task +from .agents import add_agent from .templates import insert_template, export_template diff --git a/agentstack/cli/agents.py b/agentstack/cli/agents.py new file mode 100644 index 00000000..bb904fd4 --- /dev/null +++ b/agentstack/cli/agents.py @@ -0,0 +1,34 @@ +from typing import Optional +from agentstack import conf +from agentstack import repo +from agentstack.cli import configure_default_model, parse_insertion_point +from agentstack import generation + + +def add_agent( + name: str, + role: Optional[str] = None, + goal: Optional[str] = None, + backstory: Optional[str] = None, + llm: Optional[str] = None, + position: Optional[str] = None, +): + """ + Add an agent to the user's project. + """ + conf.assert_project() + if not llm: + configure_default_model() + _position = parse_insertion_point(position) + + repo.commit_user_changes() + with repo.Transaction() as commit: + commit.add_message(f"Added agent {name}") + generation.add_agent( + name=name, + role=role, + goal=goal, + backstory=backstory, + llm=llm, + position=_position, + ) diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index a1ce51ff..a40e83ff 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -1,3 +1,4 @@ +from typing import Optional import os, sys from art import text2art import inquirer @@ -5,6 +6,7 @@ from agentstack.conf import ConfigFile from agentstack.exceptions import ValidationError from agentstack.utils import validator_not_empty, is_snake_case +from agentstack.generation import InsertionPoint PREFERRED_MODELS = [ @@ -78,3 +80,17 @@ def get_validated_input( raise ValidationError("Input must be in snake_case") return value + +def parse_insertion_point(position: Optional[str] = None) -> Optional[InsertionPoint]: + """ + Parse an insertion point CLI argument into an InsertionPoint enum. + """ + if position is None: + return None # defer assumptions + + valid_positions = {x.value for x in InsertionPoint} + if position not in valid_positions: + raise ValueError(f"Position must be one of {','.join(valid_positions)}.") + + return next(x for x in InsertionPoint if x.value == position) + diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index 2ef8e1c1..21864ea5 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -10,6 +10,7 @@ from agentstack import packaging from agentstack import frameworks from agentstack import generation +from agentstack import repo from agentstack.proj_templates import get_all_templates, TemplateConfig from agentstack.cli import welcome_message @@ -127,18 +128,23 @@ def init_project( packaging.create_venv() log.info("Installing dependencies...") packaging.install_project() + repo.init() # initialize git repo # now we can interact with the project and add Agents, Tasks, and Tools # we allow dependencies to be installed along with these, so the project must # be fully initialized first. - for task in template_data.tasks: - generation.add_task(**task.model_dump()) - - for agent in template_data.agents: - generation.add_agent(**agent.model_dump()) - - for tool in template_data.tools: - generation.add_tool(**tool.model_dump()) + with repo.Transaction() as commit: + for task in template_data.tasks: + commit.add_message(f"Added task {task.name}") + generation.add_task(**task.model_dump()) + + for agent in template_data.agents: + commit.add_message(f"Added agent {agent.name}") + generation.add_agent(**agent.model_dump()) + + for tool in template_data.tools: + commit.add_message(f"Added tool {tool.name}") + generation.add_tool(**tool.model_dump()) log.success("🚀 AgentStack project generated successfully!\n") log.info( diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index fa82d4d2..735dd5fd 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -95,6 +95,7 @@ def _import_project_module(path: Path): def run_project(command: str = 'run', cli_args: Optional[List[str]] = None): """Validate that the project is ready to run and then run it.""" + conf.assert_project() verify_agentstack_project() if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: diff --git a/agentstack/cli/tasks.py b/agentstack/cli/tasks.py new file mode 100644 index 00000000..3cff7752 --- /dev/null +++ b/agentstack/cli/tasks.py @@ -0,0 +1,30 @@ +from typing import Optional +from agentstack import conf +from agentstack import repo +from agentstack.cli import parse_insertion_point +from agentstack import generation + + +def add_task( + name: str, + description: Optional[str] = None, + expected_output: Optional[str] = None, + agent: Optional[str] = None, + position: Optional[str] = None, +): + """ + Add a task to the user's project. + """ + conf.assert_project() + _position = parse_insertion_point(position) + + repo.commit_user_changes() + with repo.Transaction() as commit: + commit.add_message(f"Added task {name}") + generation.add_task( + name=name, + description=description, + expected_output=expected_output, + agent=agent, + position=_position, + ) diff --git a/agentstack/cli/templates.py b/agentstack/cli/templates.py index 778c7b22..0cf2f6d5 100644 --- a/agentstack/cli/templates.py +++ b/agentstack/cli/templates.py @@ -69,6 +69,8 @@ def export_template(output_filename: str): """ Export the current project as a template. """ + conf.assert_project() + try: metadata = ProjectFile() except Exception as e: diff --git a/agentstack/cli/tools.py b/agentstack/cli/tools.py index 6b892857..4ad552ff 100644 --- a/agentstack/cli/tools.py +++ b/agentstack/cli/tools.py @@ -1,8 +1,10 @@ from typing import Optional import itertools import inquirer +from agentstack import conf from agentstack.utils import term_color from agentstack import generation +from agentstack import repo from agentstack._tools import get_all_tools from agentstack.agents import get_all_agents @@ -43,6 +45,8 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]): - add the tool to the user's project - add the tool to the specified agents or all agents if none are specified """ + conf.assert_project() + if not tool_name: # ask the user for the tool name tools_list = [ @@ -71,4 +75,21 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]): return # user cancelled the prompt assert tool_name # appease type checker - generation.add_tool(tool_name, agents=agents) + + repo.commit_user_changes() + with repo.Transaction() as commit: + commit.add_message(f"Added tool {tool_name}") + generation.add_tool(tool_name, agents=agents) + + +def remove_tool(tool_name: str): + """ + Remove a tool from the user's project. + """ + conf.assert_project() + + repo.commit_user_changes() + with repo.Transaction() as commit: + commit.add_message(f"Removed tool {tool_name}") + generation.remove_tool(tool_name) + diff --git a/agentstack/conf.py b/agentstack/conf.py index 4411aab4..64319f4b 100644 --- a/agentstack/conf.py +++ b/agentstack/conf.py @@ -14,6 +14,7 @@ # The path to the project directory ie. working directory. PATH: Path = Path() + def assert_project() -> None: try: ConfigFile() @@ -21,6 +22,7 @@ def assert_project() -> None: except FileNotFoundError: raise Exception("Could not find agentstack.json, are you in an AgentStack project directory?") + def set_path(path: Union[str, Path, None]): """Set the path to the project directory.""" global PATH @@ -83,6 +85,8 @@ class ConfigFile(BaseModel): The template used to generate the project. template_version: Optional[str] The version of the template system used to generate the project. + use_git: Optional[bool] + Whether to use git for automatic commits of you project. """ framework: str = DEFAULT_FRAMEWORK # TODO this should probably default to None @@ -92,6 +96,7 @@ class ConfigFile(BaseModel): agentstack_version: Optional[str] = get_version() template: Optional[str] = None template_version: Optional[str] = None + use_git: Optional[bool] = True def __init__(self): if os.path.exists(PATH / CONFIG_FILENAME): diff --git a/agentstack/generation/__init__.py b/agentstack/generation/__init__.py index b59d2c2c..ff4fba02 100644 --- a/agentstack/generation/__init__.py +++ b/agentstack/generation/__init__.py @@ -1,5 +1,16 @@ -from .gen_utils import InsertionPoint, parse_insertion_point +from enum import Enum from .agent_generation import add_agent from .task_generation import add_task from .tool_generation import add_tool, remove_tool from .files import EnvFile, ProjectFile + + +class InsertionPoint(Enum): + """ + Enum for specifying where to insert generated code. + """ + + BEGIN = 'begin' + END = 'end' + + diff --git a/agentstack/generation/agent_generation.py b/agentstack/generation/agent_generation.py index 20b3abb5..bfd38241 100644 --- a/agentstack/generation/agent_generation.py +++ b/agentstack/generation/agent_generation.py @@ -1,13 +1,15 @@ import sys -from typing import Optional +from typing import TYPE_CHECKING, Optional from agentstack import log from agentstack.exceptions import ValidationError from agentstack.conf import ConfigFile -from agentstack.generation import parse_insertion_point from agentstack import frameworks from agentstack.utils import verify_agentstack_project from agentstack.agents import AgentConfig, AGENTS_FILENAME +if TYPE_CHECKING: + from agentstack.generation import InsertionPoint + def add_agent( name: str, @@ -16,7 +18,7 @@ def add_agent( backstory: Optional[str] = None, llm: Optional[str] = None, allow_delegation: Optional[bool] = None, - position: Optional[str] = None, + position: Optional['InsertionPoint'] = None, ): agentstack_config = ConfigFile() verify_agentstack_project() @@ -31,9 +33,8 @@ def add_agent( if allow_delegation: log.warning("Agent allow_delegation is not implemented.") - _position = parse_insertion_point(position) try: - frameworks.add_agent(agent, _position) + frameworks.add_agent(agent, position) except ValidationError as e: raise ValidationError(f"Error adding agent to project:\n{e}") diff --git a/agentstack/generation/gen_utils.py b/agentstack/generation/gen_utils.py deleted file mode 100644 index ec2a60bf..00000000 --- a/agentstack/generation/gen_utils.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Optional -from enum import Enum -import ast - - -class InsertionPoint(Enum): - """ - Enum for specifying where to insert generated code. - """ - - BEGIN = 'begin' - END = 'end' - - -def insert_code_after_tag(file_path, tag, code_to_insert, next_line=False): - if next_line: - code_to_insert = ['\n'] + code_to_insert - - with open(file_path, 'r') as file: - lines = file.readlines() - - for index, line in enumerate(lines): - if tag in line: - # Insert the code block after the tag - indented_code = [ - (line[: len(line) - len(line.lstrip())] + code_line + '\n') for code_line in code_to_insert - ] - lines[index + 1 : index + 1] = indented_code - break - else: - raise ValueError(f"Tag '{tag}' not found in the file.") - - with open(file_path, 'w') as file: - file.writelines(lines) - - -def insert_after_tasks(file_path, code_to_insert): - with open(file_path, 'r') as file: - content = file.read() - - module = ast.parse(content) - - # Track the last task function and its line number - last_task_end = None - last_task_start = None - for node in ast.walk(module): - if isinstance(node, ast.FunctionDef) and any( - isinstance(deco, ast.Name) and deco.id == 'task' for deco in node.decorator_list - ): - last_task_end = node.end_lineno - last_task_start = node.lineno - - if last_task_end is not None: - lines = content.split('\n') - - # Get the indentation of the task function - task_line = lines[last_task_start - 1] # -1 for 0-based indexing - indentation = '' - for char in task_line: - if char in [' ', '\t']: - indentation += char - else: - break - - # Add the same indentation to each line of the inserted code - indented_code = '\n' + '\n'.join(indentation + line for line in code_to_insert) - - lines.insert(last_task_end, indented_code) - content = '\n'.join(lines) - - with open(file_path, 'w') as file: - file.write(content) - return True - else: - insert_code_after_tag(file_path, '# Task definitions', code_to_insert) - - -def string_in_file(file_path: str, str_to_match: str) -> bool: - with open(file_path, 'r') as file: - file_content = file.read() - return str_to_match in file_content - - -def parse_insertion_point(position: Optional[str] = None) -> Optional[InsertionPoint]: - """ - Parse an insertion point CLI argument into an InsertionPoint enum. - """ - if position is None: - return None # defer assumptions - - valid_positions = {x.value for x in InsertionPoint} - if position not in valid_positions: - raise ValueError(f"Position must be one of {','.join(valid_positions)}.") - - return next(x for x in InsertionPoint if x.value == position) diff --git a/agentstack/generation/task_generation.py b/agentstack/generation/task_generation.py index b0431929..31614f38 100644 --- a/agentstack/generation/task_generation.py +++ b/agentstack/generation/task_generation.py @@ -1,19 +1,21 @@ -from typing import Optional +from typing import TYPE_CHECKING, Optional from pathlib import Path from agentstack import log from agentstack.exceptions import ValidationError -from agentstack.generation import parse_insertion_point from agentstack import frameworks from agentstack.utils import verify_agentstack_project from agentstack.tasks import TaskConfig, TASKS_FILENAME +if TYPE_CHECKING: + from agentstack.generation import InsertionPoint + def add_task( name: str, description: Optional[str] = None, expected_output: Optional[str] = None, agent: Optional[str] = None, - position: Optional[str] = None, + position: Optional['InsertionPoint'] = None, ): verify_agentstack_project() @@ -28,10 +30,10 @@ def add_task( config.expected_output = expected_output or "Add your expected_output here" config.agent = agent or "agent_name" - _position = parse_insertion_point(position) try: - frameworks.add_task(task, _position) + frameworks.add_task(task, position) except ValidationError as e: raise ValidationError(f"Error adding task to project:\n{e}") log.success(f"📃 Added task \"{task.name}\" to your AgentStack project successfully!") + diff --git a/agentstack/log.py b/agentstack/log.py index af3ca697..9ee0a3d6 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -156,14 +156,20 @@ def _build_logger() -> logging.Logger: # global stdout, stderr log = logging.getLogger(LOG_NAME) + log.handlers.clear() # remove any existing handlers log.propagate = False # prevent inheritance from the root logger # min log level set here cascades to all handlers log.setLevel(DEBUG if conf.DEBUG else INFO) try: # `conf.PATH` can change during startup, so defer building the path - # log file only gets written to if it exists, which happens on project init log_filename = conf.PATH / LOG_FILENAME + + # log file only gets written to if it exists, which happens on project init + # this prevents us from littering log files outside of project directories + if not log_filename.exists(): + raise FileNotFoundError + file_handler = logging.FileHandler(log_filename) file_handler.setFormatter(FileFormatter()) file_handler.setLevel(DEBUG) diff --git a/agentstack/main.py b/agentstack/main.py index 9715822b..3139a9a5 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -6,15 +6,18 @@ from agentstack import auth from agentstack.cli import ( init_project, - add_tool, list_tools, - configure_default_model, + add_tool, + remove_tool, + add_agent, + add_task, run_project, export_template, ) from agentstack.telemetry import track_cli_command, update_telemetry from agentstack.utils import get_version, term_color from agentstack import generation +from agentstack import repo from agentstack.update import check_for_updates @@ -32,6 +35,12 @@ def _main(): dest="debug", action="store_true", ) + global_parser.add_argument( + "--no-git", + help="Disable automatic git commits of changes to your project.", + dest="no_git", + action="store_true", + ) parser = argparse.ArgumentParser( parents=[global_parser], description="AgentStack CLI - The easiest way to build an agent application" @@ -155,6 +164,10 @@ def _main(): # Set the debug flag conf.set_debug(args.debug) + # --no-git flag disables automatic git commits + if args.no_git: + repo.dont_track_changes() + # Handle version if args.version: log.info(f"AgentStack CLI version: {get_version()}") @@ -178,13 +191,11 @@ def _main(): if args.tools_command in ["list", "l"]: list_tools() elif args.tools_command in ["add", "a"]: - conf.assert_project() agents = [args.agent] if args.agent else None agents = args.agents.split(",") if args.agents else agents add_tool(args.name, agents) elif args.tools_command in ["remove", "r"]: - conf.assert_project() - generation.remove_tool(args.name) + remove_tool(args.name) else: tools_parser.print_help() elif args.command in ['login']: @@ -194,22 +205,28 @@ def _main(): # inside project dir commands only elif args.command in ["run", "r"]: - conf.assert_project() run_project(command=args.function, cli_args=extra_args) elif args.command in ['generate', 'g']: - conf.assert_project() if args.generate_command in ['agent', 'a']: - if not args.llm: - configure_default_model() - generation.add_agent(args.name, args.role, args.goal, args.backstory, args.llm, args.position) + add_agent( + name=args.name, + role=args.role, + goal=args.goal, + backstory=args.backstory, + llm=args.llm, + position=args.position, + ) elif args.generate_command in ['task', 't']: - generation.add_task( - args.name, args.description, args.expected_output, args.agent, args.position + add_task( + name=args.name, + description=args.description, + expected_output=args.expected_output, + agent=args.agent, + position=args.position, ) else: generate_parser.print_help() elif args.command in ['export', 'e']: - conf.assert_project() export_template(args.filename) else: parser.print_help() diff --git a/agentstack/repo.py b/agentstack/repo.py new file mode 100644 index 00000000..e550041a --- /dev/null +++ b/agentstack/repo.py @@ -0,0 +1,192 @@ +import shutil +import git +from agentstack import conf, log +from agentstack.exceptions import EnvironmentError + + +MAIN_BRANCH_NAME = "main" + +AUTOMATION_NOTE = "\n\n(This commit was made automatically by AgentStack)" +INITIAL_COMMIT_MESSAGE = "Initial commit." +USER_CHANGES_COMMIT_MESSAGE = "Adding user changes before modifying project." + +_USE_GIT = None # global state to disable git for this run + + +def should_track_changes() -> bool: + """ + If git has been disabled for this run, return False. Next, look for the value + defined in agentstack.json. Finally, default to True. + """ + global _USE_GIT + + if _USE_GIT is not None: + return _USE_GIT + + try: + return conf.ConfigFile().use_git is not False + except FileNotFoundError: + return True + + +def dont_track_changes() -> None: + """ + Disable git tracking for one run. + """ + global _USE_GIT + + _USE_GIT = False + + +class TrackingDisabledError(EnvironmentError): + """ + Raised when git is disabled for this run. + Subclasses `EnvironmentError` so we can early exit using the same logic. + """ + + pass + + +class Transaction: + """ + A transaction for committing changes to a git repository. + + Use as a context manager: + ``` + with Transaction() as transaction: + Path('foo').touch() + transaction.add_message("Created foo") + ``` + Changes will be committed automatically on exit. + """ + + automated: bool + messages: list[str] + + def __init__(self, automated: bool = True) -> None: + self.automated = automated + self.messages = [] + + def commit(self) -> None: + """Commit all changes to the repository.""" + commit_all_changes(', '.join(self.messages), automated=self.automated) + self.messages = [] + + def add_message(self, message: str) -> None: + """Add a message to the commit.""" + self.messages.append(message) + + def __enter__(self) -> 'Transaction': + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if exc_type is not None: + log.error(f"git transaction was not completed due to an Exception") + return + self.commit() + + +def _require_git(): + """ + Raise an EnvironmentError if git is not installed, raise a TrackingDisabledError + if git tracking is disabled. + """ + if not should_track_changes(): + raise TrackingDisabledError("Git tracking is disabled by the user.") + + try: + assert shutil.which('git') + except AssertionError: + message = "git is not installed.\nInstall it to track changes to files in your project." + if shutil.which('apt'): + message += "\nHint: run `sudo apt install git`" + elif shutil.which('brew'): + message += "\nHint: run `brew install git`" + elif shutil.which('port'): + message += "\nHint: run `sudo port install git`" + log.warning(message) # log now since this won't bubble to the user + raise EnvironmentError(message) + + +def _get_repo() -> git.Repo: + """ + Get the git repository for the current project. + Raises: + - `TrackingDisabledError` if git tracking is disabled. + - `EnvironmentError` if git is not installed. + - `EnvironmentError` if the repo is not found. + """ + _require_git() + try: + return git.Repo(conf.PATH.absolute()) + except git.exc.InvalidGitRepositoryError: + message = "No git repository found in the current project." + log.warning(message) # log now since this won't bubble to the user + raise EnvironmentError(message) + + +def init() -> None: + """ + Create a git repository for the current project and commit a .gitignore file + to initialize the repo. Assumes that a repo does not already exist. + """ + try: + _require_git() + except EnvironmentError as e: + return # git is not installed or tracking is disabled + + # creates a new repo at conf.PATH / '.git + repo = git.Repo.init(path=conf.PATH.absolute(), initial_branch=MAIN_BRANCH_NAME) + + # commit gitignore first so we don't add untracked files + gitignore = conf.PATH.absolute() / '.gitignore' + gitignore.touch() + + commit(INITIAL_COMMIT_MESSAGE, [str(gitignore)], automated=True) + + +def commit(message: str, files: list[str], automated: bool = True) -> None: + """ + Commit the given files to the current project with the given message. + Include AUTOMATION_NOTE in the commit message if `automated` is `True`. + """ + try: + repo = _get_repo() + except EnvironmentError as e: + return # git is not installed or tracking is disabled + + log.debug(f"Committing {len(files)} changed files") + repo.index.add(files) + repo.index.commit(message + (AUTOMATION_NOTE if automated else '')) + + +def commit_all_changes(message: str, automated: bool = True) -> None: + """ + Commit all changes to the current project with the given message. + Include AUTOMATION_NOTE in the commit message if `automated` is `True`. + """ + changed_files = get_uncommitted_files() + if len(changed_files): + return commit(message, changed_files, automated=automated) + + +def commit_user_changes(automated: bool = True) -> None: + """ + Commit any changes to the current repo as user changes. + Include AUTOMATION_NOTE in the commit message if `automated` is `True`. + """ + commit_all_changes(USER_CHANGES_COMMIT_MESSAGE, automated=automated) + + +def get_uncommitted_files() -> list[str]: + """ + Get a list of all files that have been modified since the last commit. + """ + try: + repo = _get_repo() + except EnvironmentError as e: + return [] # git is not installed or tracking is disabled + + untracked = repo.untracked_files + modified = [item.a_path for item in repo.index.diff(None) if item.a_path] + return untracked + modified diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/.gitignore b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/.gitignore index 8ce42678..7105da50 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/.gitignore +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/.gitignore @@ -162,3 +162,5 @@ cython_debug/ #.idea/ .agentops/ +agentstack.log +.agentstack* \ No newline at end of file diff --git a/agentstack/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/.gitignore b/agentstack/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/.gitignore index 8ce42678..7105da50 100644 --- a/agentstack/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/.gitignore +++ b/agentstack/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/.gitignore @@ -162,3 +162,5 @@ cython_debug/ #.idea/ .agentops/ +agentstack.log +.agentstack* \ No newline at end of file diff --git a/agentstack/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/.gitignore b/agentstack/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/.gitignore index 8ce42678..7105da50 100644 --- a/agentstack/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/.gitignore +++ b/agentstack/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/.gitignore @@ -162,3 +162,5 @@ cython_debug/ #.idea/ .agentops/ +agentstack.log +.agentstack* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 48eefdf6..b11c07c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "appdirs>=1.4.4", "python-dotenv>=1.0.1", "uv>=0.5.6", - "tomli>=2.2.1" + "tomli>=2.2.1", + "gitpython>=3.1.44", ] [project.optional-dependencies] diff --git a/tests/test_agents_config.py b/tests/test_agents_config.py index 8345ec2d..2b5a9780 100644 --- a/tests/test_agents_config.py +++ b/tests/test_agents_config.py @@ -21,7 +21,10 @@ class AgentConfigTest(unittest.TestCase): def setUp(self): - self.project_dir = BASE_PATH / 'tmp/agent_config' + self.framework = os.getenv('TEST_FRAMEWORK') + self.project_dir = BASE_PATH / 'tmp' / self.framework / 'test_agents_config' + os.makedirs(self.project_dir) + conf.set_path(self.project_dir) os.makedirs(self.project_dir / 'src/config') diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index 0be94e69..9b2e6b53 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -10,7 +10,8 @@ class CLIInitTest(unittest.TestCase): def setUp(self): - self.project_dir = Path(BASE_PATH / 'tmp/cli_init') + self.framework = os.getenv('TEST_FRAMEWORK') + self.project_dir = Path(BASE_PATH / 'tmp' / self.framework / 'test_cli_init') os.chdir(BASE_PATH) # Change to parent directory first os.makedirs(self.project_dir, exist_ok=True) os.chdir(self.project_dir) diff --git a/tests/test_generation_files.py b/tests/test_generation_files.py index 2f1ba4f3..a400a637 100644 --- a/tests/test_generation_files.py +++ b/tests/test_generation_files.py @@ -37,6 +37,7 @@ def test_read_config(self): assert config.agentstack_version == get_version() assert config.template is None assert config.template_version is None + assert config.use_git is True def test_write_config(self): with ConfigFile() as config: @@ -47,6 +48,7 @@ def test_write_config(self): config.agentstack_version = "0.2.1" config.template = "default" config.template_version = "1" + config.use_git = False tmp_data = open(self.project_dir / "agentstack.json").read() assert ( @@ -61,7 +63,8 @@ def test_write_config(self): "default_model": "openai/gpt-4o", "agentstack_version": "0.2.1", "template": "default", - "template_version": "1" + "template_version": "1", + "use_git": false }""" ) diff --git a/tests/test_log.py b/tests/test_log.py index 8db42bbb..25316f5f 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -16,9 +16,9 @@ def setUp(self): self.test_dir = BASE_PATH / 'tmp' / self.framework / 'test_log' self.test_dir.mkdir(parents=True, exist_ok=True) - # Set log file to test directory - self.test_log_file = self.test_dir / 'test.log' - log.LOG_FILENAME = self.test_log_file + conf.set_path(self.test_dir) + self.test_log_file = (self.test_dir / log.LOG_FILENAME) + self.test_log_file.touch() # Create string IO objects to capture stdout/stderr self.stdout = io.StringIO() @@ -30,13 +30,7 @@ def setUp(self): log.set_stderr(self.stderr) def tearDown(self): - # Clean up test directory - if self.test_dir.exists(): - shutil.rmtree(self.test_dir) - - # Clear string IO buffers - self.stdout.close() - self.stderr.close() + shutil.rmtree(self.test_dir) def test_debug_message(self): log.debug("Debug message") @@ -96,6 +90,18 @@ def test_stream_redirection(self): self.assertIn("Test stdout", new_stdout.getvalue()) self.assertIn("Test stderr", new_stderr.getvalue()) + def test_doesnt_create_file_when_missing(self): + # Delete log file if exists + if self.test_log_file.exists(): + self.test_log_file.unlink() + + # Test with missing log file + log.instance = None + log.info("Test missing log file") + self.assertFalse(self.test_log_file.exists()) + self.assertIn("Test missing log file", self.stdout.getvalue()) + self.assertFalse(self.test_log_file.exists()) + def test_debug_level_config(self): # Test with debug disabled conf.set_debug(False) @@ -109,17 +115,6 @@ def test_debug_level_config(self): log.debug("Visible debug") self.assertIn("Visible debug", self.stdout.getvalue()) - def test_log_file_creation(self): - # Delete log file if exists - if self.test_log_file.exists(): - self.test_log_file.unlink() - - # First log should create file - self.assertFalse(self.test_log_file.exists()) - log.info("Create log file") - self.assertTrue(self.test_log_file.exists()) - self.assertIn("Create log file", self.test_log_file.read_text()) - def test_debug_mode_filtering(self): # Test with debug mode off conf.set_debug(False) diff --git a/tests/test_repo.py b/tests/test_repo.py new file mode 100644 index 00000000..63bc88fd --- /dev/null +++ b/tests/test_repo.py @@ -0,0 +1,255 @@ +import os, sys +import shutil +from pathlib import Path +import unittest +from parameterized import parameterized +from unittest.mock import patch, MagicMock +from agentstack import conf +from agentstack import repo +from agentstack.repo import TrackingDisabledError +from agentstack.exceptions import EnvironmentError +import git + + + +BASE_PATH = Path(__file__).parent + + +class TestRepo(unittest.TestCase): + def setUp(self): + self.framework = os.getenv('TEST_FRAMEWORK') + self.test_dir = BASE_PATH / 'tmp' / self.framework / 'test_repo' + os.makedirs(self.test_dir) + os.chdir(self.test_dir) # gitpython needs a cwd + + conf.set_path(self.test_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_init(self): + repo.init() + + # Check if a git repository was created + self.assertTrue((self.test_dir / '.git').is_dir()) + # Check if the repository has the correct initial branch + git_repo = git.Repo(self.test_dir) + self.assertEqual(git_repo.active_branch.name, repo.MAIN_BRANCH_NAME) + # Check if an initial commit was made + commits = list(git_repo.iter_commits()) + self.assertEqual(len(commits), 1) + self.assertEqual(commits[0].message, f"{repo.INITIAL_COMMIT_MESSAGE}{repo.AUTOMATION_NOTE}") + + def test_get_repo_nonexistent(self): + with self.assertRaises(EnvironmentError): + repo._get_repo() + + def test_get_repo_existent(self): + repo.init() + + result = repo._get_repo() + self.assertIsInstance(result, git.Repo) + self.assertEqual(result.working_tree_dir, str(self.test_dir)) + + def test_get_uncommitted_files_new_file(self): + repo.init() + + new_file = self.test_dir / "new_file.txt" + new_file.touch() + + # Check if the new file is in the list + uncommitted = repo.get_uncommitted_files() + self.assertIn("new_file.txt", uncommitted) + + def test_get_uncommitted_files_modified_file(self): + repo.init() + + # Create and commit an initial file + initial_file = self.test_dir / "initial_file.txt" + initial_file.write_text("Initial content") + + with repo.Transaction() as transaction: + transaction.add_message("Add initial file") + + # Modify the file + initial_file.write_text("Modified content") + + # Check if the modified file is in the list + uncommitted = repo.get_uncommitted_files() + self.assertIn("initial_file.txt", uncommitted) + + @patch('agentstack.repo.conf.ConfigFile') + def test_should_track_changes_default(self, mock_config_file): + mock_config = MagicMock() + mock_config.use_git = True + mock_config_file.return_value = mock_config + + self.assertTrue(repo.should_track_changes()) + + @patch('agentstack.repo.conf.ConfigFile') + def test_should_track_changes_disabled(self, mock_config_file): + mock_config = MagicMock() + mock_config.use_git = False + mock_config_file.return_value = mock_config + + self.assertFalse(repo.should_track_changes()) + + @patch('agentstack.repo.conf.ConfigFile') + def test_should_track_changes_file_not_found(self, mock_config_file): + mock_config_file.side_effect = FileNotFoundError + + self.assertTrue(repo.should_track_changes()) + + def test_dont_track_changes(self): + # Ensure tracking is enabled initially + repo._USE_GIT = None + self.assertTrue(repo.should_track_changes()) + + # Disable tracking + repo.dont_track_changes() + self.assertFalse(repo.should_track_changes()) + + # Reset _USE_GIT for other tests + repo._USE_GIT = None + + @patch('agentstack.repo.should_track_changes', return_value=False) + def test_require_git_when_disabled(self, mock_should_track): + with self.assertRaises(TrackingDisabledError): + repo._require_git() + + def test_require_git_when_disabled_manually(self): + # Disable git tracking + repo.dont_track_changes() + + with self.assertRaises(repo.TrackingDisabledError): + repo._require_git() + + # Reset _USE_GIT for other tests + repo._USE_GIT = None + + @parameterized.expand([ + ("apt", "/usr/bin/apt", "Hint: run `sudo apt install git`"), + ("brew", "/usr/local/bin/brew", "Hint: run `brew install git`"), + ("port", "/opt/local/bin/port", "Hint: run `sudo port install git`"), + ("none", None, ""), + ]) + @patch('agentstack.repo.should_track_changes', return_value=True) + @patch('agentstack.repo.shutil.which') + def test_require_git_not_installed(self, name, package_manager_path, expected_hint, mock_which, mock_should_track): + mock_which.side_effect = lambda x: None if x != name else package_manager_path + + with self.assertRaises(EnvironmentError) as context: + repo._require_git() + + error_message = str(context.exception) + self.assertIn("git is not installed.", error_message) + + if expected_hint: + self.assertIn(expected_hint, error_message) + + @patch('agentstack.repo.should_track_changes', return_value=True) + @patch('agentstack.repo.shutil.which', return_value='/usr/bin/git') + def test_require_git_installed(self, mock_which, mock_should_track): + # This should not raise an exception + repo._require_git() + + def test_transaction_context_manager(self): + repo.init() + mock_commit = MagicMock() + + with patch('agentstack.repo.commit', mock_commit): + with repo.Transaction() as transaction: + (self.test_dir / "test_file.txt").touch() + transaction.add_message("Test message") + + mock_commit.assert_called_once_with(f"Test message", ["test_file.txt"], automated=True) + + def test_transaction_multiple_messages(self): + repo.init() + mock_commit = MagicMock() + + with patch('agentstack.repo.commit', mock_commit): + with repo.Transaction() as transaction: + (self.test_dir / "test_file.txt").touch() + transaction.add_message("First message") + (self.test_dir / "test_file_2.txt").touch() + transaction.add_message("Second message") + + mock_commit.assert_called_once_with( + f"First message, Second message", ["test_file.txt", "test_file_2.txt"], automated=True + ) + + def test_transaction_no_changes(self): + repo.init() + mock_commit = MagicMock() + + with patch('agentstack.repo.commit', mock_commit): + with repo.Transaction() as transaction: + transaction.add_message("No changes") + + assert repo.get_uncommitted_files() == [] + + mock_commit.assert_not_called() + + def test_transaction_with_exception(self): + repo.init() + mock_commit = MagicMock() + + with patch('agentstack.repo.commit', mock_commit): + try: + with repo.Transaction() as transaction: + (self.test_dir / "test_file.txt").touch() + transaction.add_message("This message should not be committed") + raise ValueError("Test exception") + except ValueError: + pass + + mock_commit.assert_not_called() + + # Verify that the file was created but not committed + self.assertTrue((self.test_dir / "test_file.txt").exists()) + self.assertIn("test_file.txt", repo.get_uncommitted_files()) + + def test_init_when_git_disabled(self): + repo.dont_track_changes() + result = repo.init() + self.assertIsNone(result) + repo._USE_GIT = None # Reset for other tests + + def test_commit_when_git_disabled(self): + repo.dont_track_changes() + result = repo.commit("Test message", ["test_file.txt"]) + self.assertIsNone(result) + repo._USE_GIT = None # Reset for other tests + + def test_commit_all_changes_when_git_disabled(self): + repo.dont_track_changes() + result = repo.commit_all_changes("Test message") + self.assertIsNone(result) + repo._USE_GIT = None # Reset for other tests + + def test_get_uncommitted_files_when_git_disabled(self): + repo.dont_track_changes() + result = repo.get_uncommitted_files() + self.assertEqual(result, []) + repo._USE_GIT = None # Reset for other tests + + def test_commit_user_changes(self): + repo.init() + + # Create a new file + test_file = self.test_dir / "user_file.txt" + test_file.write_text("User content") + + # Commit user changes + repo.commit_user_changes() + + # Check if the file was committed + git_repo = git.Repo(self.test_dir) + commits = list(git_repo.iter_commits()) + + self.assertEqual(len(commits), 2) # Initial commit + user changes commit + self.assertEqual(commits[0].message, f"{repo.USER_CHANGES_COMMIT_MESSAGE}{repo.AUTOMATION_NOTE}") + + # Check if the file is no longer in uncommitted files + self.assertNotIn("user_file.txt", repo.get_uncommitted_files())