syntax revamp

This commit is contained in:
2026-05-24 12:32:57 -07:00
parent 6c0a1b4415
commit 5041d9fbb9
20 changed files with 1512 additions and 1060 deletions
+101 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+70
View File
@@ -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():
+2 -2
View File
@@ -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