Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to uv for package/venv management in user projects #149

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion agentstack/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .cli import init_project_builder, configure_default_model, export_template
from .cli import init_project_builder, configure_default_model, export_template, welcome_message
from .init import init_project
from .tools import list_tools, add_tool
from .run import run_project
41 changes: 8 additions & 33 deletions agentstack/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,13 @@ def init_project_builder(
tools = [tools.model_dump() for tools in template_data.tools]

elif use_wizard:
welcome_message()
project_details = ask_project_details(slug_name)
welcome_message()
framework = ask_framework()
design = ask_design()
tools = ask_tools()

else:
welcome_message()
# the user has started a new project; let's give them something to work with
default_project = TemplateConfig.from_template_name('hello_alex')
project_details = {
Expand All @@ -117,9 +115,6 @@ def init_project_builder(
log.debug(f"project_details: {project_details}" f"framework: {framework}" f"design: {design}")
insert_template(project_details, framework, design, template_data)

# we have an agentstack.json file in the directory now
conf.set_path(project_details['name'])

for tool_data in tools:
generation.add_tool(tool_data['name'], agents=tool_data['agents'])

Expand Down Expand Up @@ -395,14 +390,14 @@ def insert_template(
f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env',
)

if os.path.isdir(project_details['name']):
print(
term_color(
f"Directory {template_path} already exists. Please check this and try again",
"red",
)
)
sys.exit(1)
# if os.path.isdir(project_details['name']):
# print(
# term_color(
# f"Directory {template_path} already exists. Please check this and try again",
# "red",
# )
# )
# sys.exit(1)

cookiecutter(str(template_path), no_input=True, extra_context=None)

Expand All @@ -416,26 +411,6 @@ def insert_template(
except:
print("Failed to initialize git repository. Maybe you're already in one? Do this with: git init")

# TODO: check if poetry is installed and if so, run poetry install in the new directory
# os.system("poetry install")
# os.system("cls" if os.name == "nt" else "clear")
# TODO: add `agentstack docs` command
print(
"\n"
"🚀 \033[92mAgentStack project generated successfully!\033[0m\n\n"
" Next, run:\n"
f" cd {project_metadata.project_slug}\n"
" python -m venv .venv\n"
" source .venv/bin/activate\n\n"
" Make sure you have the latest version of poetry installed:\n"
" pip install -U poetry\n\n"
" You'll need to install the project's dependencies with:\n"
" poetry install\n\n"
" Finally, try running your agent with:\n"
" agentstack run\n\n"
" Run `agentstack quickstart` or `agentstack docs` for next steps.\n"
)


def export_template(output_filename: str):
"""
Expand Down
72 changes: 72 additions & 0 deletions agentstack/cli/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import os, sys
from typing import Optional
from pathlib import Path
from agentstack import conf
from agentstack import packaging
from agentstack.cli import welcome_message, init_project_builder
from agentstack.utils import term_color


# TODO move the rest of the CLI init tooling into this file


def require_uv():
try:
uv_bin = packaging.get_uv_bin()
assert os.path.exists(uv_bin)
except (AssertionError, ImportError):
print(term_color("Error: uv is not installed.", 'red'))
print("Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation")
match sys.platform:
case 'linux' | 'darwin':
print("Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`")
case _:
pass
sys.exit(1)


def init_project(
slug_name: Optional[str] = None,
template: Optional[str] = None,
use_wizard: bool = False,
):
"""
Initialize a new project in the current directory.

- create a new virtual environment
- copy project skeleton
- install dependencies
"""
require_uv()

# TODO prevent the user from passing the --path arguent to init
if slug_name:
conf.set_path(conf.PATH / slug_name)
else:
print("Error: No project directory specified.")
print("Run `agentstack init <project_name>`")
sys.exit(1)

if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist
print(f"Error: Directory already exists: {conf.PATH}")
sys.exit(1)

welcome_message()
print(term_color("🦾 Creating a new AgentStack project...", 'blue'))
print(f"Using project directory: {conf.PATH.absolute()}")

# copy the project skeleton, create a virtual environment, and install dependencies
init_project_builder(slug_name, template, use_wizard)
packaging.create_venv()
packaging.install_project()

print(
"\n"
"🚀 \033[92mAgentStack project generated successfully!\033[0m\n\n"
" To get started, activate the virtual environment with:\n"
f" cd {conf.PATH}\n"
" source .venv/bin/activate\n\n"
" Run your new agent with:\n"
" agentstack run\n\n"
" Or, run `agentstack quickstart` or `agentstack docs` for more next steps.\n"
)
9 changes: 9 additions & 0 deletions agentstack/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ class ValidationError(Exception):
"""

pass


class EnvironmentError(Exception):
"""
Raised when an error occurs in the execution environment ie. a command is
not present or the environment is not configured as expected.
"""

pass
4 changes: 2 additions & 2 deletions agentstack/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from agentstack import conf
from agentstack.cli import (
init_project_builder,
init_project,
add_tool,
list_tools,
configure_default_model,
Expand Down Expand Up @@ -162,7 +162,7 @@ def main():
elif args.command in ["templates"]:
webbrowser.open("https://docs.agentstack.sh/quickstart")
elif args.command in ["init", "i"]:
init_project_builder(args.slug_name, args.template, args.wizard)
init_project(args.slug_name, args.template, args.wizard)
elif args.command in ["run", "r"]:
run_project(command=args.function, debug=args.debug, cli_args=extra_args)
elif args.command in ['generate', 'g']:
Expand Down
173 changes: 164 additions & 9 deletions agentstack/packaging.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,173 @@
import os
from typing import Optional
import os, sys
from typing import Optional, Callable
from pathlib import Path
import re
import subprocess
import select
from agentstack import conf

PACKAGING_CMD = "poetry"

DEFAULT_PYTHON_VERSION = "3.12"
VENV_DIR_NAME: Path = Path(".venv")

def install(package: str, path: Optional[str] = None):
if path:
os.chdir(path)
os.system(f"{PACKAGING_CMD} add {package}")
# filter uv output by these words to only show useful progress messages
RE_UV_PROGRESS = re.compile(r'^(Resolved|Prepared|Installed|Uninstalled|Audited)')


# When calling `uv` we explicitly specify the --python executable to use so that
# the packages are installed into the correct virtual environment.
# In testing, when this was not set, packages could end up in the pyenv's
# site-packages directory; it's possible an environemnt variable can control this.


def install(package: str):
"""Install a package with `uv` and add it to pyproject.toml."""

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


def install_project():
"""Install all dependencies for the user's project."""

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
on_progress=on_progress,
on_error=on_error,
)


def remove(package: str):
os.system(f"{PACKAGING_CMD} remove {package}")
"""Uninstall a package with `uv`."""

# TODO it may be worth considering removing unused sub-dependencies as well
def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'remove', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


def upgrade(package: str):
os.system(f"{PACKAGING_CMD} add {package}")
"""Upgrade a package with `uv`."""

# TODO should we try to update the project's pyproject.toml as well?
def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '-U', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


def create_venv(python_version: str = DEFAULT_PYTHON_VERSION):
"""Intialize a virtual environment in the project directory of one does not exist."""
if os.path.exists(conf.PATH / VENV_DIR_NAME):
return # venv already exists

RE_VENV_PROGRESS = re.compile(r'^(Using|Creating)')

def on_progress(line: str):
if RE_VENV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'venv', '--python', python_version],
on_progress=on_progress,
on_error=on_error,
)


def get_uv_bin() -> str:
"""Find the path to the uv binary."""
try:
import uv

return uv.find_uv_bin()
except ImportError as e:
raise e


def _setup_env() -> dict[str, str]:
"""Copy the current environment and add the virtual environment path for use by a subprocess."""
env = os.environ.copy()
env["VIRTUAL_ENV"] = str(conf.PATH / VENV_DIR_NAME.absolute())
env["UV_INTERNAL__PARENT_INTERPRETER"] = sys.executable
return env


def _wrap_command_with_callbacks(
command: list[str],
on_progress: Callable[[str], None] = lambda x: None,
on_complete: Callable[[str], None] = lambda x: None,
on_error: Callable[[str], None] = lambda x: None,
) -> None:
"""Run a command with progress callbacks."""
try:
all_lines = ''
process = subprocess.Popen(
command,
cwd=conf.PATH.absolute(),
env=_setup_env(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert process.stdout and process.stderr # appease type checker

readable = [process.stdout, process.stderr]
while readable:
ready, _, _ = select.select(readable, [], [])
for fd in ready:
line = fd.readline()
if not line:
readable.remove(fd)
continue

on_progress(line)
all_lines += line

if process.wait() == 0: # return code: success
on_complete(all_lines)
else:
on_error(all_lines)
except Exception as e:
on_error(str(e))
finally:
try:
process.terminate()
except:
pass
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
[tool.poetry]
[project]
name = "{{cookiecutter.project_metadata.project_name}}"
version = "{{cookiecutter.project_metadata.version}}"
description = "{{cookiecutter.project_metadata.description}}"
authors = ["{{cookiecutter.project_metadata.author_name}}"]
license = "{{cookiecutter.project_metadata.license}}"
package-mode = false
authors = [
{ name = "{{cookiecutter.project_metadata.author_name}}" }
]
license = { text = "{{cookiecutter.project_metadata.license}}" }
requires-python = ">=3.10"

[tool.poetry.dependencies]
python = ">=3.10,<=3.13"
agentstack = {extras = ["{{cookiecutter.framework}}"], version="{{cookiecutter.project_metadata.agentstack_version}}"}

[project.scripts]
{{cookiecutter.project_metadata.project_name}} = "{{cookiecutter.project_metadata.project_name}}.main:run"
run_crew = "{{cookiecutter.project_metadata.project_name}}.main:run"
train = "{{cookiecutter.project_metadata.project_name}}.main:train"
replay = "{{cookiecutter.project_metadata.project_name}}.main:replay"
test = "{{cookiecutter.project_metadata.project_name}}.main:test"
dependencies = [
"agentstack[{{cookiecutter.framework}}]>={{cookiecutter.project_metadata.agentstack_version}}",
]
Loading
Loading