#!/usr/bin/env python3 import os import pathlib import re import subprocess from enum import Enum, unique import click import yaml try: from latex2mathml.converter import convert as latex_to_mathml except ImportError: latex_to_mathml = None __version__ = "2.0.0" # new version CFG_PATH = pathlib.Path("./config.yml") STATE_PATH = pathlib.Path("./state.yml") TC_DIR = pathlib.Path("./_testcases") TEMPLATES_DIR = pathlib.Path("./templates") SRC_SPACE_DIR = pathlib.Path("./space") STORAGE_DIR = pathlib.Path("./storage") BUILD_DIR = pathlib.Path("./build") # ====== # Helper Functions # ====== def _natural_sort_key(s: str): """Natural sort key: splits string into text/number chunks for proper ordering.""" return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)] def _parse_range_string(range_str: str) -> list[int]: """ 범위 문자열을 정수 리스트로 변환. * ..N: 1부터 N까지 * M..N: M부터 N까지 * N: 단일 숫자 """ if range_str.startswith(".."): end_str = range_str[2:] if not end_str.isdigit(): raise ValueError("Invalid range format") return list(range(1, int(end_str) + 1)) parts = range_str.split("..") if len(parts) == 1: if not parts[0].isdigit(): raise ValueError("Invalid range format") return [int(parts[0])] elif len(parts) == 2: if not parts[0].isdigit() or not parts[1].isdigit(): raise ValueError("Invalid range format") return list(range(int(parts[0]), int(parts[1]) + 1)) else: raise ValueError("Invalid range format") def _parse_range_string_list(str_list: list[str]) -> list[int]: """ 여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함. * e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9] """ result = set() for s in str_list: result.update(_parse_range_string(s)) return list(result) @unique class Language(Enum): """ 실행 가능한 언어(Language)를 정의하는 클래스입니다. 각 언어는 고유 문자열 값을 가짐. - Python: py - C: c - C++: cpp - Rust: rs - Kotlin: kt - Lua: lua """ PYTHON = "py" C = "c" CPP = "cpp" RUST = "rs" KOTLIN = "kt" LUA = "lua" UNDEFINED = "undefined" @property def build_command(self): """ 각 언어에 대한 빌드 명령어. """ return { Language.PYTHON: [ "python", "./make.py", "build", ], Language.C: [ "make", "build", ], Language.CPP: [ "make", "build", ], Language.RUST: [ "make", "build", ], Language.KOTLIN: [ "./gradlew", "jar", ], Language.LUA: [ "make", "build", ], }[self] @property def working_dir(self): """ 각 언어의 작업 디렉토리 """ return f"{SRC_SPACE_DIR}/src-{self.value}" @property def build_executable(self): """ 각 언어의 빌드 후 생성되는 실행 파일의 이름. """ return { Language.PYTHON: "py.pyc", Language.C: "c.out", Language.CPP: "cpp.out", Language.RUST: "rs.out", Language.KOTLIN: "kt.jar", Language.LUA: "lua.luac", }[self] @property def executable_command(self): """ 각 언어에 대한 실행 명령어. """ return { Language.PYTHON: ["python", f"{BUILD_DIR}/{self.build_executable}"], Language.C: [f"{BUILD_DIR}/{self.build_executable}"], Language.CPP: [f"{BUILD_DIR}/{self.build_executable}"], Language.RUST: [f"{BUILD_DIR}/{self.build_executable}"], Language.KOTLIN: ["java", "-jar", f"{BUILD_DIR}/{self.build_executable}"], Language.LUA: ["lua", f"{BUILD_DIR}/{self.build_executable}"], }[self] @staticmethod def convert_ext(ext: str): """ 확장자를 언어 Enum으로 변환하는 메소드. 만약 확장자가 정의된 언어에 해당하지 않으면 UNDEFINED를 반환. """ match ext: case "py": return Language.PYTHON case "c": return Language.C case "cpp": return Language.CPP case "rs": return Language.RUST case "kt": return Language.KOTLIN case "lua": return Language.LUA case _: return Language.UNDEFINED @staticmethod def convert_name(format_name: str): """ 다양한 언어 형식 이름을 언어 Enum으로 변환하는 메소드. convert_ext의 Super Set임. """ if Language.convert_ext(format_name) is not Language.UNDEFINED: return Language.convert_ext(format_name) match format_name.lower(): case "py" | "py3" | "python" | "python3": return Language.PYTHON case "c": return Language.C case "cpp" | "c++": return Language.CPP case "rust" | "rs": return Language.RUST case "kt" | "kotlin": return Language.KOTLIN case "lua": return Language.LUA case _: return Language.UNDEFINED def _dispatch_target(target: str) -> tuple[str, str, Language]: splited = target.split("/") if len(splited) != 2: raise ValueError( "Invalid target format. Expected format: /." ) prob_parts = splited[1].rsplit(".", 1) if len(prob_parts) != 2: raise ValueError( "Invalid target format. Expected format: /." ) prob_name, ext = prob_parts lang = Language.convert_ext(ext) if lang is Language.UNDEFINED: raise ValueError(f"Unknown language: {ext}") return splited[0], prob_name, lang class StateManager: _instance: "StateManager | None" = None _state: dict | None = None def __new__(cls) -> "StateManager": if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def _load(self) -> dict: if self._state is not None: return self._state try: with open(STATE_PATH, "r", encoding="utf-8") as f: self._state = yaml.safe_load(f) or {} except FileNotFoundError as exc: raise click.ClickException(f"state.yml not found at {STATE_PATH}") from exc except yaml.YAMLError as e: raise click.ClickException(f"state.yml parse error: {e}") from e return self._state # type: ignore[return-value] def save(self) -> None: if self._state is None: return with open(STATE_PATH, "w", encoding="utf-8") as f: yaml.safe_dump(self._state, f) def check_space(self, lang: Language) -> bool: state = self._load() return lang.value in state.get("space", {}) and bool(state["space"][lang.value]) def get_space(self, lang: Language) -> dict: state = self._load() if not self.check_space(lang): raise ValueError(f"No space found for language: {lang}") return state["space"][lang.value] def add_space( self, lang: Language, file: str, location: str, is_completed: bool ) -> None: state = self._load() if "space" not in state: state["space"] = {} state["space"][lang.value] = { "file": file, "location": location, "is_completed": is_completed, } self._state = state def remove_space(self, lang: Language) -> None: state = self._load() if "space" in state and lang.value in state["space"]: state["space"][lang.value] = {} self._state = state def clear_space(self, lang: Language) -> None: state = self._load() if "space" in state and lang.value in state["space"]: del state["space"][lang.value] self._state = state def reload(self) -> None: self._state = None class StorageManager: _instance: "StorageManager | None" = None def __new__(cls) -> "StorageManager": if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def register_location(self, location: str) -> None: path = STORAGE_DIR / location os.makedirs(path, exist_ok=True) def check_location(self, location: str) -> bool: path = STORAGE_DIR / location return os.path.isdir(path) def register_location_lang(self, location: str, lang: Language) -> None: path = STORAGE_DIR / location / lang.value os.makedirs(path, exist_ok=True) def check_location_lang(self, location: str, lang: Language) -> bool: path = STORAGE_DIR / location / lang.value return os.path.isdir(path) def check_get_file( self, location: str, lang: Language, filename: str ) -> pathlib.Path | None: completed_path = ( STORAGE_DIR / location / lang.value / "completed" / f"{filename}.{lang.value}" ) uncompleted_path = ( STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" ) if os.path.isfile(completed_path): return completed_path elif os.path.isfile(uncompleted_path): return uncompleted_path else: return None def save_file( self, location: str, lang: Language, filename: str, content: bytes, completed: bool ) -> None: path = STORAGE_DIR / location / lang.value / ("completed" if completed else "") os.makedirs(path, exist_ok=True) with open(f"{path}/{filename}", "wb") as f: f.write(content) def read_file(self, location: str, lang: Language, filename: str) -> bytes: path = self.check_get_file(location, lang, filename) if path is None: raise FileNotFoundError( f"File not found in storage: {location}/{filename}.{lang.value}" ) file_path = f"{path}/{filename}" if not os.path.isfile(file_path): raise FileNotFoundError(f"File not found in storage: {file_path}") with open(file_path, "rb") as f: return f.read() def mark_completed(self, location: str, lang: Language, filename: str) -> None: uncompleted_file = ( STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" ) completed_file = ( STORAGE_DIR / location / lang.value / "completed" / f"{filename}.{lang.value}" ) os.makedirs(completed_file.parent, exist_ok=True) if os.path.isfile(uncompleted_file): os.rename(uncompleted_file, completed_file) def unmark_completed(self, location: str, lang: Language, filename: str) -> None: uncompleted_file = ( STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" ) completed_file = ( STORAGE_DIR / location / lang.value / "completed" / f"{filename}.{lang.value}" ) os.makedirs(uncompleted_file.parent, exist_ok=True) if os.path.isfile(completed_file): os.rename(completed_file, uncompleted_file) # ======================= # MAIN CLI LOGIC # ======================= @click.group() def cli(): pass @click.command(name="register") @click.argument("location", type=str, nargs=1, required=True) def register(location: str): """ 새로운 location을 storage에 등록하는 명령어. """ storage_manager = StorageManager() if storage_manager.check_location(location): click.secho( f"Location '{location}' is already registered.", fg="yellow", bold=True ) else: storage_manager.register_location(location) click.secho( f"Location '{location}' registered successfully.", fg="cyan", bold=True ) @click.command(name="create") @click.argument("target", type=str, nargs=1, required=True) @click.option( "--completed/--no-completed", "-c/-nc", default=False, help="Mark as completed" ) @click.option( "--no-template", "-nt", is_flag=True, help="Do not use template content (create empty file instead)", ) @click.option( "--force", "-f", is_flag=True, help="Overwrite existing file if it already exists" ) def create(target: str, completed: bool, template: bool, force: bool): """ 지정된 location을 storage에 생성하는 명령어 """ loc, prob, lang = _dispatch_target(target) if not StorageManager().check_location(loc): raise click.ClickException( f"Location '{loc}' is not registered. Please register it first." ) filename = prob existing = StorageManager().check_get_file(loc, lang, filename) if existing: click.secho( f"File '{filename}.{lang.value}' already exists in location '{loc}'", fg="yellow", bold=True, ) if not force: return if not template: content = b"" else: template_path = TEMPLATES_DIR / f"{lang.value}.txt" if template_path.is_file(): content = template_path.read_bytes() click.secho(f"Using template: {template_path}", fg="cyan") else: content = b"" click.secho(f"Template not found: {template_path}", fg="yellow") StorageManager().save_file( loc, lang, f"{filename}.{lang.value}", content, completed ) click.secho( f"Created '{filename}.{lang.value}' in location '{loc}'", fg="cyan", bold=True, ) @click.command(name="run") @click.argument("lang", type=str, nargs=1, required=True) @click.option("--testcase", "-t", default=["1"], multiple=True) @click.option("--verbose/--no-verbose", "-v/-nv", default=True) def run(lang: str, testcase: list[str], verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ # Language 확인 target_language: Language = Language.convert_name(lang) if target_language is Language.UNDEFINED: raise click.ClickException("Undefined Language Exception") # load된 state가 있는지 확인 if not StateManager().check_space(target_language): raise click.ClickException( f"No loaded file for language '{target_language.value}'. Please load a file first." ) # 테케 분석 tcs: list[int] = _parse_range_string_list(testcase) # build try: s = subprocess.run( target_language.build_command, check=True, capture_output=True, text=True, cwd=target_language.working_dir, ) print(s.stderr) except subprocess.CalledProcessError as e: click.secho(">>>>>> [BUILD TIME ERROR] >>>>>>", fg="red", bold=True) raise click.ClickException(e.stderr) # execution for each test case for tc in tcs: if not ( os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out") ): click.echo(f"{tc:02d}: {None}") else: f_in_path = f"{TC_DIR}/{tc}.in" f_out_path = f"{TC_DIR}/{tc}.out" with ( open(f_in_path, "r", encoding="utf-8") as f_in, open(f_out_path, "r", encoding="utf-8") as f_out, ): expected = f_out.read().strip() proc = subprocess.Popen( target_language.executable_command, stdin=f_in, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if not verbose: for line in proc.stdout: # type: ignore print(line, end="") stdout, stderr = proc.communicate() if proc.returncode != 0: click.secho(">>>>>> [RUN TIME ERROR] >>>>>>", fg="red", bold=True) click.secho(f"{stderr}", fg="red") click.secho(f"returncode: {proc.returncode}", fg="red") continue flag = stdout.strip() == expected symbol = ( click.style("✓", fg="green") if flag else click.style("✗", fg="red") ) click.echo(f"{tc:02d}: {symbol}") if verbose: click.secho("===" * 3, fg="bright_black") click.secho(f"[INPUT]", fg="cyan", bold=True) with open(f_in_path, "r") as f: click.echo(f.read()) click.secho(f"[ACTUAL]", fg="cyan", bold=True) click.echo(stdout.strip() if stdout else "(empty)") click.secho("===" * 3, fg="bright_black") @click.command(name="load") @click.argument("target", type=str, nargs=1, required=True) @click.option("--force", "-f", is_flag=True) def load(target: str, force: bool): """ Storage의 파일을 각 언어의 src-space로 이동시키는 명령. - force는 src-space에 이미 파일이 존재할 때, 덮어쓸지 결정 """ # 파일의 src-space가 어딘지 확인 loc, prob, lang = _dispatch_target(target) if StateManager().check_space(lang): if not force: raise click.ClickException( f"Space for language '{lang.value}' is already occupied." ) path = StorageManager().check_get_file(loc, lang, prob) if path is None: raise click.ClickException( f"File '{prob}.{lang.value}' not found in location '{loc}'. Use 'create' command to create it first." ) dest_path = SRC_SPACE_DIR / f"src-{lang.value}" / "src" / f"main.{lang.value}" with open(path, "rb") as f_src, open(dest_path, "wb") as f_dst: f_dst.write(f_src.read()) StateManager().remove_space(lang) StateManager().add_space(lang, prob, loc, False) click.secho( f"Loaded '{prob}.{lang.value}' from location '{loc}' to src-space.", fg="cyan", bold=True, ) @click.command(name="export") @click.option("--from", "from_", type=str, required=True) @click.option( "--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None ) @click.option("--copy", is_flag=True, help="is copy from state or move") def export(from_: str, completed: bool, copy: bool): # Language 알기 from_language = Language.convert_name(from_) if from_language is Language.UNDEFINED: raise click.ClickException("Undefined Language Exception") try: with open("state.yml", "r", encoding="utf-8") as f: state = yaml.safe_load(f) except FileNotFoundError as e: raise click.ClickException(e) except yaml.YAMLError as e: raise click.ClickException(e) if "space" not in state: raise click.ClickException("State(state.yml) Exception") elif not state["space"]: click.secho("state.space has no member", fg="yellow", bold=True) state["space"] = {} lang_space_state: dict | None if from_language.value not in state["space"]: lang_space_state = None else: lang_space_state = state["space"][from_language.value] if lang_space_state is None or not lang_space_state: raise click.ClickException("Export Exception: There are no pre-defined state") s_file = lang_space_state["file"] s_loc = lang_space_state["location"] s_is_completed = lang_space_state["is_completed"] if not s_file: raise click.ClickException( f"Export Exception: There is no file in {from_language.value} space" ) if not s_loc: raise click.ClickException(f"Export Exception: Location {s_loc} is not defined") if completed: s_is_completed = True dest_path: str = f"{STORAGE_DIR}/{s_loc}/{from_language.value}{'/completed' if s_is_completed else ''}/{s_file}.{from_language.value}" source_path: str = ( f"{SRC_SPACE_DIR}/src-{from_language.value}/src/main.{from_language.value}" ) os.makedirs(os.path.dirname(dest_path), exist_ok=True) with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst: f_dst.write(f_src.read()) click.echo(f"File {s_file}.{from_language.value} successfully ") if not copy: with open("state.yml", "w", encoding="utf-8") as f: state["space"][from_language.value] = {} yaml.safe_dump(state, f) os.remove(source_path) @click.command(name="state") def state(): try: with open(STATE_PATH, "r", encoding="utf-8") as f_state: state = yaml.safe_load(f_state) except FileNotFoundError as e: raise click.ClickException(e) except yaml.YAMLError as e: raise click.ClickException(e) if "space" not in state: raise click.ClickException("state.yml is wrong") elif not state["space"]: click.secho("state.space has no member", fg="yellow", bold=True) state["space"] = {} active = sum(1 for v in state["space"].values() if v) total = len(state["space"]) click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True) click.echo() for k, v in state["space"].items(): click.secho(f" {k}/", fg="yellow") if not v: click.echo(f" {click.style('—', fg='bright_black')} empty") else: status = ( click.style("✓", fg="green") if v["is_completed"] else click.style("○", fg="white") ) click.echo(f" {status} {v['file']}.{k} @ {v['location']}") @click.command(name="init") @click.argument("count", type=int) def init(count: int): """ init testcases with initial count and state.yml and config.yml """ if count < 1: raise click.UsageError("var count should be greater than 1.") if not os.path.exists("./" + TC_DIR): os.mkdir(TC_DIR) for i in range(1, count + 1): default_in_path = "./" + TC_DIR + f"/{i}.in" default_out_path = "./" + TC_DIR + f"/{i}.out" if not os.path.exists(default_in_path): with open(default_in_path, "w", encoding="utf-8") as _: pass if not os.path.exists(default_out_path): with open(default_out_path, "w", encoding="utf-8") as _: pass click.echo(f"1. Testcases was made with count {count} successfully!") if not os.path.isfile("./state.yml"): click.echo("2-1. state.yml does not exist, creating...") with open("state.yml", "w", encoding="utf-8") as f: f.write("space:\n") click.echo("2-1. state.yml is created successfully!") else: click.echo("2-1. state.yml exists") if not os.path.isfile("./config.yml"): click.echo("2-2. config.yml does not exist, creating...") with open("config.yml", "w", encoding="utf-8") as f: f.write("space:\n") click.echo("2-2. config.yml is created successfully!") else: click.echo("2-2. config.yml exists") @click.command(name="show") @click.argument("filter", type=str, default=None, required=False) @click.option( "--completed/--no-completed", "-c/-nc", default=False, help="Filter by completed status (default: uncompleted only)", ) @click.option( "--all", "show_all", is_flag=True, default=False, help="Show all problems regardless of completed status", ) def show(filter: str | None, completed: bool, show_all: bool): """ Show all stored problems in storage directory. \b FILTER format (optional): location e.g. zeta location/lang e.g. zeta/rs /lang e.g. /py """ location = None lang = None if filter: parts = filter.split("/", 1) if len(parts) == 2: location = parts[0] or None lang = parts[1] or None else: location = parts[0] storage = pathlib.Path(STORAGE_DIR) if not storage.is_dir(): raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found") # Collect all files entries = [] # (location, language, filename, is_completed) for loc_dir in sorted(storage.iterdir()): if not loc_dir.is_dir(): continue loc_name = loc_dir.name if location and loc_name != location: continue for lang_dir in sorted(loc_dir.iterdir()): if not lang_dir.is_dir(): continue lang_name = lang_dir.name if ( lang and lang_name != Language.convert_name(lang).value and lang_name != lang ): continue # uncompleted files for f in sorted( lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) ): if f.is_file(): entries.append((loc_name, lang_name, f.stem, False)) # completed files completed_dir = lang_dir / "completed" if completed_dir.is_dir(): for f in sorted( completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) ): if f.is_file(): entries.append((loc_name, lang_name, f.stem, True)) # Filter by completed status if not show_all: entries = [e for e in entries if e[3] == completed] if not entries: click.echo("No problems found.") return # Display total = len(entries) completed_count = sum(1 for e in entries if e[3]) uncompleted_count = total - completed_count click.secho( f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})", fg="cyan", bold=True, ) click.echo() # Group by location current_loc = None current_lang = None for loc_name, lang_name, file_name, is_completed in entries: if loc_name != current_loc: current_loc = loc_name current_lang = None click.secho(f"[{loc_name}]", fg="green", bold=True) if lang_name != current_lang: current_lang = lang_name click.secho(f" {lang_name}/", fg="yellow") status = ( click.style("✓", fg="green") if is_completed else click.style("○", fg="white") ) click.echo(f" {status} {file_name}.{lang_name}") @click.command(name="find") @click.argument("keyword", type=str) @click.option( "--completed/--no-completed", "-c/-nc", default=None, help="Filter by completed status (default: all)", ) def find(keyword: str, completed: bool | None): """ Find problems by keyword in storage. KEYWORD format: keyword e.g. 2447 keyword.ext e.g. 2447.py (filters by language) .ext e.g. .py (all files of a language) location/keyword e.g. zeta/2447 location/keyword.ext e.g. zeta/2447.py location/.ext e.g. zeta/.py """ location = None lang = None if "/" in keyword: parts = keyword.split("/", 1) location = parts[0] or None keyword = parts[1] # Extract language from extension if "." in keyword: keyword_base, ext = keyword.rsplit(".", 1) lang_candidate = Language.convert_ext(ext) if lang_candidate is not Language.UNDEFINED: lang = lang_candidate.value else: keyword_base = keyword else: keyword_base = keyword storage = pathlib.Path(STORAGE_DIR) if not storage.is_dir(): raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found") entries = [] # (location, language, filename, is_completed) for loc_dir in sorted(storage.iterdir()): if not loc_dir.is_dir(): continue loc_name = loc_dir.name if location and loc_name != location: continue for lang_dir in sorted(loc_dir.iterdir()): if not lang_dir.is_dir(): continue lang_name = lang_dir.name if lang and lang_name != lang: continue for f in lang_dir.iterdir(): if f.is_file() and keyword_base.lower() in f.stem.lower(): entries.append((loc_name, lang_name, f.stem, False)) completed_dir = lang_dir / "completed" if completed_dir.is_dir(): for f in completed_dir.iterdir(): if f.is_file() and keyword_base.lower() in f.stem.lower(): entries.append((loc_name, lang_name, f.stem, True)) if completed is not None: entries = [e for e in entries if e[3] == completed] entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2]))) if not entries: click.echo("No problems found.") return total = len(entries) completed_count = sum(1 for e in entries if e[3]) uncompleted_count = total - completed_count click.secho( f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})", fg="cyan", bold=True, ) click.echo() current_loc = None current_lang = None for loc_name, lang_name, file_name, is_completed in entries: if loc_name != current_loc: current_loc = loc_name current_lang = None click.secho(f"[{loc_name}]", fg="green", bold=True) if lang_name != current_lang: current_lang = lang_name click.secho(f" {lang_name}/", fg="yellow") status = ( click.style("✓", fg="green") if is_completed else click.style("○", fg="white") ) click.echo(f" {status} {file_name}.{lang_name}") @click.command(name="fetchprob") @click.argument("target", type=str, nargs=1, required=True) @click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files") def fetchprob(target: str, force: bool): """(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support.""" del target, force raise click.ClickException("fetchprob is currently disabled (BOJ closing)") cli.add_command(register) cli.add_command(create) cli.add_command(run) cli.add_command(load) cli.add_command(init) cli.add_command(export) cli.add_command(state) cli.add_command(show) cli.add_command(find) cli.add_command(fetchprob) if __name__ == "__main__": cli()