Skip to content

Commit 1f0e50a

Browse files
committed
feat: delegate update to setup.sh and add UV support
Use setup.sh install for self-update, removing manual file copy. Detect UV in the updater and use uv for creating venv/pip when available. Pin dependencies and bump package version. Add UV integration tests and adapt upgrade tests to new install flow.
1 parent 2050ddb commit 1f0e50a

File tree

7 files changed

+561
-300
lines changed

7 files changed

+561
-300
lines changed

my_unicorn/upgrade.py

Lines changed: 67 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import asyncio
1313
import shutil
14+
import subprocess
1415
import sys
1516
from importlib.metadata import PackageNotFoundError
1617
from importlib.metadata import version as get_version
@@ -156,6 +157,30 @@ def __init__(
156157
repo=GITHUB_REPO,
157158
session=session,
158159
)
160+
self._uv_available: bool = self._check_uv_available()
161+
162+
def _check_uv_available(self) -> bool:
163+
"""Check if UV is available in the system.
164+
165+
Returns:
166+
True if UV is installed and available, False otherwise
167+
168+
"""
169+
try:
170+
result = subprocess.run(
171+
["uv", "--version"],
172+
check=False,
173+
capture_output=True,
174+
text=True,
175+
timeout=5,
176+
)
177+
available = result.returncode == 0
178+
if available:
179+
logger.debug("UV is available: %s", result.stdout.strip())
180+
return available
181+
except (FileNotFoundError, subprocess.TimeoutExpired):
182+
logger.debug("UV is not available in PATH")
183+
return False
159184

160185
def get_current_version(self) -> str:
161186
"""Get the current installed version of my-unicorn.
@@ -343,55 +368,52 @@ async def check_for_update(self, refresh_cache: bool = False) -> bool:
343368
return False
344369

345370
async def perform_update(self) -> bool:
346-
"""Update by doing a fresh git clone and running the installer.
371+
"""Update by cloning repo and running setup.sh install.
372+
373+
Simplified approach that delegates all
374+
installation logic to setup.sh, eliminating code duplication.
347375
348376
Returns:
349377
True if update was successful, False otherwise
350378
351379
"""
352380
repo_dir = self.global_config["directory"]["repo"]
353-
package_dir = self.global_config["directory"]["package"]
354-
installer = package_dir / "setup.sh"
381+
installer_script = repo_dir / "setup.sh"
355382

356383
logger.debug("Starting upgrade to my-unicorn...")
357384
logger.debug("Repository directory: %s", repo_dir)
358-
logger.debug("Package directory: %s", package_dir)
359-
logger.debug("Installer script: %s", installer)
385+
logger.debug("UV available: %s", self._uv_available)
360386

361-
# Track progress with simple indicators
387+
# Inform user about UV usage
388+
if self._uv_available:
389+
logger.info("UV detected - will use UV for faster installation")
390+
else:
391+
logger.info(
392+
"Using pip for installation (install UV for faster updates)"
393+
)
394+
395+
# Track progress
362396
download_task_id = None
363-
file_task_id = None
364397
install_task_id = None
365398
cleanup_task_id = None
366399

367400
try:
368401
# 1) Prepare fresh repo directory
369402
if repo_dir.exists():
370403
logger.info("Removing old repo at %s", repo_dir)
371-
logger.debug(
372-
"Old repo directory size: %s",
373-
repo_dir.stat().st_size
374-
if repo_dir.is_file()
375-
else "directory",
376-
)
377404
shutil.rmtree(repo_dir)
378405
logger.debug("Old repo directory removed successfully")
406+
379407
repo_dir.mkdir(parents=True)
380408
logger.debug("Created fresh repo directory: %s", repo_dir)
381409

382-
# 2) Clone into repo_dir with simple progress tracking
410+
# 2) Clone repository
383411
if self.progress:
384412
download_task_id = self.progress.start_task(
385413
"repo_clone", "Cloning repository from GitHub..."
386414
)
387415

388416
logger.info("Cloning repository to %s", repo_dir)
389-
logger.debug(
390-
"Git clone command: git clone %s %s",
391-
f"{GITHUB_URL}.git",
392-
str(repo_dir),
393-
)
394-
395417
clone_process = await asyncio.create_subprocess_exec(
396418
"git",
397419
"clone",
@@ -401,40 +423,15 @@ async def perform_update(self) -> bool:
401423
stderr=asyncio.subprocess.PIPE,
402424
)
403425

404-
# Simple progress updates during clone
426+
# Progress updates during clone
405427
clone_task = asyncio.create_task(clone_process.wait())
406428
if self.progress and download_task_id:
407-
# Show progress during clone
408429
while not clone_task.done():
409430
self.progress.update_task(download_task_id)
410431
await asyncio.sleep(0.5)
411432

