Skip to content

Commit af7403d

Browse files
authored
Merge pull request #149 from tcdent/fancy-init
Migrate to `uv` for package/venv management in user projects
2 parents bfad2c7 + 157e42c commit af7403d

File tree

11 files changed

+307
-70
lines changed

11 files changed

+307
-70
lines changed

agentstack/cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
from .cli import init_project_builder, configure_default_model, export_template
1+
from .cli import init_project_builder, configure_default_model, export_template, welcome_message
2+
from .init import init_project
23
from .tools import list_tools, add_tool
34
from .run import run_project

agentstack/cli/cli.py

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,13 @@ def init_project_builder(
8686
tools = [tools.model_dump() for tools in template_data.tools]
8787

8888
elif use_wizard:
89-
welcome_message()
9089
project_details = ask_project_details(slug_name)
9190
welcome_message()
9291
framework = ask_framework()
9392
design = ask_design()
9493
tools = ask_tools()
9594

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

118-
# we have an agentstack.json file in the directory now
119-
conf.set_path(project_details['name'])
120-
121116
for tool_data in tools:
122117
generation.add_tool(tool_data['name'], agents=tool_data['agents'])
123118

@@ -410,14 +405,14 @@ def insert_template(
410405
f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env',
411406
)
412407

413-
if os.path.isdir(project_details['name']):
414-
print(
415-
term_color(
416-
f"Directory {template_path} already exists. Please check this and try again",
417-
"red",
418-
)
419-
)
420-
sys.exit(1)
408+
# if os.path.isdir(project_details['name']):
409+
# print(
410+
# term_color(
411+
# f"Directory {template_path} already exists. Please check this and try again",
412+
# "red",
413+
# )
414+
# )
415+
# sys.exit(1)
421416

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

@@ -431,26 +426,6 @@ def insert_template(
431426
except:
432427
print("Failed to initialize git repository. Maybe you're already in one? Do this with: git init")
433428

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

455430
def export_template(output_filename: str):
456431
"""

agentstack/cli/init.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import os, sys
2+
from typing import Optional
3+
from pathlib import Path
4+
from agentstack import conf
5+
from agentstack import packaging
6+
from agentstack.cli import welcome_message, init_project_builder
7+
from agentstack.utils import term_color
8+
9+
10+
# TODO move the rest of the CLI init tooling into this file
11+
12+
13+
def require_uv():
14+
try:
15+
uv_bin = packaging.get_uv_bin()
16+
assert os.path.exists(uv_bin)
17+
except (AssertionError, ImportError):
18+
print(term_color("Error: uv is not installed.", 'red'))
19+
print("Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation")
20+
match sys.platform:
21+
case 'linux' | 'darwin':
22+
print("Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`")
23+
case _:
24+
pass
25+
sys.exit(1)
26+
27+
28+
def init_project(
29+
slug_name: Optional[str] = None,
30+
template: Optional[str] = None,
31+
use_wizard: bool = False,
32+
):
33+
"""
34+
Initialize a new project in the current directory.
35+
36+
- create a new virtual environment
37+
- copy project skeleton
38+
- install dependencies
39+
"""
40+
require_uv()
41+
42+
# TODO prevent the user from passing the --path arguent to init
43+
if slug_name:
44+
conf.set_path(conf.PATH / slug_name)
45+
else:
46+
print("Error: No project directory specified.")
47+
print("Run `agentstack init <project_name>`")
48+
sys.exit(1)
49+
50+
if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist
51+
print(f"Error: Directory already exists: {conf.PATH}")
52+
sys.exit(1)
53+
54+
welcome_message()
55+
print(term_color("🦾 Creating a new AgentStack project...", 'blue'))
56+
print(f"Using project directory: {conf.PATH.absolute()}")
57+
58+
# copy the project skeleton, create a virtual environment, and install dependencies
59+
init_project_builder(slug_name, template, use_wizard)
60+
packaging.create_venv()
61+
packaging.install_project()
62+
63+
print(
64+
"\n"
65+
"🚀 \033[92mAgentStack project generated successfully!\033[0m\n\n"
66+
" To get started, activate the virtual environment with:\n"
67+
f" cd {conf.PATH}\n"
68+
" source .venv/bin/activate\n\n"
69+
" Run your new agent with:\n"
70+
" agentstack run\n\n"
71+
" Or, run `agentstack quickstart` or `agentstack docs` for more next steps.\n"
72+
)

agentstack/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ class ValidationError(Exception):
55
"""
66

77
pass
8+
9+
10+
class EnvironmentError(Exception):
11+
"""
12+
Raised when an error occurs in the execution environment ie. a command is
13+
not present or the environment is not configured as expected.
14+
"""
15+
16+
pass

agentstack/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from agentstack import conf, auth
66
from agentstack.cli import (
7-
init_project_builder,
7+
init_project,
88
add_tool,
99
list_tools,
1010
configure_default_model,
@@ -167,7 +167,7 @@ def main():
167167
elif args.command in ["templates"]:
168168
webbrowser.open("https://docs.agentstack.sh/quickstart")
169169
elif args.command in ["init", "i"]:
170-
init_project_builder(args.slug_name, args.template, args.wizard)
170+
init_project(args.slug_name, args.template, args.wizard)
171171
elif args.command in ["tools", "t"]:
172172
if args.tools_command in ["list", "l"]:
173173
list_tools()

agentstack/packaging.py

Lines changed: 164 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,173 @@
1-
import os
2-
from typing import Optional
1+
import os, sys
2+
from typing import Optional, Callable
3+
from pathlib import Path
4+
import re
5+
import subprocess
6+
import select
7+
from agentstack import conf
38

4-
PACKAGING_CMD = "poetry"
59

10+
DEFAULT_PYTHON_VERSION = "3.12"
11+
VENV_DIR_NAME: Path = Path(".venv")
612

7-
def install(package: str, path: Optional[str] = None):
8-
if path:
9-
os.chdir(path)
10-
os.system(f"{PACKAGING_CMD} add {package}")
13+
# filter uv output by these words to only show useful progress messages
14+
RE_UV_PROGRESS = re.compile(r'^(Resolved|Prepared|Installed|Uninstalled|Audited)')
15+
16+
17+
# When calling `uv` we explicitly specify the --python executable to use so that
18+
# the packages are installed into the correct virtual environment.
19+
# In testing, when this was not set, packages could end up in the pyenv's
20+
# site-packages directory; it's possible an environemnt variable can control this.
21+
22+
23+
def install(package: str):
24+
"""Install a package with `uv` and add it to pyproject.toml."""
25+
26+
def on_progress(line: str):
27+
if RE_UV_PROGRESS.match(line):
28+
print(line.strip())
29+
30+
def on_error(line: str):
31+
print(f"uv: [error]\n {line.strip()}")
32+
33+
_wrap_command_with_callbacks(
34+
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
35+
on_progress=on_progress,
36+
on_error=on_error,
37+
)
38+
39+
40+
def install_project():
41+
"""Install all dependencies for the user's project."""
42+
43+
def on_progress(line: str):
44+
if RE_UV_PROGRESS.match(line):
45+
print(line.strip())
46+
47+
def on_error(line: str):
48+
print(f"uv: [error]\n {line.strip()}")
49+
50+
_wrap_command_with_callbacks(
51+
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
52+
on_progress=on_progress,
53+
on_error=on_error,
54+
)
1155

1256

1357
def remove(package: str):
14-
os.system(f"{PACKAGING_CMD} remove {package}")
58+
"""Uninstall a package with `uv`."""
59+
60+
# TODO it may be worth considering removing unused sub-dependencies as well
61+
def on_progress(line: str):
62+
if RE_UV_PROGRESS.match(line):
63+
print(line.strip())
64+
65+
def on_error(line: str):
66+
print(f"uv: [error]\n {line.strip()}")
67+
68+
_wrap_command_with_callbacks(
69+
[get_uv_bin(), 'remove', '--python', '.venv/bin/python', package],
70+
on_progress=on_progress,
71+
on_error=on_error,
72+
)
1573

1674

1775
def upgrade(package: str):
18-
os.system(f"{PACKAGING_CMD} add {package}")
76+
"""Upgrade a package with `uv`."""
77+
78+
# TODO should we try to update the project's pyproject.toml as well?
79+
def on_progress(line: str):
80+
if RE_UV_PROGRESS.match(line):
81+
print(line.strip())
82+
83+
def on_error(line: str):
84+
print(f"uv: [error]\n {line.strip()}")
85+
86+
_wrap_command_with_callbacks(
87+
[get_uv_bin(), 'pip', 'install', '-U', '--python', '.venv/bin/python', package],
88+
on_progress=on_progress,
89+
on_error=on_error,
90+
)
91+
92+
93+
def create_venv(python_version: str = DEFAULT_PYTHON_VERSION):
94+
"""Intialize a virtual environment in the project directory of one does not exist."""
95+
if os.path.exists(conf.PATH / VENV_DIR_NAME):
96+
return # venv already exists
97+
98+
RE_VENV_PROGRESS = re.compile(r'^(Using|Creating)')
99+
100+
def on_progress(line: str):
101+
if RE_VENV_PROGRESS.match(line):
102+
print(line.strip())
103+
104+
def on_error(line: str):
105+
print(f"uv: [error]\n {line.strip()}")
106+
107+
_wrap_command_with_callbacks(
108+
[get_uv_bin(), 'venv', '--python', python_version],
109+
on_progress=on_progress,
110+
on_error=on_error,
111+
)
112+
113+
114+
def get_uv_bin() -> str:
115+
"""Find the path to the uv binary."""
116+
try:
117+
import uv
118+
119+
return uv.find_uv_bin()
120+
except ImportError as e:
121+
raise e
122+
123+
124+
def _setup_env() -> dict[str, str]:
125+
"""Copy the current environment and add the virtual environment path for use by a subprocess."""
126+
env = os.environ.copy()
127+
env["VIRTUAL_ENV"] = str(conf.PATH / VENV_DIR_NAME.absolute())
128+
env["UV_INTERNAL__PARENT_INTERPRETER"] = sys.executable
129+
return env
130+
131+
132+
def _wrap_command_with_callbacks(
133+
command: list[str],
134+
on_progress: Callable[[str], None] = lambda x: None,
135+
on_complete: Callable[[str], None] = lambda x: None,
136+
on_error: Callable[[str], None] = lambda x: None,
137+
) -> None:
138+
"""Run a command with progress callbacks."""
139+
try:
140+
all_lines = ''
141+
process = subprocess.Popen(
142+
command,
143+
cwd=conf.PATH.absolute(),
144+
env=_setup_env(),
145+
stdout=subprocess.PIPE,
146+
stderr=subprocess.PIPE,
147+
text=True,
148+
)
149+
assert process.stdout and process.stderr # appease type checker
150+
151+
readable = [process.stdout, process.stderr]
152+
while readable:
153+
ready, _, _ = select.select(readable, [], [])
154+
for fd in ready:
155+
line = fd.readline()
156+
if not line:
157+
readable.remove(fd)
158+
continue
159+
160+
on_progress(line)
161+
all_lines += line
162+
163+
if process.wait() == 0: # return code: success
164+
on_complete(all_lines)
165+
else:
166+
on_error(all_lines)
167+
except Exception as e:
168+
on_error(str(e))
169+
finally:
170+
try:
171+
process.terminate()
172+
except:
173+
pass

agentstack/telemetry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def collect_machine_telemetry(command: str):
7676

7777

7878
def track_cli_command(command: str, args: Optional[str] = None):
79-
if bool(os.environ['AGENTSTATCK_IS_TEST_ENV']):
79+
if bool(os.getenv('AGENTSTACK_IS_TEST_ENV')):
8080
return
8181

8282
try:
@@ -91,7 +91,7 @@ def track_cli_command(command: str, args: Optional[str] = None):
9191
pass
9292

9393
def update_telemetry(id: int, result: int, message: Optional[str] = None):
94-
if bool(os.environ['AGENTSTATCK_IS_TEST_ENV']):
94+
if bool(os.getenv('AGENTSTACK_IS_TEST_ENV')):
9595
return
9696

9797
try:

0 commit comments

Comments
 (0)