-
Notifications
You must be signed in to change notification settings - Fork 126
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #149 from tcdent/fancy-init
Migrate to `uv` for package/venv management in user projects
- Loading branch information
Showing
11 changed files
with
307 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.