feat(characters): named prompt-fragment library + --character/--character-prompt
Adds a 'character' subsystem for reusable prompt fragments stored as YAML lists in ~/.local/share/tensors/characters/<name>.yml. New tensors.characters module: - save_character / load_character / list_characters / delete_character - parse_elements + resolve_character (named + inline merge with dedup) - Path-traversal-safe name validation ([A-Za-z0-9_.-]+) - JSON-encoded YAML scalars on write; tolerant reader (JSON, single-quoted YAML, bare) New CLI: - tsr character save -o <name> 'elem1, elem2, ...' - tsr character list / show / delete Wired into generate + template + style-sweep: - tsr generate -C <name> / --character-prompt 'elems' injects character elements into the positive prompt (after quality_prefix, before user prompt) - tsr template -C <name> / --character-prompt 'elems' embeds a 'character' list field in the dumped JSON template (named + inline merged, deduped) - style-sweep templates accept 'character' (str|list) and 'character_prompt' keys; lists are passed through verbatim, names are looked up at run-time - generate --input JSON honors both 'character' (str=name or list=inline) and 'character_prompt' keys 37 new tests cover module behavior. Bumps version to 0.1.23.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"""tsr: Read safetensor metadata, search and download CivitAI models."""
|
"""tsr: Read safetensor metadata, search and download CivitAI models."""
|
||||||
|
|
||||||
__version__ = "0.1.22"
|
__version__ = "0.1.23"
|
||||||
|
|
||||||
from tensors.cli import main
|
from tensors.cli import main
|
||||||
from tensors.config import (
|
from tensors.config import (
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Character library: named lists of prompt elements, stored as YAML.
|
||||||
|
|
||||||
|
Characters live in ``~/.local/share/tensors/characters/<name>.yml`` and contain a
|
||||||
|
flat YAML list of strings describing a subject (appearance, identity, outfit
|
||||||
|
fragments). They are injected into a generation prompt via ``--character`` or
|
||||||
|
``--character-prompt`` on ``tsr generate``.
|
||||||
|
|
||||||
|
Format is JSON-compatible YAML — each line is ``- "value"`` so the files round-trip
|
||||||
|
through both ``json`` and any YAML parser. Manual hand-editing with plain or
|
||||||
|
single-quoted scalars is also accepted on read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path # noqa: TC003 # used in runtime return annotations exposed to typer
|
||||||
|
|
||||||
|
from tensors.config import DATA_DIR
|
||||||
|
|
||||||
|
# Storage location for character YAML files.
|
||||||
|
CHARACTERS_DIR = DATA_DIR / "characters"
|
||||||
|
|
||||||
|
# Restrict character names to a safe subset so they can't escape CHARACTERS_DIR
|
||||||
|
# via path traversal and so file listings stay tidy.
|
||||||
|
_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
# Minimum length for a quoted YAML scalar: opening + closing quote.
|
||||||
|
_MIN_QUOTED_SCALAR_LEN = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> None:
|
||||||
|
if not name or not _NAME_RE.match(name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid character name {name!r}: only letters, digits, '.', '_', '-' allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def character_path(name: str) -> Path:
|
||||||
|
"""Return the on-disk path for a character name (without ensuring it exists)."""
|
||||||
|
_validate_name(name)
|
||||||
|
return CHARACTERS_DIR / f"{name}.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_elements(value: str) -> list[str]:
|
||||||
|
"""Split a comma-separated prompt fragment into clean, order-preserving elements.
|
||||||
|
|
||||||
|
Empty pieces and duplicates are dropped. Used by ``tsr character save`` and
|
||||||
|
by the ``--character-prompt`` CLI flag so both share identical splitting
|
||||||
|
semantics.
|
||||||
|
"""
|
||||||
|
parts = [p.strip() for p in value.split(",")]
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
if p and p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_character(name: str, elements: list[str]) -> Path:
|
||||||
|
"""Persist a character's elements to disk and return the file path.
|
||||||
|
|
||||||
|
Overwrites any existing file. Each element is written on its own line as a
|
||||||
|
JSON-encoded YAML scalar (``- "value"``), which keeps embedded commas,
|
||||||
|
quotes, and unicode safe.
|
||||||
|
"""
|
||||||
|
CHARACTERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = character_path(name)
|
||||||
|
body = "\n".join(f"- {json.dumps(e, ensure_ascii=False)}" for e in elements)
|
||||||
|
path.write_text(body + "\n" if body else "")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load_character(name: str) -> list[str]:
|
||||||
|
"""Load a character's elements. Raises ``FileNotFoundError`` if missing.
|
||||||
|
|
||||||
|
Accepts both JSON-quoted scalars (``- "value"``), single-quoted YAML scalars
|
||||||
|
(``- 'value'``) and bare scalars (``- value``). Blank lines and ``#`` comments
|
||||||
|
are ignored.
|
||||||
|
"""
|
||||||
|
path = character_path(name)
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"Character {name!r} not found at {path}")
|
||||||
|
|
||||||
|
elements: list[str] = []
|
||||||
|
for raw in path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if not line.startswith("-"):
|
||||||
|
# Skip any non-list-item lines (e.g. a YAML document header users
|
||||||
|
# might add manually); we only consume flat list entries.
|
||||||
|
continue
|
||||||
|
item = line[1:].lstrip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
# Prefer strict JSON decode (covers our own writer output and any
|
||||||
|
# double-quoted YAML scalar). Fall back to single-quoted YAML, then
|
||||||
|
# bare scalar.
|
||||||
|
try:
|
||||||
|
value = json.loads(item)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
value = (
|
||||||
|
item[1:-1].replace("''", "'")
|
||||||
|
if len(item) >= _MIN_QUOTED_SCALAR_LEN and item[0] == item[-1] == "'"
|
||||||
|
else item
|
||||||
|
)
|
||||||
|
elements.append(value)
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def list_characters() -> list[str]:
|
||||||
|
"""Return sorted character names. Empty list if no characters dir exists yet."""
|
||||||
|
if not CHARACTERS_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(p.stem for p in CHARACTERS_DIR.glob("*.yml") if p.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_character(name: str) -> bool:
|
||||||
|
"""Delete a character file. Returns True on success, False if it was missing."""
|
||||||
|
path = character_path(name)
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_character(
|
||||||
|
*,
|
||||||
|
character: str | None = None,
|
||||||
|
character_prompt: str | None = None,
|
||||||
|
extra: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Merge a named character with an inline ``--character-prompt`` and optional extras.
|
||||||
|
|
||||||
|
Resolution order (first match wins per duplicate): named character →
|
||||||
|
``--character-prompt`` elements → ``extra`` list. The result preserves order
|
||||||
|
and drops duplicates, mirroring ``parse_elements``.
|
||||||
|
"""
|
||||||
|
merged: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _push(items: list[str]) -> None:
|
||||||
|
for item in items:
|
||||||
|
if item and item not in seen:
|
||||||
|
seen.add(item)
|
||||||
|
merged.append(item)
|
||||||
|
|
||||||
|
if character:
|
||||||
|
_push(load_character(character))
|
||||||
|
if character_prompt:
|
||||||
|
_push(parse_elements(character_prompt))
|
||||||
|
if extra:
|
||||||
|
_push(extra)
|
||||||
|
return merged
|
||||||
+235
@@ -780,6 +780,14 @@ def generate( # noqa: PLR0915
|
|||||||
] = None,
|
] = None,
|
||||||
no_quality: Annotated[bool, typer.Option("--no-quality", help="Disable auto quality tags")] = False,
|
no_quality: Annotated[bool, typer.Option("--no-quality", help="Disable auto quality tags")] = False,
|
||||||
no_negative: Annotated[bool, typer.Option("--no-negative", help="Disable auto negative prompt")] = False,
|
no_negative: Annotated[bool, typer.Option("--no-negative", help="Disable auto negative prompt")] = False,
|
||||||
|
character: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("-C", "--character", help="Saved character name (loaded from ~/.local/share/tensors/characters/)"),
|
||||||
|
] = None,
|
||||||
|
character_prompt: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--character-prompt", help='Inline character fragment, comma-separated (e.g. "blond hair, blue eyes")'),
|
||||||
|
] = None,
|
||||||
family: Annotated[
|
family: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
@@ -892,6 +900,21 @@ def generate( # noqa: PLR0915
|
|||||||
no_quality = bool(mapped["no_quality"])
|
no_quality = bool(mapped["no_quality"])
|
||||||
if "no_negative" in mapped and "no_negative" not in explicit:
|
if "no_negative" in mapped and "no_negative" not in explicit:
|
||||||
no_negative = bool(mapped["no_negative"])
|
no_negative = bool(mapped["no_negative"])
|
||||||
|
if "character" in mapped and "character" not in explicit:
|
||||||
|
# Accept either a saved-name string or an already-resolved list/tuple
|
||||||
|
# (templates may carry the resolved list inline). For lists we stage
|
||||||
|
# them into character_prompt by joining with commas so the existing
|
||||||
|
# CLI splitting/dedup path applies uniformly.
|
||||||
|
val = mapped["character"]
|
||||||
|
if isinstance(val, str):
|
||||||
|
character = val
|
||||||
|
elif isinstance(val, (list, tuple)):
|
||||||
|
character_prompt = ", ".join(str(x) for x in val if str(x).strip())
|
||||||
|
if "character_prompt" in mapped and "character_prompt" not in explicit:
|
||||||
|
cp_val = mapped["character_prompt"]
|
||||||
|
character_prompt = (
|
||||||
|
cp_val if isinstance(cp_val, str) else ", ".join(str(x) for x in cp_val if str(x).strip())
|
||||||
|
)
|
||||||
if "rating" in mapped and "rating" not in explicit:
|
if "rating" in mapped and "rating" not in explicit:
|
||||||
rating = mapped["rating"]
|
rating = mapped["rating"]
|
||||||
|
|
||||||
@@ -919,6 +942,8 @@ def generate( # noqa: PLR0915
|
|||||||
rating=rating,
|
rating=rating,
|
||||||
no_quality=no_quality,
|
no_quality=no_quality,
|
||||||
no_negative=no_negative,
|
no_negative=no_negative,
|
||||||
|
character=character,
|
||||||
|
character_prompt=character_prompt,
|
||||||
family=family,
|
family=family,
|
||||||
output=output,
|
output=output,
|
||||||
remote=remote,
|
remote=remote,
|
||||||
@@ -1007,6 +1032,8 @@ def _run_generation( # noqa: PLR0915
|
|||||||
rating: str | None = None,
|
rating: str | None = None,
|
||||||
no_quality: bool = False,
|
no_quality: bool = False,
|
||||||
no_negative: bool = False,
|
no_negative: bool = False,
|
||||||
|
character: str | None = None,
|
||||||
|
character_prompt: str | None = None,
|
||||||
family: str | None = None,
|
family: str | None = None,
|
||||||
output: Path | None = None,
|
output: Path | None = None,
|
||||||
remote: str | None = None,
|
remote: str | None = None,
|
||||||
@@ -1058,6 +1085,29 @@ def _run_generation( # noqa: PLR0915
|
|||||||
if not no_quality and family_defaults.get("quality_prefix"):
|
if not no_quality and family_defaults.get("quality_prefix"):
|
||||||
prompt_parts.append(family_defaults["quality_prefix"])
|
prompt_parts.append(family_defaults["quality_prefix"])
|
||||||
|
|
||||||
|
# Resolve character (named lookup + inline --character-prompt, merged + deduped)
|
||||||
|
character_elements: list[str] = []
|
||||||
|
if character or character_prompt:
|
||||||
|
from tensors.characters import resolve_character # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
|
character_elements = resolve_character(character=character, character_prompt=character_prompt)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
if character_elements:
|
||||||
|
prompt_parts.extend(character_elements)
|
||||||
|
if not json_output:
|
||||||
|
origin = f"'{character}'" if character else "inline"
|
||||||
|
console.print(
|
||||||
|
f"[dim]Character ({origin}, {len(character_elements)} elements): "
|
||||||
|
f"{', '.join(character_elements)}[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
# Add rating tag based on model family (Pony/Illustrious)
|
# Add rating tag based on model family (Pony/Illustrious)
|
||||||
if rating:
|
if rating:
|
||||||
from tensors.config import get_rating_tag # noqa: PLC0415
|
from tensors.config import get_rating_tag # noqa: PLC0415
|
||||||
@@ -1301,6 +1351,8 @@ _STYLE_SWEEP_TEMPLATE_KEYS = {
|
|||||||
"orientation",
|
"orientation",
|
||||||
"no_quality",
|
"no_quality",
|
||||||
"no_negative",
|
"no_negative",
|
||||||
|
"character",
|
||||||
|
"character_prompt",
|
||||||
"rating",
|
"rating",
|
||||||
"family",
|
"family",
|
||||||
"remote",
|
"remote",
|
||||||
@@ -1631,6 +1683,25 @@ def style_sweep( # noqa: PLR0915
|
|||||||
|
|
||||||
pending_tasks.append((i, entry, result, out_path))
|
pending_tasks.append((i, entry, result, out_path))
|
||||||
|
|
||||||
|
# Character resolution: templates may carry either a name string (look up
|
||||||
|
# at run-time) or an inline list of resolved elements (e.g. produced by
|
||||||
|
# `tsr template -C ...`). Lists are joined into `character_prompt` so
|
||||||
|
# _run_generation sees a uniform CSV string and skips the disk lookup.
|
||||||
|
char_val = tpl_data.get("character")
|
||||||
|
char_prompt_val = tpl_data.get("character_prompt")
|
||||||
|
char_name: str | None = None
|
||||||
|
char_inline: str | None = None
|
||||||
|
if isinstance(char_val, str):
|
||||||
|
char_name = char_val
|
||||||
|
elif isinstance(char_val, (list, tuple)):
|
||||||
|
char_inline = ", ".join(str(x) for x in char_val if str(x).strip())
|
||||||
|
if char_prompt_val is not None:
|
||||||
|
char_inline = (
|
||||||
|
char_prompt_val
|
||||||
|
if isinstance(char_prompt_val, str)
|
||||||
|
else ", ".join(str(x) for x in char_prompt_val if str(x).strip())
|
||||||
|
)
|
||||||
|
|
||||||
# Common kwargs for every _run_generation call — extracted from the
|
# Common kwargs for every _run_generation call — extracted from the
|
||||||
# template once, reused across sequential and parallel paths.
|
# template once, reused across sequential and parallel paths.
|
||||||
base_gen_kwargs: dict[str, Any] = {
|
base_gen_kwargs: dict[str, Any] = {
|
||||||
@@ -1652,6 +1723,8 @@ def style_sweep( # noqa: PLR0915
|
|||||||
"rating": _t("rating"),
|
"rating": _t("rating"),
|
||||||
"no_quality": bool(_t("no_quality", default=False)),
|
"no_quality": bool(_t("no_quality", default=False)),
|
||||||
"no_negative": bool(_t("no_negative", default=False)),
|
"no_negative": bool(_t("no_negative", default=False)),
|
||||||
|
"character": char_name,
|
||||||
|
"character_prompt": char_inline,
|
||||||
"family": _t("family"),
|
"family": _t("family"),
|
||||||
"remote": gen_remote,
|
"remote": gen_remote,
|
||||||
"json_output": False,
|
"json_output": False,
|
||||||
@@ -1804,6 +1877,17 @@ def template(
|
|||||||
lora_strength: Annotated[float, typer.Option("--lora-strength", help="LoRA strength")] = 0.8,
|
lora_strength: Annotated[float, typer.Option("--lora-strength", help="LoRA strength")] = 0.8,
|
||||||
orientation: Annotated[str, typer.Option("-O", "--orientation", help="Resolution: square, portrait, landscape")] = "square",
|
orientation: Annotated[str, typer.Option("-O", "--orientation", help="Resolution: square, portrait, landscape")] = "square",
|
||||||
rating: Annotated[str | None, typer.Option("--rating", "-R", help="Content rating: safe, questionable, explicit")] = None,
|
rating: Annotated[str | None, typer.Option("--rating", "-R", help="Content rating: safe, questionable, explicit")] = None,
|
||||||
|
character: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("-C", "--character", help="Saved character name (resolved into the `character` list field)"),
|
||||||
|
] = None,
|
||||||
|
character_prompt: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option(
|
||||||
|
"--character-prompt",
|
||||||
|
help='Inline character fragment, comma-separated (merged with --character into `character`)',
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
output: Annotated[Path | None, typer.Option("-o", "--output", help="Save template to file")] = None,
|
output: Annotated[Path | None, typer.Option("-o", "--output", help="Save template to file")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dump a JSON generation template with resolved defaults for a model.
|
"""Dump a JSON generation template with resolved defaults for a model.
|
||||||
@@ -1811,11 +1895,17 @@ def template(
|
|||||||
Outputs a ready-to-use JSON object with all parameters auto-resolved from the
|
Outputs a ready-to-use JSON object with all parameters auto-resolved from the
|
||||||
checkpoint family. Pipe to 'tsr generate --input' or save to a file for reuse.
|
checkpoint family. Pipe to 'tsr generate --input' or save to a file for reuse.
|
||||||
|
|
||||||
|
``--character`` and ``--character-prompt`` append a ``character`` list to the
|
||||||
|
template (saved-name elements first, inline elements appended, deduped).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
tsr template -m ponyDiffusionV6XL_v6StartWithThisOne.safetensors
|
tsr template -m ponyDiffusionV6XL_v6StartWithThisOne.safetensors
|
||||||
tsr template -m beautifulRealistic_v7.safetensors -O portrait
|
tsr template -m beautifulRealistic_v7.safetensors -O portrait
|
||||||
tsr template -m waiIllustriousSDXL_v160.safetensors -l "Elvira iIlluLoRA.safetensors"
|
tsr template -m waiIllustriousSDXL_v160.safetensors -l "Elvira iIlluLoRA.safetensors"
|
||||||
tsr template -m ponyRealism_V22.safetensors -o pony_preset.json
|
tsr template -m ponyRealism_V22.safetensors -o pony_preset.json
|
||||||
|
tsr template -m flux1-dev.safetensors -C cassie_cage # embeds saved character
|
||||||
|
tsr template -m flux1-dev.safetensors --character-prompt "blond hair, blue eyes"
|
||||||
|
tsr template -m flux1-dev.safetensors -C cassie --character-prompt "wet skin"
|
||||||
tsr generate --input "$(tsr template -m ponyRealism_V22.safetensors)" "a portrait"
|
tsr generate --input "$(tsr template -m ponyRealism_V22.safetensors)" "a portrait"
|
||||||
"""
|
"""
|
||||||
from tensors.config import get_model_generation_defaults, resolve_orientation # noqa: PLC0415
|
from tensors.config import get_model_generation_defaults, resolve_orientation # noqa: PLC0415
|
||||||
@@ -1869,6 +1959,28 @@ def template(
|
|||||||
tpl["lora"] = lora
|
tpl["lora"] = lora
|
||||||
tpl["lora_strength"] = lora_strength
|
tpl["lora_strength"] = lora_strength
|
||||||
|
|
||||||
|
# Resolve character into a flat list embedded in the template. When the
|
||||||
|
# template is later fed to `tsr generate --input`, _run_generation will
|
||||||
|
# treat a list under `character` as inline elements (no re-lookup needed).
|
||||||
|
# --character and --character-prompt merge in that order (named first,
|
||||||
|
# inline appended, duplicates dropped).
|
||||||
|
if character or character_prompt:
|
||||||
|
from tensors.characters import resolve_character # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = resolve_character(character=character, character_prompt=character_prompt)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
if resolved:
|
||||||
|
tpl["character"] = resolved
|
||||||
|
if character:
|
||||||
|
tpl["_character_name"] = character
|
||||||
|
|
||||||
# Add metadata (not used by generate, but informational)
|
# Add metadata (not used by generate, but informational)
|
||||||
tpl["_family"] = family or "unknown"
|
tpl["_family"] = family or "unknown"
|
||||||
if base_model_str:
|
if base_model_str:
|
||||||
@@ -2352,6 +2464,129 @@ def hf_download(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Character Commands
|
||||||
|
# =============================================================================
|
||||||
|
# Characters are named, comma-split prompt fragments stored as YAML lists in
|
||||||
|
# ~/.local/share/tensors/characters/<name>.yml. They are injected into the
|
||||||
|
# positive prompt by `tsr generate --character <name>` (or inline via
|
||||||
|
# `--character-prompt "elem1, elem2"`).
|
||||||
|
|
||||||
|
character_app = typer.Typer(
|
||||||
|
name="character",
|
||||||
|
help="Manage saved character prompts (~/.local/share/tensors/characters/).",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
|
app.add_typer(character_app)
|
||||||
|
|
||||||
|
|
||||||
|
@character_app.command("save")
|
||||||
|
def character_save(
|
||||||
|
elements: Annotated[str, typer.Argument(help='Comma-separated prompt elements (e.g. "blond hair, blue eyes")')],
|
||||||
|
name: Annotated[str, typer.Option("-o", "--output", help="Character name (used as filename)")],
|
||||||
|
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Save a character as a YAML list of prompt elements.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
tsr character save -o cassie_cage "blond hair, broad chin, skin imperfections"
|
||||||
|
tsr character save -o elvira "long black hair, pale skin, gothic dress"
|
||||||
|
"""
|
||||||
|
from tensors.characters import parse_elements, save_character # noqa: PLC0415
|
||||||
|
|
||||||
|
parsed = parse_elements(elements)
|
||||||
|
if not parsed:
|
||||||
|
console.print("[red]No usable elements after splitting on commas[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = save_character(name, parsed)
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
console.print_json(data={"name": name, "path": str(path), "elements": parsed})
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[green]Saved character '{name}' ({len(parsed)} elements):[/green] {path}")
|
||||||
|
for elem in parsed:
|
||||||
|
console.print(f" • {elem}")
|
||||||
|
|
||||||
|
|
||||||
|
@character_app.command("list")
|
||||||
|
def character_list(
|
||||||
|
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""List saved characters."""
|
||||||
|
from tensors.characters import CHARACTERS_DIR, list_characters # noqa: PLC0415
|
||||||
|
|
||||||
|
names = list_characters()
|
||||||
|
if json_output:
|
||||||
|
console.print_json(data={"dir": str(CHARACTERS_DIR), "characters": names})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not names:
|
||||||
|
console.print(f"[yellow]No characters saved in {CHARACTERS_DIR}.[/yellow]")
|
||||||
|
console.print("[dim]Create one with: tsr character save -o <name> \"elem1, elem2\"[/dim]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[bold]Characters[/bold] ({len(names)}) [dim]in {CHARACTERS_DIR}[/dim]")
|
||||||
|
for n in names:
|
||||||
|
console.print(f" • {n}")
|
||||||
|
|
||||||
|
|
||||||
|
@character_app.command("show")
|
||||||
|
def character_show(
|
||||||
|
name: Annotated[str, typer.Argument(help="Character name")],
|
||||||
|
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Show a character's elements."""
|
||||||
|
from tensors.characters import character_path, load_character # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
|
elements = load_character(name)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
console.print_json(data={"name": name, "path": str(character_path(name)), "elements": elements})
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[bold]{name}[/bold] [dim]({character_path(name)})[/dim]")
|
||||||
|
for elem in elements:
|
||||||
|
console.print(f" • {elem}")
|
||||||
|
|
||||||
|
|
||||||
|
@character_app.command("delete")
|
||||||
|
def character_delete(
|
||||||
|
name: Annotated[str, typer.Argument(help="Character name")],
|
||||||
|
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a saved character."""
|
||||||
|
from tensors.characters import delete_character # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = delete_character(name)
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
console.print_json(data={"name": name, "deleted": deleted})
|
||||||
|
return
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
console.print(f"[green]Deleted character '{name}'[/green]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]Character '{name}' does not exist[/yellow]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ComfyUI Commands
|
# ComfyUI Commands
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Tests for the character library (tensors.characters)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def char_env(tmp_path, monkeypatch):
|
||||||
|
"""Redirect CHARACTERS_DIR (and DATA_DIR) at fresh tmp_path per test.
|
||||||
|
|
||||||
|
The module captures CHARACTERS_DIR at import time, so we monkeypatch the
|
||||||
|
attribute directly rather than rely on env vars (config.DATA_DIR is also
|
||||||
|
captured at import time).
|
||||||
|
"""
|
||||||
|
from tensors import characters as char_mod
|
||||||
|
|
||||||
|
char_dir = tmp_path / "characters"
|
||||||
|
monkeypatch.setattr(char_mod, "CHARACTERS_DIR", char_dir)
|
||||||
|
return char_mod, char_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseElements:
|
||||||
|
def test_splits_on_commas_and_trims(self):
|
||||||
|
from tensors.characters import parse_elements
|
||||||
|
|
||||||
|
assert parse_elements("a, b , c") == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_drops_empty_and_duplicates(self):
|
||||||
|
from tensors.characters import parse_elements
|
||||||
|
|
||||||
|
assert parse_elements("a, , a, b, b , c") == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_preserves_internal_spacing(self):
|
||||||
|
from tensors.characters import parse_elements
|
||||||
|
|
||||||
|
assert parse_elements("blond hair, blue eyes") == ["blond hair", "blue eyes"]
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
from tensors.characters import parse_elements
|
||||||
|
|
||||||
|
assert parse_elements("") == []
|
||||||
|
assert parse_elements(" , , ") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveLoad:
|
||||||
|
def test_save_creates_dir_and_file(self, char_env):
|
||||||
|
char_mod, char_dir = char_env
|
||||||
|
path = char_mod.save_character("cassie", ["blond hair", "broad chin"])
|
||||||
|
|
||||||
|
assert path == char_dir / "cassie.yml"
|
||||||
|
assert path.is_file()
|
||||||
|
assert path.read_text() == '- "blond hair"\n- "broad chin"\n'
|
||||||
|
|
||||||
|
def test_load_roundtrip(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
elements = ["a", "b with spaces", "c, with, commas", 'd "quotes"']
|
||||||
|
char_mod.save_character("mixed", elements)
|
||||||
|
|
||||||
|
assert char_mod.load_character("mixed") == elements
|
||||||
|
|
||||||
|
def test_load_missing_raises(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
char_mod.load_character("nope")
|
||||||
|
|
||||||
|
def test_load_tolerates_hand_edited_single_quoted_yaml(self, char_env):
|
||||||
|
char_mod, char_dir = char_env
|
||||||
|
char_dir.mkdir(parents=True)
|
||||||
|
(char_dir / "manual.yml").write_text("- 'foo'\n- 'it''s'\n- bare\n# comment\n\n")
|
||||||
|
|
||||||
|
assert char_mod.load_character("manual") == ["foo", "it's", "bare"]
|
||||||
|
|
||||||
|
def test_save_overwrites(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("c", ["a"])
|
||||||
|
char_mod.save_character("c", ["b", "c"])
|
||||||
|
|
||||||
|
assert char_mod.load_character("c") == ["b", "c"]
|
||||||
|
|
||||||
|
def test_save_empty_list_writes_empty_file(self, char_env):
|
||||||
|
char_mod, _char_dir = char_env
|
||||||
|
path = char_mod.save_character("empty", [])
|
||||||
|
|
||||||
|
assert path.is_file()
|
||||||
|
assert path.read_text() == ""
|
||||||
|
assert char_mod.load_character("empty") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndDelete:
|
||||||
|
def test_list_empty_when_dir_missing(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
assert char_mod.list_characters() == []
|
||||||
|
|
||||||
|
def test_list_sorted_names(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("zeta", ["x"])
|
||||||
|
char_mod.save_character("alpha", ["y"])
|
||||||
|
char_mod.save_character("mu", ["z"])
|
||||||
|
|
||||||
|
assert char_mod.list_characters() == ["alpha", "mu", "zeta"]
|
||||||
|
|
||||||
|
def test_delete_existing_returns_true(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("doomed", ["x"])
|
||||||
|
|
||||||
|
assert char_mod.delete_character("doomed") is True
|
||||||
|
assert char_mod.list_characters() == []
|
||||||
|
|
||||||
|
def test_delete_missing_returns_false(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
assert char_mod.delete_character("ghost") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestNameValidation:
|
||||||
|
@pytest.mark.parametrize("bad_name", ["", "foo/bar", "../etc", "with space", "name.yml/extra", "a$b"])
|
||||||
|
def test_invalid_names_rejected(self, char_env, bad_name):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(ValueError, match=r"Invalid character name"):
|
||||||
|
char_mod.save_character(bad_name, ["x"])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("good_name", ["a", "foo_bar", "foo-bar", "foo.bar", "Mixed123", "v1.2_alpha-beta"])
|
||||||
|
def test_valid_names_accepted(self, char_env, good_name):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
path = char_mod.save_character(good_name, ["x"])
|
||||||
|
assert path.is_file()
|
||||||
|
|
||||||
|
def test_load_validates_name(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(ValueError, match=r"Invalid character name"):
|
||||||
|
char_mod.load_character("../escape")
|
||||||
|
|
||||||
|
def test_delete_validates_name(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(ValueError, match=r"Invalid character name"):
|
||||||
|
char_mod.delete_character("../escape")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCharacter:
|
||||||
|
def test_named_only(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("base", ["x", "y"])
|
||||||
|
|
||||||
|
assert char_mod.resolve_character(character="base") == ["x", "y"]
|
||||||
|
|
||||||
|
def test_inline_only(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
|
||||||
|
assert char_mod.resolve_character(character_prompt="a, b, c") == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_extra_only(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
|
||||||
|
assert char_mod.resolve_character(extra=["a", "b"]) == ["a", "b"]
|
||||||
|
|
||||||
|
def test_named_plus_inline_dedup_preserves_order(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("base", ["a", "b"])
|
||||||
|
|
||||||
|
# 'b' is shared — should appear once, in named position
|
||||||
|
assert char_mod.resolve_character(character="base", character_prompt="b, c") == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_all_three_sources_merged(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
char_mod.save_character("base", ["a", "b"])
|
||||||
|
|
||||||
|
result = char_mod.resolve_character(
|
||||||
|
character="base",
|
||||||
|
character_prompt="c, d",
|
||||||
|
extra=["d", "e"],
|
||||||
|
)
|
||||||
|
assert result == ["a", "b", "c", "d", "e"]
|
||||||
|
|
||||||
|
def test_no_args_returns_empty(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
assert char_mod.resolve_character() == []
|
||||||
|
|
||||||
|
def test_missing_named_raises(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
char_mod.resolve_character(character="absent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCharacterPath:
|
||||||
|
def test_path_does_not_require_existence(self, char_env):
|
||||||
|
char_mod, char_dir = char_env
|
||||||
|
|
||||||
|
# character_path is pure — doesn't touch disk
|
||||||
|
assert char_mod.character_path("ghost") == char_dir / "ghost.yml"
|
||||||
|
|
||||||
|
def test_path_validates_name(self, char_env):
|
||||||
|
char_mod, _ = char_env
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
char_mod.character_path("bad name")
|
||||||
Reference in New Issue
Block a user