Source code for simstack.core.services.git_uv_update_service
import asyncio
import logging
from pathlib import Path
import hashlib
from simstack.core.context import context
from simstack.models.parameters import Resource
from simstack.models.runner_model import RunnerEventEnum
from simstack.core.services.base_service import RestartService
logger = logging.getLogger("NodeRunner")
[docs]
def get_file_checksum(filepath: Path) -> str:
"""Calculate SHA256 checksum of a file"""
if not filepath.exists():
return ""
hash_sha256 = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
[docs]
class GitUvUpdateService(RestartService):
"""
Advanced service that performs git pull and uv lock upgrades.
If changes are detected, it restarts the runner.
"""
def __init__(self, resource: Resource, interval):
super().__init__("GitUvUpdate", resource, interval)
# Resolve project root (assuming we are in src/simstack/core/runner.py)
self._project_dir = context.config.project_root.resolve(strict=True)
self._uv_lock_path = context.config.project_root / "uv.lock"
self._uv_extra_depencency_path = context.config.project_root / "user_extra_config.toml"
self.extras=False
self.desired_extras=[]
#check if the file _uv_extra_depencency_path exists if yes - read toml and then extras=True
try :
import tomllib
logger.info(f" trying to read user-specified (extras) optional dependencies from {self._uv_extra_depencency_path}")
with open (self._uv_extra_depencency_path,"rb") as f:
desired_extras_all=tomllib.load(f)
# Access the nested section - this was the issue with the keys
extras_section = desired_extras_all.get("optional_dependencies_desired_by_ressource", {})
# Access raw value without triggering validation against allowed_resources
resource_name = object.__getattribute__(resource, "__dict__").get("value") or str(resource)
#logger.info(f" name {resource_name} , type {type(resource_name)}, compared against {extras_section.keys()}")
if resource_name in extras_section.keys():
self.desired_extras = extras_section[resource_name]
self.extras = True
except Exception as e:
logger.warning(f"resource {resource} spec with user file {self._uv_extra_depencency_path} failed with the exception {e}")
async def _run_command(self, cmd: list, ignore_error=False) -> str:
"""Run a shell command and return output"""
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(self._project_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
if ignore_error:
logger.warning(f"Command {' '.join(cmd)} failed: {stderr.decode()}")
else:
raise RuntimeError(f"Command {' '.join(cmd)} failed: {stderr.decode()}")
return stdout.decode().strip()
[docs]
async def execute(self):
# 1. Git Pull
# Check checksum before and after pull to see if Git brought a new lockfile
# old_uv_checksum = get_file_checksum(self._uv_lock_path)
# Ensure we don't have local lockfile changes that block the pull
await self._run_command(["git", "stash"], ignore_error=True)
git_output = await self._run_command(["git", "pull", "--recurse-submodules" ])
git_changed = "Already up to date." not in git_output
# Clear the stash now that we've pulled
# await self._run_command(["git", "stash", "drop"], ignore_error=True)
if git_changed: # or uv_locally_upgraded:
command_list=["uv", "sync", "--locked"]
if self.extras and len(self.desired_extras) > 0:
for desired_extra in self.desired_extras:
command_list.extend(["--extra", str(desired_extra)])
logger.info(f"user specified extra dependency {desired_extra}")
await self._run_command(command_list) # Update local .venv but with extras as specified by the user for the ressource
else:
await self._run_command(command_list) # Update local .venv
reason = "Git pull" if git_changed else "Local UV upgrade"
await self.write_resource_event(RunnerEventEnum.SHUTDOWN, message=reason)
logger.info(f"Update detected ({reason}). Triggering restart...")
await self.trigger_restart()