feat: rebuild evidence-first application workflow
This commit is contained in:
@@ -1,198 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Count rendered characters in LaTeX resume/CV bullets.
|
||||
Strips LaTeX markup to show what a reader actually sees on the page.
|
||||
"""Report readable length diagnostics for LaTeX resume bullets.
|
||||
|
||||
This helper has no target bands and no page-fill logic. Rendered character and
|
||||
word counts are observations; they do not determine whether a bullet is good.
|
||||
|
||||
Usage:
|
||||
python3 char_count.py "\\textbf{DFT} analysis of \\ce{TiO2} surfaces"
|
||||
echo "bullet text" | python3 char_count.py
|
||||
python3 char_count.py -f cv output/file.tex
|
||||
python3 char_count.py --raw "bullet text" # just the number
|
||||
python resume_builder/helpers/char_count.py "\\item Built ..."
|
||||
python resume_builder/helpers/char_count.py output/Role/resume.tex
|
||||
python resume_builder/helpers/char_count.py --raw "bullet text"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def strip_latex(text):
|
||||
"""Strip LaTeX markup to get rendered text."""
|
||||
# Remove \item[] prefix
|
||||
text = re.sub(r'\\item\s*(\[\s*\])?\s*', '', text)
|
||||
# \href{url}{text} -> text
|
||||
text = re.sub(r'\\href\{[^}]*\}\{([^}]*)\}', r'\1', text)
|
||||
# \textbf{X} -> X
|
||||
text = re.sub(r'\\textbf\{([^}]*)\}', r'\1', text)
|
||||
# \textit{X} -> X
|
||||
text = re.sub(r'\\textit\{([^}]*)\}', r'\1', text)
|
||||
# \underline{X} -> X
|
||||
text = re.sub(r'\\underline\{([^}]*)\}', r'\1', text)
|
||||
# \emph{X} -> X
|
||||
text = re.sub(r'\\emph\{([^}]*)\}', r'\1', text)
|
||||
# \ce{X} -> X (subscript digits still count as 1 char each)
|
||||
text = re.sub(r'\\ce\{([^}]*)\}', r'\1', text)
|
||||
# Greek letters -> 1 char each
|
||||
greeks = [
|
||||
'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
|
||||
'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'pi', 'rho', 'sigma',
|
||||
'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega',
|
||||
'Alpha', 'Beta', 'Gamma', 'Delta', 'Theta', 'Lambda', 'Sigma',
|
||||
'Phi', 'Psi', 'Omega',
|
||||
]
|
||||
for g in greeks:
|
||||
text = text.replace(f'$\\{g}$', 'G')
|
||||
text = text.replace(f'\\{g}', 'G')
|
||||
# $^\circ$ -> 1 char
|
||||
text = re.sub(r'\$\^\{?\\circ\}?\$', 'D', text)
|
||||
# $^\dagger$ -> 1 char
|
||||
text = re.sub(r'\$\^\{?\\dagger\}?\$', 'D', text)
|
||||
# Superscripts: $^{2}$ or $^2$ -> content
|
||||
text = re.sub(r'\$\^\{([^}]*)\}\$', r'\1', text)
|
||||
text = re.sub(r'\$\^(.)\$', r'\1', text)
|
||||
# Subscripts: $_{2}$ or $_2$ -> content
|
||||
text = re.sub(r'\$_\{([^}]*)\}\$', r'\1', text)
|
||||
text = re.sub(r'\$_(.)\$', r'\1', text)
|
||||
# \sim -> 1 char (~)
|
||||
text = text.replace('$\\sim$', '~')
|
||||
text = text.replace('\\sim', '~')
|
||||
text = text.replace('\\textasciitilde', '~')
|
||||
# $<$ $>$ -> 1 char
|
||||
text = re.sub(r'\$([<>])\$', r'\1', text)
|
||||
# --- -> em-dash (1 char but ~2x wide)
|
||||
text = text.replace('---', '\u2014')
|
||||
# -- -> en-dash (1 char)
|
||||
text = text.replace('--', '\u2013')
|
||||
# Remove remaining $ (math mode delimiters)
|
||||
text = text.replace('$', '')
|
||||
# Remove remaining \commands
|
||||
text = re.sub(r'\\[a-zA-Z]+\s*', '', text)
|
||||
# Remove remaining braces
|
||||
text = text.replace('{', '').replace('}', '')
|
||||
# Collapse multiple spaces
|
||||
text = re.sub(r' +', ' ', text)
|
||||
return text.strip()
|
||||
def strip_latex(text: str) -> str:
|
||||
"""Approximate reader-visible text without imposing layout targets."""
|
||||
text = re.sub(r"\\item\s*(\[\s*\])?\s*", "", text)
|
||||
text = re.sub(r"\\href\{[^}]*\}\{([^}]*)\}", r"\1", text)
|
||||
for command in ("textbf", "textit", "underline", "emph", "ce"):
|
||||
text = re.sub(rf"\\{command}\{{([^}}]*)\}}", r"\1", text)
|
||||
text = re.sub(r"\$\^\{?\\circ\}?\$", "°", text)
|
||||
text = re.sub(r"\$\^\{([^}]*)\}\$", r"\1", text)
|
||||
text = re.sub(r"\$\^(.)\$", r"\1", text)
|
||||
text = text.replace("$\\sim$", "~").replace("\\sim", "~")
|
||||
text = text.replace("---", "—").replace("--", "–")
|
||||
text = text.replace("\\&", "&").replace("\\%", "%")
|
||||
text = re.sub(r"\\[A-Za-z]+\s*", "", text)
|
||||
text = text.replace("$", "").replace("{", "").replace("}", "")
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def count_bold_chars(text):
|
||||
"""Count characters inside \\textbf{} commands."""
|
||||
return sum(len(m) for m in re.findall(r'\\textbf\{([^}]*)\}', text))
|
||||
|
||||
|
||||
def count_em_dashes(text):
|
||||
"""Count em-dashes (---) which render ~2x wide."""
|
||||
return len(re.findall(r'---', text))
|
||||
|
||||
|
||||
def classify_bullet(char_count, bold_chars, fmt):
|
||||
"""Classify bullet into variant and check limits."""
|
||||
if fmt == 'resume':
|
||||
base = 119
|
||||
penalty = 0.5
|
||||
tiers = [
|
||||
('1L', 105, 111, 117, None),
|
||||
('2L', 189, 205, 218, 78),
|
||||
]
|
||||
else:
|
||||
base = 91
|
||||
penalty = 0.25
|
||||
tiers = [
|
||||
('1L', 88, 93, 101, None),
|
||||
('2L', 168, 182, 190, 65),
|
||||
('3L', 250, 268, 280, 65),
|
||||
]
|
||||
|
||||
effective = base - (penalty * bold_chars)
|
||||
|
||||
for variant, lo, hi, hard_max, orphan in tiers:
|
||||
if char_count <= hard_max:
|
||||
if char_count < lo:
|
||||
status = 'SHORT'
|
||||
elif char_count <= hi:
|
||||
status = 'OK'
|
||||
else:
|
||||
status = 'NEAR MAX'
|
||||
return variant, status, lo, hi, hard_max, orphan, effective
|
||||
|
||||
return 'OVER', 'OVER LIMIT', 0, 0, 0, None, effective
|
||||
|
||||
|
||||
def format_one(raw, fmt):
|
||||
"""Format analysis for a single bullet."""
|
||||
rendered = strip_latex(raw)
|
||||
n = len(rendered)
|
||||
bold = count_bold_chars(raw)
|
||||
em = count_em_dashes(raw)
|
||||
|
||||
variant, status, lo, hi, hard_max, orphan, eff = classify_bullet(n, bold, fmt)
|
||||
|
||||
parts = [f" {n:3d} chars | {variant} {fmt.upper()} | {status} (target {lo}-{hi}, max {hard_max})"]
|
||||
if bold:
|
||||
parts.append(f" Bold: {bold} chars -> effective limit/line: {eff:.0f}")
|
||||
if em:
|
||||
parts.append(f" Em-dashes: {em} (each ~2x wide, budget +{em} extra)")
|
||||
parts.append(f" Rendered: {rendered}")
|
||||
return '\n'.join(parts), variant
|
||||
|
||||
|
||||
def extract_items(text):
|
||||
"""Extract \\item lines from .tex source."""
|
||||
items = []
|
||||
for line in text.split('\n'):
|
||||
s = line.strip()
|
||||
if s.startswith('\\item'):
|
||||
items.append(s)
|
||||
def extract_items(source: str) -> list[str]:
|
||||
"""Extract item bodies, including wrapped source lines."""
|
||||
items: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in source.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("\\item"):
|
||||
if current:
|
||||
items.append(" ".join(current))
|
||||
current = [stripped]
|
||||
elif current and not stripped.startswith("\\end{itemize}"):
|
||||
current.append(stripped)
|
||||
elif current:
|
||||
items.append(" ".join(current))
|
||||
current = []
|
||||
if current:
|
||||
items.append(" ".join(current))
|
||||
return items
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Count rendered characters in LaTeX resume/CV bullets')
|
||||
parser.add_argument('input', nargs='?',
|
||||
help='Bullet text or .tex file path')
|
||||
parser.add_argument('-f', '--format', choices=['resume', 'cv'],
|
||||
default='resume', help='Document format (default: resume)')
|
||||
parser.add_argument('--raw', action='store_true',
|
||||
help='Output only char count (for scripting)')
|
||||
def diagnose(raw: str) -> tuple[str, int, int, int]:
|
||||
rendered = strip_latex(raw)
|
||||
words = re.findall(r"\b[\w+#./-]+\b", rendered, flags=re.UNICODE)
|
||||
clauses = len(re.findall(r"[;:]|\s—\s", rendered)) + 1 if rendered else 0
|
||||
return rendered, len(rendered), len(words), clauses
|
||||
|
||||
|
||||
def format_report(raw: str) -> str:
|
||||
rendered, characters, words, clauses = diagnose(raw)
|
||||
note = ""
|
||||
if words > 35 or clauses > 3:
|
||||
note = "\n Review: potentially dense; check whether it contains multiple accomplishments."
|
||||
return (
|
||||
f" {words} words | {characters} rendered characters | {clauses} clause(s)\n"
|
||||
f" Rendered: {rendered}{note}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Report LaTeX bullet length diagnostics")
|
||||
parser.add_argument("input", nargs="?", help="Bullet text or .tex file")
|
||||
parser.add_argument("--raw", action="store_true", help="Output rendered character count only")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.input and args.input.endswith('.tex'):
|
||||
with open(args.input) as f:
|
||||
items = extract_items(f.read())
|
||||
if args.input and args.input.endswith(".tex") and Path(args.input).is_file():
|
||||
items = extract_items(Path(args.input).read_text(encoding="utf-8"))
|
||||
if not items:
|
||||
print("No \\item lines found.")
|
||||
print("No \\item bullets found.")
|
||||
return
|
||||
total_lines = 0
|
||||
print(f"Found {len(items)} bullets ({args.format} format):\n")
|
||||
for i, item in enumerate(items, 1):
|
||||
for index, item in enumerate(items, 1):
|
||||
if args.raw:
|
||||
print(len(strip_latex(item)))
|
||||
print(diagnose(item)[1])
|
||||
else:
|
||||
report, variant = format_one(item, args.format)
|
||||
print(f"Bullet {i}:")
|
||||
print(report)
|
||||
print()
|
||||
if variant not in ('OVER',):
|
||||
total_lines += int(variant[0])
|
||||
if not args.raw:
|
||||
print(f"Total rendered lines: {total_lines}")
|
||||
elif args.input:
|
||||
if args.raw:
|
||||
print(len(strip_latex(args.input)))
|
||||
else:
|
||||
report, _ = format_one(args.input, args.format)
|
||||
print(report)
|
||||
else:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if args.raw:
|
||||
print(len(strip_latex(line)))
|
||||
else:
|
||||
report, _ = format_one(line, args.format)
|
||||
print(report)
|
||||
print()
|
||||
print(f"Bullet {index}:\n{format_report(item)}\n")
|
||||
return
|
||||
|
||||
raw = args.input if args.input is not None else sys.stdin.read().strip()
|
||||
if not raw:
|
||||
parser.error("provide bullet text, a .tex file, or stdin")
|
||||
print(diagnose(raw)[1] if args.raw else format_report(raw))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record and summarize the controlled ten-application cohort."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TRACKER = ROOT / "job_scout" / "state" / "application_cohort.json"
|
||||
FIT_CLASSES = ("core", "adjacent", "stretch")
|
||||
CHANNELS = ("strong", "moderate", "weak")
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
return json.loads(TRACKER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save(data: dict) -> None:
|
||||
TRACKER.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def summary(data: dict) -> None:
|
||||
applications = data["applications"]
|
||||
print(f"Cohort: {data['cohort_id']} ({len(applications)}/10 applications)")
|
||||
for fit_class in FIT_CLASSES:
|
||||
count = sum(item["fit_class"] == fit_class for item in applications)
|
||||
target = data["target_mix"][fit_class]
|
||||
print(f" {fit_class}: {count}/{target}")
|
||||
for channel in CHANNELS:
|
||||
count = sum(item["channel"] == channel for item in applications)
|
||||
print(f" channel {channel}: {count}")
|
||||
outcomes: dict[str, int] = {}
|
||||
for item in applications:
|
||||
outcome = item.get("outcome", "open")
|
||||
outcomes[outcome] = outcomes.get(outcome, 0) + 1
|
||||
print(" outcomes: " + ", ".join(f"{key}={value}" for key, value in sorted(outcomes.items())))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("summary")
|
||||
add = subparsers.add_parser("add")
|
||||
add.add_argument("--company", required=True)
|
||||
add.add_argument("--role", required=True)
|
||||
add.add_argument("--date", required=True)
|
||||
add.add_argument("--fit-class", choices=FIT_CLASSES, required=True)
|
||||
add.add_argument("--evidence-fit", type=float, required=True)
|
||||
add.add_argument("--channel", choices=CHANNELS, required=True)
|
||||
add.add_argument("--hard-gate", choices=("pass", "fail"), required=True)
|
||||
add.add_argument("--outcome", default="open")
|
||||
|
||||
args = parser.parse_args()
|
||||
data = load()
|
||||
if args.command == "add":
|
||||
if len(data["applications"]) >= 10:
|
||||
raise SystemExit("cohort already contains ten applications")
|
||||
if args.hard_gate == "fail" and args.fit_class != "stretch":
|
||||
raise SystemExit("a hard-gate failure must be recorded as stretch")
|
||||
data["applications"].append(
|
||||
{
|
||||
"company": args.company,
|
||||
"role": args.role,
|
||||
"application_date": args.date,
|
||||
"fit_class": args.fit_class,
|
||||
"evidence_fit": args.evidence_fit,
|
||||
"hard_gate": args.hard_gate,
|
||||
"channel": args.channel,
|
||||
"outcome": args.outcome,
|
||||
}
|
||||
)
|
||||
save(data)
|
||||
summary(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate canonical resume data, generation sources, and generated documents.
|
||||
|
||||
Usage:
|
||||
python resume_builder/helpers/validate_resume_system.py
|
||||
python resume_builder/helpers/validate_resume_system.py --document output/Acme/resume.tex
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CLAIMS_PATH = ROOT / "resume_builder" / "canonical" / "claims.json"
|
||||
HISTORY_PATH = ROOT / "resume_builder" / "canonical" / "historical_outputs.json"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def canonical_checks(claims: dict, history: dict) -> tuple[list[str], list[str]]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if claims.get("schema_version") != 1:
|
||||
errors.append("claims.json schema_version must be 1")
|
||||
if history.get("schema_version") != 1:
|
||||
errors.append("historical_outputs.json schema_version must be 1")
|
||||
|
||||
for key in ("identity", "education", "employment", "claims", "skills"):
|
||||
if not claims.get(key):
|
||||
errors.append(f"claims.json missing non-empty {key}")
|
||||
|
||||
for collection in ("education", "employment", "claims"):
|
||||
ids = [item.get("id") for item in claims.get(collection, [])]
|
||||
if None in ids:
|
||||
errors.append(f"{collection} contains an item without id")
|
||||
duplicates = sorted({item for item in ids if ids.count(item) > 1})
|
||||
if duplicates:
|
||||
errors.append(f"duplicate {collection} ids: {', '.join(duplicates)}")
|
||||
|
||||
skills = claims.get("skills", [])
|
||||
skill_names = [item.get("name") for item in skills]
|
||||
duplicates = sorted({item for item in skill_names if skill_names.count(item) > 1})
|
||||
if duplicates:
|
||||
errors.append(f"duplicate skills: {', '.join(duplicates)}")
|
||||
for skill in skills:
|
||||
if skill.get("output") not in {
|
||||
"allowed",
|
||||
"allowed-with-context",
|
||||
"certification-context-only",
|
||||
"forbidden",
|
||||
}:
|
||||
errors.append(f"invalid output policy for skill {skill.get('name')}")
|
||||
|
||||
config = read_text(ROOT / "config.md")
|
||||
bundle_names = re.findall(r"\|\s*(bundle_[a-z0-9_]+\.md)\s*\|", config)
|
||||
for bundle_name in bundle_names:
|
||||
if not (ROOT / "resume_builder" / "bundles" / bundle_name).exists():
|
||||
errors.append(f"configured bundle does not exist: {bundle_name}")
|
||||
|
||||
expected_files = [
|
||||
ROOT / "resume_builder" / "templates" / "resume_template.tex",
|
||||
ROOT / "resume_builder" / "reference" / "resume_reference.md",
|
||||
ROOT / "resume_builder" / "reference" / "critique_framework.md",
|
||||
]
|
||||
for path in expected_files:
|
||||
if not path.exists():
|
||||
errors.append(f"required workflow file missing: {path.relative_to(ROOT)}")
|
||||
|
||||
history_folders: list[str] = []
|
||||
for key in ("unsafe_do_not_reuse", "historical_revalidate"):
|
||||
for item in history.get(key, []):
|
||||
history_folders.append(item["folder"] if isinstance(item, dict) else item)
|
||||
duplicates = sorted({item for item in history_folders if history_folders.count(item) > 1})
|
||||
if duplicates:
|
||||
errors.append(f"historical output folders classified twice: {', '.join(duplicates)}")
|
||||
|
||||
if claims["identity"].get("swiss_permit", "").startswith("UNVERIFIED"):
|
||||
warnings.append("Swiss permit type remains unverified; generators must ask before naming it")
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def workflow_checks() -> list[str]:
|
||||
errors: list[str] = []
|
||||
checks = {
|
||||
ROOT / "resume_builder" / "templates" / "resume_template.tex": [
|
||||
"Research Experience",
|
||||
"FLIPPED format",
|
||||
"Selected Publications",
|
||||
],
|
||||
ROOT / "resume_builder" / "reference" / "resume_reference.md": [
|
||||
"ALL variable bullets",
|
||||
"<= 3 lines white space",
|
||||
"20-21 variable bullets",
|
||||
],
|
||||
ROOT / ".agents" / "skills" / "critique" / "SKILL.md": [
|
||||
"Publications 10%",
|
||||
"Eight-Dimension Scoring",
|
||||
],
|
||||
}
|
||||
for path, forbidden_phrases in checks.items():
|
||||
text = read_text(path).lower()
|
||||
for phrase in forbidden_phrases:
|
||||
if phrase.lower() in text:
|
||||
errors.append(f"obsolete workflow rule remains in {path.relative_to(ROOT)}: {phrase}")
|
||||
return errors
|
||||
|
||||
|
||||
def scan_document(path: Path, claims: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
text = read_text(path)
|
||||
lowered = text.lower()
|
||||
for pattern in claims.get("global_forbidden_output_patterns", []):
|
||||
if pattern.lower() in lowered:
|
||||
errors.append(f"{path}: forbidden output pattern: {pattern}")
|
||||
for skill in claims.get("skills", []):
|
||||
if skill.get("output") == "forbidden" and skill["name"].lower() in lowered:
|
||||
errors.append(f"{path}: forbidden or unverified skill: {skill['name']}")
|
||||
if "\\begin{rsubsection}" in lowered:
|
||||
for line_number, line in enumerate(text.splitlines(), 1):
|
||||
if "\\begin{rSubsection}" not in line:
|
||||
continue
|
||||
if re.search(r"Production Ownership|AI-Ready|Customer-Embedded|Shipping ML", line, re.I):
|
||||
errors.append(f"{path}:{line_number}: marketing theme used before formal employer/title")
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--document", action="append", default=[], help="Generated .tex file to validate")
|
||||
args = parser.parse_args()
|
||||
|
||||
claims = load_json(CLAIMS_PATH)
|
||||
history = load_json(HISTORY_PATH)
|
||||
errors, warnings = canonical_checks(claims, history)
|
||||
errors.extend(workflow_checks())
|
||||
for document in args.document:
|
||||
path = Path(document)
|
||||
if not path.is_absolute():
|
||||
path = ROOT / path
|
||||
if not path.exists():
|
||||
errors.append(f"document not found: {path}")
|
||||
else:
|
||||
errors.extend(scan_document(path, claims))
|
||||
|
||||
for warning in warnings:
|
||||
print(f"WARN: {warning}")
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
if errors:
|
||||
print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)")
|
||||
return 1
|
||||
print(f"PASS: canonical system valid ({len(warnings)} warning(s))")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user