Add prompt route smoke checks
This commit is contained in:
@@ -209,7 +209,8 @@ Improve later:
|
||||
Near-term:
|
||||
|
||||
- Add final row hygiene already done through `prompt_hygiene.py`.
|
||||
- Add a metadata invariant checker for rows before return.
|
||||
- Add a metadata smoke checker for representative rows through
|
||||
`tools/prompt_smoke.py`.
|
||||
- Normalize every row with one function before JSON serialization.
|
||||
|
||||
Medium-term:
|
||||
@@ -224,6 +225,8 @@ Near-term:
|
||||
- Normalize pair metadata with one helper.
|
||||
- Confirm pair prompts, captions, and soft/hard rows carry the same sanitized
|
||||
scene/camera/clothing fields.
|
||||
- Keep same-room pair continuity synchronized in both assembled prompt text and
|
||||
`hardcore_row.scene_text`; `tools/prompt_smoke.py` covers this drift case.
|
||||
|
||||
Medium-term:
|
||||
|
||||
@@ -236,8 +239,10 @@ Medium-term:
|
||||
Near-term:
|
||||
|
||||
- Add final prose hygiene already done through `prompt_hygiene.py`.
|
||||
- Add tests for close foreplay, POV oral, POV penetration, aftercare, manual
|
||||
stimulation, and camera-scene preservation.
|
||||
- Add smoke coverage through `tools/prompt_smoke.py` for metadata-driven Krea2
|
||||
formatting across built-in rows, hardcore rows, same-cast pairs, and POV
|
||||
pairs. Expand it next for close foreplay, POV penetration, and camera-scene
|
||||
preservation.
|
||||
|
||||
Medium-term:
|
||||
|
||||
@@ -249,7 +254,8 @@ Medium-term:
|
||||
Near-term:
|
||||
|
||||
- Add final tag hygiene already done through `prompt_hygiene.py`.
|
||||
- Add smoke tests for trigger preservation and duplicate tag removal.
|
||||
- Add smoke tests for trigger preservation and duplicate tag removal through
|
||||
`tools/prompt_smoke.py`.
|
||||
|
||||
Medium-term:
|
||||
|
||||
@@ -260,7 +266,8 @@ Medium-term:
|
||||
Near-term:
|
||||
|
||||
- Add final prose hygiene already done through `prompt_hygiene.py`.
|
||||
- Verify training captions keep trigger exactly once.
|
||||
- Verify training captions keep trigger exactly once through
|
||||
`tools/prompt_smoke.py`.
|
||||
|
||||
Medium-term:
|
||||
|
||||
@@ -293,7 +300,8 @@ Medium-term:
|
||||
|
||||
## Recommended Next Passes
|
||||
|
||||
1. Add metadata invariant checks and small smoke fixtures.
|
||||
1. Expand `tools/prompt_smoke.py` with camera-scene, explicit nude, and
|
||||
different-camera pair fixtures.
|
||||
2. Split Krea action/POV/clothing helpers into separate modules.
|
||||
3. Add category JSON pool reference validation to `tools/prompt_map_audit.py`.
|
||||
4. Extract scene-camera adapters from `prompt_builder.py`.
|
||||
|
||||
@@ -655,6 +655,28 @@ The script does not import ComfyUI. It parses the repo and prints:
|
||||
Use its output to spot doc drift after adding a new node or pool. If a new node
|
||||
or pool appears there but not in this map, update the relevant route table.
|
||||
|
||||
## Behavioral Smoke Helper
|
||||
|
||||
Route behavior should be checked when changing prompt generation, pair assembly,
|
||||
formatter metadata parsing, trigger handling, expression disabling, or scene
|
||||
continuity. Run:
|
||||
|
||||
```bash
|
||||
python tools/prompt_smoke.py
|
||||
```
|
||||
|
||||
The script does not import ComfyUI. It builds representative metadata rows and
|
||||
pair metadata through the core Python APIs, then verifies:
|
||||
|
||||
- generated rows keep prompt, negative prompt, scene, composition, action item,
|
||||
and role graph metadata populated;
|
||||
- Krea2, SDXL, and natural caption routes use metadata instead of text fallback;
|
||||
- SDXL and caption trigger handling keeps one trigger;
|
||||
- negative prompts do not duplicate comma-list items;
|
||||
- same-room Insta/OF continuity keeps prompt text and `hardcore_row.scene_text`
|
||||
synchronized;
|
||||
- expression-disabled rows do not fall back to generated expression text.
|
||||
|
||||
## Editing Cheatsheet
|
||||
|
||||
| Symptom | First file/function to inspect |
|
||||
|
||||
+4
-1
@@ -8661,6 +8661,9 @@ def build_insta_of_pair(
|
||||
soft_row = _apply_coworking_composition(soft_row, soft_subject_kind)
|
||||
hard_row = _apply_coworking_composition(hard_row, hard_subject_kind)
|
||||
hard_scene = soft_row["scene_text"] if options["continuity"] == "same_creator_same_room" else hard_row["scene_text"]
|
||||
if hard_scene != hard_row.get("scene_text"):
|
||||
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
||||
hard_row["scene_text"] = hard_scene
|
||||
hard_composition = _coworking_composition_prompt(hard_scene, hard_row["composition"], hard_subject_kind)
|
||||
if hard_composition != hard_row["composition"]:
|
||||
hard_row["source_composition"] = hard_row.get("source_composition") or hard_row["composition"]
|
||||
@@ -8758,7 +8761,7 @@ def build_insta_of_pair(
|
||||
if "body is fully exposed" in hard_clothing_state.lower() or "bare skin unobstructed" in hard_clothing_state.lower():
|
||||
hard_scene = _body_exposure_scene_text(hard_scene)
|
||||
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
||||
hard_row["scene_text"] = _body_exposure_scene_text(hard_row.get("scene_text", ""))
|
||||
hard_row["scene_text"] = hard_scene
|
||||
hard_detail_density = options["hardcore_detail_density"]
|
||||
hard_detail_directive = {
|
||||
"compact": "Use one compact position-first sexual action sentence; avoid repeated aftermath wording. ",
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke-test core prompt routes without importing ComfyUI.
|
||||
|
||||
The checks here are intentionally lightweight invariants, not golden prompt
|
||||
snapshots. They prove that representative rows still carry structured metadata
|
||||
and that the Krea2, SDXL, and caption formatter paths consume metadata instead
|
||||
of silently falling back to raw text parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import caption_naturalizer # noqa: E402
|
||||
import krea_formatter # noqa: E402
|
||||
import prompt_builder as pb # noqa: E402
|
||||
import sdxl_formatter # noqa: E402
|
||||
|
||||
|
||||
Trigger = "sxcppnl7"
|
||||
SdxlTrigger = "mythp0rt"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmokeReport:
|
||||
passed: list[str] = field(default_factory=list)
|
||||
failed: list[str] = field(default_factory=list)
|
||||
|
||||
def ok(self, name: str) -> None:
|
||||
self.passed.append(name)
|
||||
print(f"PASS {name}")
|
||||
|
||||
def fail(self, name: str, message: str) -> None:
|
||||
detail = f"{name}: {message}"
|
||||
self.failed.append(detail)
|
||||
print(f"FAIL {detail}")
|
||||
|
||||
|
||||
def _clean_key(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def _expect(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _expect_text(name: str, value: Any, min_len: int = 8) -> str:
|
||||
text = str(value or "").strip()
|
||||
_expect(len(text) >= min_len, f"{name} is empty or too short")
|
||||
_expect("None" not in text, f"{name} leaked None")
|
||||
_expect(" " not in text, f"{name} has repeated spaces")
|
||||
_expect(" ," not in text and " ." not in text, f"{name} has bad punctuation spacing")
|
||||
return text
|
||||
|
||||
|
||||
def _expect_no_duplicate_comma_items(name: str, value: Any) -> None:
|
||||
items = [_clean_key(part) for part in str(value or "").split(",")]
|
||||
items = [part for part in items if part]
|
||||
duplicates = sorted({part for part in items if items.count(part) > 1})
|
||||
_expect(not duplicates, f"{name} has duplicate comma items: {duplicates[:5]}")
|
||||
|
||||
|
||||
def _trigger_count(text: str, trigger: str) -> int:
|
||||
return len(re.findall(rf"(?<![a-z0-9_]){re.escape(trigger)}(?![a-z0-9_])", text, flags=re.IGNORECASE))
|
||||
|
||||
|
||||
def _expect_trigger_once(name: str, value: Any, trigger: str) -> None:
|
||||
text = str(value or "")
|
||||
count = _trigger_count(text, trigger)
|
||||
_expect(count == 1, f"{name} should contain trigger {trigger!r} exactly once, got {count}")
|
||||
|
||||
|
||||
def _expect_row_base(row: dict[str, Any], name: str) -> None:
|
||||
_expect(isinstance(row, dict), f"{name} did not return a metadata row")
|
||||
_expect_text(f"{name}.prompt", row.get("prompt"), 20)
|
||||
_expect_text(f"{name}.negative_prompt", row.get("negative_prompt"), 8)
|
||||
_expect_no_duplicate_comma_items(f"{name}.negative_prompt", row.get("negative_prompt"))
|
||||
_expect(json.loads(_json(row)) == row, f"{name} is not JSON-stable")
|
||||
|
||||
|
||||
def _expect_custom_row(row: dict[str, Any], name: str) -> None:
|
||||
_expect_row_base(row, name)
|
||||
_expect(row.get("source") == "json_category", f"{name}.source should be json_category")
|
||||
_expect_text(f"{name}.item", row.get("item"), 8)
|
||||
_expect_text(f"{name}.scene_text", row.get("scene_text"), 8)
|
||||
_expect_text(f"{name}.composition", row.get("composition"), 8)
|
||||
_expect_text(f"{name}.role_graph", row.get("source_role_graph") or row.get("role_graph"), 8)
|
||||
_expect(isinstance(row.get("item_axis_values"), dict), f"{name}.item_axis_values missing")
|
||||
|
||||
|
||||
def _expect_formatter_outputs(row: dict[str, Any], name: str, *, target: str = "auto") -> None:
|
||||
metadata = _json(row)
|
||||
krea = krea_formatter.format_krea2_prompt("", metadata_json=metadata, target=target)
|
||||
_expect("metadata" in krea.get("method", ""), f"{name}.krea did not use metadata: {krea.get('method')}")
|
||||
_expect_text(f"{name}.krea_prompt", krea.get("krea_prompt"), 20)
|
||||
_expect_no_duplicate_comma_items(f"{name}.krea_negative", krea.get("negative_prompt"))
|
||||
|
||||
sdxl = sdxl_formatter.format_sdxl_prompt(
|
||||
"",
|
||||
metadata_json=metadata,
|
||||
target=target,
|
||||
trigger=SdxlTrigger,
|
||||
prepend_trigger=True,
|
||||
)
|
||||
_expect("metadata" in sdxl.get("method", ""), f"{name}.sdxl did not use metadata: {sdxl.get('method')}")
|
||||
_expect_text(f"{name}.sdxl_prompt", sdxl.get("sdxl_prompt"), 20)
|
||||
_expect_trigger_once(f"{name}.sdxl_prompt", sdxl.get("sdxl_prompt"), SdxlTrigger)
|
||||
_expect_no_duplicate_comma_items(f"{name}.sdxl_negative", sdxl.get("negative_prompt"))
|
||||
|
||||
caption, method = caption_naturalizer.naturalize_caption(
|
||||
"",
|
||||
metadata_json=metadata,
|
||||
trigger=Trigger,
|
||||
include_trigger=True,
|
||||
)
|
||||
_expect("metadata" in method, f"{name}.caption did not use metadata: {method}")
|
||||
_expect_text(f"{name}.caption", caption, 20)
|
||||
_expect_trigger_once(f"{name}.caption", caption, Trigger)
|
||||
|
||||
|
||||
def _character_cast(*, pov_man: bool = False) -> str:
|
||||
cast = pb.build_character_slot_json(
|
||||
subject_type="woman",
|
||||
label="A",
|
||||
age="25-year-old adult",
|
||||
ethnicity="western_european",
|
||||
figure="balanced",
|
||||
body="slim",
|
||||
descriptor_detail="full",
|
||||
expression_intensity=0.65,
|
||||
softcore_expression_intensity=0.45,
|
||||
hardcore_expression_intensity=0.85,
|
||||
)["character_cast"]
|
||||
return pb.build_character_slot_json(
|
||||
subject_type="man",
|
||||
label="A",
|
||||
age="40-year-old adult",
|
||||
ethnicity="western_european",
|
||||
figure="balanced",
|
||||
body="average",
|
||||
descriptor_detail="compact",
|
||||
expression_intensity=0.55,
|
||||
softcore_expression_intensity=0.35,
|
||||
hardcore_expression_intensity=0.75,
|
||||
presence_mode="pov" if pov_man else "visible",
|
||||
character_cast=cast,
|
||||
)["character_cast"]
|
||||
|
||||
|
||||
def _action_filter(focus: str) -> str:
|
||||
kwargs = {
|
||||
"allow_toys": False,
|
||||
"allow_double": False,
|
||||
"allow_penetration": focus in ("penetration_only", "keep_pool"),
|
||||
"allow_foreplay": focus in ("foreplay_only", "keep_pool"),
|
||||
"allow_interaction": focus in ("interaction_only", "keep_pool"),
|
||||
"allow_manual": focus in ("manual_only", "keep_pool"),
|
||||
"allow_oral": focus in ("oral_only", "keep_pool"),
|
||||
"allow_outercourse": focus in ("outercourse_only", "keep_pool"),
|
||||
"allow_anal": focus in ("anal_only", "keep_pool"),
|
||||
"allow_climax": focus in ("climax_only", "keep_pool"),
|
||||
}
|
||||
return pb.build_hardcore_action_filter_json(focus=focus, **kwargs)
|
||||
|
||||
|
||||
def _prompt_row(
|
||||
*,
|
||||
name: str,
|
||||
category: str,
|
||||
subcategory: str,
|
||||
seed: int,
|
||||
character_cast: str = "",
|
||||
women_count: int = 1,
|
||||
men_count: int = 1,
|
||||
hardcore_position_config: str = "",
|
||||
) -> dict[str, Any]:
|
||||
row = pb.build_prompt(
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
row_number=1,
|
||||
start_index=1,
|
||||
seed=seed,
|
||||
clothing="random",
|
||||
ethnicity="any",
|
||||
poses="random",
|
||||
backside_bias=0.35,
|
||||
figure="random",
|
||||
no_plus_women=False,
|
||||
no_black=False,
|
||||
minimal_clothing_ratio=0.5,
|
||||
standard_pose_ratio=0.5,
|
||||
trigger=Trigger,
|
||||
prepend_trigger_to_prompt=True,
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
character_cast=character_cast,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
expression_enabled=True,
|
||||
expression_intensity=0.6,
|
||||
hardcore_position_config=hardcore_position_config,
|
||||
)
|
||||
_expect_row_base(row, name)
|
||||
return row
|
||||
|
||||
|
||||
def smoke_builtin_single() -> None:
|
||||
row = _prompt_row(name="builtin_single_woman", category="woman", subcategory="random", seed=1001, men_count=0)
|
||||
_expect(row.get("source") == "built_in_generator", "builtin row should come from built-in generator")
|
||||
_expect_trigger_once("builtin_single_woman.prompt", row.get("prompt"), Trigger)
|
||||
_expect_formatter_outputs(row, "builtin_single_woman", target="single")
|
||||
|
||||
|
||||
def smoke_hardcore_category_routes() -> None:
|
||||
cast = _character_cast()
|
||||
cases = [
|
||||
("hardcore_penetration", "Penetrative sex", "penetration_only"),
|
||||
("hardcore_oral", "Oral sex", "oral_only"),
|
||||
("hardcore_manual", "Manual stimulation", "manual_only"),
|
||||
("hardcore_outercourse", "Outercourse and genital teasing", "outercourse_only"),
|
||||
("hardcore_foreplay", "Foreplay and teasing", "foreplay_only"),
|
||||
("hardcore_aftercare", "Aftercare and cleanup", "interaction_only"),
|
||||
]
|
||||
for index, (name, subcategory, focus) in enumerate(cases, start=1101):
|
||||
row = _prompt_row(
|
||||
name=name,
|
||||
category="Hardcore sexual poses",
|
||||
subcategory=subcategory,
|
||||
seed=index,
|
||||
character_cast=cast,
|
||||
women_count=1,
|
||||
men_count=1,
|
||||
hardcore_position_config=_action_filter(focus),
|
||||
)
|
||||
_expect_custom_row(row, name)
|
||||
_expect(row.get("subject_type") == "configured_cast", f"{name} should use configured cast")
|
||||
_expect_formatter_outputs(row, name, target="single")
|
||||
|
||||
|
||||
def _insta_options(**overrides: Any) -> str:
|
||||
options = pb.build_insta_of_options_json(
|
||||
softcore_cast="same_as_hardcore",
|
||||
hardcore_cast="couple",
|
||||
hardcore_women_count=1,
|
||||
hardcore_men_count=1,
|
||||
softcore_level="lingerie_tease",
|
||||
hardcore_level="hardcore",
|
||||
platform_style="hybrid",
|
||||
continuity="same_creator_same_room",
|
||||
hardcore_clothing_continuity="explicit_nude",
|
||||
softcore_camera_mode="standard",
|
||||
hardcore_camera_mode="standard",
|
||||
camera_detail="compact",
|
||||
hardcore_detail_density="balanced",
|
||||
)
|
||||
data = json.loads(options)
|
||||
data.update(overrides)
|
||||
return _json(data)
|
||||
|
||||
|
||||
def _expect_pair(pair: dict[str, Any], name: str) -> None:
|
||||
_expect(pair.get("mode") == "Insta/OF", f"{name}.mode should be Insta/OF")
|
||||
_expect_row_base(pair.get("softcore_row") or {}, f"{name}.softcore_row")
|
||||
_expect_custom_row(pair.get("hardcore_row") or {}, f"{name}.hardcore_row")
|
||||
_expect_text(f"{name}.softcore_prompt", pair.get("softcore_prompt"), 20)
|
||||
_expect_text(f"{name}.hardcore_prompt", pair.get("hardcore_prompt"), 20)
|
||||
_expect_no_duplicate_comma_items(f"{name}.softcore_negative", pair.get("softcore_negative_prompt"))
|
||||
_expect_no_duplicate_comma_items(f"{name}.hardcore_negative", pair.get("hardcore_negative_prompt"))
|
||||
_expect_formatter_outputs(pair, name, target="softcore")
|
||||
_expect_formatter_outputs(pair, f"{name}.hardcore", target="hardcore")
|
||||
|
||||
|
||||
def smoke_insta_pair() -> None:
|
||||
pair = pb.build_insta_of_pair(
|
||||
row_number=1,
|
||||
start_index=1,
|
||||
seed=2101,
|
||||
ethnicity="any",
|
||||
figure="random",
|
||||
no_plus_women=False,
|
||||
no_black=False,
|
||||
trigger=Trigger,
|
||||
prepend_trigger_to_prompt=True,
|
||||
options_json=_insta_options(),
|
||||
character_cast=_character_cast(),
|
||||
hardcore_position_config=_action_filter("penetration_only"),
|
||||
)
|
||||
_expect_pair(pair, "insta_pair_same_cast")
|
||||
_expect(pair["softcore_row"].get("scene_text") == pair["hardcore_row"].get("scene_text"), "pair scene continuity broke")
|
||||
|
||||
|
||||
def smoke_insta_pair_pov() -> None:
|
||||
pair = pb.build_insta_of_pair(
|
||||
row_number=1,
|
||||
start_index=1,
|
||||
seed=2201,
|
||||
ethnicity="any",
|
||||
figure="random",
|
||||
no_plus_women=False,
|
||||
no_black=False,
|
||||
trigger=Trigger,
|
||||
prepend_trigger_to_prompt=True,
|
||||
options_json=_insta_options(),
|
||||
character_cast=_character_cast(pov_man=True),
|
||||
hardcore_position_config=_action_filter("oral_only"),
|
||||
)
|
||||
_expect_pair(pair, "insta_pair_pov_man")
|
||||
pov_labels = pair.get("pov_character_labels") or []
|
||||
_expect("Man A" in pov_labels, "pair POV labels should include Man A")
|
||||
hard_row = pair.get("hardcore_row") or {}
|
||||
_expect("Man A" in (hard_row.get("pov_character_labels") or []), "hard row POV labels should include Man A")
|
||||
krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore")
|
||||
prompt = krea.get("krea_prompt") or ""
|
||||
_expect("viewer" in prompt.lower(), "POV Krea prompt should mention viewer perspective")
|
||||
|
||||
|
||||
def smoke_no_expression_fallback() -> None:
|
||||
cast = pb.build_character_slot_json(
|
||||
subject_type="woman",
|
||||
label="A",
|
||||
age="25-year-old adult",
|
||||
ethnicity="western_european",
|
||||
body="slim",
|
||||
descriptor_detail="full",
|
||||
expression_enabled=False,
|
||||
)["character_cast"]
|
||||
row = _prompt_row(
|
||||
name="hardcore_expression_disabled",
|
||||
category="Hardcore sexual poses",
|
||||
subcategory="Penetrative sex",
|
||||
seed=2301,
|
||||
character_cast=cast,
|
||||
women_count=1,
|
||||
men_count=1,
|
||||
hardcore_position_config=_action_filter("penetration_only"),
|
||||
)
|
||||
_expect_custom_row(row, "hardcore_expression_disabled")
|
||||
_expect(not row.get("expression"), "expression should stay disabled without fallback")
|
||||
_expect("Facial expressions:" not in row.get("prompt", ""), "disabled expression leaked into prompt")
|
||||
_expect_formatter_outputs(row, "hardcore_expression_disabled", target="single")
|
||||
|
||||
|
||||
SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [
|
||||
("builtin_single_woman", smoke_builtin_single),
|
||||
("hardcore_category_routes", smoke_hardcore_category_routes),
|
||||
("insta_pair_same_cast", smoke_insta_pair),
|
||||
("insta_pair_pov_man", smoke_insta_pair_pov),
|
||||
("expression_disabled", smoke_no_expression_fallback),
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--case",
|
||||
choices=[name for name, _func in SMOKE_CASES],
|
||||
action="append",
|
||||
help="Run only the named smoke case. Can be passed multiple times.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
selected = set(args.case or [])
|
||||
report = SmokeReport()
|
||||
for name, func in SMOKE_CASES:
|
||||
if selected and name not in selected:
|
||||
continue
|
||||
try:
|
||||
func()
|
||||
except Exception as exc: # noqa: BLE001 - report all smoke failures uniformly.
|
||||
report.fail(name, str(exc))
|
||||
else:
|
||||
report.ok(name)
|
||||
print(f"\nSummary: {len(report.passed)} passed, {len(report.failed)} failed")
|
||||
if report.failed:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user