huge refactor of plugin system
This commit is contained in:
+421
-5
@@ -105,9 +105,13 @@ class CmdletArg:
|
||||
storage_flags = SharedArgs.STORAGE.to_flags()
|
||||
# Returns: ('--storage', '-storage', '-s')
|
||||
"""
|
||||
normalized_name = str(self.name or "").lstrip("-")
|
||||
if not normalized_name:
|
||||
return tuple()
|
||||
|
||||
flags = [
|
||||
f"--{self.name}",
|
||||
f"-{self.name}"
|
||||
f"--{normalized_name}",
|
||||
f"-{normalized_name}"
|
||||
] # Both double-dash and single-dash variants
|
||||
|
||||
# Add short form if alias exists
|
||||
@@ -116,8 +120,8 @@ class CmdletArg:
|
||||
|
||||
# Add negation forms for flag type
|
||||
if self.type == "flag":
|
||||
flags.append(f"--no-{self.name}")
|
||||
flags.append(f"-no{self.name}") # Single-dash negation variant
|
||||
flags.append(f"--no-{normalized_name}")
|
||||
flags.append(f"-no{normalized_name}") # Single-dash negation variant
|
||||
if self.alias:
|
||||
flags.append(f"-n{self.alias}")
|
||||
|
||||
@@ -1658,6 +1662,59 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
List of normalized tag strings (empty strings filtered out)
|
||||
"""
|
||||
|
||||
def _split_top_level_commas(text: str) -> List[str]:
|
||||
segments: List[str] = []
|
||||
current: List[str] = []
|
||||
paren_depth = 0
|
||||
angle_depth = 0
|
||||
quote: Optional[str] = None
|
||||
escape = False
|
||||
|
||||
for ch in text:
|
||||
if escape:
|
||||
current.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
current.append(ch)
|
||||
escape = True
|
||||
continue
|
||||
if quote:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
current.append(ch)
|
||||
quote = ch
|
||||
continue
|
||||
if ch == "(":
|
||||
paren_depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == ")":
|
||||
paren_depth = max(0, paren_depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "<":
|
||||
angle_depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == ">":
|
||||
angle_depth = max(0, angle_depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "," and paren_depth == 0 and angle_depth == 0:
|
||||
segments.append("".join(current).strip())
|
||||
current = []
|
||||
continue
|
||||
current.append(ch)
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail or segments:
|
||||
segments.append(tail)
|
||||
return segments
|
||||
|
||||
def _expand_pipe_namespace(text: str) -> List[str]:
|
||||
parts = text.split("|")
|
||||
expanded: List[str] = []
|
||||
@@ -1684,7 +1741,7 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
|
||||
tags: List[str] = []
|
||||
for argument in arguments:
|
||||
for token in argument.split(","):
|
||||
for token in _split_top_level_commas(str(argument)):
|
||||
text = token.strip()
|
||||
if not text:
|
||||
continue
|
||||
@@ -1704,6 +1761,365 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
return tags
|
||||
|
||||
|
||||
_TAG_VALUE_TEMPLATE_RE = re.compile(r"#\(([^)]+)\)")
|
||||
_TAG_VALUE_FUNCTION_RE = re.compile(r"<([a-zA-Z_][a-zA-Z0-9_-]*)\((.*?)\)>")
|
||||
|
||||
|
||||
def _normalize_tag_value_template_name(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
except Exception:
|
||||
text = " ".join(text.split())
|
||||
return text
|
||||
|
||||
|
||||
def _tag_value_template_keys(value: Any) -> list[str]:
|
||||
normalized = _normalize_tag_value_template_name(value)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
keys = [normalized]
|
||||
|
||||
trimmed_hash = re.sub(r"\s*#+\s*$", "", normalized).strip()
|
||||
if trimmed_hash and trimmed_hash not in keys:
|
||||
keys.append(trimmed_hash)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def _add_tag_values_to_lookup(lookup: Dict[str, List[str]], tag_text: Any) -> None:
|
||||
text = str(tag_text or "").strip()
|
||||
if not text or ":" not in text:
|
||||
return
|
||||
if _TAG_VALUE_TEMPLATE_RE.search(text) or _TAG_VALUE_FUNCTION_RE.search(text):
|
||||
return
|
||||
|
||||
namespace, value = text.split(":", 1)
|
||||
value_text = str(value or "").strip()
|
||||
if not value_text:
|
||||
return
|
||||
|
||||
for key in _tag_value_template_keys(namespace):
|
||||
values = lookup.setdefault(key, [])
|
||||
if value_text not in values:
|
||||
values.append(value_text)
|
||||
|
||||
|
||||
def build_tag_value_lookup(
|
||||
tags: Optional[Iterable[Any]],
|
||||
*,
|
||||
result: Any = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Build a placeholder lookup from existing tags and lightweight result fields.
|
||||
|
||||
Placeholder lookups use ``#(namespace)`` syntax. Namespace matching is
|
||||
case-insensitive and trims repeated whitespace. A trailing ``#`` in the
|
||||
placeholder is ignored so inputs like ``#(track #)`` can resolve ``track:9``.
|
||||
"""
|
||||
|
||||
lookup: Dict[str, List[str]] = {}
|
||||
for tag in tags or []:
|
||||
_add_tag_values_to_lookup(lookup, tag)
|
||||
|
||||
title_text = extract_title_from_result(result)
|
||||
if title_text:
|
||||
_add_tag_values_to_lookup(lookup, f"title:{title_text}")
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
def _split_tag_value_function_args(value: Any) -> list[str]:
|
||||
text = str(value or "")
|
||||
args: list[str] = []
|
||||
current: list[str] = []
|
||||
depth = 0
|
||||
quote: Optional[str] = None
|
||||
escape = False
|
||||
|
||||
for ch in text:
|
||||
if escape:
|
||||
current.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
current.append(ch)
|
||||
escape = True
|
||||
continue
|
||||
if quote:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
current.append(ch)
|
||||
quote = ch
|
||||
continue
|
||||
if ch in {"(", "[", "{"}:
|
||||
depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch in {")", "]", "}"}:
|
||||
depth = max(0, depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "," and depth == 0:
|
||||
args.append("".join(current).strip())
|
||||
current = []
|
||||
continue
|
||||
current.append(ch)
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail or args:
|
||||
args.append(tail)
|
||||
return args
|
||||
|
||||
|
||||
def _strip_tag_value_function_arg(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def _padding_width_from_spec(value: Any) -> Optional[int]:
|
||||
spec = _strip_tag_value_function_arg(value)
|
||||
if not spec:
|
||||
return None
|
||||
if re.fullmatch(r"0+", spec):
|
||||
return len(spec)
|
||||
if spec.isdigit():
|
||||
try:
|
||||
width = int(spec)
|
||||
except Exception:
|
||||
return None
|
||||
return width if width > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def _replace_tag_value_placeholders(
|
||||
value: Any,
|
||||
lookup: Dict[str, List[str]],
|
||||
*,
|
||||
preserve_unresolved: bool,
|
||||
) -> tuple[str, bool]:
|
||||
text = str(value or "")
|
||||
unresolved = False
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
nonlocal unresolved
|
||||
keys = _tag_value_template_keys(match.group(1) or "")
|
||||
values: List[str] = []
|
||||
for key in keys:
|
||||
for candidate in lookup.get(key, []):
|
||||
if candidate not in values:
|
||||
values.append(candidate)
|
||||
if not values:
|
||||
unresolved = True
|
||||
return match.group(0) if preserve_unresolved else ""
|
||||
return ", ".join(values)
|
||||
|
||||
return _TAG_VALUE_TEMPLATE_RE.sub(_replace, text), unresolved
|
||||
|
||||
|
||||
def _coerce_tag_value_integer(value: Any) -> Optional[int]:
|
||||
text = _strip_tag_value_function_arg(value)
|
||||
if not text:
|
||||
return None
|
||||
if not re.fullmatch(r"[+-]?\d+", text):
|
||||
return None
|
||||
try:
|
||||
return int(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_tag_value_function(
|
||||
name: str,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
lookup: Dict[str, List[str]],
|
||||
) -> Optional[str]:
|
||||
func = str(name or "").strip().lower()
|
||||
|
||||
resolved_values: list[str] = []
|
||||
unresolved_flags: list[bool] = []
|
||||
for arg in args:
|
||||
rendered, unresolved = _replace_tag_value_placeholders(
|
||||
arg,
|
||||
lookup,
|
||||
preserve_unresolved=True,
|
||||
)
|
||||
resolved_values.append(_strip_tag_value_function_arg(rendered))
|
||||
unresolved_flags.append(unresolved)
|
||||
|
||||
if func in {"padding", "pad", "zfill"}:
|
||||
if len(resolved_values) != 2 or any(unresolved_flags):
|
||||
return None
|
||||
width = _padding_width_from_spec(resolved_values[0])
|
||||
if width is None:
|
||||
return None
|
||||
return str(resolved_values[1]).zfill(width)
|
||||
|
||||
if func == "default":
|
||||
if len(resolved_values) != 2:
|
||||
return None
|
||||
primary = resolved_values[0]
|
||||
fallback = resolved_values[1]
|
||||
if not unresolved_flags[0] and str(primary).strip():
|
||||
return str(primary)
|
||||
if unresolved_flags[1]:
|
||||
return None
|
||||
return str(fallback)
|
||||
|
||||
if func == "replace":
|
||||
if len(resolved_values) != 3 or any(unresolved_flags):
|
||||
return None
|
||||
return str(resolved_values[0]).replace(
|
||||
str(resolved_values[1]),
|
||||
str(resolved_values[2]),
|
||||
)
|
||||
|
||||
if func in {"increment", "inc", "add"}:
|
||||
if len(resolved_values) not in {1, 2}:
|
||||
return None
|
||||
if unresolved_flags[0]:
|
||||
return None
|
||||
base_value = _coerce_tag_value_integer(resolved_values[0])
|
||||
if base_value is None:
|
||||
return None
|
||||
step_value = 1
|
||||
if len(resolved_values) == 2:
|
||||
if unresolved_flags[1]:
|
||||
return None
|
||||
parsed_step = _coerce_tag_value_integer(resolved_values[1])
|
||||
if parsed_step is None:
|
||||
return None
|
||||
step_value = parsed_step
|
||||
return str(base_value + step_value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _render_tag_value_function_templates(
|
||||
value: Any,
|
||||
*,
|
||||
lookup: Dict[str, List[str]],
|
||||
) -> tuple[str, bool]:
|
||||
text = str(value or "")
|
||||
unresolved = False
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
nonlocal unresolved
|
||||
func_name = match.group(1) or ""
|
||||
func_args = _split_tag_value_function_args(match.group(2) or "")
|
||||
rendered = _apply_tag_value_function(
|
||||
func_name,
|
||||
func_args,
|
||||
lookup=lookup,
|
||||
)
|
||||
if rendered is None:
|
||||
unresolved = True
|
||||
return match.group(0)
|
||||
return rendered
|
||||
|
||||
previous = None
|
||||
rendered = text
|
||||
while previous != rendered and _TAG_VALUE_FUNCTION_RE.search(rendered):
|
||||
previous = rendered
|
||||
rendered = _TAG_VALUE_FUNCTION_RE.sub(_replace, rendered)
|
||||
if unresolved:
|
||||
break
|
||||
return rendered, unresolved
|
||||
|
||||
|
||||
def render_tag_value_templates(
|
||||
tags: Sequence[Any],
|
||||
*,
|
||||
existing_tags: Optional[Iterable[Any]] = None,
|
||||
result: Any = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Resolve ``#(namespace)`` placeholders and ``<transform(...)>`` functions.
|
||||
|
||||
Returns ``(resolved_tags, unresolved_templates)``. Tags whose placeholders
|
||||
cannot be fully resolved are omitted from ``resolved_tags`` and returned in
|
||||
``unresolved_templates`` so callers can warn or summarize skipped items.
|
||||
|
||||
Currently supported transforms:
|
||||
- ``<padding(00,#(episode))>`` or ``<pad(2,#(episode))>`` for zero-padding
|
||||
- ``<default(#(season),0)>`` to fall back when a placeholder is missing
|
||||
- ``<replace(#(title),old,new)>`` for simple substring replacement
|
||||
- ``<increment(#(episode),1)>`` for integer arithmetic
|
||||
"""
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
lookup = build_tag_value_lookup(existing_tags, result=result)
|
||||
|
||||
for raw_tag in tags or []:
|
||||
text = str(raw_tag or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
has_template = bool(
|
||||
_TAG_VALUE_TEMPLATE_RE.search(text)
|
||||
or _TAG_VALUE_FUNCTION_RE.search(text)
|
||||
)
|
||||
entry = {
|
||||
"raw": text,
|
||||
"resolved": None,
|
||||
"has_template": has_template,
|
||||
}
|
||||
if not has_template:
|
||||
entry["resolved"] = text
|
||||
_add_tag_values_to_lookup(lookup, text)
|
||||
entries.append(entry)
|
||||
|
||||
progress = True
|
||||
while progress:
|
||||
progress = False
|
||||
for entry in entries:
|
||||
if entry["resolved"] is not None or not entry["has_template"]:
|
||||
continue
|
||||
|
||||
rendered, unresolved = _replace_tag_value_placeholders(
|
||||
entry["raw"],
|
||||
lookup,
|
||||
preserve_unresolved=bool(_TAG_VALUE_FUNCTION_RE.search(str(entry["raw"]))),
|
||||
)
|
||||
|
||||
rendered, function_unresolved = _render_tag_value_function_templates(
|
||||
rendered,
|
||||
lookup=lookup,
|
||||
)
|
||||
if function_unresolved:
|
||||
continue
|
||||
|
||||
if unresolved and _TAG_VALUE_TEMPLATE_RE.search(rendered):
|
||||
continue
|
||||
|
||||
rendered = rendered.strip()
|
||||
if not rendered:
|
||||
entry["resolved"] = ""
|
||||
progress = True
|
||||
continue
|
||||
|
||||
entry["resolved"] = rendered
|
||||
_add_tag_values_to_lookup(lookup, rendered)
|
||||
progress = True
|
||||
|
||||
resolved_tags = merge_sequences(
|
||||
[entry["resolved"] for entry in entries if isinstance(entry.get("resolved"), str) and entry.get("resolved")],
|
||||
case_sensitive=True,
|
||||
)
|
||||
unresolved_templates = [
|
||||
str(entry["raw"])
|
||||
for entry in entries
|
||||
if entry["has_template"] and not entry.get("resolved")
|
||||
]
|
||||
return resolved_tags, unresolved_templates
|
||||
|
||||
|
||||
def fmt_bytes(n: Optional[int]) -> str:
|
||||
"""Format bytes as human-readable with 1 decimal place (MB/GB).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user