412433
await clone_task
413434

414-
logger.debug(
415-
"Git clone process completed with return code: %s",
416-
clone_process.returncode,
417-
)
418-
if clone_process.returncode == 0:
419-
logger.debug("Repository cloned successfully to %s", repo_dir)
420-
# Check if repo directory has expected content
421-
try:
422-
repo_contents = list(repo_dir.iterdir())
423-
logger.debug(
424-
"Cloned repository contains %d items",
425-
len(repo_contents),
426-
)
427-
logger.debug(
428-
"Repository structure: %s",
429-
[item.name for item in repo_contents],
430-
)
431-
except Exception as e:
432-
logger.debug(
433-
"Could not list repo directory contents: %s", e
434-
)
435-
else:
436-
logger.debug("Git clone failed, will handle error")
437-
438435
if self.progress and download_task_id:
439436
self.progress.finish_task(
440437
download_task_id,
@@ -445,151 +442,57 @@ async def perform_update(self) -> bool:
445442
)
446443

447444
if clone_process.returncode != 0:
448-
logger.error(
449-
"Git clone failed with return code %s",
450-
clone_process.returncode,
451-
)
445+
logger.error("Git clone failed")
452446
print("❌ Failed to download repository")
453447
return False
454448

455-
# 3) Copy files with simple progress tracking
456-
if self.progress:
457-
file_task_id = self.progress.start_task(
458-
"file_operations", "Copying project files..."
459-
)
460-
461-
logger.info("Copying code + scripts to %s", package_dir)
462-
logger.debug("Ensuring package directory exists: %s", package_dir)
463-
package_dir.mkdir(parents=True, exist_ok=True)
464-
logger.debug("Package directory created/verified")
465-
466-
# Copy over the package code, scripts, and installation files
467-
files_to_copy = (
468-
"my_unicorn",
469-
"scripts",
470-
"pyproject.toml",
471-
"setup.sh",
472-
)
473-
for name in files_to_copy:
474-
src = repo_dir / name
475-
dst = package_dir / name
476-
477-
logger.debug("Processing file/directory: %s", name)
478-
logger.debug("Source: %s", src)
479-
logger.debug("Destination: %s", dst)
480-
481-
if not src.exists():
482-
logger.warning("Source file/directory not found: %s", src)
483-
logger.debug("Skipping %s - source does not exist", name)
484-
continue
485-
486-
if self.progress and file_task_id:
487-
self.progress.update_task(
488-
file_task_id,
489-
description=f"Copying {name}...",
490-
)
491-
492-
# Remove the old directory/file
493-
# (but preserve venv and other dirs)
494-
if dst.exists():
495-
logger.debug("Destination exists, removing old: %s", dst)
496-
if dst.is_dir():
497-
logger.debug("Removing old directory: %s", dst)
498-
shutil.rmtree(dst)
499-
else:
500-
logger.debug("Removing old file: %s", dst)
501-
dst.unlink()
502-
503-
# Copy fresh
504-
if src.is_dir():
505-
logger.debug(
506-
"Copying directory tree from %s to %s", src, dst
507-
)
508-
_ = shutil.copytree(src, dst)
509-
logger.debug("Directory copy completed for %s", name)
510-
else:
511-
logger.debug("Copying file from %s to %s", src, dst)
512-
_ = shutil.copy2(src, dst)
513-
logger.debug("File copy completed for %s", name)
514-
515-
if self.progress and file_task_id:
516-
self.progress.finish_task(
517-
file_task_id,
518-
success=True,
519-
final_description="Files copied successfully",
449+
# 3) Run setup.sh install from the cloned repo
450+
if not installer_script.exists():
451+
logger.error(
452+
"Installer script missing at %s", installer_script
520453
)
521-
522-
# 4) Run installer with simple progress tracking
523-
if not installer.exists():
524-
logger.error("Installer script missing at %s", installer)
525454
print("❌ Installer script not found.")
526455
return False
527456

528-
# Make installer executable
529-
logger.debug("Setting installer script permissions: %s", installer)
530-
installer.chmod(0o755)
531-
logger.debug("Installer script is now executable")
457+
logger.debug("Making installer executable: %s", installer_script)
458+
installer_script.chmod(0o755)
532459

533460
if self.progress:
461+
install_msg = (
462+
"Running installation with UV..."
463+
if self._uv_available
464+
else "Running installation with pip..."
465+
)
534466
install_task_id = self.progress.start_task(
535467
"upgrade_installation",
536-
"Running installer in UPDATE mode...",
468+
install_msg,
537469
)
538470

539-
logger.debug("Starting installer subprocess")
540-
logger.debug("Command: bash %s update", str(installer))
541-
logger.debug("Working directory: %s", str(package_dir))
471+
logger.info("Executing: bash %s install", installer_script)
542472
install_process = await asyncio.create_subprocess_exec(
543473
"bash",
544-
str(installer),
545-
"update",
546-
cwd=str(package_dir),
474+
str(installer_script),
475+
"install",
476+
cwd=str(repo_dir),
547477
stdout=asyncio.subprocess.PIPE,
548478
stderr=asyncio.subprocess.STDOUT,
549479
)
550-
logger.debug(
551-
"Installer subprocess started with PID: %s",
552-
install_process.pid,
553-
)
554480

555-
# Stream output and update progress
481+
# Stream output with progress updates
556482
if install_process.stdout:
557483
while True:
558484
line = await install_process.stdout.readline()
559485
if not line:
560486
break
561-
562487
line_str = line.decode().strip()
563-
564-
# Debug log all installer output for troubleshooting
565488
logger.debug("Installer output: %s", line_str)
566489

567-
# Detailed logging for specific venv and
568-
# installation operations
569-
if "virtual environment" in line_str.lower():
570-
logger.debug("VENV OPERATION: %s", line_str)
571-
elif "pip install" in line_str.lower():
572-
logger.debug("PIP INSTALL: %s", line_str)
573-
elif "installing" in line_str.lower():
574-
logger.debug("PACKAGE INSTALL: %s", line_str)
575-
elif "creating" in line_str.lower():
576-
logger.debug("CREATION STEP: %s", line_str)
577-
elif "updating" in line_str.lower():
578-
logger.debug("UPDATE STEP: %s", line_str)
579-
elif "complete" in line_str.lower():
580-
logger.debug("COMPLETION: %s", line_str)
581-
elif "error" in line_str.lower():
582-
logger.debug("ERROR OUTPUT: %s", line_str)
583-
elif "warning" in line_str.lower():
584-
logger.debug("WARNING OUTPUT: %s", line_str)
585-
586-
# Update progress indicator on key installer stages
587490
if (
588491
self.progress
589492
and install_task_id
590493
and any(
591-
keyword in line_str.lower()
592-
for keyword in [
494+
kw in line_str.lower()
495+
for kw in [
593496
"creating",
594497
"installing",
595498
"updating",
@@ -599,17 +502,8 @@ async def perform_update(self) -> bool:
599502
):
600503
self.progress.update_task(install_task_id)
601504

602-
_ = await install_process.wait()
603-
604-
logger.debug("Installer process completed")
505+
await install_process.wait()
605506
logger.debug("Installer exit code: %s", install_process.returncode)
606-
if install_process.returncode == 0:
607-
logger.debug("Installation successful")
608-
else:
609-
logger.debug(
610-
"Installation failed with code: %s",
611-
install_process.returncode,
612-
)
613507

614508
if self.progress and install_task_id:
615509
self.progress.finish_task(
@@ -627,29 +521,14 @@ async def perform_update(self) -> bool:
627521
)
628522
return False
629523

630-
# 5) Clean up repo_dir with simple progress tracking
524+
# 4) Clean up cloned repository
631525
if self.progress:
632526
cleanup_task_id = self.progress.start_task(
633527
"cleanup", "Cleaning up temporary files..."
634528
)
635529

636530
if repo_dir.exists():
637531
logger.info("Cleaning up repo directory")
638-
logger.debug("Removing repo directory: %s", repo_dir)
639-
try:
640-
dir_size = sum(
641-
f.stat().st_size
642-
for f in repo_dir.rglob("*")
643-
if f.is_file()
644-
)
645-
logger.debug(
646-
"Repo directory size before cleanup: %d bytes",
647-
dir_size,
648-
)
649-
except Exception as e:
650-
logger.debug(
651-
"Could not calculate repo directory size: %s", e
652-
)
653532
shutil.rmtree(repo_dir)
654533
logger.debug("Repo directory cleanup completed")
655534

@@ -660,18 +539,13 @@ async def perform_update(self) -> bool:
660539
final_description="Cleanup completed",
661540
)
662541

663-
logger.debug("Self-update completed successfully")
664-
logger.debug(
665-
"All operations completed: clone, file copy, "
666-
"installation, cleanup"
667-
)
542+
logger.info("Self-update completed successfully")
668543
return True
669544

670545
except Exception as e:
671-
# Finish any pending progress tasks with error
546+
# Finish any pending progress tasks
672547
for task_id in [
673548
download_task_id,
674-
file_task_id,
675549
install_task_id,
676550
cleanup_task_id,
677551
]:

0 commit comments

Comments
 (0)