WIP: snapshot before clean ComfyUI rebuild
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99
This commit is contained in:
co-authored by
Claude Opus 5
parent
975bd4958c
commit
2fa4d5e9a3
@@ -0,0 +1,58 @@
|
||||
@echo off
|
||||
rem ---------------------------------------------------------------------------
|
||||
rem Double-click launcher for csv_to_styles.py
|
||||
rem
|
||||
rem Editors such as Cursor and VS Code claim the .py extension via the per-user
|
||||
rem UserChoice registry key, so double-clicking a .py file opens the editor
|
||||
rem instead of running it. .cmd is always executed by the shell, so this gets
|
||||
rem the converter running without changing any file associations.
|
||||
rem
|
||||
rem Drag a CSV onto this file to preselect it, or just double-click and pick
|
||||
rem both files from the dialogs.
|
||||
rem ---------------------------------------------------------------------------
|
||||
|
||||
setlocal
|
||||
title Convert Styles CSV
|
||||
|
||||
set "SCRIPT=%~dp0csv_to_styles.py"
|
||||
|
||||
if not exist "%SCRIPT%" goto :nofile
|
||||
|
||||
rem Prefer the py launcher; fall back to python on PATH.
|
||||
where py >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
py "%SCRIPT%" %*
|
||||
goto :done
|
||||
)
|
||||
|
||||
where python >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
python "%SCRIPT%" %*
|
||||
goto :done
|
||||
)
|
||||
|
||||
goto :nopython
|
||||
|
||||
:nofile
|
||||
echo.
|
||||
echo Could not find csv_to_styles.py next to this launcher.
|
||||
echo Expected it at:
|
||||
echo %SCRIPT%
|
||||
echo.
|
||||
echo Keep this .cmd file in the same folder as the script.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:nopython
|
||||
echo.
|
||||
echo Python was not found.
|
||||
echo.
|
||||
echo Neither "py" nor "python" is on your PATH. Install Python 3 from
|
||||
echo https://www.python.org/downloads/ and tick "Add python.exe to PATH".
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:done
|
||||
endlocal
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
csv_to_styles.py - Convert a styles CSV into the JSON format the RM Styles nodes read.
|
||||
|
||||
Run with no arguments to get file-picker dialogs for the input CSV and the output
|
||||
JSON location:
|
||||
|
||||
python tools/csv_to_styles.py
|
||||
|
||||
Or pass paths directly:
|
||||
|
||||
python tools/csv_to_styles.py "C:\\...\\styles\\StylesSFW.csv" "C:\\...\\my_styles.json"
|
||||
|
||||
The converter is built around the real defects found in the ComfyUI styles CSVs on
|
||||
this machine, not a hypothetical clean file. It repairs:
|
||||
|
||||
* Encoding - files saved as cp1252/latin-1 that fail to decode as UTF-8
|
||||
(Styles.csv has a 0xa0 non-breaking space at byte 45146)
|
||||
* BOM - leading UTF-8 byte-order mark
|
||||
* Headers - Number/Name/Positive/Negative/Lora, the A1111
|
||||
name/prompt/negative_prompt shape, and a trailing comma in the
|
||||
header row producing a nameless column
|
||||
* Lora syntax - "<lora:Raven.safetensors:1)" (closing paren instead of >),
|
||||
space-separated instead of comma-separated entries, leading-dot
|
||||
weights (".8"), stray trailing commas
|
||||
* Text - non-breaking spaces, control characters, embedded newlines,
|
||||
doubled commas, leading/trailing whitespace and commas
|
||||
* Numbering - blank, non-numeric, zero, negative and duplicate style numbers
|
||||
(the node silently drops number <= 0 and lets a duplicate
|
||||
overwrite the earlier style)
|
||||
* Junk rows - fully empty rows and placeholder rows such as "999,,,,,"
|
||||
|
||||
Every style is emitted with the full field set the node expects, so no output is
|
||||
ever missing a key.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
# Encodings tried in order. utf-8-sig first so a BOM is consumed rather than
|
||||
# becoming part of the first header name.
|
||||
ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
|
||||
|
||||
# Header aliases -> canonical field. Compared lowercased with punctuation stripped.
|
||||
HEADER_ALIASES = {
|
||||
"number": "number", "num": "number", "#": "number", "id": "number", "index": "number",
|
||||
"name": "name", "title": "name", "style": "name", "stylename": "name",
|
||||
"positive": "positive", "prompt": "positive", "positiveprompt": "positive",
|
||||
"pos": "positive", "text": "positive",
|
||||
"negative": "negative", "negativeprompt": "negative", "neg": "negative",
|
||||
"motion": "motion", "motionprompt": "motion", "video": "motion",
|
||||
"lora": "imageLoras", "loras": "imageLoras", "imagelora": "imageLoras",
|
||||
"imageloras": "imageLoras", "pdxllora": "imageLoras", "pdxlloras": "imageLoras",
|
||||
"motionlora": "motionLoras", "motionloras": "motionLoras",
|
||||
"motionlorahigh": "motionLorasHigh", "motionlorashigh": "motionLorasHigh",
|
||||
"motionhigh": "motionLorasHigh",
|
||||
"motionloralow": "motionLorasLow", "motionloraslow": "motionLorasLow",
|
||||
"motionlow": "motionLorasLow",
|
||||
}
|
||||
|
||||
TEXT_FIELDS = ("name", "positive", "negative", "motion")
|
||||
LORA_FIELDS = ("imageLoras", "motionLoras", "motionLorasHigh", "motionLorasLow")
|
||||
|
||||
# Tolerant lora matcher. Accepts >, ), ] or end-of-string as the terminator, any
|
||||
# amount of internal whitespace, and weights written as "1", "0.8" or ".8".
|
||||
LORA_RE = re.compile(
|
||||
r"<\s*lora\s*:\s*([^:<>)\]]+?)\s*:\s*([0-9]*\.?[0-9]+)\s*(?:[>)\]]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class Report:
|
||||
"""Collects what was repaired so the run can be summarised honestly."""
|
||||
|
||||
def __init__(self):
|
||||
self.counts = {}
|
||||
self.notes = []
|
||||
|
||||
def fix(self, label, n=1):
|
||||
if n:
|
||||
self.counts[label] = self.counts.get(label, 0) + n
|
||||
|
||||
def note(self, msg):
|
||||
self.notes.append(msg)
|
||||
|
||||
|
||||
def read_csv_text(path, forced_encoding=None, report=None):
|
||||
"""Read the CSV as text, finding an encoding that actually decodes it."""
|
||||
raw = Path(path).read_bytes()
|
||||
|
||||
if forced_encoding:
|
||||
return raw.decode(forced_encoding), forced_encoding
|
||||
|
||||
for enc in ENCODINGS:
|
||||
try:
|
||||
text = raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if report and enc not in ("utf-8", "utf-8-sig"):
|
||||
report.fix(f"decoded as {enc} (not valid UTF-8)")
|
||||
if report and enc == "utf-8-sig" and raw[:3] == b"\xef\xbb\xbf":
|
||||
report.fix("stripped UTF-8 BOM")
|
||||
return text, enc
|
||||
|
||||
# latin-1 maps every byte, so this is effectively unreachable
|
||||
return raw.decode("utf-8", errors="replace"), "utf-8 (with replacements)"
|
||||
|
||||
|
||||
def normalise_key(key):
|
||||
"""Reduce a header cell to a comparable token."""
|
||||
if key is None:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9#]", "", key.strip().lower())
|
||||
|
||||
|
||||
def map_headers(fieldnames, report):
|
||||
"""Map the CSV's headers onto canonical field names."""
|
||||
mapping = {}
|
||||
unknown = []
|
||||
for raw in fieldnames or []:
|
||||
key = normalise_key(raw)
|
||||
if not key:
|
||||
# trailing comma in the header row yields a nameless column
|
||||
if raw is not None:
|
||||
report.fix("ignored nameless trailing column")
|
||||
continue
|
||||
canonical = HEADER_ALIASES.get(key)
|
||||
if canonical:
|
||||
# first header wins if a file repeats a column
|
||||
mapping.setdefault(raw, canonical)
|
||||
else:
|
||||
unknown.append(raw)
|
||||
|
||||
if unknown:
|
||||
report.note(f"Ignored unrecognised column(s): {', '.join(repr(u) for u in unknown)}")
|
||||
return mapping
|
||||
|
||||
|
||||
def clean_text(value, report=None):
|
||||
"""Normalise a free-text prompt cell into something safe for the node."""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
original = value
|
||||
text = unicodedata.normalize("NFC", str(value))
|
||||
|
||||
if "\xa0" in text:
|
||||
if report:
|
||||
report.fix("replaced non-breaking space")
|
||||
text = text.replace("\xa0", " ")
|
||||
|
||||
# Embedded newlines and control characters survive CSV quoting but make the
|
||||
# prompt unreadable in the editor and mangle the node's console preview.
|
||||
if any(ch == "\n" or ch == "\r" for ch in text):
|
||||
if report:
|
||||
report.fix("flattened embedded newline")
|
||||
text = re.sub(r"[\r\n\t]+", " ", text)
|
||||
|
||||
stripped = "".join(ch for ch in text if ch >= " " or ch == " ")
|
||||
if stripped != text and report:
|
||||
report.fix("removed control character")
|
||||
text = stripped
|
||||
|
||||
text = re.sub(r"[ ]{2,}", " ", text)
|
||||
|
||||
# ",," and stray edge commas are harmless to JSON but produce empty prompt
|
||||
# tokens that some samplers weight oddly.
|
||||
collapsed = re.sub(r"\s*,(\s*,)+", ",", text)
|
||||
if collapsed != text and report:
|
||||
report.fix("collapsed doubled comma")
|
||||
text = collapsed
|
||||
|
||||
text = text.strip().strip(",").strip()
|
||||
|
||||
if text != original and report is not None and text == original.strip():
|
||||
report.fix("trimmed whitespace")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def parse_loras(value, report=None):
|
||||
"""Parse a lora cell into [{'path': ..., 'weight': ...}], repairing bad syntax."""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
text = clean_text(value)
|
||||
if not text:
|
||||
return []
|
||||
|
||||
loras = []
|
||||
matches = list(LORA_RE.finditer(text))
|
||||
|
||||
if matches:
|
||||
# Flag the malformed spellings we actually repaired
|
||||
for m in matches:
|
||||
terminator = m.group(0)[-1]
|
||||
if terminator in ")]":
|
||||
if report:
|
||||
report.fix("repaired lora closing bracket")
|
||||
elif terminator not in ">":
|
||||
if report:
|
||||
report.fix("repaired unterminated lora tag")
|
||||
|
||||
if len(matches) > 1:
|
||||
between = text[matches[0].end():matches[1].start()]
|
||||
if between.strip() == "" and report:
|
||||
report.fix("split space-separated lora tags")
|
||||
|
||||
for m in matches:
|
||||
path = m.group(1).strip().strip(",").strip()
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
weight = float(m.group(2))
|
||||
except ValueError:
|
||||
weight = 1.0
|
||||
if report:
|
||||
report.fix("defaulted unparseable lora weight to 1.0")
|
||||
loras.append({"path": path, "weight": round(weight, 4)})
|
||||
return loras
|
||||
|
||||
# No <lora:...> syntax at all - treat as bare comma-separated filenames
|
||||
for part in text.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
loras.append({"path": part, "weight": 1.0})
|
||||
if loras and report:
|
||||
report.fix("converted bare lora name(s) to tagged form")
|
||||
return loras
|
||||
|
||||
|
||||
def row_is_empty(style):
|
||||
"""A row with no prompt, no name and no loras carries no information."""
|
||||
if any(style[f] for f in TEXT_FIELDS):
|
||||
return False
|
||||
if any(style[f] for f in LORA_FIELDS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def convert(csv_path, forced_encoding=None, start_number=1):
|
||||
"""Convert a CSV file into the node's styles structure. Returns (data, report)."""
|
||||
import csv as csv_module
|
||||
|
||||
report = Report()
|
||||
text, encoding = read_csv_text(csv_path, forced_encoding, report)
|
||||
report.note(f"Read {csv_path} as {encoding}")
|
||||
|
||||
reader = csv_module.DictReader(text.splitlines(True))
|
||||
header_map = map_headers(reader.fieldnames, report)
|
||||
|
||||
if "positive" not in header_map.values():
|
||||
raise SystemExit(
|
||||
f"Could not find a prompt column in {csv_path}.\n"
|
||||
f" Headers found: {reader.fieldnames}\n"
|
||||
f" Expected one of: Positive / Prompt / Positive Prompt / Text"
|
||||
)
|
||||
|
||||
parsed = []
|
||||
dropped_empty = 0
|
||||
|
||||
for raw_row in reader:
|
||||
style = {
|
||||
"number": None, "name": "", "positive": "", "negative": "", "motion": "",
|
||||
"imageLoras": [], "motionLoras": [], "motionLorasHigh": [], "motionLorasLow": [],
|
||||
}
|
||||
raw_number = None
|
||||
|
||||
for column, canonical in header_map.items():
|
||||
cell = raw_row.get(column)
|
||||
if canonical == "number":
|
||||
raw_number = cell
|
||||
elif canonical in LORA_FIELDS:
|
||||
style[canonical] = parse_loras(cell, report)
|
||||
else:
|
||||
style[canonical] = clean_text(cell, report)
|
||||
|
||||
if row_is_empty(style):
|
||||
dropped_empty += 1
|
||||
continue
|
||||
|
||||
style["number"] = raw_number
|
||||
parsed.append(style)
|
||||
|
||||
if dropped_empty:
|
||||
report.fix("dropped empty/placeholder row", dropped_empty)
|
||||
|
||||
# --- numbering -------------------------------------------------------
|
||||
# Keep valid, unique, positive numbers; assign fresh ones to everything else.
|
||||
taken = set()
|
||||
needs_number = []
|
||||
|
||||
for style in parsed:
|
||||
raw = (style["number"] or "").strip() if isinstance(style["number"], str) else style["number"]
|
||||
value = None
|
||||
if raw not in (None, ""):
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
report.fix("replaced non-numeric style number")
|
||||
value = None
|
||||
|
||||
if value is None:
|
||||
needs_number.append(style)
|
||||
continue
|
||||
if value < 1:
|
||||
# the node skips any style with number <= 0
|
||||
report.fix("renumbered style with number <= 0")
|
||||
needs_number.append(style)
|
||||
continue
|
||||
if value in taken:
|
||||
# the node keys styles by number, so a duplicate silently wins
|
||||
report.fix("renumbered duplicate style number")
|
||||
needs_number.append(style)
|
||||
continue
|
||||
|
||||
style["number"] = value
|
||||
taken.add(value)
|
||||
|
||||
next_free = max(start_number, 1)
|
||||
for style in needs_number:
|
||||
while next_free in taken:
|
||||
next_free += 1
|
||||
style["number"] = next_free
|
||||
taken.add(next_free)
|
||||
|
||||
parsed.sort(key=lambda s: s["number"])
|
||||
|
||||
data = {"styles": [
|
||||
{
|
||||
"number": s["number"],
|
||||
"name": s["name"],
|
||||
"positive": s["positive"],
|
||||
"negative": s["negative"],
|
||||
"motion": s["motion"],
|
||||
"imageLoras": s["imageLoras"],
|
||||
"motionLoras": s["motionLoras"],
|
||||
"motionLorasHigh": s["motionLorasHigh"],
|
||||
"motionLorasLow": s["motionLorasLow"],
|
||||
}
|
||||
for s in parsed
|
||||
]}
|
||||
|
||||
return data, report
|
||||
|
||||
|
||||
def verify(json_path):
|
||||
"""Re-read the written file exactly the way the node does, and report the count."""
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
loadable = {}
|
||||
for style in data.get("styles", []):
|
||||
num = style.get("number")
|
||||
if num and num > 0:
|
||||
loadable[num] = style
|
||||
return loadable
|
||||
|
||||
|
||||
# --- file pickers --------------------------------------------------------
|
||||
|
||||
# Set when a dialog or console prompt was used, so the launcher knows to hold the
|
||||
# window open afterwards (a dropped CSV still needs a Save As dialog).
|
||||
USED_DIALOG = False
|
||||
|
||||
|
||||
def pick_files(csv_path, json_path):
|
||||
"""Fill in whichever paths weren't given, using dialogs when available."""
|
||||
global USED_DIALOG
|
||||
|
||||
if csv_path and json_path:
|
||||
return csv_path, json_path
|
||||
|
||||
USED_DIALOG = True
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
except Exception:
|
||||
# No GUI available - fall back to prompting on the console
|
||||
if not csv_path:
|
||||
csv_path = input("Path to source CSV: ").strip().strip('"').strip("'")
|
||||
if not json_path:
|
||||
json_path = input("Path for output JSON: ").strip().strip('"').strip("'")
|
||||
return csv_path, json_path
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes("-topmost", True)
|
||||
|
||||
# tools/ -> ComfyUI-RMAutomation -> custom_nodes -> ComfyUI root
|
||||
try:
|
||||
default_dir = str(Path(__file__).resolve().parents[3] / "styles")
|
||||
except IndexError:
|
||||
default_dir = ""
|
||||
if not default_dir or not Path(default_dir).is_dir():
|
||||
default_dir = os.getcwd()
|
||||
|
||||
if not csv_path:
|
||||
csv_path = filedialog.askopenfilename(
|
||||
title="Select the styles CSV to convert",
|
||||
initialdir=default_dir,
|
||||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||
)
|
||||
if not csv_path:
|
||||
root.destroy()
|
||||
raise SystemExit("Cancelled - no input file selected.")
|
||||
|
||||
if not json_path:
|
||||
suggested = Path(csv_path).stem + ".json"
|
||||
json_path = filedialog.asksaveasfilename(
|
||||
title="Save the converted styles JSON as",
|
||||
initialdir=str(Path(csv_path).parent),
|
||||
initialfile=suggested,
|
||||
defaultextension=".json",
|
||||
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
||||
)
|
||||
if not json_path:
|
||||
root.destroy()
|
||||
raise SystemExit("Cancelled - no output file selected.")
|
||||
|
||||
root.destroy()
|
||||
return csv_path, json_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a styles CSV into RM Styles JSON.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Run with no arguments to pick both files with a dialog.",
|
||||
)
|
||||
parser.add_argument("csv", nargs="?", help="source CSV (prompts if omitted)")
|
||||
parser.add_argument("json", nargs="?", help="destination JSON (prompts if omitted)")
|
||||
parser.add_argument("--encoding", help="force a source encoding instead of detecting")
|
||||
parser.add_argument("--start-number", type=int, default=1,
|
||||
help="first number to hand out when renumbering (default 1)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="report what would change without writing the file")
|
||||
args = parser.parse_args()
|
||||
|
||||
csv_path, json_path = pick_files(args.csv, args.json)
|
||||
|
||||
csv_path = os.path.expandvars(os.path.expanduser(str(csv_path).strip().strip('"').strip("'")))
|
||||
json_path = os.path.expandvars(os.path.expanduser(str(json_path).strip().strip('"').strip("'")))
|
||||
|
||||
if not Path(csv_path).is_file():
|
||||
raise SystemExit(f"Input file not found: {csv_path}")
|
||||
|
||||
data, report = convert(csv_path, args.encoding, args.start_number)
|
||||
|
||||
print()
|
||||
print("=" * 68)
|
||||
print(f" {Path(csv_path).name} -> {Path(json_path).name}")
|
||||
print("=" * 68)
|
||||
for note in report.notes:
|
||||
print(f" {note}")
|
||||
print()
|
||||
|
||||
if report.counts:
|
||||
print(" Repairs applied:")
|
||||
for label, count in sorted(report.counts.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {count:>5} {label}")
|
||||
else:
|
||||
print(" No repairs needed.")
|
||||
print()
|
||||
|
||||
styles = data["styles"]
|
||||
with_loras = sum(1 for s in styles if s["imageLoras"])
|
||||
with_names = sum(1 for s in styles if s["name"])
|
||||
numbers = [s["number"] for s in styles]
|
||||
|
||||
print(f" Styles converted : {len(styles)}")
|
||||
if numbers:
|
||||
print(f" Number range : {min(numbers)}-{max(numbers)}")
|
||||
print(f" With a name : {with_names}")
|
||||
print(f" With image loras : {with_loras}")
|
||||
print()
|
||||
|
||||
if args.dry_run:
|
||||
print(" --dry-run: nothing written.")
|
||||
return
|
||||
|
||||
out = Path(json_path)
|
||||
os.makedirs(out.parent, exist_ok=True)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
loadable = verify(out)
|
||||
print(f" Written to {out}")
|
||||
print(f" Verified: the node will load {len(loadable)} of {len(styles)} styles.")
|
||||
|
||||
if len(loadable) != len(styles):
|
||||
missing = [s["number"] for s in styles if s["number"] not in loadable]
|
||||
print(f" WARNING: unreadable style numbers: {missing[:20]}")
|
||||
sys.exit(1)
|
||||
print()
|
||||
print(" Point a RM Styles node's custom_json_path at that file.")
|
||||
print()
|
||||
|
||||
|
||||
def run():
|
||||
"""Entry point that survives being double-clicked.
|
||||
|
||||
Launched from Explorer the console closes the moment the process exits, which
|
||||
would take the repair summary - or a traceback - with it. Hold the window open
|
||||
whenever the run was interactive: no arguments at all, or any run that had to
|
||||
ask for a path (dragging a CSV onto the launcher still needs a Save As dialog).
|
||||
"""
|
||||
interactive = len(sys.argv) == 1
|
||||
code = 0
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
# argparse and this script's own error paths both exit this way; print
|
||||
# the message here so it isn't swallowed along with the window.
|
||||
if isinstance(exc.code, str):
|
||||
print(f"\n{exc.code}")
|
||||
code = 1
|
||||
elif exc.code:
|
||||
code = exc.code
|
||||
except KeyboardInterrupt:
|
||||
print("\nCancelled.")
|
||||
code = 1
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
code = 1
|
||||
|
||||
if interactive or USED_DIALOG:
|
||||
try:
|
||||
input("Press Enter to close this window...")
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user