feat(cli): skip unchanged files using device file_hashes.json
Read and update file_hashes.json on deploy; add --force-upload and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
57
device.py
57
device.py
@@ -10,6 +10,14 @@ import os
|
||||
import time
|
||||
import serial
|
||||
|
||||
from deploy_manifest import (
|
||||
MANIFEST_FILENAME,
|
||||
build_manifest_bytes,
|
||||
normalize_remote_path,
|
||||
parse_manifest,
|
||||
sha256_hex_file,
|
||||
)
|
||||
|
||||
# Add lib directory to path - handle both normal execution and PyInstaller bundle
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running as a PyInstaller bundle
|
||||
@@ -108,14 +116,42 @@ class DeviceConnection:
|
||||
self._fs_writefile_with_wdt(remote_path, data)
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def _manifest_remote_path(self) -> str:
|
||||
return "/" + MANIFEST_FILENAME
|
||||
|
||||
def read_hash_manifest(self) -> dict:
|
||||
"""Load path -> sha256 hex map from file_hashes.json on the device."""
|
||||
remote = self._manifest_remote_path()
|
||||
if not self.transport.fs_exists(remote):
|
||||
return {}
|
||||
try:
|
||||
data = self.transport.fs_readfile(remote)
|
||||
return parse_manifest(data)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def write_hash_manifest(self, files: dict) -> None:
|
||||
"""Write merged file_hashes.json to the device root."""
|
||||
self._fs_writefile_with_wdt(
|
||||
self._manifest_remote_path(),
|
||||
build_manifest_bytes(files),
|
||||
)
|
||||
|
||||
def upload_directory(self, local_dir, remote_dir=None):
|
||||
def upload_directory(self, local_dir, remote_dir=None, *, force: bool = False):
|
||||
"""
|
||||
Upload a directory recursively to the device.
|
||||
|
||||
Skips files whose sha256 matches file_hashes.json on the device unless
|
||||
force is True. Updates the manifest after upload.
|
||||
|
||||
Args:
|
||||
local_dir: Local directory path to upload
|
||||
remote_dir: Remote directory path (default: root, uses basename of local_dir)
|
||||
force: Upload every file even when the manifest hash matches
|
||||
|
||||
Returns:
|
||||
(files_copied, dirs_created, files_skipped)
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -129,11 +165,14 @@ class DeviceConnection:
|
||||
remote_dir = os.path.basename(os.path.abspath(local_dir))
|
||||
|
||||
# Ensure remote directory exists
|
||||
if not self.transport.fs_exists(remote_dir):
|
||||
if remote_dir and not self.transport.fs_exists(remote_dir):
|
||||
self.transport.fs_mkdir(remote_dir)
|
||||
|
||||
manifest = {} if force else self.read_hash_manifest()
|
||||
|
||||
# Walk through local directory and copy files
|
||||
files_copied = 0
|
||||
files_skipped = 0
|
||||
dirs_created = 0
|
||||
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
@@ -161,6 +200,8 @@ class DeviceConnection:
|
||||
for file in files:
|
||||
if file.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
if file == MANIFEST_FILENAME:
|
||||
continue
|
||||
local_file = os.path.join(root, file)
|
||||
# Handle root directory case properly
|
||||
if remote_base == '/':
|
||||
@@ -168,13 +209,23 @@ class DeviceConnection:
|
||||
else:
|
||||
remote_file = '/'.join([remote_base, file])
|
||||
|
||||
manifest_key = normalize_remote_path(remote_file)
|
||||
local_hash = sha256_hex_file(local_file)
|
||||
if not force and manifest.get(manifest_key) == local_hash:
|
||||
print(f"Skipping (unchanged): {remote_file}", file=sys.stderr)
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
print(f"Uploading: {remote_file}", file=sys.stderr)
|
||||
with open(local_file, 'rb') as f:
|
||||
data = f.read()
|
||||
self._fs_writefile_with_wdt(remote_file, data)
|
||||
manifest[manifest_key] = local_hash
|
||||
files_copied += 1
|
||||
|
||||
return files_copied, dirs_created
|
||||
self.write_hash_manifest(manifest)
|
||||
|
||||
return files_copied, dirs_created, files_skipped
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user