syntax revamp
This commit is contained in:
+101
-12
@@ -70,6 +70,17 @@ def _tokenize_stage(stage_text: str) -> list[str]:
|
||||
return text.split()
|
||||
|
||||
|
||||
def _parse_pipeline_tokens(raw: str) -> list[tuple[str, list[str]]]:
|
||||
parsed: list[tuple[str, list[str]]] = []
|
||||
for stage in _split_pipeline_stages(raw):
|
||||
tokens = _tokenize_stage(stage)
|
||||
if not tokens:
|
||||
continue
|
||||
cmd = str(tokens[0]).replace("_", "-").strip().lower()
|
||||
parsed.append((cmd, tokens))
|
||||
return parsed
|
||||
|
||||
|
||||
def _has_flag(tokens: list[str], *flags: str) -> bool:
|
||||
want = {str(f).strip().lower() for f in flags if str(f).strip()}
|
||||
if not want:
|
||||
@@ -115,18 +126,10 @@ def _validate_add_note_requires_add_file_order(raw: str) -> Optional[SyntaxError
|
||||
Rationale: add-note requires a known (store, hash) target; piping before add-file
|
||||
means the item likely has no hash yet.
|
||||
"""
|
||||
stages = _split_pipeline_stages(raw)
|
||||
if len(stages) <= 1:
|
||||
parsed = _parse_pipeline_tokens(raw)
|
||||
if len(parsed) <= 1:
|
||||
return None
|
||||
|
||||
parsed: list[tuple[str, list[str]]] = []
|
||||
for stage in stages:
|
||||
tokens = _tokenize_stage(stage)
|
||||
if not tokens:
|
||||
continue
|
||||
cmd = str(tokens[0]).replace("_", "-").strip().lower()
|
||||
parsed.append((cmd, tokens))
|
||||
|
||||
add_file_positions = [i for i, (cmd, _toks) in enumerate(parsed) if cmd == "add-file"]
|
||||
if not add_file_positions:
|
||||
return None
|
||||
@@ -165,7 +168,85 @@ def _validate_add_note_requires_add_file_order(raw: str) -> Optional[SyntaxError
|
||||
return None
|
||||
|
||||
|
||||
def validate_pipeline_text(text: str) -> Optional[SyntaxErrorDetail]:
|
||||
def _validate_file_cmdlet_stage_actions(raw: str) -> Optional[SyntaxErrorDetail]:
|
||||
parsed = _parse_pipeline_tokens(raw)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
try:
|
||||
from cmdlet.file_cmdlet import File as FileCmdlet
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for cmd, tokens in parsed:
|
||||
if cmd != "file":
|
||||
continue
|
||||
|
||||
action, _passthrough, seen = FileCmdlet._extract_action(tokens[1:])
|
||||
if action is not None:
|
||||
continue
|
||||
|
||||
if seen:
|
||||
rendered = ", ".join(f"-{name}" for name in seen)
|
||||
return SyntaxErrorDetail(
|
||||
f"Pipeline error: 'file' has conflicting actions ({rendered}); choose exactly one."
|
||||
)
|
||||
|
||||
if _has_flag(tokens[1:], "-plugin", "--plugin", "-instance", "--instance", "-path", "--path"):
|
||||
return SyntaxErrorDetail(
|
||||
"Pipeline error: 'file' requires an explicit action here. "
|
||||
"Use 'file -add -plugin local -instance <name|path>' for local export, or 'file -search ...' for search."
|
||||
)
|
||||
|
||||
return SyntaxErrorDetail(
|
||||
"Pipeline error: 'file' requires -search/-query for search or exactly one action flag "
|
||||
"(-search, -add, -delete, -merge, -download, -convert, -trim, -archive, -screenshot)."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_add_file_stage_preflight(
|
||||
raw: str,
|
||||
config: Optional[Dict[str, Any]],
|
||||
) -> Optional[SyntaxErrorDetail]:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
|
||||
parsed = _parse_pipeline_tokens(raw)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
try:
|
||||
from cmdlet.file.add import Add_File
|
||||
from cmdlet.file_cmdlet import File as FileCmdlet
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for cmd, tokens in parsed:
|
||||
stage_args: Optional[list[str]] = None
|
||||
|
||||
if cmd == "add-file":
|
||||
stage_args = tokens[1:]
|
||||
elif cmd == "file":
|
||||
action, passthrough, _seen = FileCmdlet._extract_action(tokens[1:])
|
||||
if action == "add":
|
||||
stage_args = passthrough
|
||||
|
||||
if stage_args is None:
|
||||
continue
|
||||
|
||||
message = Add_File.validate_preflight_args(stage_args, config)
|
||||
if message:
|
||||
return SyntaxErrorDetail(message)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_pipeline_text(
|
||||
text: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[SyntaxErrorDetail]:
|
||||
"""Validate raw CLI input before tokenization/execution.
|
||||
|
||||
This is intentionally lightweight and focuses on user-facing syntax issues:
|
||||
@@ -252,11 +333,19 @@ def validate_pipeline_text(text: str) -> Optional[SyntaxErrorDetail]:
|
||||
if not in_single and not in_double and not ch.isspace():
|
||||
seen_nonspace_since_pipe = True
|
||||
|
||||
# Semantic rules (still lightweight; no cmdlet imports)
|
||||
# Pipeline-only semantic rules.
|
||||
semantic_error = _validate_add_note_requires_add_file_order(raw)
|
||||
if semantic_error is not None:
|
||||
return semantic_error
|
||||
|
||||
semantic_error = _validate_file_cmdlet_stage_actions(raw)
|
||||
if semantic_error is not None:
|
||||
return semantic_error
|
||||
|
||||
semantic_error = _validate_add_file_stage_preflight(raw, config)
|
||||
if semantic_error is not None:
|
||||
return semantic_error
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+8
-2
@@ -612,6 +612,8 @@ def write_tags(
|
||||
url: Iterable[str],
|
||||
hash_value: Optional[str] = None,
|
||||
db=None,
|
||||
*,
|
||||
emit_debug: bool = True,
|
||||
) -> None:
|
||||
"""Write tags to database or sidecar file (tags only).
|
||||
|
||||
@@ -665,7 +667,8 @@ def write_tags(
|
||||
|
||||
if lines:
|
||||
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
debug(f"Tags: {sidecar}")
|
||||
if emit_debug:
|
||||
debug(f"Tags: {sidecar}")
|
||||
else:
|
||||
try:
|
||||
sidecar.unlink()
|
||||
@@ -681,6 +684,8 @@ def write_metadata(
|
||||
url: Optional[Iterable[str]] = None,
|
||||
relationships: Optional[Iterable[str]] = None,
|
||||
db=None,
|
||||
*,
|
||||
emit_debug: bool = True,
|
||||
) -> None:
|
||||
"""Write metadata to database or sidecar file.
|
||||
|
||||
@@ -753,7 +758,8 @@ def write_metadata(
|
||||
# Write metadata file
|
||||
if lines:
|
||||
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
debug(f"Wrote metadata to {sidecar}")
|
||||
if emit_debug:
|
||||
debug(f"Wrote metadata to {sidecar}")
|
||||
else:
|
||||
# Remove if no content
|
||||
try:
|
||||
|
||||
+5
-9
@@ -2089,7 +2089,7 @@ class PipelineExecutor:
|
||||
# Command expansion via @N:
|
||||
# - Default behavior: expand ONLY for single-row selections.
|
||||
# - Special case: allow multi-row expansion for add-file directory tables by
|
||||
# combining selected rows into a single `-path file1,file2,...` argument.
|
||||
# combining selected rows into one comma-separated positional source token.
|
||||
if source_cmd and not skip_pipe_expansion and not prefer_row_action:
|
||||
src = str(source_cmd).replace("_", "-").strip().lower()
|
||||
|
||||
@@ -2107,19 +2107,15 @@ class PipelineExecutor:
|
||||
[str(x) for x in row_args if x is not None]
|
||||
)
|
||||
|
||||
# Combine `['-path', <file>]` from each row into one `-path` arg.
|
||||
# Combine `[<file>]` from each row into one positional source token.
|
||||
paths: List[str] = []
|
||||
can_merge = bool(row_args_list) and (
|
||||
len(row_args_list) == len(selection_indices)
|
||||
)
|
||||
if can_merge:
|
||||
for ra in row_args_list:
|
||||
if len(ra) == 2 and str(ra[0]).strip().lower() in {
|
||||
"-path",
|
||||
"--path",
|
||||
"-p",
|
||||
}:
|
||||
p = str(ra[1]).strip()
|
||||
if len(ra) == 1:
|
||||
p = str(ra[0]).strip()
|
||||
if p:
|
||||
paths.append(p)
|
||||
else:
|
||||
@@ -2127,7 +2123,7 @@ class PipelineExecutor:
|
||||
break
|
||||
|
||||
if can_merge and paths:
|
||||
selected_row_args.extend(["-path", ",".join(paths)])
|
||||
selected_row_args.append(",".join(paths))
|
||||
elif len(selection_indices) == 1 and row_args_list:
|
||||
selected_row_args.extend(row_args_list[0])
|
||||
else:
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
_REPL_STATE_FILENAME = "medeia-repl-state.json"
|
||||
|
||||
|
||||
def repl_queue_dir(root: Path) -> Path:
|
||||
return Path(root) / "Log" / "repl_queue"
|
||||
|
||||
|
||||
def repl_state_path(root: Path) -> Path:
|
||||
return Path(root) / "Log" / _REPL_STATE_FILENAME
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: Dict[str, Any]) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
temp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
temp_path.replace(path)
|
||||
return path
|
||||
|
||||
|
||||
def touch_repl_state(
|
||||
root: Path,
|
||||
*,
|
||||
session_id: Optional[str] = None,
|
||||
pid: Optional[int] = None,
|
||||
status: str = "running",
|
||||
) -> Path:
|
||||
payload: Dict[str, Any] = {
|
||||
"status": str(status or "running").strip() or "running",
|
||||
"updated_at": time.time(),
|
||||
"pid": int(pid) if pid is not None else int(os.getpid()),
|
||||
}
|
||||
if isinstance(session_id, str) and session_id.strip():
|
||||
payload["session_id"] = session_id.strip()
|
||||
return _write_json_atomic(repl_state_path(root), payload)
|
||||
|
||||
|
||||
def read_repl_state(root: Path) -> Optional[Dict[str, Any]]:
|
||||
path = repl_state_path(root)
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return None
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def clear_repl_state(root: Path) -> None:
|
||||
path = repl_state_path(root)
|
||||
try:
|
||||
path.unlink()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def repl_state_is_alive(root: Path, *, max_age_seconds: float = 3.0) -> bool:
|
||||
payload = read_repl_state(root)
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if str(payload.get("status") or "").strip().lower() != "running":
|
||||
return False
|
||||
try:
|
||||
updated_at = float(payload.get("updated_at") or 0.0)
|
||||
except Exception:
|
||||
return False
|
||||
if updated_at <= 0:
|
||||
return False
|
||||
try:
|
||||
return (time.time() - updated_at) <= max(0.0, float(max_age_seconds))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _legacy_repl_queue_glob(root: Path) -> list[Path]:
|
||||
log_dir = Path(root) / "Log"
|
||||
if not log_dir.exists():
|
||||
|
||||
@@ -75,8 +75,8 @@ def build_default_selection(
|
||||
except Exception:
|
||||
resolved_path = path_text
|
||||
|
||||
args = ["-path", resolved_path]
|
||||
return args, ["get-file", "-path", resolved_path]
|
||||
args = [resolved_path]
|
||||
return args, ["download-file", resolved_path]
|
||||
|
||||
return hash_args, hash_action
|
||||
|
||||
|
||||
Reference in New Issue
Block a user