khh
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
32
.github/workflows/smoke-mm.yml
vendored
Normal file
32
.github/workflows/smoke-mm.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: smoke-mm
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
name: Install & smoke test mm --help
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Create venv and install
|
||||||
|
run: |
|
||||||
|
python -m venv venv
|
||||||
|
. venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -e .
|
||||||
|
|
||||||
|
- name: Run smoke test (mm --help)
|
||||||
|
run: |
|
||||||
|
. venv/bin/activate
|
||||||
|
mm --help
|
||||||
211
MPV/LUA/main.lua
211
MPV/LUA/main.lua
@@ -4,7 +4,7 @@ local msg = require 'mp.msg'
|
|||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local MEDEIA_LUA_VERSION = '2025-12-18'
|
local MEDEIA_LUA_VERSION = '2025-12-24'
|
||||||
|
|
||||||
-- Track whether uosc is available so menu calls don't fail with
|
-- Track whether uosc is available so menu calls don't fail with
|
||||||
-- "Can't find script 'uosc' to send message to."
|
-- "Can't find script 'uosc' to send message to."
|
||||||
@@ -159,6 +159,8 @@ local function write_temp_log(prefix, text)
|
|||||||
|
|
||||||
local dir = ''
|
local dir = ''
|
||||||
-- Prefer repo-root Log/ for easier discovery.
|
-- Prefer repo-root Log/ for easier discovery.
|
||||||
|
-- NOTE: Avoid spawning cmd.exe/sh just to mkdir on Windows/Linux; console flashes are
|
||||||
|
-- highly undesirable. If the directory doesn't exist, we fall back to TEMP.
|
||||||
do
|
do
|
||||||
local function find_up(start_dir, relative_path, max_levels)
|
local function find_up(start_dir, relative_path, max_levels)
|
||||||
local d = start_dir
|
local d = start_dir
|
||||||
@@ -186,13 +188,6 @@ local function write_temp_log(prefix, text)
|
|||||||
local parent = cli:match('(.*)[/\\]') or ''
|
local parent = cli:match('(.*)[/\\]') or ''
|
||||||
if parent ~= '' then
|
if parent ~= '' then
|
||||||
dir = utils.join_path(parent, 'Log')
|
dir = utils.join_path(parent, 'Log')
|
||||||
-- Best-effort create dir.
|
|
||||||
local sep = package and package.config and package.config:sub(1, 1) or '/'
|
|
||||||
if sep == '\\' then
|
|
||||||
pcall(utils.subprocess, { args = { 'cmd.exe', '/c', 'mkdir "' .. dir .. '" 1>nul 2>nul' } })
|
|
||||||
else
|
|
||||||
pcall(utils.subprocess, { args = { 'sh', '-lc', 'mkdir -p ' .. string.format('%q', dir) .. ' >/dev/null 2>&1' } })
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -207,7 +202,15 @@ local function write_temp_log(prefix, text)
|
|||||||
local path = utils.join_path(dir, name)
|
local path = utils.join_path(dir, name)
|
||||||
local fh = io.open(path, 'w')
|
local fh = io.open(path, 'w')
|
||||||
if not fh then
|
if not fh then
|
||||||
return nil
|
-- If Log/ wasn't created (or is not writable), fall back to TEMP.
|
||||||
|
local tmp = os.getenv('TEMP') or os.getenv('TMP') or ''
|
||||||
|
if tmp ~= '' and tmp ~= dir then
|
||||||
|
path = utils.join_path(tmp, name)
|
||||||
|
fh = io.open(path, 'w')
|
||||||
|
end
|
||||||
|
if not fh then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
fh:write(text)
|
fh:write(text)
|
||||||
fh:close()
|
fh:close()
|
||||||
@@ -350,6 +353,30 @@ local function _is_windows()
|
|||||||
return sep == '\\'
|
return sep == '\\'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function _resolve_python_exe(prefer_no_console)
|
||||||
|
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
|
||||||
|
if (not prefer_no_console) or (not _is_windows()) then
|
||||||
|
return python
|
||||||
|
end
|
||||||
|
|
||||||
|
local low = tostring(python):lower()
|
||||||
|
if low == 'python' then
|
||||||
|
return 'pythonw'
|
||||||
|
end
|
||||||
|
if low == 'python.exe' then
|
||||||
|
return 'pythonw.exe'
|
||||||
|
end
|
||||||
|
if low:sub(-10) == 'python.exe' then
|
||||||
|
local candidate = python:sub(1, #python - 10) .. 'pythonw.exe'
|
||||||
|
if utils.file_info(candidate) then
|
||||||
|
return candidate
|
||||||
|
end
|
||||||
|
return 'pythonw'
|
||||||
|
end
|
||||||
|
-- Already pythonw or some other launcher.
|
||||||
|
return python
|
||||||
|
end
|
||||||
|
|
||||||
local function _extract_target_from_memory_uri(text)
|
local function _extract_target_from_memory_uri(text)
|
||||||
if type(text) ~= 'string' then
|
if type(text) ~= 'string' then
|
||||||
return nil
|
return nil
|
||||||
@@ -475,10 +502,10 @@ end
|
|||||||
local function _get_current_item_is_image()
|
local function _get_current_item_is_image()
|
||||||
local video_info = mp.get_property_native('current-tracks/video')
|
local video_info = mp.get_property_native('current-tracks/video')
|
||||||
if type(video_info) == 'table' then
|
if type(video_info) == 'table' then
|
||||||
if video_info.image and not video_info.albumart then
|
if video_info.image == true then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if video_info.image == false and video_info.albumart == true then
|
if video_info.image == false then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -489,8 +516,6 @@ local function _get_current_item_is_image()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Cover art / splash support disabled (removed per user request)
|
|
||||||
|
|
||||||
|
|
||||||
local function _set_image_property(value)
|
local function _set_image_property(value)
|
||||||
pcall(mp.set_property_native, 'user-data/mpv/image', value and true or false)
|
pcall(mp.set_property_native, 'user-data/mpv/image', value and true or false)
|
||||||
@@ -789,7 +814,8 @@ local function _pick_folder_windows()
|
|||||||
-- Native folder picker via PowerShell + WinForms.
|
-- Native folder picker via PowerShell + WinForms.
|
||||||
local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]]
|
local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]]
|
||||||
local res = utils.subprocess({
|
local res = utils.subprocess({
|
||||||
args = { 'powershell', '-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps },
|
-- Hide the PowerShell console window (dialog still shows).
|
||||||
|
args = { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps },
|
||||||
cancellable = false,
|
cancellable = false,
|
||||||
})
|
})
|
||||||
if res and res.status == 0 and res.stdout then
|
if res and res.status == 0 and res.stdout then
|
||||||
@@ -807,8 +833,8 @@ local ensure_pipeline_helper_running
|
|||||||
local function _run_helper_request_response(req, timeout_seconds)
|
local function _run_helper_request_response(req, timeout_seconds)
|
||||||
_last_ipc_error = ''
|
_last_ipc_error = ''
|
||||||
if not ensure_pipeline_helper_running() then
|
if not ensure_pipeline_helper_running() then
|
||||||
_lua_log('ipc: helper not running; cannot execute request')
|
_lua_log('ipc: helper not ready; cannot execute request')
|
||||||
_last_ipc_error = 'helper not running'
|
_last_ipc_error = 'helper not ready'
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -824,7 +850,6 @@ local function _run_helper_request_response(req, timeout_seconds)
|
|||||||
local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP))
|
local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP))
|
||||||
_lua_log('ipc: helper not ready; ready=' .. rv)
|
_lua_log('ipc: helper not ready; ready=' .. rv)
|
||||||
_last_ipc_error = 'helper not ready (ready=' .. rv .. ')'
|
_last_ipc_error = 'helper not ready (ready=' .. rv .. ')'
|
||||||
_pipeline_helper_started = false
|
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -875,7 +900,6 @@ local function _run_helper_request_response(req, timeout_seconds)
|
|||||||
|
|
||||||
_lua_log('ipc: timeout waiting response; ' .. label)
|
_lua_log('ipc: timeout waiting response; ' .. label)
|
||||||
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
|
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
|
||||||
_pipeline_helper_started = false
|
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -893,56 +917,6 @@ local function _refresh_store_cache(timeout_seconds)
|
|||||||
local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1)
|
local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1)
|
||||||
if not resp or not resp.success or type(resp.choices) ~= 'table' then
|
if not resp or not resp.success or type(resp.choices) ~= 'table' then
|
||||||
_lua_log('stores: failed to load store choices via helper; stderr=' .. tostring(resp and resp.stderr or '') .. ' error=' .. tostring(resp and resp.error or ''))
|
_lua_log('stores: failed to load store choices via helper; stderr=' .. tostring(resp and resp.stderr or '') .. ' error=' .. tostring(resp and resp.error or ''))
|
||||||
|
|
||||||
-- Fallback: directly call Python to import MedeiaCLI.get_store_choices().
|
|
||||||
-- This avoids helper IPC issues and still stays in sync with the REPL.
|
|
||||||
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
|
|
||||||
local cli_path = (opts and opts.cli_path) and tostring(opts.cli_path) or nil
|
|
||||||
if not cli_path or cli_path == '' or not utils.file_info(cli_path) then
|
|
||||||
local base_dir = mp.get_script_directory() or utils.getcwd() or ''
|
|
||||||
if base_dir ~= '' then
|
|
||||||
cli_path = find_file_upwards(base_dir, 'CLI.py', 8)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if cli_path and cli_path ~= '' then
|
|
||||||
local root = tostring(cli_path):match('(.*)[/\\]') or ''
|
|
||||||
if root ~= '' then
|
|
||||||
local code = "import json, sys; sys.path.insert(0, r'" .. root .. "'); from CLI import MedeiaCLI; print(json.dumps(MedeiaCLI.get_store_choices()))"
|
|
||||||
local res = utils.subprocess({
|
|
||||||
args = { python, '-c', code },
|
|
||||||
cancellable = false,
|
|
||||||
})
|
|
||||||
if res and res.status == 0 and res.stdout then
|
|
||||||
local out_text = tostring(res.stdout)
|
|
||||||
local last_line = ''
|
|
||||||
for line in out_text:gmatch('[^\r\n]+') do
|
|
||||||
if trim(line) ~= '' then
|
|
||||||
last_line = line
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local ok, parsed = pcall(utils.parse_json, last_line ~= '' and last_line or out_text)
|
|
||||||
if ok and type(parsed) == 'table' then
|
|
||||||
local out = {}
|
|
||||||
for _, v in ipairs(parsed) do
|
|
||||||
local name = trim(tostring(v or ''))
|
|
||||||
if name ~= '' then
|
|
||||||
out[#out + 1] = name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #out > 0 then
|
|
||||||
_cached_store_names = out
|
|
||||||
_store_cache_loaded = true
|
|
||||||
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via python fallback')
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
_lua_log('stores: python fallback failed; status=' .. tostring(res and res.status or 'nil') .. ' stderr=' .. tostring(res and res.stderr or ''))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1295,11 +1269,8 @@ local function _run_pipeline_detached(pipeline_cmd)
|
|||||||
if not pipeline_cmd or pipeline_cmd == '' then
|
if not pipeline_cmd or pipeline_cmd == '' then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
|
local resp = _run_helper_request_response({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0)
|
||||||
local cli = (opts and opts.cli_path) and tostring(opts.cli_path) or 'CLI.py'
|
return (resp and resp.success) and true or false
|
||||||
local args = { python, cli, 'pipeline', '--pipeline', pipeline_cmd }
|
|
||||||
local ok = utils.subprocess_detached({ args = args })
|
|
||||||
return ok ~= nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _open_save_location_picker_for_pending_download()
|
local function _open_save_location_picker_for_pending_download()
|
||||||
@@ -1659,62 +1630,10 @@ mp.register_script_message('medios-download-pick-path', function()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
ensure_pipeline_helper_running = function()
|
ensure_pipeline_helper_running = function()
|
||||||
-- If a helper is already running (e.g. started by the launcher), just use it.
|
-- IMPORTANT: do NOT spawn Python from inside mpv.
|
||||||
if _is_pipeline_helper_ready() then
|
-- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows
|
||||||
_pipeline_helper_started = true
|
-- no-console flags; spawning here can flash a console window.
|
||||||
return true
|
return _is_pipeline_helper_ready() and true or false
|
||||||
end
|
|
||||||
|
|
||||||
-- We tried to start a helper before but it isn't ready anymore; restart.
|
|
||||||
if _pipeline_helper_started then
|
|
||||||
_pipeline_helper_started = false
|
|
||||||
end
|
|
||||||
|
|
||||||
local helper_path = nil
|
|
||||||
|
|
||||||
-- Prefer deriving repo root from located CLI.py if available.
|
|
||||||
if opts and opts.cli_path and utils.file_info(opts.cli_path) then
|
|
||||||
local root = tostring(opts.cli_path):match('(.*)[/\\]') or ''
|
|
||||||
if root ~= '' then
|
|
||||||
local candidate = utils.join_path(root, 'MPV/pipeline_helper.py')
|
|
||||||
if utils.file_info(candidate) then
|
|
||||||
helper_path = candidate
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not helper_path then
|
|
||||||
local base_dir = mp.get_script_directory() or ""
|
|
||||||
if base_dir == "" then
|
|
||||||
base_dir = utils.getcwd() or ""
|
|
||||||
end
|
|
||||||
helper_path = find_file_upwards(base_dir, 'MPV/pipeline_helper.py', 8)
|
|
||||||
end
|
|
||||||
if not helper_path then
|
|
||||||
_lua_log('ipc: cannot find helper script MPV/pipeline_helper.py (script_dir=' .. tostring(mp.get_script_directory() or '') .. ')')
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Ensure mpv actually has a JSON IPC server for the helper to connect to.
|
|
||||||
if not ensure_mpv_ipc_server() then
|
|
||||||
_lua_log('ipc: mpv input-ipc-server is not set; start mpv with --input-ipc-server=\\\\.\\pipe\\mpv-medeia-macina')
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
|
|
||||||
local ipc = get_mpv_ipc_path()
|
|
||||||
-- Give the helper enough time to connect (Windows pipe can take a moment).
|
|
||||||
local args = {python, helper_path, '--ipc', ipc, '--timeout', '30'}
|
|
||||||
_lua_log('ipc: starting helper: ' .. table.concat(args, ' '))
|
|
||||||
|
|
||||||
local ok = utils.subprocess_detached({ args = args })
|
|
||||||
if ok == nil then
|
|
||||||
_lua_log('ipc: failed to start helper (subprocess_detached returned nil)')
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
_pipeline_helper_started = true
|
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds)
|
local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds)
|
||||||
@@ -1824,34 +1743,9 @@ function M.run_pipeline(pipeline_cmd, seeds)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local args = {opts.python_path, opts.cli_path, "pipeline", "--pipeline", pipeline_cmd}
|
mp.osd_message('Error: pipeline helper not available', 6)
|
||||||
|
_lua_log('ipc: helper not available; refusing to spawn python subprocess')
|
||||||
if seeds then
|
return nil
|
||||||
local seeds_json = utils.format_json(seeds)
|
|
||||||
table.insert(args, "--seeds-json")
|
|
||||||
table.insert(args, seeds_json)
|
|
||||||
end
|
|
||||||
|
|
||||||
_lua_log("Running pipeline: " .. pipeline_cmd)
|
|
||||||
-- If the persistent IPC helper isn't available, fall back to a subprocess.
|
|
||||||
-- Note: mpv's subprocess helper does not support an `env` parameter.
|
|
||||||
local res = utils.subprocess({
|
|
||||||
args = args,
|
|
||||||
cancellable = false,
|
|
||||||
})
|
|
||||||
|
|
||||||
if res.status ~= 0 then
|
|
||||||
local err = (res.stderr and res.stderr ~= "") and res.stderr
|
|
||||||
or (res.error_string and res.error_string ~= "") and res.error_string
|
|
||||||
or "unknown"
|
|
||||||
local log_path = write_temp_log('medeia-cli-pipeline-stderr', tostring(res.stderr or err))
|
|
||||||
local suffix = log_path and (' (log: ' .. log_path .. ')') or ''
|
|
||||||
_lua_log("Pipeline error: " .. err .. suffix)
|
|
||||||
mp.osd_message("Error: pipeline failed" .. suffix, 6)
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
return res.stdout
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Helper to run pipeline and parse JSON output
|
-- Helper to run pipeline and parse JSON output
|
||||||
@@ -2132,13 +2026,12 @@ mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)
|
|||||||
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
|
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
|
||||||
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)
|
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)
|
||||||
|
|
||||||
-- Cover art observers removed (disabled per user request)
|
|
||||||
|
|
||||||
-- Start the persistent pipeline helper eagerly at launch.
|
-- Start the persistent pipeline helper eagerly at launch.
|
||||||
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
|
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
|
||||||
mp.add_timeout(0, function()
|
mp.add_timeout(0, function()
|
||||||
pcall(ensure_mpv_ipc_server)
|
pcall(ensure_mpv_ipc_server)
|
||||||
pcall(ensure_pipeline_helper_running)
|
|
||||||
pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION)
|
pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ _LYRIC_LOG_FH: Optional[Any] = None
|
|||||||
_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None
|
_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_pythonw_exe(python_exe: Optional[str]) -> Optional[str]:
|
||||||
|
"""Return a pythonw.exe adjacent to python.exe if available (Windows only)."""
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
return python_exe
|
||||||
|
try:
|
||||||
|
exe = str(python_exe or "").strip()
|
||||||
|
except Exception:
|
||||||
|
exe = ""
|
||||||
|
if not exe:
|
||||||
|
return None
|
||||||
|
low = exe.lower()
|
||||||
|
if low.endswith("pythonw.exe"):
|
||||||
|
return exe
|
||||||
|
if low.endswith("python.exe"):
|
||||||
|
try:
|
||||||
|
candidate = exe[:-10] + "pythonw.exe"
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
def _windows_hidden_subprocess_kwargs() -> Dict[str, Any]:
|
def _windows_hidden_subprocess_kwargs() -> Dict[str, Any]:
|
||||||
"""Best-effort kwargs to avoid flashing console windows on Windows.
|
"""Best-effort kwargs to avoid flashing console windows on Windows.
|
||||||
|
|
||||||
@@ -413,8 +436,12 @@ class MPV:
|
|||||||
except Exception:
|
except Exception:
|
||||||
repo_root = Path.cwd()
|
repo_root = Path.cwd()
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
py = _windows_pythonw_exe(py) or py
|
||||||
|
|
||||||
cmd: List[str] = [
|
cmd: List[str] = [
|
||||||
sys.executable,
|
py or "python",
|
||||||
"-m",
|
"-m",
|
||||||
"MPV.lyric",
|
"MPV.lyric",
|
||||||
"--ipc",
|
"--ipc",
|
||||||
@@ -448,7 +475,18 @@ class MPV:
|
|||||||
# Make the current directory the repo root so `-m MPV.lyric` resolves reliably.
|
# Make the current directory the repo root so `-m MPV.lyric` resolves reliably.
|
||||||
kwargs["cwd"] = str(repo_root)
|
kwargs["cwd"] = str(repo_root)
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS
|
# Ensure we don't flash a console window when spawning the helper.
|
||||||
|
flags = 0
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x00000008
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x08000000
|
||||||
|
kwargs["creationflags"] = flags
|
||||||
|
kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
|
||||||
|
|
||||||
_LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs)
|
_LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs)
|
||||||
debug(f"Lyric loader started (log={log_path})")
|
debug(f"Lyric loader started (log={log_path})")
|
||||||
@@ -582,6 +620,8 @@ class MPV:
|
|||||||
helper_path = (repo_root / "MPV" / "pipeline_helper.py").resolve()
|
helper_path = (repo_root / "MPV" / "pipeline_helper.py").resolve()
|
||||||
if helper_path.exists():
|
if helper_path.exists():
|
||||||
py = sys.executable or "python"
|
py = sys.executable or "python"
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
py = _windows_pythonw_exe(py) or py
|
||||||
helper_cmd = [
|
helper_cmd = [
|
||||||
py,
|
py,
|
||||||
str(helper_path),
|
str(helper_path),
|
||||||
@@ -591,6 +631,13 @@ class MPV:
|
|||||||
"30",
|
"30",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
helper_env = os.environ.copy()
|
||||||
|
try:
|
||||||
|
existing_pp = helper_env.get("PYTHONPATH")
|
||||||
|
helper_env["PYTHONPATH"] = str(repo_root) if not existing_pp else (str(repo_root) + os.pathsep + str(existing_pp))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
helper_kwargs: Dict[str, Any] = {}
|
helper_kwargs: Dict[str, Any] = {}
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
flags = 0
|
flags = 0
|
||||||
@@ -605,6 +652,9 @@ class MPV:
|
|||||||
helper_kwargs["creationflags"] = flags
|
helper_kwargs["creationflags"] = flags
|
||||||
helper_kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
|
helper_kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
|
||||||
|
|
||||||
|
helper_kwargs["cwd"] = str(repo_root)
|
||||||
|
helper_kwargs["env"] = helper_env
|
||||||
|
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
helper_cmd,
|
helper_cmd,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -134,6 +136,91 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
op_name = str(op or "").strip().lower()
|
op_name = str(op or "").strip().lower()
|
||||||
|
|
||||||
|
if op_name in {"run-detached", "run_detached", "pipeline-detached", "pipeline_detached"}:
|
||||||
|
pipeline_text = ""
|
||||||
|
seeds = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
pipeline_text = str(data.get("pipeline") or "").strip()
|
||||||
|
seeds = data.get("seeds")
|
||||||
|
if not pipeline_text:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"error": "Missing pipeline",
|
||||||
|
"table": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
py = sys.executable or "python"
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
try:
|
||||||
|
exe = str(py or "").strip()
|
||||||
|
except Exception:
|
||||||
|
exe = ""
|
||||||
|
low = exe.lower()
|
||||||
|
if low.endswith("python.exe"):
|
||||||
|
try:
|
||||||
|
candidate = exe[:-10] + "pythonw.exe"
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
py = candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cmd = [py, str((_repo_root() / "CLI.py").resolve()), "pipeline", "--pipeline", pipeline_text]
|
||||||
|
if seeds is not None:
|
||||||
|
try:
|
||||||
|
cmd.extend(["--seeds-json", json.dumps(seeds, ensure_ascii=False)])
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; seeds are optional.
|
||||||
|
pass
|
||||||
|
|
||||||
|
popen_kwargs: Dict[str, Any] = {
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": subprocess.DEVNULL,
|
||||||
|
"stderr": subprocess.DEVNULL,
|
||||||
|
"cwd": str(_repo_root()),
|
||||||
|
}
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
flags = 0
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x00000008
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x08000000
|
||||||
|
popen_kwargs["creationflags"] = int(flags)
|
||||||
|
try:
|
||||||
|
si = subprocess.STARTUPINFO()
|
||||||
|
si.dwFlags |= int(getattr(subprocess, "STARTF_USESHOWWINDOW", 0x00000001))
|
||||||
|
si.wShowWindow = subprocess.SW_HIDE
|
||||||
|
popen_kwargs["startupinfo"] = si
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
popen_kwargs["start_new_session"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"error": f"Failed to spawn detached pipeline: {type(exc).__name__}: {exc}",
|
||||||
|
"table": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"error": None,
|
||||||
|
"table": None,
|
||||||
|
"pid": int(getattr(proc, "pid", 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
# Provide store backend choices using the same source as CLI/Typer autocomplete.
|
# Provide store backend choices using the same source as CLI/Typer autocomplete.
|
||||||
if op_name in {"store-choices", "store_choices", "get-store-choices", "get_store_choices"}:
|
if op_name in {"store-choices", "store_choices", "get-store-choices", "get_store_choices"}:
|
||||||
from CLI import MedeiaCLI # noqa: WPS433
|
from CLI import MedeiaCLI # noqa: WPS433
|
||||||
|
|||||||
138
SYS/env_check.py
Normal file
138
SYS/env_check.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Environment compatibility checks for known packaging issues.
|
||||||
|
|
||||||
|
This module provides a focused check for `urllib3` correctness and a
|
||||||
|
helpful, actionable error message when the environment looks broken
|
||||||
|
(e.g., due to `urllib3-future` installing a site-packages hook).
|
||||||
|
|
||||||
|
It is intentionally lightweight and safe to import early at process
|
||||||
|
startup so the CLI can detect and surface environment problems before
|
||||||
|
trying to import cmdlets or other modules.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import site
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from SYS.logger import log, debug
|
||||||
|
|
||||||
|
|
||||||
|
def _find_potential_urllib3_pth() -> list[str]:
|
||||||
|
"""Return a list of path strings that look like interfering .pth files."""
|
||||||
|
found: list[str] = []
|
||||||
|
try:
|
||||||
|
paths = site.getsitepackages() or []
|
||||||
|
except Exception:
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
for sp in set(paths):
|
||||||
|
try:
|
||||||
|
candidate = Path(sp) / "urllib3_future.pth"
|
||||||
|
if candidate.exists():
|
||||||
|
found.append(str(candidate))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def check_urllib3_compat() -> Tuple[bool, str]:
|
||||||
|
"""Quick check whether `urllib3` looks usable.
|
||||||
|
|
||||||
|
Returns (True, "OK") when everything seems fine. When a problem is
|
||||||
|
detected the returned tuple is (False, <actionable message>) where the
|
||||||
|
message contains steps the user can run to fix the environment.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import urllib3 # type: ignore
|
||||||
|
except Exception as exc: # pragma: no cover - hard to reliably simulate ImportError across envs
|
||||||
|
pths = _find_potential_urllib3_pth()
|
||||||
|
lines = [
|
||||||
|
"Your Python environment appears to have a broken or incomplete 'urllib3' installation.",
|
||||||
|
f"ImportError: {exc!s}",
|
||||||
|
]
|
||||||
|
if pths:
|
||||||
|
lines.append(f"Found potential interfering .pth file(s): {', '.join(pths)}")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"Recommended fixes (activate the project's virtualenv first):",
|
||||||
|
" python -m pip uninstall urllib3-future -y",
|
||||||
|
" python -m pip install --upgrade --force-reinstall urllib3",
|
||||||
|
" python -m pip install niquests -U",
|
||||||
|
"You may also re-run the bootstrap script: scripts\\bootstrap.ps1 (Windows) or scripts/bootstrap.sh (POSIX).",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return False, "\n".join(lines)
|
||||||
|
|
||||||
|
# Basic sanity checks on the *imported* urllib3 module
|
||||||
|
problems: list[str] = []
|
||||||
|
if not getattr(urllib3, "__version__", None):
|
||||||
|
problems.append("missing urllib3.__version__")
|
||||||
|
if not hasattr(urllib3, "exceptions"):
|
||||||
|
problems.append("missing urllib3.exceptions")
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = importlib.util.find_spec("urllib3.exceptions")
|
||||||
|
if spec is None or not getattr(spec, "origin", None):
|
||||||
|
problems.append("urllib3.exceptions not importable")
|
||||||
|
except Exception:
|
||||||
|
problems.append("urllib3.exceptions not importable (importlib check failed)")
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
pths = _find_potential_urllib3_pth()
|
||||||
|
lines = [
|
||||||
|
"Your Python environment appears to have a broken 'urllib3' package:",
|
||||||
|
f"Problems found: {', '.join(problems)}",
|
||||||
|
]
|
||||||
|
if pths:
|
||||||
|
lines.append(f"Found potential interfering .pth file(s): {', '.join(pths)}")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"Recommended fixes (activate the project's virtualenv first):",
|
||||||
|
" python -m pip uninstall urllib3-future -y",
|
||||||
|
" python -m pip install --upgrade --force-reinstall urllib3",
|
||||||
|
" python -m pip install niquests -U",
|
||||||
|
"You may also re-run the bootstrap script: scripts\\bootstrap.ps1 (Windows) or scripts/bootstrap.sh (POSIX).",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return False, "\n".join(lines)
|
||||||
|
|
||||||
|
# Looks good
|
||||||
|
debug("urllib3 appears usable: version=%s, exceptions=%s", getattr(urllib3, "__version__", "<unknown>"), hasattr(urllib3, "exceptions"))
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_urllib3_ok(exit_on_error: bool = True) -> bool:
|
||||||
|
"""Ensure urllib3 is usable and print an actionable message if not.
|
||||||
|
|
||||||
|
- If `exit_on_error` is True (default) this will call `sys.exit(2)` when
|
||||||
|
a problem is detected so callers that call this early in process
|
||||||
|
startup won't continue with a partially-broken environment.
|
||||||
|
- If `exit_on_error` is False the function will print the message and
|
||||||
|
return False so the caller can decide how to proceed.
|
||||||
|
"""
|
||||||
|
ok, message = check_urllib3_compat()
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Prominent user-facing output
|
||||||
|
border = "=" * 80
|
||||||
|
log(border)
|
||||||
|
log("ENVIRONMENT PROBLEM DETECTED: Broken 'urllib3' package")
|
||||||
|
log(message)
|
||||||
|
log(border)
|
||||||
|
|
||||||
|
if exit_on_error:
|
||||||
|
log("Please follow the steps above to fix your environment, then re-run this command.")
|
||||||
|
try:
|
||||||
|
sys.exit(2)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual debugging helper
|
||||||
|
ok, message = check_urllib3_compat()
|
||||||
|
print(message)
|
||||||
|
sys.exit(0 if ok else 2)
|
||||||
18
SYS/tasks.py
18
SYS/tasks.py
@@ -102,6 +102,23 @@ def _run_task(args, parser) -> int:
|
|||||||
'command': command,
|
'command': command,
|
||||||
'cwd': args.cwd or os.getcwd(),
|
'cwd': args.cwd or os.getcwd(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
popen_kwargs = {}
|
||||||
|
if os.name == 'nt':
|
||||||
|
# Avoid flashing a console window when spawning console-subsystem executables.
|
||||||
|
flags = 0
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x08000000
|
||||||
|
popen_kwargs['creationflags'] = flags
|
||||||
|
try:
|
||||||
|
si = subprocess.STARTUPINFO()
|
||||||
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
si.wShowWindow = subprocess.SW_HIDE
|
||||||
|
popen_kwargs['startupinfo'] = si
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
@@ -112,6 +129,7 @@ def _run_task(args, parser) -> int:
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
notifier('downlow-task-event', {
|
notifier('downlow-task-event', {
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"""Pipeline execution utilities for the Textual UI.
|
"""Pipeline execution utilities for the Textual UI.
|
||||||
|
|
||||||
This module mirrors the CLI pipeline behaviour while exposing a class-based
|
The TUI is a frontend to the CLI, so it must use the same pipeline executor
|
||||||
interface that the TUI can call. It keeps all pipeline/cmdlet integration in
|
implementation as the CLI (`CLI.PipelineExecutor`).
|
||||||
one place so the interface layer stays focused on presentation.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import shlex
|
import shlex
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Dict, List, Optional, Sequence
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -23,11 +21,10 @@ for path in (ROOT_DIR, BASE_DIR):
|
|||||||
sys.path.insert(0, str_path)
|
sys.path.insert(0, str_path)
|
||||||
|
|
||||||
import pipeline as ctx
|
import pipeline as ctx
|
||||||
from cmdlet import REGISTRY
|
from CLI import ConfigLoader, PipelineExecutor as CLIPipelineExecutor, WorkerManagerRegistry
|
||||||
from config import get_local_storage_path, load_config
|
from SYS.logger import set_debug
|
||||||
from SYS.worker_manager import WorkerManager
|
from rich_display import capture_rich_output
|
||||||
|
from result_table import ResultTable
|
||||||
from CLI import MedeiaCLI
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -73,24 +70,16 @@ class PipelineRunResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PipelineExecutor:
|
class PipelineRunner:
|
||||||
"""Thin wrapper over the cmdlet registry + pipeline context."""
|
"""TUI wrapper that delegates to the canonical CLI pipeline executor."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self) -> None:
|
||||||
self,
|
self._config_loader = ConfigLoader(root=ROOT_DIR)
|
||||||
*,
|
self._executor = CLIPipelineExecutor(config_loader=self._config_loader)
|
||||||
config: Optional[Dict[str, Any]] = None,
|
self._worker_manager = None
|
||||||
worker_manager: Optional[WorkerManager] = None,
|
|
||||||
) -> None:
|
|
||||||
self._config = config or load_config()
|
|
||||||
self._worker_manager = worker_manager
|
|
||||||
if self._worker_manager is None:
|
|
||||||
self._worker_manager = self._ensure_worker_manager()
|
|
||||||
if self._worker_manager:
|
|
||||||
self._config["_worker_manager"] = self._worker_manager
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def worker_manager(self) -> Optional[WorkerManager]:
|
def worker_manager(self):
|
||||||
return self._worker_manager
|
return self._worker_manager
|
||||||
|
|
||||||
def run_pipeline(
|
def run_pipeline(
|
||||||
@@ -98,290 +87,214 @@ class PipelineExecutor:
|
|||||||
pipeline_text: str,
|
pipeline_text: str,
|
||||||
*,
|
*,
|
||||||
seeds: Optional[Any] = None,
|
seeds: Optional[Any] = None,
|
||||||
|
isolate: bool = False,
|
||||||
on_log: Optional[Callable[[str], None]] = None,
|
on_log: Optional[Callable[[str], None]] = None,
|
||||||
) -> PipelineRunResult:
|
) -> PipelineRunResult:
|
||||||
"""Execute a pipeline string and return structured results.
|
snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
if isolate:
|
||||||
|
snapshot = self._snapshot_ctx_state()
|
||||||
|
|
||||||
Args:
|
normalized = str(pipeline_text or "").strip()
|
||||||
pipeline_text: Raw pipeline text entered by the user.
|
|
||||||
on_log: Optional callback that receives human-readable log lines.
|
|
||||||
"""
|
|
||||||
normalized = pipeline_text.strip()
|
|
||||||
result = PipelineRunResult(pipeline=normalized, success=False)
|
result = PipelineRunResult(pipeline=normalized, success=False)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
result.error = "Pipeline is empty"
|
result.error = "Pipeline is empty"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
tokens = self._tokenize(normalized)
|
try:
|
||||||
stages = self._split_stages(tokens)
|
from cli_syntax import validate_pipeline_text
|
||||||
if not stages:
|
|
||||||
result.error = "Pipeline contains no stages"
|
syntax_error = validate_pipeline_text(normalized)
|
||||||
|
if syntax_error:
|
||||||
|
result.error = syntax_error.message
|
||||||
|
result.stderr = syntax_error.message
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(normalized)
|
||||||
|
except Exception as exc:
|
||||||
|
result.error = f"Syntax error: {exc}"
|
||||||
|
result.stderr = result.error
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
result.error = "Pipeline contains no tokens"
|
||||||
|
return result
|
||||||
|
|
||||||
|
config = self._config_loader.load()
|
||||||
|
try:
|
||||||
|
set_debug(bool(config.get("debug", False)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._worker_manager = WorkerManagerRegistry.ensure(config)
|
||||||
|
except Exception:
|
||||||
|
self._worker_manager = None
|
||||||
|
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
ctx.set_current_command_text(normalized)
|
ctx.set_current_command_text(normalized)
|
||||||
|
|
||||||
if seeds is not None:
|
if seeds is not None:
|
||||||
try:
|
try:
|
||||||
# Mirror CLI behavior: treat seeds as output of a virtual previous stage.
|
|
||||||
if not isinstance(seeds, list):
|
if not isinstance(seeds, list):
|
||||||
seeds = [seeds]
|
seeds = [seeds]
|
||||||
setter = getattr(ctx, "set_last_result_items_only", None)
|
ctx.set_last_result_items_only(list(seeds))
|
||||||
if callable(setter):
|
|
||||||
setter(seeds)
|
|
||||||
else:
|
|
||||||
ctx.set_last_items(list(seeds))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
stdout_buffer = io.StringIO()
|
stdout_buffer = io.StringIO()
|
||||||
stderr_buffer = io.StringIO()
|
stderr_buffer = io.StringIO()
|
||||||
piped_result: Any = None
|
|
||||||
worker_session = self._start_worker_session(normalized)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(
|
with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer):
|
||||||
stderr_buffer
|
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
|
||||||
):
|
if on_log:
|
||||||
for index, stage_tokens in enumerate(stages):
|
on_log("Executing pipeline via CLI executor...")
|
||||||
stage = self._execute_stage(
|
self._executor.execute_tokens(list(tokens))
|
||||||
index=index,
|
except Exception as exc:
|
||||||
total=len(stages),
|
result.error = f"{type(exc).__name__}: {exc}"
|
||||||
stage_tokens=stage_tokens,
|
|
||||||
piped_input=piped_result,
|
|
||||||
on_log=on_log,
|
|
||||||
)
|
|
||||||
result.stages.append(stage)
|
|
||||||
|
|
||||||
if stage.status != "completed":
|
|
||||||
result.error = stage.error or f"Stage {stage.name} failed"
|
|
||||||
return result
|
|
||||||
|
|
||||||
if index == len(stages) - 1:
|
|
||||||
result.emitted = stage.emitted
|
|
||||||
result.result_table = stage.result_table
|
|
||||||
else:
|
|
||||||
piped_result = stage.emitted
|
|
||||||
|
|
||||||
result.success = True
|
|
||||||
return result
|
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
|
ctx.clear_current_command_text()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
result.stdout = stdout_buffer.getvalue()
|
result.stdout = stdout_buffer.getvalue()
|
||||||
result.stderr = stderr_buffer.getvalue()
|
result.stderr = stderr_buffer.getvalue()
|
||||||
ctx.clear_current_command_text()
|
|
||||||
if worker_session is not None:
|
|
||||||
status = "completed" if result.success else "error"
|
|
||||||
worker_session.finish(status=status, message=result.error or "")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Stage execution helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _execute_stage(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
index: int,
|
|
||||||
total: int,
|
|
||||||
stage_tokens: Sequence[str],
|
|
||||||
piped_input: Any,
|
|
||||||
on_log: Optional[Callable[[str], None]],
|
|
||||||
) -> PipelineStageResult:
|
|
||||||
if not stage_tokens:
|
|
||||||
return PipelineStageResult(name="(empty)", args=[], status="skipped")
|
|
||||||
|
|
||||||
cmd_name = stage_tokens[0].replace("_", "-").lower()
|
|
||||||
stage_args = stage_tokens[1:]
|
|
||||||
stage = PipelineStageResult(name=cmd_name, args=stage_args)
|
|
||||||
|
|
||||||
if cmd_name.startswith("@"):
|
|
||||||
return self._apply_selection_stage(
|
|
||||||
token=cmd_name,
|
|
||||||
stage=stage,
|
|
||||||
piped_input=piped_input,
|
|
||||||
on_log=on_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd_fn = REGISTRY.get(cmd_name)
|
|
||||||
if not cmd_fn:
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = f"Unknown command: {cmd_name}"
|
|
||||||
return stage
|
|
||||||
|
|
||||||
pipeline_ctx = ctx.PipelineStageContext(stage_index=index, total_stages=total, pipe_index=index)
|
|
||||||
ctx.set_stage_context(pipeline_ctx)
|
|
||||||
|
|
||||||
|
# Pull the canonical state out of pipeline context.
|
||||||
|
table = None
|
||||||
try:
|
try:
|
||||||
return_code = cmd_fn(piped_input, list(stage_args), self._config)
|
table = ctx.get_display_table() or ctx.get_current_stage_table() or ctx.get_last_result_table()
|
||||||
except Exception as exc: # pragma: no cover - surfaced in UI
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = f"{type(exc).__name__}: {exc}"
|
|
||||||
if on_log:
|
|
||||||
on_log(stage.error)
|
|
||||||
return stage
|
|
||||||
finally:
|
|
||||||
ctx.set_stage_context(None)
|
|
||||||
|
|
||||||
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
|
|
||||||
stage.emitted = emitted
|
|
||||||
|
|
||||||
# Capture the ResultTable if the cmdlet set one
|
|
||||||
# Check display table first (overlay), then last result table
|
|
||||||
stage.result_table = ctx.get_display_table() or ctx.get_last_result_table()
|
|
||||||
|
|
||||||
if return_code != 0:
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = f"Exit code {return_code}"
|
|
||||||
else:
|
|
||||||
stage.status = "completed"
|
|
||||||
stage.error = None
|
|
||||||
|
|
||||||
worker_id = self._current_worker_id()
|
|
||||||
if self._worker_manager and worker_id:
|
|
||||||
label = f"[Stage {index + 1}/{total}] {cmd_name} {stage.status}"
|
|
||||||
self._worker_manager.log_step(worker_id, label)
|
|
||||||
|
|
||||||
# Don't clear the table if we just captured it, but ensure items are set for next stage
|
|
||||||
# If we have a table, we should probably keep it in ctx for history if needed
|
|
||||||
# But for pipeline execution, we mainly care about passing items to next stage
|
|
||||||
# ctx.set_last_result_table(None, emitted) <-- This was clearing it
|
|
||||||
|
|
||||||
# Ensure items are available for next stage
|
|
||||||
ctx.set_last_items(emitted)
|
|
||||||
return stage
|
|
||||||
|
|
||||||
def _apply_selection_stage(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
token: str,
|
|
||||||
stage: PipelineStageResult,
|
|
||||||
piped_input: Any,
|
|
||||||
on_log: Optional[Callable[[str], None]],
|
|
||||||
) -> PipelineStageResult:
|
|
||||||
# Bare '@' means use the subject associated with the current result table (e.g., the file shown in a tag/URL view)
|
|
||||||
if token == "@":
|
|
||||||
subject = ctx.get_last_result_subject()
|
|
||||||
if subject is None:
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = "Selection requested (@) but there is no current result context"
|
|
||||||
return stage
|
|
||||||
stage.emitted = subject if isinstance(subject, list) else [subject]
|
|
||||||
ctx.set_last_items(stage.emitted)
|
|
||||||
stage.status = "completed"
|
|
||||||
if on_log:
|
|
||||||
on_log("Selected current table subject via @")
|
|
||||||
return stage
|
|
||||||
|
|
||||||
selection = self._parse_selection(token)
|
|
||||||
items = piped_input or []
|
|
||||||
if not isinstance(items, list):
|
|
||||||
items = list(items if isinstance(items, Sequence) else [items])
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = "Selection requested but there is no upstream data"
|
|
||||||
return stage
|
|
||||||
|
|
||||||
if selection is None:
|
|
||||||
stage.emitted = list(items)
|
|
||||||
else:
|
|
||||||
zero_based = sorted(i - 1 for i in selection if i > 0)
|
|
||||||
stage.emitted = [items[i] for i in zero_based if 0 <= i < len(items)]
|
|
||||||
|
|
||||||
if not stage.emitted:
|
|
||||||
stage.status = "failed"
|
|
||||||
stage.error = "Selection matched no rows"
|
|
||||||
return stage
|
|
||||||
|
|
||||||
ctx.set_last_items(stage.emitted)
|
|
||||||
ctx.set_last_result_table(None, stage.emitted)
|
|
||||||
stage.status = "completed"
|
|
||||||
if on_log:
|
|
||||||
on_log(f"Selected {len(stage.emitted)} item(s) via {token}")
|
|
||||||
return stage
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Worker/session helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _start_worker_session(self, pipeline_text: str) -> Optional[_WorkerSession]:
|
|
||||||
manager = self._ensure_worker_manager()
|
|
||||||
if manager is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
worker_id = f"tui_pipeline_{uuid.uuid4().hex[:8]}"
|
|
||||||
tracked = manager.track_worker(
|
|
||||||
worker_id,
|
|
||||||
worker_type="pipeline",
|
|
||||||
title="Pipeline run",
|
|
||||||
description=pipeline_text,
|
|
||||||
pipe=pipeline_text,
|
|
||||||
)
|
|
||||||
if not tracked:
|
|
||||||
return None
|
|
||||||
|
|
||||||
manager.log_step(worker_id, "Pipeline started")
|
|
||||||
self._config["_current_worker_id"] = worker_id
|
|
||||||
return _WorkerSession(manager=manager, worker_id=worker_id, config=self._config)
|
|
||||||
|
|
||||||
def _ensure_worker_manager(self) -> Optional[WorkerManager]:
|
|
||||||
if self._worker_manager:
|
|
||||||
return self._worker_manager
|
|
||||||
library_root = get_local_storage_path(self._config)
|
|
||||||
if not library_root:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
self._worker_manager = WorkerManager(Path(library_root), auto_refresh_interval=0)
|
|
||||||
self._config["_worker_manager"] = self._worker_manager
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self._worker_manager = None
|
table = None
|
||||||
return self._worker_manager
|
|
||||||
|
|
||||||
def _current_worker_id(self) -> Optional[str]:
|
items: List[Any] = []
|
||||||
worker_id = self._config.get("_current_worker_id")
|
|
||||||
return str(worker_id) if worker_id else None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Parsing helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@staticmethod
|
|
||||||
def _tokenize(pipeline_text: str) -> List[str]:
|
|
||||||
try:
|
try:
|
||||||
return shlex.split(pipeline_text)
|
items = list(ctx.get_last_result_items() or [])
|
||||||
except ValueError:
|
except Exception:
|
||||||
return pipeline_text.split()
|
items = []
|
||||||
|
|
||||||
|
if table is None and items:
|
||||||
|
try:
|
||||||
|
synth = ResultTable("Results")
|
||||||
|
for item in items:
|
||||||
|
synth.add_result(item)
|
||||||
|
table = synth
|
||||||
|
except Exception:
|
||||||
|
table = None
|
||||||
|
|
||||||
|
result.emitted = items
|
||||||
|
result.result_table = table
|
||||||
|
|
||||||
|
combined = (result.stdout + "\n" + result.stderr).strip().lower()
|
||||||
|
failure_markers = (
|
||||||
|
"unknown command:",
|
||||||
|
"pipeline order error:",
|
||||||
|
"invalid selection:",
|
||||||
|
"invalid pipeline syntax",
|
||||||
|
"failed to execute pipeline",
|
||||||
|
"[error]",
|
||||||
|
)
|
||||||
|
if result.error:
|
||||||
|
result.success = False
|
||||||
|
elif any(m in combined for m in failure_markers):
|
||||||
|
result.success = False
|
||||||
|
if not result.error:
|
||||||
|
result.error = "Pipeline failed"
|
||||||
|
else:
|
||||||
|
result.success = True
|
||||||
|
|
||||||
|
if isolate and snapshot is not None:
|
||||||
|
try:
|
||||||
|
self._restore_ctx_state(snapshot)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; isolation should never break normal operation.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_stages(tokens: Sequence[str]) -> List[List[str]]:
|
def _snapshot_ctx_state() -> Dict[str, Any]:
|
||||||
stages: List[List[str]] = []
|
"""Best-effort snapshot of pipeline context so TUI popups don't clobber UI state."""
|
||||||
current: List[str] = []
|
|
||||||
for token in tokens:
|
|
||||||
if token == "|":
|
|
||||||
if current:
|
|
||||||
stages.append(current)
|
|
||||||
current = []
|
|
||||||
else:
|
|
||||||
current.append(token)
|
|
||||||
if current:
|
|
||||||
stages.append(current)
|
|
||||||
return stages
|
|
||||||
|
|
||||||
@staticmethod
|
def _copy(val: Any) -> Any:
|
||||||
def _parse_selection(token: str) -> Optional[Sequence[int]]:
|
if isinstance(val, list):
|
||||||
parsed = MedeiaCLI.parse_selection_syntax(token)
|
return val.copy()
|
||||||
return sorted(parsed) if parsed else None
|
if isinstance(val, dict):
|
||||||
|
return val.copy()
|
||||||
|
return val
|
||||||
|
|
||||||
|
snap: Dict[str, Any] = {}
|
||||||
|
keys = [
|
||||||
|
"_LIVE_PROGRESS",
|
||||||
|
"_CURRENT_CONTEXT",
|
||||||
|
"_LAST_SEARCH_QUERY",
|
||||||
|
"_PIPELINE_REFRESHED",
|
||||||
|
"_PIPELINE_LAST_ITEMS",
|
||||||
|
"_LAST_RESULT_TABLE",
|
||||||
|
"_LAST_RESULT_ITEMS",
|
||||||
|
"_LAST_RESULT_SUBJECT",
|
||||||
|
"_RESULT_TABLE_HISTORY",
|
||||||
|
"_RESULT_TABLE_FORWARD",
|
||||||
|
"_CURRENT_STAGE_TABLE",
|
||||||
|
"_DISPLAY_ITEMS",
|
||||||
|
"_DISPLAY_TABLE",
|
||||||
|
"_DISPLAY_SUBJECT",
|
||||||
|
"_PIPELINE_LAST_SELECTION",
|
||||||
|
"_PIPELINE_COMMAND_TEXT",
|
||||||
|
"_CURRENT_CMDLET_NAME",
|
||||||
|
"_CURRENT_STAGE_TEXT",
|
||||||
|
"_PIPELINE_VALUES",
|
||||||
|
"_PENDING_PIPELINE_TAIL",
|
||||||
|
"_PENDING_PIPELINE_SOURCE",
|
||||||
|
"_UI_LIBRARY_REFRESH_CALLBACK",
|
||||||
|
]
|
||||||
|
|
||||||
class _WorkerSession:
|
for k in keys:
|
||||||
"""Minimal worker session wrapper for the TUI executor."""
|
snap[k] = _copy(getattr(ctx, k, None))
|
||||||
|
|
||||||
def __init__(self, *, manager: WorkerManager, worker_id: str, config: Optional[Dict[str, Any]] = None) -> None:
|
# Deepen copies where nested lists are common.
|
||||||
self._manager = manager
|
|
||||||
self.worker_id = worker_id
|
|
||||||
self._config = config
|
|
||||||
|
|
||||||
def finish(self, *, status: str, message: str) -> None:
|
|
||||||
try:
|
try:
|
||||||
self._manager.finish_worker(self.worker_id, result=status, error_msg=message)
|
hist = list(getattr(ctx, "_RESULT_TABLE_HISTORY", []) or [])
|
||||||
self._manager.log_step(self.worker_id, f"Pipeline {status}")
|
snap["_RESULT_TABLE_HISTORY"] = [
|
||||||
|
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
|
||||||
|
for (t, items, subj) in hist
|
||||||
|
if isinstance((t, items, subj), tuple)
|
||||||
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if self._config and self._config.get("_current_worker_id") == self.worker_id:
|
|
||||||
self._config.pop("_current_worker_id", None)
|
try:
|
||||||
|
fwd = list(getattr(ctx, "_RESULT_TABLE_FORWARD", []) or [])
|
||||||
|
snap["_RESULT_TABLE_FORWARD"] = [
|
||||||
|
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
|
||||||
|
for (t, items, subj) in fwd
|
||||||
|
if isinstance((t, items, subj), tuple)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
tail = list(getattr(ctx, "_PENDING_PIPELINE_TAIL", []) or [])
|
||||||
|
snap["_PENDING_PIPELINE_TAIL"] = [list(stage) for stage in tail if isinstance(stage, list)]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
values = getattr(ctx, "_PIPELINE_VALUES", None)
|
||||||
|
if isinstance(values, dict):
|
||||||
|
snap["_PIPELINE_VALUES"] = values.copy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return snap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_ctx_state(snapshot: Dict[str, Any]) -> None:
|
||||||
|
for k, v in (snapshot or {}).items():
|
||||||
|
try:
|
||||||
|
setattr(ctx, k, v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
754
TUI/tui.py
754
TUI/tui.py
@@ -1,26 +1,20 @@
|
|||||||
"""Modern Textual UI for driving Medeia-Macina pipelines."""
|
"""Modern Textual UI for driving Medeia-Macina pipelines."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Sequence
|
from typing import Any, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
from textual import work
|
from textual import on, work
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
from textual.events import Key
|
||||||
from textual.widgets import (
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
Button,
|
from textual.screen import ModalScreen
|
||||||
DataTable,
|
from textual.widgets import Button, DataTable, Footer, Header, Input, Label, OptionList, Select, Static, TextArea
|
||||||
Footer,
|
from textual.widgets.option_list import Option
|
||||||
Header,
|
|
||||||
Input,
|
|
||||||
ListItem,
|
|
||||||
ListView,
|
|
||||||
Static,
|
|
||||||
TextArea,
|
|
||||||
Tree,
|
|
||||||
)
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
ROOT_DIR = BASE_DIR.parent
|
ROOT_DIR = BASE_DIR.parent
|
||||||
@@ -29,25 +23,198 @@ for path in (BASE_DIR, ROOT_DIR):
|
|||||||
if str_path not in sys.path:
|
if str_path not in sys.path:
|
||||||
sys.path.insert(0, str_path)
|
sys.path.insert(0, str_path)
|
||||||
|
|
||||||
from menu_actions import ( # type: ignore # noqa: E402
|
from pipeline_runner import PipelineRunResult # type: ignore # noqa: E402
|
||||||
PIPELINE_PRESETS,
|
|
||||||
PipelinePreset,
|
|
||||||
)
|
|
||||||
from pipeline_runner import PipelineExecutor, PipelineRunResult # type: ignore # noqa: E402
|
|
||||||
from result_table import ResultTable # type: ignore # noqa: E402
|
from result_table import ResultTable # type: ignore # noqa: E402
|
||||||
|
|
||||||
|
from config import load_config # type: ignore # noqa: E402
|
||||||
|
from Store.registry import Store as StoreRegistry # type: ignore # noqa: E402
|
||||||
|
from cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type: ignore # noqa: E402
|
||||||
|
from cli_syntax import validate_pipeline_text # type: ignore # noqa: E402
|
||||||
|
|
||||||
class PresetListItem(ListItem):
|
from pipeline_runner import PipelineRunner # type: ignore # noqa: E402
|
||||||
"""List entry that stores its pipeline preset."""
|
|
||||||
|
|
||||||
def __init__(self, preset: PipelinePreset) -> None:
|
|
||||||
super().__init__(
|
def _dedup_preserve_order(items: List[str]) -> List[str]:
|
||||||
Static(
|
out: List[str] = []
|
||||||
f"[b]{preset.label}[/b]\n[pale_green4]{preset.description}[/pale_green4]",
|
seen: set[str] = set()
|
||||||
classes="preset-entry",
|
for raw in items:
|
||||||
)
|
s = str(raw or "").strip()
|
||||||
)
|
if not s:
|
||||||
self.preset = preset
|
continue
|
||||||
|
key = s.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tag_names(emitted: Sequence[Any]) -> List[str]:
|
||||||
|
tags: List[str] = []
|
||||||
|
for obj in emitted or []:
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "tag_name"):
|
||||||
|
val = getattr(obj, "tag_name")
|
||||||
|
if val:
|
||||||
|
tags.append(str(val))
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k in ("tag_name", "tag", "name", "value"):
|
||||||
|
v = obj.get(k)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
tags.append(v.strip())
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
return _dedup_preserve_order(tags)
|
||||||
|
|
||||||
|
|
||||||
|
class TextPopup(ModalScreen[None]):
|
||||||
|
def __init__(self, *, title: str, text: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = str(title)
|
||||||
|
self._text = str(text or "")
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static(self._title, id="popup-title")
|
||||||
|
yield TextArea(self._text, id="popup-text", read_only=True)
|
||||||
|
yield Button("Close", id="popup-close")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "popup-close":
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TagEditorPopup(ModalScreen[None]):
|
||||||
|
def __init__(self, *, seeds: Any, store_name: str, file_hash: Optional[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._seeds = seeds
|
||||||
|
self._store = str(store_name or "").strip()
|
||||||
|
self._hash = str(file_hash or "").strip() if file_hash else ""
|
||||||
|
self._original_tags: List[str] = []
|
||||||
|
self._status: Optional[Static] = None
|
||||||
|
self._editor: Optional[TextArea] = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("Tags", id="popup-title")
|
||||||
|
yield TextArea("", id="tags-editor")
|
||||||
|
with Horizontal(id="tags-buttons"):
|
||||||
|
yield Button("Save", id="tags-save")
|
||||||
|
yield Button("Close", id="tags-close")
|
||||||
|
yield Static("", id="tags-status")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._status = self.query_one("#tags-status", Static)
|
||||||
|
self._editor = self.query_one("#tags-editor", TextArea)
|
||||||
|
self._set_status("Loading tags…")
|
||||||
|
self._load_tags_background()
|
||||||
|
|
||||||
|
def _set_status(self, msg: str) -> None:
|
||||||
|
if self._status:
|
||||||
|
self._status.update(str(msg or ""))
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _load_tags_background(self) -> None:
|
||||||
|
app = self.app # PipelineHubApp
|
||||||
|
try:
|
||||||
|
runner: PipelineRunner = getattr(app, "executor")
|
||||||
|
cmd = f"@1 | get-tag -emit"
|
||||||
|
res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True)
|
||||||
|
tags = _extract_tag_names(res.emitted)
|
||||||
|
except Exception as exc:
|
||||||
|
tags = []
|
||||||
|
try:
|
||||||
|
app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}")
|
||||||
|
except Exception:
|
||||||
|
self._set_status(f"Error: {type(exc).__name__}: {exc}")
|
||||||
|
self._original_tags = tags
|
||||||
|
try:
|
||||||
|
app.call_from_thread(self._apply_loaded_tags, tags)
|
||||||
|
except Exception:
|
||||||
|
self._apply_loaded_tags(tags)
|
||||||
|
|
||||||
|
def _apply_loaded_tags(self, tags: List[str]) -> None:
|
||||||
|
if self._editor:
|
||||||
|
self._editor.text = "\n".join(tags)
|
||||||
|
self._set_status(f"Loaded {len(tags)} tag(s)")
|
||||||
|
|
||||||
|
def _parse_editor_tags(self) -> List[str]:
|
||||||
|
raw = ""
|
||||||
|
try:
|
||||||
|
raw = str(self._editor.text or "") if self._editor else ""
|
||||||
|
except Exception:
|
||||||
|
raw = ""
|
||||||
|
lines = [t.strip() for t in raw.replace("\r\n", "\n").split("\n")]
|
||||||
|
return _dedup_preserve_order([t for t in lines if t])
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "tags-close":
|
||||||
|
self.dismiss(None)
|
||||||
|
return
|
||||||
|
if event.button.id == "tags-save":
|
||||||
|
self._save_tags()
|
||||||
|
|
||||||
|
def _save_tags(self) -> None:
|
||||||
|
desired = self._parse_editor_tags()
|
||||||
|
current = _dedup_preserve_order(list(self._original_tags or []))
|
||||||
|
|
||||||
|
desired_set = {t.lower() for t in desired}
|
||||||
|
current_set = {t.lower() for t in current}
|
||||||
|
|
||||||
|
to_add = [t for t in desired if t.lower() not in current_set]
|
||||||
|
to_del = [t for t in current if t.lower() not in desired_set]
|
||||||
|
|
||||||
|
if not to_add and not to_del:
|
||||||
|
self._set_status("No changes")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._set_status("Saving…")
|
||||||
|
self._save_tags_background(to_add, to_del, desired)
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _save_tags_background(self, to_add: List[str], to_del: List[str], desired: List[str]) -> None:
|
||||||
|
app = self.app # PipelineHubApp
|
||||||
|
try:
|
||||||
|
runner: PipelineRunner = getattr(app, "executor")
|
||||||
|
store_tok = json.dumps(self._store)
|
||||||
|
query_chunk = f" -query {json.dumps(f'hash:{self._hash}')}" if self._hash else ""
|
||||||
|
|
||||||
|
failures: List[str] = []
|
||||||
|
|
||||||
|
if to_del:
|
||||||
|
del_args = " ".join(json.dumps(t) for t in to_del)
|
||||||
|
del_cmd = f"@1 | delete-tag -store {store_tok}{query_chunk} {del_args}"
|
||||||
|
del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True)
|
||||||
|
if not getattr(del_res, "success", False):
|
||||||
|
failures.append(str(getattr(del_res, "error", "") or getattr(del_res, "stderr", "") or "delete-tag failed").strip())
|
||||||
|
|
||||||
|
if to_add:
|
||||||
|
add_args = " ".join(json.dumps(t) for t in to_add)
|
||||||
|
add_cmd = f"@1 | add-tag -store {store_tok}{query_chunk} {add_args}"
|
||||||
|
add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True)
|
||||||
|
if not getattr(add_res, "success", False):
|
||||||
|
failures.append(str(getattr(add_res, "error", "") or getattr(add_res, "stderr", "") or "add-tag failed").strip())
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
msg = failures[0]
|
||||||
|
try:
|
||||||
|
app.call_from_thread(self._set_status, f"Error: {msg}")
|
||||||
|
except Exception:
|
||||||
|
self._set_status(f"Error: {msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._original_tags = list(desired)
|
||||||
|
try:
|
||||||
|
app.call_from_thread(self._set_status, f"Saved (+{len(to_add)}, -{len(to_del)})")
|
||||||
|
except Exception:
|
||||||
|
self._set_status(f"Saved (+{len(to_add)}, -{len(to_del)})")
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}")
|
||||||
|
except Exception:
|
||||||
|
self._set_status(f"Error: {type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
class PipelineHubApp(App):
|
class PipelineHubApp(App):
|
||||||
@@ -58,22 +225,27 @@ class PipelineHubApp(App):
|
|||||||
Binding("ctrl+enter", "run_pipeline", "Run Pipeline"),
|
Binding("ctrl+enter", "run_pipeline", "Run Pipeline"),
|
||||||
Binding("f5", "refresh_workers", "Refresh Workers"),
|
Binding("f5", "refresh_workers", "Refresh Workers"),
|
||||||
Binding("ctrl+l", "focus_command", "Focus Input", show=False),
|
Binding("ctrl+l", "focus_command", "Focus Input", show=False),
|
||||||
|
Binding("ctrl+g", "focus_logs", "Focus Logs", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.executor = PipelineExecutor()
|
self.executor = PipelineRunner()
|
||||||
self.result_items: List[Any] = []
|
self.result_items: List[Any] = []
|
||||||
self.log_lines: List[str] = []
|
self.log_lines: List[str] = []
|
||||||
self.command_input: Optional[Input] = None
|
self.command_input: Optional[Input] = None
|
||||||
|
self.store_select: Optional[Select] = None
|
||||||
|
self.path_input: Optional[Input] = None
|
||||||
self.log_output: Optional[TextArea] = None
|
self.log_output: Optional[TextArea] = None
|
||||||
self.results_table: Optional[DataTable] = None
|
self.results_table: Optional[DataTable] = None
|
||||||
self.metadata_tree: Optional[Tree] = None
|
|
||||||
self.worker_table: Optional[DataTable] = None
|
self.worker_table: Optional[DataTable] = None
|
||||||
self.preset_list: Optional[ListView] = None
|
|
||||||
self.status_panel: Optional[Static] = None
|
self.status_panel: Optional[Static] = None
|
||||||
self.current_result_table: Optional[ResultTable] = None
|
self.current_result_table: Optional[ResultTable] = None
|
||||||
|
self.suggestion_list: Optional[OptionList] = None
|
||||||
|
self._cmdlet_names: List[str] = []
|
||||||
self._pipeline_running = False
|
self._pipeline_running = False
|
||||||
|
self._pipeline_worker: Any = None
|
||||||
|
self._selected_row_index: int = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Layout
|
# Layout
|
||||||
@@ -81,43 +253,58 @@ class PipelineHubApp(App):
|
|||||||
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
|
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
with Container(id="app-shell"):
|
with Container(id="app-shell"):
|
||||||
with Horizontal(id="command-pane"):
|
with Vertical(id="command-pane"):
|
||||||
self.command_input = Input(
|
with Horizontal(id="command-row"):
|
||||||
placeholder='download-data "<url>" | merge-file | add-tags -store local | add-file -storage local',
|
yield Input(placeholder="Enter pipeline command...", id="pipeline-input")
|
||||||
id="pipeline-input",
|
yield Button("Run", id="run-button")
|
||||||
)
|
yield Button("Tags", id="tags-button")
|
||||||
yield self.command_input
|
yield Button("Metadata", id="metadata-button")
|
||||||
yield Button("Run", id="run-button", variant="primary")
|
yield Button("Relationships", id="relationships-button")
|
||||||
self.status_panel = Static("Idle", id="status-panel")
|
yield Static("Ready", id="status-panel")
|
||||||
yield self.status_panel
|
yield OptionList(id="cmd-suggestions")
|
||||||
with Horizontal(id="content-row"):
|
|
||||||
with VerticalScroll(id="left-pane"):
|
with Vertical(id="results-pane"):
|
||||||
yield Static("Pipeline Presets", classes="section-title")
|
yield Label("Results", classes="section-title")
|
||||||
self.preset_list = ListView(
|
yield DataTable(id="results-table")
|
||||||
*(PresetListItem(preset) for preset in PIPELINE_PRESETS),
|
|
||||||
id="preset-list",
|
with Vertical(id="bottom-pane"):
|
||||||
)
|
yield Label("Store + Output", classes="section-title")
|
||||||
yield self.preset_list
|
with Horizontal(id="store-row"):
|
||||||
yield Static("Logs", classes="section-title")
|
yield Select([], id="store-select")
|
||||||
self.log_output = TextArea(id="log-output", read_only=True)
|
yield Input(placeholder="Output path (optional)", id="output-path")
|
||||||
yield self.log_output
|
|
||||||
yield Static("Workers", classes="section-title")
|
with Horizontal(id="logs-workers-row"):
|
||||||
self.worker_table = DataTable(id="workers-table")
|
with Vertical(id="logs-pane"):
|
||||||
yield self.worker_table
|
yield Label("Logs", classes="section-title")
|
||||||
with Vertical(id="right-pane"):
|
yield TextArea(id="log-output", read_only=True)
|
||||||
yield Static("Results", classes="section-title")
|
|
||||||
self.results_table = DataTable(id="results-table")
|
with Vertical(id="workers-pane"):
|
||||||
yield self.results_table
|
yield Label("Workers", classes="section-title")
|
||||||
yield Static("Metadata", classes="section-title")
|
yield DataTable(id="workers-table")
|
||||||
self.metadata_tree = Tree("Run a pipeline", id="metadata-tree")
|
|
||||||
yield self.metadata_tree
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
self.command_input = self.query_one("#pipeline-input", Input)
|
||||||
|
self.status_panel = self.query_one("#status-panel", Static)
|
||||||
|
self.results_table = self.query_one("#results-table", DataTable)
|
||||||
|
self.worker_table = self.query_one("#workers-table", DataTable)
|
||||||
|
self.log_output = self.query_one("#log-output", TextArea)
|
||||||
|
self.store_select = self.query_one("#store-select", Select)
|
||||||
|
self.path_input = self.query_one("#output-path", Input)
|
||||||
|
self.suggestion_list = self.query_one("#cmd-suggestions", OptionList)
|
||||||
|
|
||||||
|
if self.suggestion_list:
|
||||||
|
self.suggestion_list.display = False
|
||||||
|
|
||||||
if self.results_table:
|
if self.results_table:
|
||||||
|
self.results_table.cursor_type = "row"
|
||||||
|
self.results_table.zebra_stripes = True
|
||||||
self.results_table.add_columns("Row", "Title", "Source", "File")
|
self.results_table.add_columns("Row", "Title", "Source", "File")
|
||||||
if self.worker_table:
|
if self.worker_table:
|
||||||
self.worker_table.add_columns("ID", "Type", "Status", "Details")
|
self.worker_table.add_columns("ID", "Type", "Status", "Details")
|
||||||
|
|
||||||
|
self._populate_store_options()
|
||||||
|
self._load_cmdlet_names()
|
||||||
if self.executor.worker_manager:
|
if self.executor.worker_manager:
|
||||||
self.set_interval(2.0, self.refresh_workers)
|
self.set_interval(2.0, self.refresh_workers)
|
||||||
self.refresh_workers()
|
self.refresh_workers()
|
||||||
@@ -131,10 +318,24 @@ class PipelineHubApp(App):
|
|||||||
if self.command_input:
|
if self.command_input:
|
||||||
self.command_input.focus()
|
self.command_input.focus()
|
||||||
|
|
||||||
|
def action_focus_logs(self) -> None:
|
||||||
|
if self.log_output:
|
||||||
|
self.log_output.focus()
|
||||||
|
|
||||||
def action_run_pipeline(self) -> None:
|
def action_run_pipeline(self) -> None:
|
||||||
if self._pipeline_running:
|
if self._pipeline_running:
|
||||||
self.notify("Pipeline already running", severity="warning", timeout=3)
|
# Self-heal if the background worker already stopped (e.g. error in thread).
|
||||||
return
|
worker = self._pipeline_worker
|
||||||
|
try:
|
||||||
|
is_running = bool(getattr(worker, "is_running", False))
|
||||||
|
except Exception:
|
||||||
|
is_running = True
|
||||||
|
if (worker is None) or (not is_running):
|
||||||
|
self._pipeline_running = False
|
||||||
|
self._pipeline_worker = None
|
||||||
|
else:
|
||||||
|
self.notify("Pipeline already running", severity="warning", timeout=3)
|
||||||
|
return
|
||||||
if not self.command_input:
|
if not self.command_input:
|
||||||
return
|
return
|
||||||
pipeline_text = self.command_input.value.strip()
|
pipeline_text = self.command_input.value.strip()
|
||||||
@@ -142,12 +343,33 @@ class PipelineHubApp(App):
|
|||||||
self.notify("Enter a pipeline to run", severity="warning", timeout=3)
|
self.notify("Enter a pipeline to run", severity="warning", timeout=3)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
pipeline_text = self._apply_store_path_and_tags(pipeline_text)
|
||||||
|
|
||||||
self._pipeline_running = True
|
self._pipeline_running = True
|
||||||
self._set_status("Running…", level="info")
|
self._set_status("Running…", level="info")
|
||||||
self._clear_log()
|
self._clear_log()
|
||||||
self._append_log_line(f"$ {pipeline_text}")
|
self._append_log_line(f"$ {pipeline_text}")
|
||||||
self._clear_results()
|
self._clear_results()
|
||||||
self._run_pipeline_background(pipeline_text)
|
self._pipeline_worker = self._run_pipeline_background(pipeline_text)
|
||||||
|
|
||||||
|
@on(Input.Changed, "#pipeline-input")
|
||||||
|
def on_pipeline_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
text = str(event.value or "")
|
||||||
|
self._update_suggestions(text)
|
||||||
|
self._update_syntax_status(text)
|
||||||
|
|
||||||
|
@on(OptionList.OptionSelected, "#cmd-suggestions")
|
||||||
|
def on_suggestion_selected(self, event: OptionList.OptionSelected) -> None:
|
||||||
|
if not self.command_input or not self.suggestion_list:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
suggestion = str(event.option.prompt)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
new_text = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion)
|
||||||
|
self.command_input.value = new_text
|
||||||
|
self.suggestion_list.display = False
|
||||||
|
self.command_input.focus()
|
||||||
|
|
||||||
def action_refresh_workers(self) -> None:
|
def action_refresh_workers(self) -> None:
|
||||||
self.refresh_workers()
|
self.refresh_workers()
|
||||||
@@ -158,34 +380,178 @@ class PipelineHubApp(App):
|
|||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "run-button":
|
if event.button.id == "run-button":
|
||||||
self.action_run_pipeline()
|
self.action_run_pipeline()
|
||||||
|
elif event.button.id == "tags-button":
|
||||||
|
self._open_tags_popup()
|
||||||
|
elif event.button.id == "metadata-button":
|
||||||
|
self._open_metadata_popup()
|
||||||
|
elif event.button.id == "relationships-button":
|
||||||
|
self._open_relationships_popup()
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
if event.input.id == "pipeline-input":
|
if event.input.id == "pipeline-input":
|
||||||
self.action_run_pipeline()
|
self.action_run_pipeline()
|
||||||
|
|
||||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
def on_key(self, event: Key) -> None:
|
||||||
if isinstance(event.item, PresetListItem) and self.command_input:
|
# Make Tab accept autocomplete when typing commands.
|
||||||
self.command_input.value = event.item.preset.pipeline
|
if event.key != "tab":
|
||||||
self.notify(f"Loaded preset: {event.item.preset.label}", timeout=2)
|
return
|
||||||
event.stop()
|
if not self.command_input or not self.command_input.has_focus:
|
||||||
|
return
|
||||||
|
suggestion = self._get_first_suggestion()
|
||||||
|
if not suggestion:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.command_input.value = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion)
|
||||||
|
if self.suggestion_list:
|
||||||
|
self.suggestion_list.display = False
|
||||||
|
event.prevent_default()
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
def _get_first_suggestion(self) -> str:
|
||||||
|
if not self.suggestion_list or not bool(getattr(self.suggestion_list, "display", False)):
|
||||||
|
return ""
|
||||||
|
# Textual OptionList API differs across versions; handle best-effort.
|
||||||
|
try:
|
||||||
|
options = list(getattr(self.suggestion_list, "options", []) or [])
|
||||||
|
if options:
|
||||||
|
first = options[0]
|
||||||
|
return str(getattr(first, "prompt", "") or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _populate_store_options(self) -> None:
|
||||||
|
"""Populate the store dropdown from the configured Store registry."""
|
||||||
|
if not self.store_select:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cfg = load_config() or {}
|
||||||
|
except Exception:
|
||||||
|
cfg = {}
|
||||||
|
|
||||||
|
stores: List[str] = []
|
||||||
|
try:
|
||||||
|
stores = StoreRegistry(config=cfg, suppress_debug=True).list_backends()
|
||||||
|
except Exception:
|
||||||
|
stores = []
|
||||||
|
|
||||||
|
# Always offer a reasonable default even if config is missing.
|
||||||
|
if "local" not in [s.lower() for s in stores]:
|
||||||
|
stores = ["local", *stores]
|
||||||
|
|
||||||
|
options = [(name, name) for name in stores]
|
||||||
|
try:
|
||||||
|
self.store_select.set_options(options)
|
||||||
|
if options:
|
||||||
|
current = getattr(self.store_select, "value", None)
|
||||||
|
# Textual Select uses a sentinel for "no selection".
|
||||||
|
if (current is None) or (current == "") or (current is Select.BLANK):
|
||||||
|
self.store_select.value = options[0][1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _get_selected_store(self) -> Optional[str]:
|
||||||
|
if not self.store_select:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = getattr(self.store_select, "value", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value is None or value is Select.BLANK:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not text or text == "Select.BLANK":
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _apply_store_path_and_tags(self, pipeline_text: str) -> str:
|
||||||
|
"""Apply store/path/tags UI fields to the pipeline text.
|
||||||
|
|
||||||
|
Rules (simple + non-destructive):
|
||||||
|
- If output path is set and the first stage is download-media and has no -path/--path, append -path.
|
||||||
|
- If a store is selected and pipeline has no add-file stage, append add-file -store <store>.
|
||||||
|
"""
|
||||||
|
base = str(pipeline_text or "").strip()
|
||||||
|
if not base:
|
||||||
|
return base
|
||||||
|
|
||||||
|
selected_store = self._get_selected_store()
|
||||||
|
|
||||||
|
output_path = ""
|
||||||
|
if self.path_input:
|
||||||
|
try:
|
||||||
|
output_path = str(self.path_input.value or "").strip()
|
||||||
|
except Exception:
|
||||||
|
output_path = ""
|
||||||
|
|
||||||
|
stages = [s.strip() for s in base.split("|") if s.strip()]
|
||||||
|
if not stages:
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Identify first stage command name for conservative auto-augmentation.
|
||||||
|
first_stage_cmd = ""
|
||||||
|
try:
|
||||||
|
first_stage_cmd = str(stages[0].split()[0]).replace("_", "-").strip().lower() if stages[0].split() else ""
|
||||||
|
except Exception:
|
||||||
|
first_stage_cmd = ""
|
||||||
|
|
||||||
|
# Apply -path to download-media first stage (only if missing)
|
||||||
|
if output_path:
|
||||||
|
first = stages[0]
|
||||||
|
low = first.lower()
|
||||||
|
if low.startswith("download-media") and " -path" not in low and " --path" not in low:
|
||||||
|
stages[0] = f"{first} -path {json.dumps(output_path)}"
|
||||||
|
|
||||||
|
joined = " | ".join(stages)
|
||||||
|
|
||||||
|
low_joined = joined.lower()
|
||||||
|
|
||||||
|
# Only auto-append add-file for download pipelines.
|
||||||
|
should_auto_add_file = bool(
|
||||||
|
selected_store
|
||||||
|
and ("add-file" not in low_joined)
|
||||||
|
and (first_stage_cmd in {"download-media", "download-file", "download-torrent"})
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_auto_add_file:
|
||||||
|
store_token = json.dumps(selected_store)
|
||||||
|
joined = f"{joined} | add-file -store {store_token}"
|
||||||
|
|
||||||
|
return joined
|
||||||
|
|
||||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
if not self.results_table or event.control is not self.results_table:
|
if not self.results_table or event.control is not self.results_table:
|
||||||
return
|
return
|
||||||
index = event.cursor_row
|
index = int(event.cursor_row or 0)
|
||||||
if 0 <= index < len(self.result_items):
|
if index < 0:
|
||||||
self._display_metadata(index)
|
index = 0
|
||||||
|
self._selected_row_index = index
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Pipeline execution helpers
|
# Pipeline execution helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@work(exclusive=True, thread=True)
|
@work(exclusive=True, thread=True)
|
||||||
def _run_pipeline_background(self, pipeline_text: str) -> None:
|
def _run_pipeline_background(self, pipeline_text: str) -> None:
|
||||||
run_result = self.executor.run_pipeline(pipeline_text, on_log=self._log_from_worker)
|
try:
|
||||||
|
run_result = self.executor.run_pipeline(pipeline_text, on_log=self._log_from_worker)
|
||||||
|
except Exception as exc:
|
||||||
|
# Ensure the UI never gets stuck in "running" state.
|
||||||
|
run_result = PipelineRunResult(
|
||||||
|
pipeline=str(pipeline_text or ""),
|
||||||
|
success=False,
|
||||||
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
|
stderr=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
self.call_from_thread(self._on_pipeline_finished, run_result)
|
self.call_from_thread(self._on_pipeline_finished, run_result)
|
||||||
|
|
||||||
def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None:
|
def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None:
|
||||||
self._pipeline_running = False
|
self._pipeline_running = False
|
||||||
|
self._pipeline_worker = None
|
||||||
status_level = "success" if run_result.success else "error"
|
status_level = "success" if run_result.success else "error"
|
||||||
status_text = "Completed" if run_result.success else "Failed"
|
status_text = "Completed" if run_result.success else "Failed"
|
||||||
self._set_status(status_text, level=status_level)
|
self._set_status(status_text, level=status_level)
|
||||||
@@ -219,6 +585,8 @@ class PipelineHubApp(App):
|
|||||||
self.current_result_table = run_result.result_table
|
self.current_result_table = run_result.result_table
|
||||||
self._populate_results_table()
|
self._populate_results_table()
|
||||||
self.refresh_workers()
|
self.refresh_workers()
|
||||||
|
if self.result_items:
|
||||||
|
self._selected_row_index = 0
|
||||||
|
|
||||||
def _log_from_worker(self, message: str) -> None:
|
def _log_from_worker(self, message: str) -> None:
|
||||||
self.call_from_thread(self._append_log_line, message)
|
self.call_from_thread(self._append_log_line, message)
|
||||||
@@ -251,35 +619,213 @@ class PipelineHubApp(App):
|
|||||||
for idx, item in enumerate(self.result_items, start=1):
|
for idx, item in enumerate(self.result_items, start=1):
|
||||||
self.results_table.add_row(str(idx), str(item), "—", "—", key=str(idx - 1))
|
self.results_table.add_row(str(idx), str(item), "—", "—", key=str(idx - 1))
|
||||||
|
|
||||||
def _display_metadata(self, index: int) -> None:
|
def _load_cmdlet_names(self) -> None:
|
||||||
if not self.metadata_tree:
|
try:
|
||||||
|
ensure_registry_loaded()
|
||||||
|
names = list_cmdlet_names() or []
|
||||||
|
self._cmdlet_names = sorted({str(n).replace("_", "-") for n in names if str(n).strip()})
|
||||||
|
except Exception:
|
||||||
|
self._cmdlet_names = []
|
||||||
|
|
||||||
|
def _update_syntax_status(self, text: str) -> None:
|
||||||
|
if self._pipeline_running:
|
||||||
return
|
return
|
||||||
root = self.metadata_tree.root
|
raw = str(text or "").strip()
|
||||||
root.label = "Metadata"
|
if not raw:
|
||||||
root.remove_children()
|
self._set_status("Ready", level="info")
|
||||||
|
return
|
||||||
if self.current_result_table and 0 <= index < len(self.current_result_table.rows):
|
try:
|
||||||
row = self.current_result_table.rows[index]
|
err = validate_pipeline_text(raw)
|
||||||
for col in row.columns:
|
except Exception:
|
||||||
root.add(f"[b]{col.name}[/b]: {col.value}")
|
err = None
|
||||||
elif 0 <= index < len(self.result_items):
|
if err:
|
||||||
item = self.result_items[index]
|
self._set_status(err.message, level="error")
|
||||||
if isinstance(item, dict):
|
|
||||||
self._populate_tree_node(root, item)
|
|
||||||
else:
|
|
||||||
root.add(str(item))
|
|
||||||
|
|
||||||
def _populate_tree_node(self, node, data: Any) -> None:
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for key, value in data.items():
|
|
||||||
child = node.add(f"[b]{key}[/b]")
|
|
||||||
self._populate_tree_node(child, value)
|
|
||||||
elif isinstance(data, Sequence) and not isinstance(data, (str, bytes)):
|
|
||||||
for idx, value in enumerate(data):
|
|
||||||
child = node.add(f"[{idx}]")
|
|
||||||
self._populate_tree_node(child, value)
|
|
||||||
else:
|
else:
|
||||||
node.add(str(data))
|
self._set_status("Ready", level="info")
|
||||||
|
|
||||||
|
def _update_suggestions(self, text: str) -> None:
|
||||||
|
if not self.suggestion_list:
|
||||||
|
return
|
||||||
|
raw = str(text or "")
|
||||||
|
prefix = self._current_cmd_prefix(raw)
|
||||||
|
if not prefix:
|
||||||
|
self.suggestion_list.display = False
|
||||||
|
return
|
||||||
|
|
||||||
|
pref_low = prefix.lower()
|
||||||
|
matches = [n for n in self._cmdlet_names if n.lower().startswith(pref_low)]
|
||||||
|
matches = matches[:10]
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
self.suggestion_list.display = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.suggestion_list.clear_options() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
# Fallback for older/newer Textual APIs.
|
||||||
|
self.suggestion_list.options = [] # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.suggestion_list.add_options([Option(m) for m in matches]) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.suggestion_list.options = [Option(m) for m in matches] # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.suggestion_list.display = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _current_cmd_prefix(text: str) -> str:
|
||||||
|
"""Best-effort prefix for cmdlet name completion.
|
||||||
|
|
||||||
|
Completes the token immediately after start-of-line or a '|'.
|
||||||
|
"""
|
||||||
|
raw = str(text or "")
|
||||||
|
# Find the segment after the last pipe.
|
||||||
|
segment = raw.split("|")[-1]
|
||||||
|
# Remove leading whitespace.
|
||||||
|
segment = segment.lstrip()
|
||||||
|
if not segment:
|
||||||
|
return ""
|
||||||
|
# Only complete the first token of the segment.
|
||||||
|
m = re.match(r"([A-Za-z0-9_\-]*)", segment)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_suggestion_to_text(text: str, suggestion: str) -> str:
|
||||||
|
raw = str(text or "")
|
||||||
|
parts = raw.split("|")
|
||||||
|
if not parts:
|
||||||
|
return suggestion
|
||||||
|
last = parts[-1]
|
||||||
|
# Preserve leading spaces after the pipe.
|
||||||
|
leading = "".join(ch for ch in last if ch.isspace())
|
||||||
|
trimmed = last.lstrip()
|
||||||
|
# Replace first token in last segment.
|
||||||
|
replaced = re.sub(r"^[A-Za-z0-9_\-]*", suggestion, trimmed)
|
||||||
|
parts[-1] = leading + replaced
|
||||||
|
return "|".join(parts)
|
||||||
|
|
||||||
|
def _resolve_selected_item(self) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
|
||||||
|
"""Return (item, store_name, hash) for the currently selected row."""
|
||||||
|
index = int(getattr(self, "_selected_row_index", 0) or 0)
|
||||||
|
if index < 0:
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
item: Any = None
|
||||||
|
|
||||||
|
# Prefer mapping displayed table row -> source item.
|
||||||
|
if self.current_result_table and 0 <= index < len(getattr(self.current_result_table, "rows", []) or []):
|
||||||
|
row = self.current_result_table.rows[index]
|
||||||
|
src_idx = getattr(row, "source_index", None)
|
||||||
|
if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items):
|
||||||
|
item = self.result_items[src_idx]
|
||||||
|
|
||||||
|
if item is None and 0 <= index < len(self.result_items):
|
||||||
|
item = self.result_items[index]
|
||||||
|
|
||||||
|
store_name = None
|
||||||
|
file_hash = None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
store_name = item.get("store")
|
||||||
|
file_hash = item.get("hash")
|
||||||
|
else:
|
||||||
|
store_name = getattr(item, "store", None)
|
||||||
|
file_hash = getattr(item, "hash", None)
|
||||||
|
|
||||||
|
store_text = str(store_name).strip() if store_name is not None else ""
|
||||||
|
hash_text = str(file_hash).strip() if file_hash is not None else ""
|
||||||
|
|
||||||
|
if not store_text:
|
||||||
|
# Fallback to UI store selection when item doesn't carry it.
|
||||||
|
store_text = self._get_selected_store() or ""
|
||||||
|
|
||||||
|
return item, (store_text or None), (hash_text or None)
|
||||||
|
|
||||||
|
def _open_tags_popup(self) -> None:
|
||||||
|
if self._pipeline_running:
|
||||||
|
self.notify("Pipeline already running", severity="warning", timeout=3)
|
||||||
|
return
|
||||||
|
item, store_name, file_hash = self._resolve_selected_item()
|
||||||
|
if item is None:
|
||||||
|
self.notify("No selected item", severity="warning", timeout=3)
|
||||||
|
return
|
||||||
|
if not store_name:
|
||||||
|
self.notify("Selected item missing store", severity="warning", timeout=4)
|
||||||
|
return
|
||||||
|
|
||||||
|
seeds: Any = item
|
||||||
|
if isinstance(item, dict):
|
||||||
|
seeds = dict(item)
|
||||||
|
try:
|
||||||
|
if store_name and not str(seeds.get("store") or "").strip():
|
||||||
|
seeds["store"] = store_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if file_hash and not str(seeds.get("hash") or "").strip():
|
||||||
|
seeds["hash"] = file_hash
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.push_screen(TagEditorPopup(seeds=seeds, store_name=store_name, file_hash=file_hash))
|
||||||
|
|
||||||
|
def _open_metadata_popup(self) -> None:
|
||||||
|
item, _store_name, _file_hash = self._resolve_selected_item()
|
||||||
|
if item is None:
|
||||||
|
self.notify("No selected item", severity="warning", timeout=3)
|
||||||
|
return
|
||||||
|
text = ""
|
||||||
|
idx = int(getattr(self, "_selected_row_index", 0) or 0)
|
||||||
|
if self.current_result_table and 0 <= idx < len(getattr(self.current_result_table, "rows", []) or []):
|
||||||
|
row = self.current_result_table.rows[idx]
|
||||||
|
lines = [f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
try:
|
||||||
|
text = json.dumps(item, indent=2, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
text = str(item)
|
||||||
|
else:
|
||||||
|
text = str(item)
|
||||||
|
self.push_screen(TextPopup(title="Metadata", text=text))
|
||||||
|
|
||||||
|
def _open_relationships_popup(self) -> None:
|
||||||
|
item, _store_name, _file_hash = self._resolve_selected_item()
|
||||||
|
if item is None:
|
||||||
|
self.notify("No selected item", severity="warning", timeout=3)
|
||||||
|
return
|
||||||
|
|
||||||
|
relationships = None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
relationships = item.get("relationships") or item.get("relationship")
|
||||||
|
else:
|
||||||
|
relationships = getattr(item, "relationships", None)
|
||||||
|
if not relationships:
|
||||||
|
relationships = getattr(item, "get_relationships", lambda: None)()
|
||||||
|
|
||||||
|
if not relationships:
|
||||||
|
self.push_screen(TextPopup(title="Relationships", text="No relationships"))
|
||||||
|
return
|
||||||
|
|
||||||
|
lines: List[str] = []
|
||||||
|
if isinstance(relationships, dict):
|
||||||
|
for rel_type, value in relationships.items():
|
||||||
|
if isinstance(value, list):
|
||||||
|
if not value:
|
||||||
|
lines.append(f"{rel_type}: (empty)")
|
||||||
|
for v in value:
|
||||||
|
lines.append(f"{rel_type}: {v}")
|
||||||
|
else:
|
||||||
|
lines.append(f"{rel_type}: {value}")
|
||||||
|
else:
|
||||||
|
lines.append(str(relationships))
|
||||||
|
self.push_screen(TextPopup(title="Relationships", text="\n".join(lines)))
|
||||||
|
|
||||||
def _clear_log(self) -> None:
|
def _clear_log(self) -> None:
|
||||||
self.log_lines = []
|
self.log_lines = []
|
||||||
@@ -301,9 +847,7 @@ class PipelineHubApp(App):
|
|||||||
self.result_items = []
|
self.result_items = []
|
||||||
if self.results_table:
|
if self.results_table:
|
||||||
self.results_table.clear()
|
self.results_table.clear()
|
||||||
if self.metadata_tree:
|
self._selected_row_index = 0
|
||||||
self.metadata_tree.root.label = "Awaiting results"
|
|
||||||
self.metadata_tree.root.remove_children()
|
|
||||||
|
|
||||||
def _set_status(self, message: str, *, level: str = "info") -> None:
|
def _set_status(self, message: str, *, level: str = "info") -> None:
|
||||||
if not self.status_panel:
|
if not self.status_panel:
|
||||||
|
|||||||
113
TUI/tui.tcss
113
TUI/tui.tcss
@@ -14,6 +14,11 @@
|
|||||||
border: round $primary;
|
border: round $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#command-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
#pipeline-input {
|
#pipeline-input {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
min-height: 3;
|
min-height: 3;
|
||||||
@@ -38,22 +43,61 @@
|
|||||||
border: solid $panel-darken-1;
|
border: solid $panel-darken-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content-row {
|
#cmd-suggestions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1fr;
|
height: auto;
|
||||||
|
max-height: 8;
|
||||||
|
margin-top: 1;
|
||||||
|
background: $surface;
|
||||||
|
border: round $panel-darken-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#left-pane,
|
#results-pane {
|
||||||
#right-pane {
|
width: 100%;
|
||||||
|
height: 2fr;
|
||||||
|
padding: 1;
|
||||||
|
background: $panel;
|
||||||
|
border: round $panel-darken-2;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#store-select {
|
||||||
|
width: 24;
|
||||||
|
margin-right: 2;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output-path {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
height: 100%;
|
height: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#bottom-pane {
|
||||||
|
width: 100%;
|
||||||
|
height: 1fr;
|
||||||
padding: 1;
|
padding: 1;
|
||||||
background: $panel;
|
background: $panel;
|
||||||
border: round $panel-darken-2;
|
border: round $panel-darken-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#left-pane {
|
|
||||||
max-width: 60;
|
#store-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs-workers-row {
|
||||||
|
width: 100%;
|
||||||
|
height: 1fr;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs-pane,
|
||||||
|
#workers-pane {
|
||||||
|
width: 1fr;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -62,33 +106,19 @@
|
|||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-entry {
|
|
||||||
padding: 1;
|
|
||||||
border: tall $panel-darken-1;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#preset-list {
|
|
||||||
height: 25;
|
|
||||||
border: solid $secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
#log-output {
|
#log-output {
|
||||||
height: 16;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
#workers-table {
|
#workers-table {
|
||||||
height: auto;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
#results-table {
|
#results-table {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
#metadata-tree {
|
|
||||||
height: 1fr;
|
|
||||||
border: round $panel-darken-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-info {
|
.status-info {
|
||||||
background: $boost;
|
background: $boost;
|
||||||
@@ -110,3 +140,38 @@
|
|||||||
min-width: 10;
|
min-width: 10;
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#tags-button,
|
||||||
|
#metadata-button,
|
||||||
|
#relationships-button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 12;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
text-style: bold;
|
||||||
|
content-align: center middle;
|
||||||
|
border: round $panel-darken-2;
|
||||||
|
background: $boost;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-text,
|
||||||
|
#tags-editor {
|
||||||
|
height: 1fr;
|
||||||
|
border: round $panel-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tags-buttons {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tags-status {
|
||||||
|
width: 1fr;
|
||||||
|
height: 3;
|
||||||
|
content-align: left middle;
|
||||||
|
}
|
||||||
@@ -1328,6 +1328,38 @@ def _unique_destination_path(dest: Path) -> Path:
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _print_live_safe_stderr(message: str) -> None:
|
||||||
|
"""Print to stderr without breaking Rich Live progress output."""
|
||||||
|
try:
|
||||||
|
from rich_display import stderr_console # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
cm = None
|
||||||
|
try:
|
||||||
|
import pipeline as _pipeline_ctx # type: ignore
|
||||||
|
suspend = getattr(_pipeline_ctx, "suspend_live_progress", None)
|
||||||
|
cm = suspend() if callable(suspend) else None
|
||||||
|
except Exception:
|
||||||
|
cm = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from contextlib import nullcontext
|
||||||
|
except Exception:
|
||||||
|
nullcontext = None # type: ignore
|
||||||
|
if cm is None:
|
||||||
|
cm = nullcontext() if callable(nullcontext) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cm is not None:
|
||||||
|
with cm:
|
||||||
|
stderr_console.print(str(message))
|
||||||
|
else:
|
||||||
|
stderr_console.print(str(message))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def apply_output_path_from_pipeobjects(
|
def apply_output_path_from_pipeobjects(
|
||||||
*,
|
*,
|
||||||
cmd_name: str,
|
cmd_name: str,
|
||||||
@@ -1350,6 +1382,16 @@ def apply_output_path_from_pipeobjects(
|
|||||||
if not dest_raw:
|
if not dest_raw:
|
||||||
return list(emits or [])
|
return list(emits or [])
|
||||||
|
|
||||||
|
# Guard: users sometimes pass a URL into -path by mistake (e.g. `-path https://...`).
|
||||||
|
# Treat that as invalid for filesystem moves and avoid breaking Rich Live output.
|
||||||
|
try:
|
||||||
|
dest_str = str(dest_raw).strip()
|
||||||
|
if "://" in dest_str:
|
||||||
|
_print_live_safe_stderr(f"Ignoring -path value that looks like a URL: {dest_str}")
|
||||||
|
return list(emits or [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cmd_norm = str(cmd_name or "").replace("_", "-").strip().lower()
|
cmd_norm = str(cmd_name or "").replace("_", "-").strip().lower()
|
||||||
if not cmd_norm:
|
if not cmd_norm:
|
||||||
return list(emits or [])
|
return list(emits or [])
|
||||||
@@ -1410,7 +1452,7 @@ def apply_output_path_from_pipeobjects(
|
|||||||
try:
|
try:
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to create destination directory: {dest_dir} ({exc})", file=sys.stderr)
|
_print_live_safe_stderr(f"Failed to create destination directory: {dest_dir} ({exc})")
|
||||||
return items
|
return items
|
||||||
|
|
||||||
for idx, src in zip(artifact_indices, artifact_paths):
|
for idx, src in zip(artifact_indices, artifact_paths):
|
||||||
@@ -1418,15 +1460,18 @@ def apply_output_path_from_pipeobjects(
|
|||||||
final = _unique_destination_path(final)
|
final = _unique_destination_path(final)
|
||||||
try:
|
try:
|
||||||
if src.resolve() == final.resolve():
|
if src.resolve() == final.resolve():
|
||||||
|
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
shutil.move(str(src), str(final))
|
shutil.move(str(src), str(final))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to save output to {final}: {exc}", file=sys.stderr)
|
_print_live_safe_stderr(f"Failed to save output to {final}: {exc}")
|
||||||
continue
|
continue
|
||||||
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -1445,7 +1490,7 @@ def apply_output_path_from_pipeobjects(
|
|||||||
try:
|
try:
|
||||||
final.parent.mkdir(parents=True, exist_ok=True)
|
final.parent.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to create destination directory: {final.parent} ({exc})", file=sys.stderr)
|
_print_live_safe_stderr(f"Failed to create destination directory: {final.parent} ({exc})")
|
||||||
return items
|
return items
|
||||||
|
|
||||||
final = _unique_destination_path(final)
|
final = _unique_destination_path(final)
|
||||||
@@ -1453,13 +1498,89 @@ def apply_output_path_from_pipeobjects(
|
|||||||
if src.resolve() != final.resolve():
|
if src.resolve() != final.resolve():
|
||||||
shutil.move(str(src), str(final))
|
shutil.move(str(src), str(final))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to save output to {final}: {exc}", file=sys.stderr)
|
_print_live_safe_stderr(f"Failed to save output to {final}: {exc}")
|
||||||
return items
|
return items
|
||||||
|
|
||||||
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _print_saved_output_panel(item: Any, final_path: Path) -> None:
|
||||||
|
"""When -path is used, print a Rich panel summarizing the saved output.
|
||||||
|
|
||||||
|
Shows: Title, Location, Hash.
|
||||||
|
Best-effort: reads existing fields first to avoid recomputing hashes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rich.panel import Panel # type: ignore
|
||||||
|
from rich.table import Table # type: ignore
|
||||||
|
from rich_display import stderr_console # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If Rich Live progress is active, pause it while printing so the panel
|
||||||
|
# doesn't get overwritten/truncated by Live's cursor control.
|
||||||
|
try:
|
||||||
|
import pipeline as _pipeline_ctx # type: ignore
|
||||||
|
suspend = getattr(_pipeline_ctx, "suspend_live_progress", None)
|
||||||
|
cm = suspend() if callable(suspend) else None
|
||||||
|
except Exception:
|
||||||
|
cm = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from contextlib import nullcontext
|
||||||
|
except Exception:
|
||||||
|
nullcontext = None # type: ignore
|
||||||
|
|
||||||
|
if cm is None:
|
||||||
|
cm = nullcontext() if callable(nullcontext) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
location = str(final_path)
|
||||||
|
except Exception:
|
||||||
|
location = ""
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = str(get_field(item, "title") or get_field(item, "name") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
if not title:
|
||||||
|
try:
|
||||||
|
title = str(final_path.stem or final_path.name)
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
file_hash = ""
|
||||||
|
try:
|
||||||
|
file_hash = str(get_field(item, "hash") or get_field(item, "sha256") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
if not file_hash:
|
||||||
|
try:
|
||||||
|
from SYS.utils import sha256_file # type: ignore
|
||||||
|
file_hash = str(sha256_file(final_path) or "").strip()
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
|
||||||
|
grid = Table.grid(padding=(0, 1))
|
||||||
|
grid.add_column(justify="right", style="bold")
|
||||||
|
grid.add_column()
|
||||||
|
grid.add_row("Title", title or "(unknown)")
|
||||||
|
grid.add_row("Location", location or "(unknown)")
|
||||||
|
grid.add_row("Hash", file_hash or "(unknown)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cm is not None:
|
||||||
|
with cm:
|
||||||
|
stderr_console.print(Panel(grid, title="Saved", expand=False))
|
||||||
|
else:
|
||||||
|
stderr_console.print(Panel(grid, title="Saved", expand=False))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _apply_saved_path_update(item: Any, *, old_path: str, new_path: str) -> None:
|
def _apply_saved_path_update(item: Any, *, old_path: str, new_path: str) -> None:
|
||||||
"""Update a PipeObject-like item after its backing file has moved."""
|
"""Update a PipeObject-like item after its backing file has moved."""
|
||||||
old_str = str(old_path)
|
old_str = str(old_path)
|
||||||
@@ -1952,9 +2073,6 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
|
|||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Debug: Print formatted table
|
|
||||||
pipe_obj.debug_table()
|
|
||||||
|
|
||||||
return pipe_obj
|
return pipe_obj
|
||||||
|
|
||||||
# Fallback: build from path argument or bare value
|
# Fallback: build from path argument or bare value
|
||||||
@@ -2000,9 +2118,6 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
|
|||||||
extra={},
|
extra={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Debug: Print formatted table
|
|
||||||
pipe_obj.debug_table()
|
|
||||||
|
|
||||||
return pipe_obj
|
return pipe_obj
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1077,7 +1077,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
# Handle Save Playlist
|
# Handle Save Playlist
|
||||||
if save_mode:
|
if save_mode:
|
||||||
playlist_name = index_arg or f"Playlist {subprocess.check_output(['date', '/t'], shell=True).decode().strip()}"
|
# Avoid `shell=True` / `date /t` on Windows (can flash a cmd.exe window).
|
||||||
|
# Use Python's datetime instead.
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
playlist_name = index_arg or f"Playlist {datetime.now().strftime('%Y-%m-%d')}"
|
||||||
# If index_arg was used for name, clear it so it doesn't trigger index logic
|
# If index_arg was used for name, clear it so it doesn't trigger index logic
|
||||||
if index_arg:
|
if index_arg:
|
||||||
index_arg = None
|
index_arg = None
|
||||||
@@ -1193,12 +1197,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
ctx.set_last_result_table_overlay(table, [p['items'] for p in playlists])
|
ctx.set_last_result_table_overlay(table, [p['items'] for p in playlists])
|
||||||
ctx.set_current_stage_table(table)
|
ctx.set_current_stage_table(table)
|
||||||
|
|
||||||
# In pipeline mode, the CLI renders current-stage tables; printing here duplicates output.
|
# Do not print directly here.
|
||||||
suppress_direct_print = bool(isinstance(config, dict) and config.get("_quiet_background_output"))
|
# Both CmdletExecutor and PipelineExecutor render the current-stage/overlay table,
|
||||||
if not suppress_direct_print:
|
# so printing here would duplicate output.
|
||||||
from rich_display import stdout_console
|
|
||||||
|
|
||||||
stdout_console().print(table)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Everything below was originally outside a try block; keep it inside so `start_opts` is in scope.
|
# Everything below was originally outside a try block; keep it inside so `start_opts` is in scope.
|
||||||
@@ -1513,12 +1514,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
ctx.set_last_result_table_overlay(table, pipe_objects)
|
ctx.set_last_result_table_overlay(table, pipe_objects)
|
||||||
ctx.set_current_stage_table(table)
|
ctx.set_current_stage_table(table)
|
||||||
|
|
||||||
# In pipeline mode, the CLI renders current-stage tables; printing here duplicates output.
|
# Do not print directly here.
|
||||||
suppress_direct_print = bool(isinstance(config, dict) and config.get("_quiet_background_output"))
|
# Both CmdletExecutor and PipelineExecutor render the current-stage/overlay table,
|
||||||
if not suppress_direct_print:
|
# so printing here would duplicate output.
|
||||||
from rich_display import stdout_console
|
|
||||||
|
|
||||||
stdout_console().print(table)
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Notes
|
|||||||
|
|
||||||
- On Windows you may need to run PowerShell with an appropriate ExecutionPolicy (example shows using `-ExecutionPolicy Bypass`).
|
- On Windows you may need to run PowerShell with an appropriate ExecutionPolicy (example shows using `-ExecutionPolicy Bypass`).
|
||||||
- The scripts default to a venv directory named `.venv` in the repository root. Use `-VenvPath` (PowerShell) or `--venv` (bash) to choose a different directory.
|
- The scripts default to a venv directory named `.venv` in the repository root. Use `-VenvPath` (PowerShell) or `--venv` (bash) to choose a different directory.
|
||||||
|
- The scripts will also install Playwright browser binaries by default (Chromium only) after installing Python dependencies. Use `--no-playwright` (bash) or `-NoPlaywright` (PowerShell) to opt out, or `--playwright-browsers <list>` / `-PlaywrightBrowsers <list>` to request specific engines (comma-separated, or use `all` to install all engines).
|
||||||
- The scripts are intended to make day-to-day developer setup easy; tweak flags for your desired install mode (editable vs normal) and shortcut preferences.
|
- The scripts are intended to make day-to-day developer setup easy; tweak flags for your desired install mode (editable vs normal) and shortcut preferences.
|
||||||
|
|
||||||
## Deno — installed by bootstrap
|
## Deno — installed by bootstrap
|
||||||
@@ -82,3 +83,31 @@ DENO_VERSION=v1.34.3 ./scripts/bootstrap.sh
|
|||||||
If you'd like, I can also:
|
If you'd like, I can also:
|
||||||
- Add a short README section in `readme.md` referencing this doc, or
|
- Add a short README section in `readme.md` referencing this doc, or
|
||||||
- Add a small icon and polish Linux desktop entries with an icon path.
|
- Add a small icon and polish Linux desktop entries with an icon path.
|
||||||
|
|
||||||
|
## Troubleshooting: urllib3 / urllib3-future conflicts ⚠️
|
||||||
|
|
||||||
|
On some environments a third-party package (for example `urllib3-future`) may
|
||||||
|
install a site-packages hook that interferes with the real `urllib3` package.
|
||||||
|
When this happens you might see errors like:
|
||||||
|
|
||||||
|
Error importing cmdlet 'get_tag': No module named 'urllib3.exceptions'
|
||||||
|
|
||||||
|
The bootstrap scripts now run a verification step after installing dependencies
|
||||||
|
and will stop if a broken `urllib3` is detected to avoid leaving you with a
|
||||||
|
partially broken venv.
|
||||||
|
|
||||||
|
Recommended fix (activate the venv first or use the venv python explicitly):
|
||||||
|
|
||||||
|
PowerShell / Windows (from repo root):
|
||||||
|
|
||||||
|
.venv\Scripts\python.exe -m pip uninstall urllib3-future -y
|
||||||
|
.venv\Scripts\python.exe -m pip install --upgrade --force-reinstall urllib3
|
||||||
|
.venv\Scripts\python.exe -m pip install niquests -U
|
||||||
|
|
||||||
|
POSIX (Linux/macOS):
|
||||||
|
|
||||||
|
.venv/bin/python -m pip uninstall urllib3-future -y
|
||||||
|
.venv/bin/python -m pip install --upgrade --force-reinstall urllib3
|
||||||
|
.venv/bin/python -m pip install niquests -U
|
||||||
|
|
||||||
|
If problems persist, re-run the bootstrap script after applying the fixes.
|
||||||
|
|||||||
44
docs/ISSUES/urllib3-future.md
Normal file
44
docs/ISSUES/urllib3-future.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
Title: urllib3-future .pth hook can leave urllib3 broken after installation
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
We observed environments where installing `urllib3-future` (or packages
|
||||||
|
that depend on it, such as `niquests`) leaves a `.pth` file in site-packages
|
||||||
|
that overrides `urllib3` in a way that removes expected attributes (e.g.:
|
||||||
|
`__version__`, `exceptions`) and causes import-time failures in downstream
|
||||||
|
projects (e.g., `No module named 'urllib3.exceptions'`).
|
||||||
|
|
||||||
|
Steps to reproduce (rough):
|
||||||
|
|
||||||
|
1. Install `urllib3` and `urllib3-future` (or a package that depends on it)
|
||||||
|
2. Observe that `import urllib3` may succeed but `urllib3.exceptions` or
|
||||||
|
`urllib3.__version__` are missing, or `importlib.util.find_spec('urllib3.exceptions')`
|
||||||
|
returns `None`.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
- Downstream packages that expect modern `urllib3` behavior break in subtle
|
||||||
|
ways at import time.
|
||||||
|
|
||||||
|
Suggested actions for upstream:
|
||||||
|
|
||||||
|
- Avoid using a `.pth` that replaces or mutates the `urllib3` package in-place,
|
||||||
|
or ensure it keeps the original `urllib3` semantics intact (i.e., do not create
|
||||||
|
a namespace that hides core attributes/members).
|
||||||
|
- Provide clear upgrade/migration notes for hosts that may have mixed
|
||||||
|
`urllib3` and `urllib3-future` installed.
|
||||||
|
|
||||||
|
Notes / local workaround:
|
||||||
|
|
||||||
|
- In our project we implemented a startup compatibility check and fail-fast
|
||||||
|
guidance that suggests running:
|
||||||
|
|
||||||
|
python -m pip uninstall urllib3-future -y
|
||||||
|
python -m pip install --upgrade --force-reinstall urllib3
|
||||||
|
python -m pip install niquests -U
|
||||||
|
|
||||||
|
and we added CI smoke tests and bootstrap verification so the problem is
|
||||||
|
caught during setup rather than later at runtime.
|
||||||
|
|
||||||
|
Please consider this a friendly bug report and feel free to ask for any
|
||||||
|
additional diagnostics or reproduction details.
|
||||||
8
docs/KNOWN_ISSUES.md
Normal file
8
docs/KNOWN_ISSUES.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Known issues and brief remediation steps
|
||||||
|
|
||||||
|
- urllib3 / urllib3-future conflict
|
||||||
|
- Symptom: `No module named 'urllib3.exceptions'` or missing `urllib3.__version__`.
|
||||||
|
- Root cause: a `.pth` file or packaging hook from `urllib3-future` may mutate the
|
||||||
|
`urllib3` namespace in incompatible ways.
|
||||||
|
- Remediation: uninstall `urllib3-future`, reinstall `urllib3`, and re-install
|
||||||
|
`niquests` if required. See `docs/ISSUES/urllib3-future.md` for more details.
|
||||||
@@ -1,13 +1,60 @@
|
|||||||
"""Entry point wrapper for Medeia-Macina CLI."""
|
"""Entry point wrapper for Medeia-Macina CLI.
|
||||||
|
|
||||||
|
This file is intentionally backwards-compatible. When installed from the
|
||||||
|
packaged distribution the preferred entry is `medeia_macina.cli_entry.main`.
|
||||||
|
When running from the repository (or in legacy installs) the module will
|
||||||
|
attempt to import `MedeiaCLI` from the top-level `CLI` module.
|
||||||
|
"""
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add the current directory to sys.path so we can import CLI
|
|
||||||
root_dir = Path(__file__).parent
|
|
||||||
if str(root_dir) not in sys.path:
|
|
||||||
sys.path.insert(0, str(root_dir))
|
|
||||||
|
|
||||||
from CLI import MedeiaCLI
|
def _run_packaged_entry(argv=None) -> int:
|
||||||
|
"""Try to delegate to the packaged entry (`medeia_macina.cli_entry:main`)."""
|
||||||
|
try:
|
||||||
|
from medeia_macina.cli_entry import main as _main
|
||||||
|
|
||||||
|
return int(_main(argv) or 0)
|
||||||
|
except Exception:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _run_legacy_entry() -> None:
|
||||||
|
"""Legacy behaviour: make repo root importable and run CLI.
|
||||||
|
|
||||||
|
This supports running directly from the source tree where `CLI.py` is
|
||||||
|
available as a top-level module.
|
||||||
|
"""
|
||||||
|
root_dir = Path(__file__).resolve().parent
|
||||||
|
if str(root_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root_dir))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from CLI import MedeiaCLI
|
||||||
|
except Exception as exc: # pragma: no cover - user environment issues
|
||||||
|
raise ImportError(
|
||||||
|
"Could not import 'MedeiaCLI' from top-level 'CLI'. "
|
||||||
|
"If you installed the package into a virtualenv, activate it and run: \n"
|
||||||
|
" pip install -e .\n"
|
||||||
|
"or re-run the project bootstrap to ensure an up-to-date install."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
MedeiaCLI().run()
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatibility: try to expose `MedeiaCLI` at import-time when the
|
||||||
|
# project is being used from a development checkout (so modules that import
|
||||||
|
# the top-level `medeia_entry` can still access the CLI class).
|
||||||
|
try:
|
||||||
|
from CLI import MedeiaCLI as MedeiaCLI # type: ignore
|
||||||
|
except Exception:
|
||||||
|
# It's okay if the legacy top-level CLI isn't importable in installed packages.
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
MedeiaCLI().run()
|
rc = _run_packaged_entry(sys.argv[1:])
|
||||||
|
if rc >= 0:
|
||||||
|
raise SystemExit(rc)
|
||||||
|
# Fall back to legacy import when packaged entry couldn't be invoked.
|
||||||
|
_run_legacy_entry()
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ def _import_medeia_entry_module():
|
|||||||
return importlib.import_module("medeia_entry")
|
return importlib.import_module("medeia_entry")
|
||||||
|
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Could not import 'medeia_entry'. Ensure the project was installed properly or run from the repo root."
|
"Could not import 'medeia_entry'. This often means the package is not installed into the active virtualenv or is an outdated install.\n"
|
||||||
|
"Remedy: activate your venv and run: pip install -e . (or re-run the bootstrap script).\n"
|
||||||
|
"If problems persist, recreate the venv and reinstall the project."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,10 +154,26 @@ def _run_cli(clean_args: List[str]) -> int:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
mod = _import_medeia_entry_module()
|
mod = _import_medeia_entry_module()
|
||||||
try:
|
|
||||||
|
# Backwards compatibility: the imported module may not expose `MedeiaCLI` as
|
||||||
|
# an attribute (for example, the installed `medeia_entry` delegates to the
|
||||||
|
# packaged entrypoint instead of importing the top-level `CLI` module at
|
||||||
|
# import-time). Try a few strategies to obtain or invoke the CLI:
|
||||||
|
MedeiaCLI = None
|
||||||
|
if hasattr(mod, "MedeiaCLI"):
|
||||||
MedeiaCLI = getattr(mod, "MedeiaCLI")
|
MedeiaCLI = getattr(mod, "MedeiaCLI")
|
||||||
except AttributeError:
|
else:
|
||||||
raise ImportError("Imported module 'medeia_entry' does not define 'MedeiaCLI'")
|
# Try importing the top-level `CLI` module directly (editable/repo mode)
|
||||||
|
try:
|
||||||
|
from CLI import MedeiaCLI as _M # type: ignore
|
||||||
|
|
||||||
|
MedeiaCLI = _M
|
||||||
|
except Exception:
|
||||||
|
raise ImportError(
|
||||||
|
"Imported module 'medeia_entry' does not define 'MedeiaCLI' and direct import of top-level 'CLI' failed.\n"
|
||||||
|
"Remedy: activate your venv and run: pip install -e . (or re-run the bootstrap script).\n"
|
||||||
|
"If problems persist, recreate the venv and reinstall the project."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app = MedeiaCLI()
|
app = MedeiaCLI()
|
||||||
@@ -209,6 +227,22 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
print(f"Error parsing mode flags: {exc}", file=sys.stderr)
|
print(f"Error parsing mode flags: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
# Early environment sanity check to detect urllib3/urllib3-future conflicts.
|
||||||
|
# When a broken urllib3 is detected we print an actionable message and
|
||||||
|
# exit early to avoid confusing import-time errors later during startup.
|
||||||
|
try:
|
||||||
|
from SYS.env_check import ensure_urllib3_ok
|
||||||
|
try:
|
||||||
|
ensure_urllib3_ok(exit_on_error=True)
|
||||||
|
except SystemExit as exc:
|
||||||
|
# Bubble out the exit code as the CLI return value for clearer
|
||||||
|
# behavior in shell sessions and scripts.
|
||||||
|
return int(getattr(exc, "code", 2) or 2)
|
||||||
|
except Exception:
|
||||||
|
# If the sanity check itself cannot be imported or run, don't block
|
||||||
|
# startup; we'll continue and let normal import errors surface.
|
||||||
|
pass
|
||||||
|
|
||||||
# If GUI requested, delegate directly (GUI may decide to honor any args itself)
|
# If GUI requested, delegate directly (GUI may decide to honor any args itself)
|
||||||
if mode == "gui":
|
if mode == "gui":
|
||||||
return _run_gui(clean_args)
|
return _run_gui(clean_args)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ output).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, TextIO
|
from typing import Any, Iterator, TextIO
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
@@ -50,3 +51,24 @@ def console_for(file: TextIO | None) -> Console:
|
|||||||
|
|
||||||
def rprint(renderable: Any = "", *, file: TextIO | None = None) -> None:
|
def rprint(renderable: Any = "", *, file: TextIO | None = None) -> None:
|
||||||
console_for(file).print(renderable)
|
console_for(file).print(renderable)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def capture_rich_output(*, stdout: TextIO, stderr: TextIO) -> Iterator[None]:
|
||||||
|
"""Temporarily redirect Rich output helpers to provided streams.
|
||||||
|
|
||||||
|
Note: `stdout_console()` / `stderr_console()` use global Console instances,
|
||||||
|
so `contextlib.redirect_stdout` alone will not capture Rich output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _STDOUT_CONSOLE, _STDERR_CONSOLE
|
||||||
|
|
||||||
|
previous_stdout = _STDOUT_CONSOLE
|
||||||
|
previous_stderr = _STDERR_CONSOLE
|
||||||
|
try:
|
||||||
|
_STDOUT_CONSOLE = Console(file=stdout)
|
||||||
|
_STDERR_CONSOLE = Console(file=stderr)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_STDOUT_CONSOLE = previous_stdout
|
||||||
|
_STDERR_CONSOLE = previous_stderr
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ param(
|
|||||||
[string]$Python = "",
|
[string]$Python = "",
|
||||||
[switch]$Force,
|
[switch]$Force,
|
||||||
[switch]$NoInstall,
|
[switch]$NoInstall,
|
||||||
|
[switch]$NoPlaywright,
|
||||||
|
[string]$PlaywrightBrowsers = "chromium",
|
||||||
[switch]$Quiet
|
[switch]$Quiet
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,17 +148,56 @@ if (-not $NoInstall) {
|
|||||||
} catch {
|
} catch {
|
||||||
Write-Log "pip install failed: $_" "ERROR"; exit 6
|
Write-Log "pip install failed: $_" "ERROR"; exit 6
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Write-Log "Skipping install (--NoInstall set)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install Deno (official installer) - installed automatically
|
# Install Playwright browsers (default: chromium) unless explicitly disabled
|
||||||
try {
|
if (-not $NoPlaywright) {
|
||||||
$denoCmd = Get-Command 'deno' -ErrorAction SilentlyContinue
|
Write-Log "Ensuring Playwright browsers are installed (browsers=$PlaywrightBrowsers)..."
|
||||||
} catch {
|
try {
|
||||||
$denoCmd = $null
|
& $venvPython -c "import importlib; importlib.import_module('playwright')" 2>$null
|
||||||
}
|
if ($LASTEXITCODE -ne 0) {
|
||||||
if ($denoCmd) {
|
Write-Log "'playwright' package not found in venv; installing via pip..."
|
||||||
|
& $venvPython -m pip install playwright
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "Failed to check/install 'playwright' package: $_" "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($PlaywrightBrowsers -eq 'all') {
|
||||||
|
Write-Log "Installing all Playwright browsers..."
|
||||||
|
& $venvPython -m playwright install
|
||||||
|
} else {
|
||||||
|
$list = $PlaywrightBrowsers -split ','
|
||||||
|
foreach ($b in $list) {
|
||||||
|
$btrim = $b.Trim()
|
||||||
|
if ($btrim) {
|
||||||
|
Write-Log "Installing Playwright browser: $btrim"
|
||||||
|
& $venvPython -m playwright install $btrim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "Playwright browser install failed: $_" "ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify environment for known package conflicts (urllib3 compatibility)
|
||||||
|
Write-Log "Verifying environment for known package conflicts (urllib3 compatibility)..."
|
||||||
|
try {
|
||||||
|
& $venvPython -c "import sys; from SYS.env_check import check_urllib3_compat; ok, msg = check_urllib3_compat(); print(msg); sys.exit(0 if ok else 2)"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Log "Bootstrap detected a potentially broken 'urllib3' installation. See message above." "ERROR"
|
||||||
|
Write-Log "Suggested fixes (activate the venv first):" "INFO"
|
||||||
|
Write-Log " $ $venvPython -m pip uninstall urllib3-future -y" "INFO"
|
||||||
|
Write-Log " $ $venvPython -m pip install --upgrade --force-reinstall urllib3" "INFO"
|
||||||
|
Write-Log " $ $venvPython -m pip install niquests -U" "INFO"
|
||||||
|
Write-Log "Aborting bootstrap to avoid leaving a broken environment." "ERROR"
|
||||||
|
exit 7
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "Failed to run environment verification: $_" "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
Write-Log "Deno is already installed: $($denoCmd.Path)"
|
Write-Log "Deno is already installed: $($denoCmd.Path)"
|
||||||
} else {
|
} else {
|
||||||
Write-Log "Installing Deno via official installer (https://deno.land)"
|
Write-Log "Installing Deno via official installer (https://deno.land)"
|
||||||
@@ -242,12 +283,12 @@ try {
|
|||||||
$cmdText = @"
|
$cmdText = @"
|
||||||
@echo off
|
@echo off
|
||||||
set "REPO=__REPO__"
|
set "REPO=__REPO__"
|
||||||
if exist "%REPO%\.venv\Scripts\mm.exe" (
|
if exist "%REPO%\.venv\Scripts\python.exe" (
|
||||||
"%REPO%\.venv\Scripts\mm.exe" %*
|
"%REPO%\.venv\Scripts\python.exe" "%REPO%\CLI.py" %*
|
||||||
exit /b %ERRORLEVEL%
|
exit /b %ERRORLEVEL%
|
||||||
)
|
)
|
||||||
if exist "%REPO%\.venv\Scripts\python.exe" (
|
if exist "%REPO%\CLI.py" (
|
||||||
"%REPO%\.venv\Scripts\python.exe" -m medeia_macina.cli_entry %*
|
python "%REPO%\CLI.py" %*
|
||||||
exit /b %ERRORLEVEL%
|
exit /b %ERRORLEVEL%
|
||||||
)
|
)
|
||||||
python -m medeia_macina.cli_entry %*
|
python -m medeia_macina.cli_entry %*
|
||||||
@@ -266,12 +307,12 @@ python -m medeia_macina.cli_entry %*
|
|||||||
Param([Parameter(ValueFromRemainingArguments=$true)] $args)
|
Param([Parameter(ValueFromRemainingArguments=$true)] $args)
|
||||||
$repo = "__REPO__"
|
$repo = "__REPO__"
|
||||||
$venv = Join-Path $repo '.venv'
|
$venv = Join-Path $repo '.venv'
|
||||||
$exe = Join-Path $venv 'Scripts\mm.exe'
|
|
||||||
if (Test-Path $exe) { & $exe @args; exit $LASTEXITCODE }
|
|
||||||
$py = Join-Path $venv 'Scripts\python.exe'
|
$py = Join-Path $venv 'Scripts\python.exe'
|
||||||
if (Test-Path $py) { & $py -m medeia_entry @args; exit $LASTEXITCODE }
|
$cli = Join-Path $repo 'CLI.py'
|
||||||
|
if (Test-Path $py) { & $py $cli @args; exit $LASTEXITCODE }
|
||||||
|
if (Test-Path $cli) { & $py $cli @args; exit $LASTEXITCODE }
|
||||||
# fallback
|
# fallback
|
||||||
python -m medeia_entry @args
|
python $cli @args
|
||||||
'@
|
'@
|
||||||
# Inject the actual repo path safely (escape embedded double-quotes if any)
|
# Inject the actual repo path safely (escape embedded double-quotes if any)
|
||||||
$ps1Text = $ps1Text.Replace('__REPO__', $repo.Replace('"', '""'))
|
$ps1Text = $ps1Text.Replace('__REPO__', $repo.Replace('"', '""'))
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ DESKTOP=false
|
|||||||
PYTHON_CMD=""
|
PYTHON_CMD=""
|
||||||
NOINSTALL=false
|
NOINSTALL=false
|
||||||
FORCE=false
|
FORCE=false
|
||||||
|
QUIET=false
|
||||||
|
# Playwright options
|
||||||
|
PLAYWRIGHT_BROWSERS="chromium" # comma-separated (chromium,firefox,webkit) or 'all'
|
||||||
|
NO_PLAYWRIGHT=false
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -19,6 +23,9 @@ Options:
|
|||||||
--python <python> Python executable to use (e.g. python3)
|
--python <python> Python executable to use (e.g. python3)
|
||||||
-d, --desktop Create a desktop launcher (~/.local/share/applications and ~/Desktop)
|
-d, --desktop Create a desktop launcher (~/.local/share/applications and ~/Desktop)
|
||||||
-n, --no-install Skip pip install
|
-n, --no-install Skip pip install
|
||||||
|
--no-playwright Skip installing Playwright browsers (default: install chromium)
|
||||||
|
--playwright-browsers <list> Comma-separated list of browsers to install (default: chromium)
|
||||||
|
-q, --quiet Quiet / non-interactive mode; abort on errors instead of prompting
|
||||||
-f, --force Overwrite existing venv without prompting
|
-f, --force Overwrite existing venv without prompting
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
EOF
|
EOF
|
||||||
@@ -32,7 +39,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
-d|--desktop) DESKTOP=true; shift;;
|
-d|--desktop) DESKTOP=true; shift;;
|
||||||
-n|--no-install) NOINSTALL=true; shift;;
|
-n|--no-install) NOINSTALL=true; shift;;
|
||||||
-f|--force) FORCE=true; shift;;
|
-f|--force) FORCE=true; shift;;
|
||||||
|
-q|--quiet) QUIET=true; shift;;
|
||||||
-h|--help) usage; exit 0;;
|
-h|--help) usage; exit 0;;
|
||||||
|
--no-playwright) NO_PLAYWRIGHT=true; shift;;
|
||||||
|
--playwright-browsers) PLAYWRIGHT_BROWSERS="$2"; shift 2;;
|
||||||
*) echo "Unknown option: $1"; usage; exit 1;;
|
*) echo "Unknown option: $1"; usage; exit 1;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -51,21 +61,52 @@ fi
|
|||||||
echo "Using Python: $PY"
|
echo "Using Python: $PY"
|
||||||
|
|
||||||
if [[ -d "$VENV_PATH" ]]; then
|
if [[ -d "$VENV_PATH" ]]; then
|
||||||
|
# Detect whether the existing venv has a working python executable
|
||||||
|
VENV_PY=""
|
||||||
|
for cand in "$VENV_PATH/bin/python" "$VENV_PATH/bin/python3" "$VENV_PATH/Scripts/python.exe"; do
|
||||||
|
if [[ -x "$cand" ]]; then
|
||||||
|
VENV_PY="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [[ "$FORCE" == "true" ]]; then
|
if [[ "$FORCE" == "true" ]]; then
|
||||||
echo "Removing existing venv $VENV_PATH"
|
echo "Removing existing venv $VENV_PATH"
|
||||||
rm -rf "$VENV_PATH"
|
rm -rf "$VENV_PATH"
|
||||||
else
|
else
|
||||||
read -p "$VENV_PATH already exists. Overwrite? [y/N] " REPLY
|
if [[ -z "$VENV_PY" ]]; then
|
||||||
if [[ "$REPLY" != "y" && "$REPLY" != "Y" ]]; then
|
if [[ "$QUIET" == "true" ]]; then
|
||||||
echo "Aborted."; exit 0
|
echo "ERROR: Existing venv appears incomplete or broken (no python executable). Use --force to recreate." >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
read -p "$VENV_PATH exists but appears invalid (no python executable). Overwrite to recreate? (y/N) " REPLY
|
||||||
|
if [[ "$REPLY" != "y" && "$REPLY" != "Y" ]]; then
|
||||||
|
echo "Aborted."; exit 4
|
||||||
|
fi
|
||||||
|
rm -rf "$VENV_PATH"
|
||||||
|
else
|
||||||
|
if [[ "$QUIET" == "true" ]]; then
|
||||||
|
echo "Using existing venv at $VENV_PATH (quiet mode)"
|
||||||
|
else
|
||||||
|
read -p "$VENV_PATH already exists. Overwrite? (y/N) (default: use existing venv) " REPLY
|
||||||
|
if [[ "$REPLY" == "y" || "$REPLY" == "Y" ]]; then
|
||||||
|
echo "Removing existing venv $VENV_PATH"
|
||||||
|
rm -rf "$VENV_PATH"
|
||||||
|
else
|
||||||
|
echo "Continuing using existing venv at $VENV_PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
rm -rf "$VENV_PATH"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Creating venv at $VENV_PATH"
|
if [[ -d "$VENV_PATH" && -n "${VENV_PY:-}" && -x "${VENV_PY:-}" ]]; then
|
||||||
$PY -m venv "$VENV_PATH"
|
echo "Using existing venv at $VENV_PATH"
|
||||||
VENV_PY="$VENV_PATH/bin/python"
|
else
|
||||||
|
echo "Creating venv at $VENV_PATH"
|
||||||
|
$PY -m venv "$VENV_PATH"
|
||||||
|
VENV_PY="$VENV_PATH/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -x "$VENV_PY" ]]; then
|
if [[ ! -x "$VENV_PY" ]]; then
|
||||||
echo "ERROR: venv python not found at $VENV_PY" >&2
|
echo "ERROR: venv python not found at $VENV_PY" >&2
|
||||||
@@ -98,6 +139,41 @@ if [[ "$NOINSTALL" != "true" ]]; then
|
|||||||
echo "Action: Try running: $VENV_PY -m pip install -e . or inspect the venv site-packages to verify the installation." >&2
|
echo "Action: Try running: $VENV_PY -m pip install -e . or inspect the venv site-packages to verify the installation." >&2
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Verifying environment for known issues (urllib3 compatibility)..."
|
||||||
|
if ! "$VENV_PY" -c 'from SYS.env_check import check_urllib3_compat; ok,msg = check_urllib3_compat(); print(msg); import sys; sys.exit(0 if ok else 2)'; then
|
||||||
|
echo "ERROR: Bootstrap detected a potentially broken 'urllib3' installation. See message above." >&2
|
||||||
|
echo "You can attempt to fix with:" >&2
|
||||||
|
echo " $VENV_PY -m pip uninstall urllib3-future -y" >&2
|
||||||
|
echo " $VENV_PY -m pip install --upgrade --force-reinstall urllib3" >&2
|
||||||
|
echo " $VENV_PY -m pip install niquests -U" >&2
|
||||||
|
exit 7
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Playwright browsers (default: chromium) unless explicitly disabled
|
||||||
|
if [[ "$NO_PLAYWRIGHT" != "true" && "$NOINSTALL" != "true" ]]; then
|
||||||
|
echo "Ensuring Playwright browsers are installed (browsers=$PLAYWRIGHT_BROWSERS)..."
|
||||||
|
# Install package if missing in venv
|
||||||
|
if ! "$VENV_PY" -c 'import importlib, sys; importlib.import_module("playwright")' >/dev/null 2>&1; then
|
||||||
|
echo "'playwright' package not found in venv; installing via pip..."
|
||||||
|
"$VENV_PY" -m pip install playwright
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compute install behavior: 'all' means install all engines, otherwise split comma list
|
||||||
|
if [[ "$PLAYWRIGHT_BROWSERS" == "all" ]]; then
|
||||||
|
echo "Installing all Playwright browsers..."
|
||||||
|
"$VENV_PY" -m playwright install || echo "Warning: Playwright browser install failed" >&2
|
||||||
|
else
|
||||||
|
IFS=',' read -ra PWB <<< "$PLAYWRIGHT_BROWSERS"
|
||||||
|
for b in "${PWB[@]}"; do
|
||||||
|
b_trimmed=$(echo "$b" | tr -d '[:space:]')
|
||||||
|
if [[ -n "$b_trimmed" ]]; then
|
||||||
|
echo "Installing Playwright browser: $b_trimmed"
|
||||||
|
"$VENV_PY" -m playwright install "$b_trimmed" || echo "Warning: Playwright install for $b_trimmed failed" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Skipping install (--no-install)"
|
echo "Skipping install (--no-install)"
|
||||||
fi
|
fi
|
||||||
@@ -139,7 +215,7 @@ if [[ "$DESKTOP" == "true" ]]; then
|
|||||||
Name=Medeia-Macina
|
Name=Medeia-Macina
|
||||||
Comment=Launch Medeia-Macina
|
Comment=Launch Medeia-Macina
|
||||||
Exec=$EXEC_PATH
|
Exec=$EXEC_PATH
|
||||||
Terminal=true
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Utility;
|
Categories=Utility;
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -251,13 +251,14 @@ def main() -> int:
|
|||||||
sh_text = """#!/usr/bin/env bash
|
sh_text = """#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
VENV="$SCRIPT_DIR/.venv"
|
REPO="$SCRIPT_DIR"
|
||||||
if [ -x "$VENV/bin/mm" ]; then
|
VENV="$REPO/.venv"
|
||||||
exec "$VENV/bin/mm" "$@"
|
PY="$VENV/bin/python"
|
||||||
elif [ -x "$VENV/bin/python" ]; then
|
CLI_SCRIPT="$REPO/CLI.py"
|
||||||
exec "$VENV/bin/python" -m medeia_entry "$@"
|
if [ -x "$PY" ]; then
|
||||||
|
exec "$PY" "$CLI_SCRIPT" "$@"
|
||||||
else
|
else
|
||||||
exec python -m medeia_entry "$@"
|
exec python "$CLI_SCRIPT" "$@"
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -268,13 +269,14 @@ fi
|
|||||||
|
|
||||||
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
|
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
|
||||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$venv = Join-Path $scriptDir '.venv'
|
$repo = $scriptDir
|
||||||
$exe = Join-Path $venv 'Scripts\mm.exe'
|
$venv = Join-Path $repo '.venv'
|
||||||
if (Test-Path $exe) { & $exe @args; exit $LASTEXITCODE }
|
|
||||||
$py = Join-Path $venv 'Scripts\python.exe'
|
$py = Join-Path $venv 'Scripts\python.exe'
|
||||||
if (Test-Path $py) { & $py -m medeia_entry @args; exit $LASTEXITCODE }
|
$cli = Join-Path $repo 'CLI.py'
|
||||||
|
if (Test-Path $py) { & $py $cli @args; exit $LASTEXITCODE }
|
||||||
|
if (Test-Path $cli) { & $py $cli @args; exit $LASTEXITCODE }
|
||||||
# fallback
|
# fallback
|
||||||
python -m medeia_entry @args
|
python $cli @args
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ps1.write_text(ps1_text, encoding="utf-8")
|
ps1.write_text(ps1_text, encoding="utf-8")
|
||||||
@@ -284,9 +286,9 @@ python -m medeia_entry @args
|
|||||||
bat_text = (
|
bat_text = (
|
||||||
"@echo off\r\n"
|
"@echo off\r\n"
|
||||||
"set SCRIPT_DIR=%~dp0\r\n"
|
"set SCRIPT_DIR=%~dp0\r\n"
|
||||||
"if exist \"%SCRIPT_DIR%\\.venv\\Scripts\\mm.exe\" \"%SCRIPT_DIR%\\.venv\\Scripts\\mm.exe\" %*\r\n"
|
"if exist \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\CLI.py\" %*\r\n"
|
||||||
"if exist \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" -m medeia_entry %*\r\n"
|
"if exist \"%SCRIPT_DIR%\\CLI.py\" python \"%SCRIPT_DIR%\\CLI.py\" %*\r\n"
|
||||||
"python -m medeia_entry %*\r\n"
|
"python -m medeia_macina.cli_entry %*\r\n"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
bat.write_text(bat_text, encoding="utf-8")
|
bat.write_text(bat_text, encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user