Skip to content

Commit

Permalink
Add install, run and ensurepath commands (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
tusharsadhwani authored Jun 26, 2024
1 parent 2c86151 commit bdb1062
Show file tree
Hide file tree
Showing 19 changed files with 897 additions and 76 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.8"
python-version: "3.12"

- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ venv*
build
dist
.tox
.coverage
.coverage*
# files generated during tests
testvenv
tests/yen_packages
2 changes: 2 additions & 0 deletions install.bat
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ mkdir "%yenpath%" 2>nul
REM Download yen executable and save it to the .yen\bin directory
SET "download_url=https://github.com/tusharsadhwani/yen/releases/latest/download/yen-rs-x86_64-pc-windows-msvc.exe"
curl -SL --progress-bar "%download_url%" --output "%yenpath%\yen.exe"
REM Download userpath too
curl -SL --progress-bar "https://yen.tushar.lol/userpath.pyz" --output "%yenpath%\userpath.pyz"

REM Get the user's PATH without the system-wide PATH
for /f "skip=2 tokens=2,*" %%A in ('reg query HKCU\Environment /v PATH') do (
Expand Down
2 changes: 2 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ if (-not (Test-Path $yenpath)) {
# Download yen executable and save it to the .yen\bin directory
$downloadUrl = "https://github.com/tusharsadhwani/yen/releases/latest/download/yen-rs-x86_64-pc-windows-msvc.exe"
Invoke-WebRequest -Uri $downloadUrl -OutFile "$yenpath\yen.exe"
# Download userpath too
Invoke-WebRequest -Uri "https://yen.tushar.lol/userpath.pyz" -OutFile "$yenpath\userpath.pyz"

# Get the user's PATH without the system-wide PATH
$userPath = (Get-ItemProperty -Path 'HKCU:\Environment' -Name 'Path').Path
Expand Down
14 changes: 12 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ fi

BINARY="yen-rs-${ARCH}-${PLATFORM}"

DOWNLOAD_URL=https://github.com/${REPO}/releases/latest/download/${BINARY}

printf "This script will automatically download and install yen for you.\nGetting it from this url: $DOWNLOAD_URL\nThe binary will be installed into '$INSTALL_DIR'\n"

if ! hash curl 2> /dev/null; then
Expand All @@ -39,6 +37,7 @@ cleanup() {

trap cleanup EXIT

DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
HTTP_CODE=$(curl -SL --progress-bar "$DOWNLOAD_URL" --output "$TEMP_FILE" --write-out "%{http_code}")
if [ ${HTTP_CODE} -lt 200 ] || [ ${HTTP_CODE} -gt 299 ]; then
echo "error: '${DOWNLOAD_URL}' is not available"
Expand All @@ -49,6 +48,17 @@ fi
mkdir -p "$INSTALL_DIR"
cp "$TEMP_FILE" "$INSTALL_DIR/yen"

# Download userpath too
USERPATH_URL="https://yen.tushar.lol/userpath.pyz"
HTTP_CODE=$(curl -SL --progress-bar "$USERPATH_URL" --output "$TEMP_FILE" --write-out "%{http_code}")
if [ ${HTTP_CODE} -lt 200 ] || [ ${HTTP_CODE} -gt 299 ]; then
echo "error: '${USERPATH_URL}' is not available"
exit 1
fi
mkdir -p "$INSTALL_DIR"
cp "$TEMP_FILE" "$INSTALL_DIR/userpath.pyz"


update_shell() {
FILE=$1
LINE=$2
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ classifiers =
[options]
packages = find:
install_requires =
microvenv>=2023.2.0
rich>=13.5.3
python_requires = >=3.7
package_dir = =src
Expand All @@ -44,6 +43,7 @@ dev =
pytest
pytest-cov
tox
userpath

[options.package_data]
yen =
Expand Down
188 changes: 161 additions & 27 deletions src/yen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,105 @@
import hashlib
import os
import os.path
import platform
import shutil
import subprocess
import sys
import tarfile
from urllib.request import urlretrieve

from yen.downloader import download, read_url
from yen.github import NotAvailable, resolve_python_version
from yen.github import resolve_python_version

PYTHON_INSTALLS_PATH = os.getenv("YEN_PYTHONS_PATH") or os.path.expanduser(
"~/.yen_pythons"
YEN_BIN_PATH = os.path.abspath(
os.getenv("YEN_BIN_PATH", os.path.expanduser("~/.yen/bin"))
)
PYTHON_INSTALLS_PATH = os.path.abspath(
os.getenv("YEN_PYTHONS_PATH", os.path.expanduser("~/.yen_pythons"))
)
PACKAGE_INSTALLS_PATH = os.path.abspath(
os.getenv("YEN_PACKAGES_PATH", os.path.expanduser("~/.yen_packages"))
)

USERPATH_PATH = os.path.join(YEN_BIN_PATH, "userpath.pyz")

DEFAULT_PYTHON_VERSION = "3.12"


class ExecutableDoesNotExist(Exception): ...

def check_path() -> None:
"""Ensure that PYTHON_INSTALLS_PATH is in PATH."""
if PYTHON_INSTALLS_PATH not in os.environ["PATH"]:

def check_path(path: str) -> None:
"""Check if given path is in PATH, and inform the user otherwise."""
if platform.system() == "Windows":
_ensure_userpath()
python_bin_path = find_or_download_python()
process = subprocess.run([python_bin_path, USERPATH_PATH, "check", path])
path_exists = process.returncode == 0
else:
path_exists = path in os.environ["PATH"].split(os.pathsep)

if not path_exists:
print(
"\033[33m\n"
"Warning: PYTHON_INSTALLS_PATH is not in PATH.\n"
"Add the following line to your shell's configuration file:\n"
"\033[0;1m"
f"export PATH={PYTHON_INSTALLS_PATH}:$PATH"
"\033[m"
"\033[33m"
"Warning: The executable just installed is not in PATH.\n"
"Run `yen ensurepath` to add it to your PATH."
"\033[m",
file=sys.stderr,
)


def _ensure_userpath() -> None:
"""Downloads `userpath.pyz`, if it doesn't exist in `YEN_BIN_PATH`."""
if os.path.exists(USERPATH_PATH):
return

os.makedirs(YEN_BIN_PATH, exist_ok=True)
urlretrieve("http://yen.tushar.lol/userpath.pyz", filename=USERPATH_PATH)


def find_or_download_python() -> str:
"""
Finds and returns any Python binary from `PYTHON_INSTALLS_PATH`.
If no Pythons exist, downloads the default version and returns that.
"""
for python_folder_name in os.listdir(PYTHON_INSTALLS_PATH):
python_folder = os.path.join(PYTHON_INSTALLS_PATH, python_folder_name)
python_bin_path = _python_bin_path(python_folder)
if os.path.isfile(python_bin_path):
return python_bin_path

# No Python binary found. Download one.
_, python_bin_path = ensure_python(DEFAULT_PYTHON_VERSION)
return python_bin_path


def ensurepath() -> None:
"""Ensures that PACKAGE_INSTALLS_PATH is in PATH."""
_ensure_userpath()
python_bin_path = find_or_download_python()
subprocess.run(
[python_bin_path, USERPATH_PATH, "append", PACKAGE_INSTALLS_PATH],
check=True,
)


def _python_bin_path(python_directory: str) -> str:
"""Return the python binary path in a downloaded and extracted Python."""
if platform.system() == "Windows":
return os.path.join(python_directory, "python", "python.exe")
else:
return os.path.join(python_directory, "python", "bin", "python3")


def ensure_python(python_version: str) -> tuple[str, str]:
"""Checks if given Python version exists locally. If not, downloads it."""
os.makedirs(PYTHON_INSTALLS_PATH, exist_ok=True)

python_version, download_link = resolve_python_version(python_version)
download_directory = os.path.join(PYTHON_INSTALLS_PATH, python_version)

if os.name == "nt":
python_bin_path = os.path.join(download_directory, "python/python.exe")
else:
python_bin_path = os.path.join(download_directory, "python/bin/python3")
python_bin_path = _python_bin_path(download_directory)
if os.path.exists(python_bin_path):
# already installed
return python_version, python_bin_path
Expand All @@ -58,9 +122,10 @@ def ensure_python(python_version: str) -> tuple[str, str]:
checksum_link = download_link + ".sha256"
expected_checksum = read_url(checksum_link).rstrip("\n")
if checksum != expected_checksum:
print(f"\033[1;31mError:\033[m Checksum did not match!")
print("\033[1;31mError:\033[m Checksum did not match!")
os.remove(downloaded_filepath)
raise SystemExit(1)
print("Checksum verified!")

with tarfile.open(downloaded_filepath, mode="r:gz") as tar:
tar.extractall(download_directory)
Expand All @@ -71,22 +136,91 @@ def ensure_python(python_version: str) -> tuple[str, str]:
return python_version, python_bin_path


def create_venv(python_version: str, python_bin_path: str, venv_path: str) -> None:
if os.path.exists(venv_path):
print(f"\033[1;31mError:\033[m {venv_path} already exists.")
raise SystemExit(2)

def create_venv(python_bin_path: str, venv_path: str) -> None:
# TODO: bundle microvenv.pyz as a dependency, venv is genuinely too slow
# microvenv doesn't support windows, fallback to venv for that. teehee.
subprocess.run([python_bin_path, "-m", "venv", venv_path], check=True)
print(f"Created \033[1m{venv_path}\033[m with Python {python_version} ✨".encode())


def _venv_binary_path(binary_name: str, venv_path: str) -> str:
is_windows = platform.system() == "Windows"
venv_bin_path = os.path.join(venv_path, "Scripts" if is_windows else "bin")
binary_path = os.path.join(
venv_bin_path, f"{binary_name}.exe" if is_windows else binary_name
)
return binary_path


def install_package(
package_name: str,
python_bin_path: str,
executable_name: str,
*,
is_module: bool = False,
force_reinstall: bool = False,
) -> tuple[str, bool]:
is_windows = platform.system() == "Windows"
shim_path = os.path.join(PACKAGE_INSTALLS_PATH, package_name)
if is_windows:
# This is somewhat of a hack.
# For the condition where shim_path exists and we do `yen run`,
# `is_module` is false but we still want to return early.
# But for the condition where we try to create the module the first time,
# `is_module` will be true, and in that case we want to use `.bat` as well
if is_module or os.path.exists(shim_path + ".bat"):
shim_path += ".bat"
else:
shim_path += ".exe"

venv_name = f"venv_{package_name}"
venv_path = os.path.join(PACKAGE_INSTALLS_PATH, venv_name)
if os.path.exists(shim_path):
if not force_reinstall:
return shim_path, True # True as in package already existed
else:
os.remove(shim_path)
shutil.rmtree(venv_path, ignore_errors=True)

create_venv(python_bin_path, venv_path)

venv_python_path = _venv_binary_path("python", venv_path)
subprocess.run(
[venv_python_path, "-m", "pip", "install", package_name],
check=True,
capture_output=True,
)

if is_module:
with open(shim_path, "w") as file:
if is_windows:
file.write(f"@echo off\n{venv_python_path} -m {package_name} %*")
else:
file.write(f'#!/bin/sh\n{venv_python_path} -m {package_name} "$@"')

os.chmod(shim_path, 0o777)
else:
executable_path = _venv_binary_path(executable_name, venv_path)
if not os.path.exists(executable_path):
# cleanup the venv created
shutil.rmtree(venv_path)
raise ExecutableDoesNotExist

# the created binary is always moveable
shutil.move(executable_path, shim_path)

return shim_path, False # False as in package didn't exist and was just installed


def run_package(shim_path: str, args: list[str]) -> None:
subprocess.run([shim_path, *args])


def create_symlink(python_bin_path: str, python_version: str) -> None:
python_major_minor = "python" + ".".join(python_version.split(".")[:2])
symlink_path = os.path.join(PYTHON_INSTALLS_PATH, python_major_minor)
python_version = "python" + ".".join(python_version.split(".")[:2])
symlink_path = os.path.join(PYTHON_INSTALLS_PATH, python_version)

if os.path.exists(symlink_path):
os.remove(symlink_path)

os.symlink(python_bin_path, symlink_path)
print(f"\033[1m{python_major_minor}\033[m created in {PYTHON_INSTALLS_PATH} 🐍")
check_path()
check_path(PYTHON_INSTALLS_PATH)
1 change: 1 addition & 0 deletions src/yen/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support executing the CLI by doing `python -m yen`."""

from yen.cli import cli

if __name__ == "__main__":
Expand Down
Loading

0 comments on commit bdb1062

Please sign in to comment.