huge refactor of plugin system

This commit is contained in:
2026-04-30 18:56:22 -07:00
parent ea3ead248b
commit be5a11da97
99 changed files with 7603 additions and 11320 deletions
+421 -5
View File
@@ -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).