Merge f-022-left: interrupted-move prompt (app + Terminal), deterministic disk test, engine-minimum test reads the tree version

This commit is contained in:
Niko1221 committed 2026-10-20 17:18:00 +02:00
commit 1f727f4b52
20 files changed
+505 -38

No files matched your search

+20
View File
@@ -224,6 +224,26 @@ export function createControl({ control = findControl(), env = process.env, run
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
async interruptedModelsMove(dataFolder) {
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json.interrupted ?? null;
throw failure(result, 'The models folder could not be checked.');
},
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
if (result.json?.ok && result.json.event === 'result') return result.json;
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
if (result.json?.ok) return result.json;
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+3
View File
@@ -974,6 +974,9 @@ async function api(req, res, path, method, url) {
if(picked.cancelled)return json(res,200,{cancelled:true});
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
+29 -2
View File
@@ -424,11 +424,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
setDownloadDirectory(target);lastRead=0;await refresh(true);
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
}
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
}
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
function runModelsMove(old,target,run){
rmSync(moveFile(),{force:true});
return begin('models-move',async job=>{
job.cancelFile=moveFile();job.summary='Starting…';
try{
const result=await control.moveModels(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
if(event.event!=='progress')return;
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
@@ -445,10 +449,33 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}finally{rmSync(moveFile(),{force:true});}
},{family:undefined});
}
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
async function interruptedMove(){
if(!packaged)return null;
const now=currentModelsFolder();if(!now)return null;
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
try{return await control.interruptedModelsMove(now);}catch{return null;}
}
async function finishInterruptedMove({stopRunning=false}={}){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
if(instances.size){
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
for(const id of [...instances.keys()])await unload(id);
}
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
}
async function leaveInterruptedMove(){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
lastRead=0;await refresh(true);
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
}
@@ -54,6 +54,8 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
const [notice, setNotice] = useState('');
const [stopping, setStopping] = useState(false);
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
const folder = native?.downloadDirectory ?? '';
const free = native?.downloadFreeBytes ?? null;
const usage = native?.installed?.usage?.total_bytes ?? 0;
@@ -64,6 +66,41 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
useEffect(() => {
if (moving || outcome) return;
let live = true;
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
return () => { live = false; };
}, [moving, outcome, folder]);
async function finishMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
setResumed(unfinished);
setUnfinished(null);
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
async function leaveMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
await api.post('/api/native/models-folder/leave', {});
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
setUnfinished(null);
await refreshNative(true);
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
async function pick(path = '') {
setPending(true);
@@ -106,7 +143,7 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
try { await nativeAction(`operations/${moving.id}/cancel`); }
catch (e) { setError({ message: (e as Error).message }); }
}
function closeResult() { setOutcome(null); setJobId(null); leave(); setNotice(''); }
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
@@ -125,6 +162,12 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
</form> : null}
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
</div></div> : null}
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
</section>
@@ -155,7 +198,7 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
<li>{tr('From {0} to {1}', [savedPlan?.current ?? '', savedPlan?.folder ?? ''])}</li></ul></details> : null}
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
</Dialog>
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
@@ -199,7 +242,7 @@ function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; cho
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
if (!outcome) return null;
const result = (outcome.result ?? {}) as Outcome;
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? `${name} (${gb(m?.bytes ?? 0)})` : null; }).filter((x): x is string => !!x));
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
if (outcome.state === 'error') {
const full = result.code === 'disk_full';
const where = driveOf(result.to ?? '');
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "Ein Modell läuft. Stoppen Sie es zuerst und ändern Sie dann den Ordner.",
"s8414": "Wählen Sie einen Modellordner.",
"s8415": "Wählen Sie, was mit Ihren Modellen geschehen soll.",
"s8416": "Diese Möglichkeit gibt es hier nicht."
"s8416": "Diese Möglichkeit gibt es hier nicht.",
"s8417": "Ein Verschieben wurde unterbrochen. Beenden Sie es oder lassen Sie es sein.",
"s8418": "Strata hat Ihre Modelle von {0} nach {1} verschoben, als es angehalten wurde. Es ist nichts verloren.",
"s8419": "Verschieben beenden",
"s8420": "Belassen",
"s8421": "Beenden legt alle Ihre Modelle in {1} ab. Belassen lässt sie in {0} und entfernt die unvollständigen Kopien aus {1}.",
"s8422": "Die unvollständigen Kopien wurden entfernt. Ihre Modelle liegen in {0}."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "A model is running. Stop it first, then change the folder.",
"s8414": "Choose a models folder.",
"s8415": "Choose what to do with your models.",
"s8416": "That choice is not possible here."
"s8416": "That choice is not possible here.",
"s8417": "A move was interrupted. Finish it or leave it.",
"s8418": "Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.",
"s8419": "Finish the move",
"s8420": "Leave it",
"s8421": "Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.",
"s8422": "The partial copies were removed. Your models are in {0}."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "Hay un modelo en ejecución. Deténlo primero y después cambia la carpeta.",
"s8414": "Elige una carpeta de modelos.",
"s8415": "Elige qué hacer con tus modelos.",
"s8416": "Esa opción no es posible aquí."
"s8416": "Esa opción no es posible aquí.",
"s8417": "Se interrumpió un traslado. Termínelo o déjelo como estaba.",
"s8418": "Strata estaba trasladando sus modelos de {0} a {1} cuando se detuvo. No se ha perdido nada.",
"s8419": "Terminar el traslado",
"s8420": "Dejarlo",
"s8421": "Terminar pone todos sus modelos en {1}. Dejarlo los mantiene en {0} y elimina las copias parciales de {1}.",
"s8422": "Se eliminaron las copias parciales. Sus modelos están en {0}."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "モデルが実行中です。先に停止してから、フォルダを変更してください。",
"s8414": "モデルフォルダを選んでください。",
"s8415": "モデルをどうするか選んでください。",
"s8416": "この選択肢はここでは使えません。"
"s8416": "この選択肢はここでは使えません。",
"s8417": "移動が中断されました。完了するか、中止してください。",
"s8418": "Strata がモデルを {0} から {1} へ移動している途中で止まりました。失われたものはありません。",
"s8419": "移動を完了する",
"s8420": "中止する",
"s8421": "完了すると、すべてのモデルが {1} に入ります。中止すると、モデルは {0} に残り、{1} の不完全なコピーが削除されます。",
"s8422": "不完全なコピーを削除しました。モデルは {0} にあります。"
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "모델이 실행 중입니다. 먼저 중지한 뒤 폴더를 변경하세요.",
"s8414": "모델 폴더를 선택하세요.",
"s8415": "모델을 어떻게 할지 선택하세요.",
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다."
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다.",
"s8417": "이동이 중단되었습니다. 마무리하거나 그대로 두세요.",
"s8418": "Strata가 모델을 {0}에서 {1}(으)로 옮기던 중 멈췄습니다. 잃어버린 것은 없습니다.",
"s8419": "이동 마무리",
"s8420": "그대로 두기",
"s8421": "마무리하면 모든 모델이 {1}에 들어갑니다. 그대로 두면 모델은 {0}에 남고 {1}의 불완전한 복사본이 삭제됩니다.",
"s8422": "불완전한 복사본을 삭제했습니다. 모델은 {0}에 있습니다."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "Há um modelo em execução. Pare-o primeiro e depois altere a pasta.",
"s8414": "Escolha uma pasta de modelos.",
"s8415": "Escolha o que fazer com seus modelos.",
"s8416": "Essa opção não é possível aqui."
"s8416": "Essa opção não é possível aqui.",
"s8417": "Uma mudança foi interrompida. Termine-a ou deixe como estava.",
"s8418": "O Strata estava mudando seus modelos de {0} para {1} quando parou. Nada foi perdido.",
"s8419": "Terminar a mudança",
"s8420": "Deixar como está",
"s8421": "Terminar coloca todos os seus modelos em {1}. Deixar como está mantém os modelos em {0} e remove as cópias parciais de {1}.",
"s8422": "As cópias parciais foram removidas. Seus modelos estão em {0}."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "Модель запущена. Сначала остановите её, затем смените папку.",
"s8414": "Выберите папку моделей.",
"s8415": "Выберите, что сделать с моделями.",
"s8416": "Этот вариант здесь невозможен."
"s8416": "Этот вариант здесь невозможен.",
"s8417": "Перенос был прерван. Завершите его или оставьте как есть.",
"s8418": "Strata переносил ваши модели из {0} в {1}, когда остановился. Ничего не потеряно.",
"s8419": "Завершить перенос",
"s8420": "Оставить как есть",
"s8421": "Если завершить, все модели окажутся в {1}. Если оставить, модели останутся в {0}, а неполные копии в {1} будут удалены.",
"s8422": "Неполные копии удалены. Ваши модели находятся в {0}."
}
+7 -1
View File
@@ -3437,5 +3437,11 @@
"s8413": "有模型正在运行。请先停止它,再更改文件夹。",
"s8414": "请选择模型文件夹。",
"s8415": "请选择如何处理你的模型。",
"s8416": "此处无法使用该选项。"
"s8416": "此处无法使用该选项。",
"s8417": "移动被中断了。请完成它,或保持原样。",
"s8418": "Strata 正在把你的模型从 {0} 移到 {1} 时停止了。没有任何内容丢失。",
"s8419": "完成移动",
"s8420": "保持原样",
"s8421": "完成后,所有模型都会放到 {1}。保持原样则模型留在 {0},并删除 {1} 中不完整的副本。",
"s8422": "已删除不完整的副本。你的模型在 {0}。"
}
+44 -3
View File
@@ -8,7 +8,7 @@ import { createNativeControl } from '../server/native-control.mjs';
import { createControl } from '../server/control-runtime.mjs';
/** The host in the packaged app with a stand-in for Strata Control: the move itself is Control's (tests/terminal/test_models_move.py). */
function fixture(t, { plan = {}, move = null } = {}) {
function fixture(t, { plan = {}, move = null, unfinished = null } = {}) {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'strata-mfolder-')));
const install = join(root, 'install'), data = join(root, 'host'), oldFolder = join(root, 'old'), newFolder = join(root, 'new');
for (const d of [install, data, oldFolder, newFolder]) mkdirSync(d, { recursive: true });
@@ -34,6 +34,13 @@ function fixture(t, { plan = {}, move = null } = {}) {
options.onEvent({ event: 'progress', done: 5e9, total: 74e9, file: 'models/gyro-s/a.gguf', group: 'gyro-s', index: 1, count: 3, seconds_left: 300, same_drive: false });
return { ok: true, event: 'result', done: true, stopped: false, moved: ['gyro-s', 'coder'], remaining: [], bytes_moved: 74e9, total_bytes: 74e9, files_moved: 3, same_drive: false };
},
async interruptedModelsMove(from) { calls.push(['status', from]); return unfinished; },
async resumeModelsMove(from, options) {
calls.push(['resume', from, options.installDir]);
options.onEvent({ event: 'progress', done: 20e9, total: 74e9, file: 'models/coder/c.gguf', group: 'coder', index: 2, count: 3, seconds_left: 120, same_drive: false });
return { ok: true, event: 'result', done: true, stopped: false, moved: ['coder'], remaining: [], bytes_moved: 74e9, total_bytes: 74e9, files_moved: 2, same_drive: false };
},
async leaveModelsMove(from, options) { calls.push(['leave', from, options.installDir]); return { ok: true, removed: 1, restored: 1, bytes_removed: 5 }; },
async stop() { return { ok: true }; },
};
const spawnProcess = () => { throw new Error('the host starts no process here'); };
@@ -160,11 +167,45 @@ test('a move needs the app: the Control command line is built from the plan and
await assert.rejects(failing.moveModels('/o', '/n'), e => e.code === 'disk_full' && e.details === 'ENOSPC' && /ran out of space/.test(e.message));
});
test('an unfinished move is found at the next start, and Finish resumes it as a job that changes the folder only when done', async t => {
const f = fixture(t);
f.control.interruptedModelsMove = async from => { f.calls.push(['status', from]); return { state: 'failed', from: f.oldFolder, to: f.newFolder, bytes: 74e9 }; };
assert.equal((await f.native.interruptedMove()).to, f.newFolder);
assert.deepEqual(f.calls[0], ['status', f.oldFolder]);
const job = await f.native.finishInterruptedMove({ stopRunning: true });
const done = await finish(f.native, job.id);
assert.equal(done.state, 'done');
assert.equal(f.calls.find(c => c[0] === 'resume')[1], f.oldFolder);
assert.equal(f.native.state().downloadDirectory, f.newFolder);
});
test('Leave it keeps the old folder and asks Control to remove the partial copies; nothing interrupted means nothing to do', async t => {
const f = fixture(t);
assert.equal(await f.native.interruptedMove(), null);
await assert.rejects(f.native.leaveInterruptedMove(), /No move was interrupted/);
f.control.interruptedModelsMove = async () => ({ state: 'running', from: f.oldFolder, to: f.newFolder, bytes: 1 });
const result = await f.native.leaveInterruptedMove();
assert.equal(result.folder, f.oldFolder);
assert.deepEqual(f.calls.find(c => c[0] === 'leave').slice(0, 2), ['leave', f.oldFolder]);
assert.equal(f.native.state().downloadDirectory, f.oldFolder);
});
test('the Control command lines for status, resume and leave', async () => {
const seen = [];
const control = createControl({ control: { dir: '/c', python: '/c/py', cli: '/c/strata.py' }, run: async args => { seen.push(args); return args.includes('--status') ? { code: 0, json: { ok: true, interrupted: { from: '/o', to: '/n' } }, text: '', stderr: '' } : args.includes('--leave') ? { code: 0, json: { ok: true, removed: 0 }, text: '', stderr: '' } : { code: 0, json: { ok: true, event: 'result', done: true }, text: '', stderr: '' }; } });
assert.equal((await control.interruptedModelsMove('/o')).to, '/n');
await control.resumeModelsMove('/o', { installDir: '/i', cancelFile: '/c.cancel' });
await control.leaveModelsMove('/o', { installDir: '/i' });
assert.deepEqual(seen[0], ['models', 'move', '--status', '--from', '/o', '--output', 'json']);
assert.deepEqual(seen[1], ['models', 'move', '--resume', '--from', '/o', '--install-dir', '/i', '--cancel-file', '/c.cancel', '--output', 'jsonl']);
assert.deepEqual(seen[2], ['models', 'move', '--leave', '--from', '/o', '--install-dir', '/i', '--output', 'json']);
});
test('the models folder strings exist in all 8 languages with the same placeholders', () => {
const read = lang => JSON.parse(readFileSync(new URL(`../src/locales/${lang}.json`, import.meta.url), 'utf8'));
const en = read('en');
const ours = Object.keys(en).filter(k => Number(k.slice(1)) >= 8339 && Number(k.slice(1)) <= 8416);
assert.equal(ours.length, 78);
const ours = Object.keys(en).filter(k => Number(k.slice(1)) >= 8339 && Number(k.slice(1)) <= 8422);
assert.equal(ours.length, 84);
for (const lang of ['de', 'es', 'ja', 'ko', 'pt', 'ru', 'zh']) {
const text = read(lang);
for (const key of ours) {
+54 -1
View File
@@ -160,7 +160,10 @@ def parser() -> argparse.ArgumentParser:
help="inspect only; adoption requires a qualified model workflow")
models_import.add_argument("--output", choices=("human", "json"), default="human")
models_move = models_sp.add_parser("move", help="move the model files (models, packs, mtp) to another folder: copied, checked, then the old copies removed")
models_move.add_argument("to", metavar="NEW_FOLDER")
models_move.add_argument("to", metavar="NEW_FOLDER", nargs="?", help="not needed with --resume, --leave or --status")
models_move.add_argument("--resume", action="store_true", help="finish a move that was interrupted")
models_move.add_argument("--leave", action="store_true", help="give up an interrupted move: keep the old folder and remove the partial copies")
models_move.add_argument("--status", action="store_true", help="say whether a move was interrupted (changes nothing)")
models_move.add_argument("--from", dest="source", metavar="OLD_FOLDER", help="the folder the models are in now (default: the data folder in use)")
models_move.add_argument("--install-dir", help="absolute path to a Strata installation whose run configs should follow the files")
models_move.add_argument("--plan", action="store_true", help="say what would happen and change nothing")
@@ -613,6 +616,11 @@ def _models_move(a: argparse.Namespace) -> int:
from strata_control import discovery, models, models_move as mm
machine = a.output in ("json", "jsonl")
old = Path(a.source) if a.source else discovery.data_dir(Path.home() / "Strata")[0]
if a.status or a.resume or a.leave:
return _models_move_unfinished(a, old)
if not a.to:
print("strata: name the new folder, or use --resume / --leave / --status for an interrupted move.", file=sys.stderr)
return 2
new = Path(a.to).expanduser()
if a.plan:
result = mm.plan(old, new)
@@ -650,6 +658,51 @@ def _models_move(a: argparse.Namespace) -> int:
return 0 if summary["done"] else 6
def _interrupted_words(st: dict) -> str:
return (f"A move of your models was interrupted. They are in {st['from']} and {st['to']}. "
"Finish it: strata models move --resume. Leave it (keep the models in the first folder): strata models move --leave.")
def _models_move_unfinished(a: argparse.Namespace, old: Path) -> int:
"""`strata models move --status | --resume | --leave`: the move the last run left unfinished (A move was interrupted. Finish it or leave it)."""
from strata_control import models, models_move as mm
machine = a.output in ("json", "jsonl")
st = mm.interrupted(old)
if a.status:
if machine:
print(json.dumps({"schema_version": 1, "ok": True, "interrupted": st}, ensure_ascii=False, allow_nan=False))
else:
print(_interrupted_words(st) if st else "No move was interrupted.")
return 0
if not st:
if machine:
print(json.dumps({"schema_version": 1, "ok": False, "error": {"code": "nothing", "message": "No move was interrupted.", "details": ""}}))
else:
print("strata: no move was interrupted.", file=sys.stderr)
return 5
try:
installs = [models.resolve_install(a.install_dir)[0]] if a.install_dir else [p for p, _s in models.all_installs()]
except Exception:
installs = []
configs = [c for i in installs for c in mm.config_files(i)]
if a.leave:
try:
result = mm.leave(Path(st["from"]), Path(st["to"]), configs=configs)
except mm.MoveError as e:
if machine:
print(json.dumps({"schema_version": 1, "ok": False, "error": {"code": e.code, "message": str(e), "details": e.details}}, ensure_ascii=False))
else:
print(f"strata: {e} {e.details}".rstrip(), file=sys.stderr)
return 5
if machine:
print(json.dumps({"schema_version": 1, "ok": True, "event": "result", "left": True, **result}, ensure_ascii=False))
else:
print(f"Done. Your models stay in {st['from']}; the partial copies in {st['to']} were removed.")
return 0
a.to, a.source, a.resume, a.status, a.leave = st["to"], st["from"], False, False, False
return _models_move(a)
def _import_words(plan: dict) -> str:
"""The plain-words result of `strata models import --plan`: what the files are, what is already set up, what changes (nothing)."""
cand = plan.get("candidate_selection") or {}
+13
View File
@@ -317,6 +317,17 @@ def _say(line: str = "") -> None:
sys.stdout.flush()
def _interrupted_move_notice() -> None:
"""One line when a move of the models folder was left unfinished (never blocks the launch)."""
try:
from strata_control import discovery, models_move
st = models_move.interrupted(discovery.data_dir(Path.home() / "Strata")[0])
except Exception:
return
if st:
_say("A move of your models was interrupted. Finish it with `strata models move --resume`, or keep them where they are with `strata models move --leave`.")
def _printer(quiet: bool = False):
"""An emit callback that prints readable lines: new text once, progress in steps of 10% when not on a terminal."""
state = {"last": None, "pct": -10, "stage": None}
@@ -547,6 +558,8 @@ def run_launch(a: argparse.Namespace) -> int:
if a.model or a.model_path:
return fail(str(e), 5)
s, notices = settings_from_args(a, catalog)
if not machine:
_interrupted_move_notice()
have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.tune)
interactive = sys.stdin.isatty() and sys.stdout.isatty() and not a.no_tui and not machine and not a.plan
plain_questions = False
+106 -13
View File
@@ -272,12 +272,6 @@ def _read_state(new: Path) -> dict:
return {}
def interrupted(data: Path) -> dict | None:
"""The unfinished move recorded in a data folder (``models-move.json``), for 'A move was interrupted'."""
st = _read_state(data)
return st if st.get("state") == "running" else None
def _write_state(new: Path, **fields) -> None:
try:
tmp = new / (STATE_FILE + ".tmp")
@@ -287,6 +281,50 @@ def _write_state(new: Path, **fields) -> None:
pass
def _save_state(old: Path, new: Path, **fields) -> None:
"""The state goes in the new folder and, as a pointer, in the old one: the old folder stays the data folder until the move is done,
so that is where the next start looks."""
_write_state(new, **fields)
_write_state(old, **fields)
def _clear_state(*folders: Path) -> None:
for f in folders:
_quiet_remove(Path(f) / STATE_FILE)
def _same_path(a, b) -> bool:
try:
return _same(Path(a), Path(b))
except (OSError, ValueError, TypeError):
return False
def interrupted(data: Path) -> dict | None:
"""The unfinished move recorded in the data folder (``models-move.json``), for 'A move was interrupted. Finish it or leave it'.
A move that is running right now (its process is alive) is not interrupted. A stopped or failed move that left files in both folders is.
Returns ``{state, from, to, bytes}`` or None.
"""
data = Path(data)
st = _read_state(data)
if st.get("state") not in ("running", "stopped", "failed") or not st.get("from") or not st.get("to"):
return None
if not _same_path(st["from"], data): # a pointer left in some other folder is not ours
return None
if not Path(st["from"]).is_dir():
return None
pid, token = st.get("pid"), st.get("token")
if st["state"] == "running" and isinstance(pid, int) and token and pid != os.getpid():
try:
from strata_control import procs
if procs.start_token(pid) == token:
return None
except Exception:
pass
return st
def repoint_config(cfg_file: Path, old: Path, new: Path) -> bool:
"""A run config whose model files moved from ``old`` to ``new`` names them there (each path only when its file is now there and no longer
at the old place). Same rule as setup.py repoint_config. Returns whether the file changed."""
@@ -366,8 +404,17 @@ def move(old: Path, new: Path, *, on_progress: Callable[[dict], None] | None = N
todo[e.group].append(e)
left = {g: len(v) for g, v in todo.items()}
moved_groups: list[str] = []
state = {"state": "running", "from": str(old), "to": str(new), "started": now(), "bytes": total}
_write_state(new, **state)
before = _read_state(old)
if not (_same_path(before.get("from", ""), old) and _same_path(before.get("to", ""), new)):
before = {}
try:
from strata_control import procs
token = procs.start_token(os.getpid())
except Exception:
token = None
state = {"state": "running", "from": str(old), "to": str(new), "started": now(), "bytes": total, "pid": os.getpid(), "token": token,
"files": sorted({e.rel for e in entries} | set(before.get("files") or []))}
_save_state(old, new, **state)
done_bytes = 0
files_moved = 0
last_emit = [0.0]
@@ -459,22 +506,68 @@ def move(old: Path, new: Path, *, on_progress: Callable[[dict], None] | None = N
repoint()
repoint()
state.update(state="done", finished=now())
_write_state(new, **state)
_quiet_remove(new / STATE_FILE)
_clear_state(old, new)
return {"done": True, "stopped": False, "moved": moved_groups, "remaining": [], "bytes_moved": done_bytes, "total_bytes": total,
"files_moved": files_moved, "same_drive": same}
except Stopped:
repoint()
state.update(state="stopped")
_write_state(new, **state)
_quiet_remove(new / STATE_FILE) if not moved_groups and not files_moved else None
if files_moved:
_save_state(old, new, **state)
else:
_clear_state(old, new)
remaining = [g for g, n in left.items() if n > 0]
return {"done": False, "stopped": True, "moved": moved_groups, "remaining": remaining, "bytes_moved": done_bytes, "total_bytes": total,
"files_moved": files_moved, "same_drive": same}
except MoveError as err:
repoint()
state.update(state="failed", error=err.code)
_write_state(new, **state)
if files_moved:
_save_state(old, new, **state)
else:
_clear_state(old, new)
err.summary = {"moved": moved_groups, "remaining": [g for g, n in left.items() if n > 0], "bytes_moved": done_bytes, "total_bytes": total,
"files_moved": files_moved}
raise
def leave(old: Path, new: Path, *, configs: list[Path] | None = None) -> dict:
"""'Leave it': give up an unfinished move and keep the old folder as the complete one.
Only the files the move itself listed are touched. A copy in the new folder whose original is still in the old one is removed; a file
that exists only in the new folder (its original went after a checked copy) is put back in the old folder, so nothing is lost.
``.part`` files are removed and the run configs point at the old folder again. Returns ``{removed, restored, bytes_removed}``.
"""
old, new = Path(old), Path(new)
st = _read_state(old) or _read_state(new)
rels = [r for r in (st.get("files") or []) if isinstance(r, str) and r and ".." not in Path(r).parts and not Path(r).is_absolute()]
removed = restored = bytes_removed = 0
for rel in rels:
src, dst = old / rel, new / rel
_quiet_remove(Path(str(dst) + ".part"))
if not dst.exists():
continue
try:
size = dst.stat().st_size
if src.exists():
os.remove(dst)
removed += 1
bytes_removed += size
else:
src.parent.mkdir(parents=True, exist_ok=True)
if same_drive(old, new):
os.replace(dst, src)
else:
_copy_verified(dst, src, size, lambda: False, lambda n: None)
os.remove(dst)
restored += 1
except OSError as e:
raise MoveError("io", f"Strata could not clean up {dst.name}.", f"{type(e).__name__}: {e}") from e
_prune(dst.parent, new)
for cfg in (configs or []):
try:
repoint_config(cfg, new, old)
except OSError:
pass
_clear_state(old, new)
return {"removed": removed, "restored": restored, "bytes_removed": bytes_removed}
+123 -6
View File
@@ -52,6 +52,22 @@ class MoveCase(unittest.TestCase):
"""Another drive: the files are copied, whatever this machine's volumes are."""
return mock.patch.object(mm, "same_drive", return_value=False)
def break_after(self, n):
"""A copy that works n times, then fails: a move cut off part way."""
real, calls = mm._copy_verified, [0]
def flaky(*args, **kw):
calls[0] += 1
if calls[0] > n:
raise mm.MoveError("io", "cut off")
return real(*args, **kw)
return mock.patch.object(mm, "_copy_verified", side_effect=flaky)
def cut_off(self, n=1):
with self.copy_mode(), self.break_after(n):
with self.assertRaises(mm.MoveError):
self.run_move()
def run_move(self, **kw):
events = []
summary = mm.move(self.old, self.new, on_progress=events.append, configs=[self.config], **kw)
@@ -222,13 +238,68 @@ class MoveTests(MoveCase):
self.check_whole()
def test_an_interrupted_move_is_remembered(self):
self.cut_off(1)
st = mm.interrupted(self.old) # the old folder is still the data folder: it holds the pointer
self.assertEqual(st["to"], str(self.new))
self.assertEqual(st["state"], "failed")
self.assertEqual(mm.interrupted(self.base / "elsewhere"), None)
mm._write_state(self.old, state="running", pid=os.getpid() + 7, token=None, **{"from": str(self.old), "to": str(self.new)})
self.assertEqual(mm.interrupted(self.old)["state"], "running")
def test_a_move_that_is_running_now_is_not_interrupted(self):
from strata_control import procs
mm._write_state(self.old, state="running", pid=os.getpid() + 7, token="t", **{"from": str(self.old), "to": str(self.new)})
with mock.patch.object(procs, "start_token", return_value="t"):
self.assertIsNone(mm.interrupted(self.old))
with mock.patch.object(procs, "start_token", return_value=None):
self.assertIsNotNone(mm.interrupted(self.old))
def test_nothing_moved_leaves_nothing_to_ask_about(self):
self.cut_off(0)
self.assertIsNone(mm.interrupted(self.old))
self.assertFalse((self.new / mm.STATE_FILE).exists())
def test_finish_resumes_the_move(self):
self.cut_off(1)
st = mm.interrupted(self.old)
with self.copy_mode():
with self.assertRaises(mm.MoveError):
with mock.patch.object(mm, "_copy_verified", side_effect=mm.MoveError("io", "x")):
self.run_move()
self.assertEqual(mm.interrupted(self.new), None) # failed is not "running"
mm._write_state(self.new, state="running", **{"from": str(self.old), "to": str(self.new)})
self.assertEqual(mm.interrupted(self.new)["from"], str(self.old))
summary = mm.move(Path(st["from"]), Path(st["to"]), configs=[self.config])
self.assertTrue(summary["done"])
self.check_whole()
self.assertIsNone(mm.interrupted(self.old))
self.assertFalse((self.old / mm.STATE_FILE).exists() or (self.new / mm.STATE_FILE).exists())
def test_leave_keeps_the_old_folder_complete(self):
self.cut_off(1)
(self.new / "models/stray.gguf.part").write_bytes(b"x") # not listed by the move, but a .part of ours is
(self.new / "models/gyro-s").mkdir(parents=True, exist_ok=True)
(self.new / "models/gyro-s/a.gguf.part").write_bytes(b"cut")
theirs = self.new / "models/own/mine.gguf" # a file of the person's, not part of the move
theirs.parent.mkdir(parents=True)
theirs.write_bytes(b"mine")
st = mm.interrupted(self.old)
with self.copy_mode():
result = mm.leave(Path(st["from"]), Path(st["to"]), configs=[self.config])
self.assertGreaterEqual(result["restored"] + result["removed"], 1)
for rel, data in self.files.items():
self.assertEqual((self.old / rel).read_bytes(), data, rel)
self.assertFalse((self.new / rel).exists(), rel)
self.assertEqual(list(self.new.rglob("*.part")), [(self.new / "models/stray.gguf.part")])
self.assertTrue(theirs.exists())
self.assertIsNone(mm.interrupted(self.old))
self.assertFalse((self.new / mm.STATE_FILE).exists())
cfg = json.loads(self.config.read_text())
self.assertTrue(all(str(self.new) not in str(v) for v in cfg.values()))
self.assertIn(str(self.old / "models/gyro-s/a.gguf"), cfg["args"])
def test_leave_after_a_crash_removes_the_duplicate(self):
self.cut_off(1)
dup = self.new / "models/coder/c.gguf" # copied and renamed, original not yet removed
dup.parent.mkdir(parents=True, exist_ok=True)
dup.write_bytes(self.files["models/coder/c.gguf"])
mm.leave(self.old, self.new)
self.assertFalse(dup.exists())
self.assertTrue((self.old / "models/coder/c.gguf").exists())
def test_configs_are_rewritten_only_for_files_that_are_there(self):
self.config.write_text(json.dumps({"args": ["--native", str(self.old / "models/gone.gguf")]}))
@@ -265,6 +336,52 @@ class CommandLineTests(MoveCase):
self.assertTrue(lines[-1]["done"])
self.assertTrue((self.new / "models/coder/c.gguf").exists())
def run_cli(self, *argv):
from strata_cli import cli
from strata_control import discovery
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out), self.copy_mode(), mock.patch.object(discovery, "data_dir", return_value=(self.old, "settings")):
code = cli.main(["models", "move", *argv])
return code, out.getvalue()
def test_status_resume_and_leave(self):
code, text = self.run_cli("--status", "--output", "json")
self.assertEqual((code, json.loads(text)["interrupted"]), (0, None))
self.assertEqual(self.run_cli("--resume", "--output", "json")[0], 5)
self.cut_off(1)
code, text = self.run_cli("--status", "--output", "json")
self.assertEqual(json.loads(text)["interrupted"]["to"], str(self.new))
self.assertIn("--resume", self.run_cli("--status")[1])
code, text = self.run_cli("--leave", "--install-dir", str(self.install), "--output", "json")
self.assertEqual(code, 0, text)
self.assertTrue((self.old / "models/coder/c.gguf").exists())
self.cut_off(1)
code, text = self.run_cli("--resume", "--install-dir", str(self.install), "--output", "jsonl")
self.assertEqual(code, 0, text)
self.assertTrue([json.loads(l) for l in text.splitlines() if l.startswith("{")][-1]["done"])
self.assertTrue((self.new / "models/coder/c.gguf").exists())
self.assertFalse((self.old / "models/coder/c.gguf").exists())
def test_launch_says_it_in_one_line(self):
from strata_cli.launch import cmd
from strata_control import discovery
import contextlib
import io
self.cut_off(1)
with mock.patch.object(discovery, "data_dir", return_value=(self.old, "settings")):
out = io.StringIO()
with contextlib.redirect_stdout(out):
cmd._interrupted_move_notice()
self.assertEqual(len(out.getvalue().strip().splitlines()), 1)
self.assertIn("models move --resume", out.getvalue())
mm.leave(self.old, self.new)
out = io.StringIO()
with contextlib.redirect_stdout(out):
cmd._interrupted_move_notice()
self.assertEqual(out.getvalue(), "")
def test_the_cancel_file_stops_the_run(self):
from strata_cli import cli
import contextlib
+1 -1
View File
@@ -442,7 +442,7 @@ class SetupIntegration(unittest.TestCase):
self.assertNotIn("an earlier start", out.getvalue())
out = io.StringIO()
with mock.patch.object(self.S, "ROOT", Path(self.tmp.name)), \
mock.patch.object(self.S, "data_folder", lambda d: (Path(self.tmp.name) / "data", [])), \
mock.patch.object(self.S, "data_folder", lambda d, *a, **k: (Path(self.tmp.name) / "data", [])), \
mock.patch.object(self.S, "update_installed_engine", lambda *a: None), \
mock.patch.object(sys, "argv", ["setup.py", "--calibrate", "--no-start"]), \
contextlib.redirect_stdout(out):
+7 -1
View File
@@ -6,6 +6,7 @@ install removing a stale strata_kq library.
"""
from __future__ import annotations
import re
import sys
import tempfile
import unittest
@@ -155,7 +156,12 @@ class EnginePlaceholders(unittest.TestCase):
self.assertEqual(setup.MIN_ENGINE, (0, 1, 45))
for m in ("UD-Q5_K_XL", "UD-Q6_K_XL", "NVFP4", "NVFP4-NVIDIA"):
self.assertEqual(setup.MODELS[m]["engine"], (0, 1, 44), m)
self.assertIn("0.1.45", (ROOT / "CMakeLists.txt").read_text(encoding="utf-8").split("project(strata VERSION", 1)[1][:12])
# No minimum may name an engine newer than this tree (a placeholder for a release that does not exist yet would make setup
# ask for an engine nobody can download). The tree's version is read from CMakeLists.txt, so this does not go stale each release.
text = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8")
tree = tuple(int(n) for n in re.search(r"project\(strata VERSION ([0-9.]+)", text).group(1).split("."))
for what, minimum in [("MIN_ENGINE", setup.MIN_ENGINE), ("NVFP4_ENGINE", setup.NVFP4_ENGINE)] + [(m, setup.MODELS[m]["engine"]) for m in setup.MODELS if "engine" in setup.MODELS[m]]:
self.assertLessEqual(tuple(minimum), tree[:len(minimum)], f"{what} {minimum} is ahead of the tree {tree}")
class KqLibrary(unittest.TestCase):
+3
View File
@@ -11,6 +11,7 @@ import contextlib
import hashlib
import io
import json
import shutil
import sys
import tempfile
import types
@@ -186,6 +187,8 @@ class Base(unittest.TestCase):
mock.patch.object(setup, "cpu_cores", lambda: None), # #642: not a hybrid CPU
mock.patch.object(setup, "page_file_gb", lambda: 16.0),
mock.patch.object(setup, "free_gb", lambda p: free),
# whatever else asks the disk (shutil.disk_usage) gets the same answer: the tests never depend on this PC's free space
mock.patch.object(shutil, "disk_usage", lambda p: shutil._ntuple_diskusage(int(max(free, 1000) * 1e9), int(max(free, 1000) * 1e9 - free * 1e9), int(free * 1e9))),
mock.patch.object(setup, "rotational_disk", lambda p: None), # #605: not the test PC's disk
mock.patch.object(setup, "is_wsl", lambda: False), # #974: the tests are not run inside WSL
mock.patch.object(setup, "pip_install", lambda *a, **k: None),