#!/usr/bin/env python3 import base64 import os import pathlib import re import subprocess import sys import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from enum import Enum, auto, unique from html import escape, unescape 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[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) def _dispatch_target(target: str) -> tuple[str, str]: splited = target.split("/") if len(splited) != 2: raise ValueError("Invalid target format. Expected format: /") return splited[0], splited[1] @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 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 get_space(self, lang: str) -> dict | None: state = self.load() return state.get("space", {}).get(lang) def set_space(self, lang: str, data: dict) -> None: state = self.load() if "space" not in state: state["space"] = {} state["space"][lang] = data self._state = state def clear_space(self, lang: str) -> None: state = self.load() if "space" in state and lang in state["space"]: del state["space"][lang] 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: str, filename: str, content: bytes, completed: bool ) -> None: path = STORAGE_DIR / location / lang / ("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.echo(f"Location '{location}' is already registered.") else: storage_manager.register_location(location) click.echo(f"Location '{location}' registered successfully.") @click.command(name="run") @click.argument("target", 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(target: str, testcase: str, verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ loc, prob = _dispatch_target(target) # Language 확인 from_language: 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("Run Exception: There are no pre-defined state") # 테케 분석 try: tcs: list[int] = _parse_range_string_list(target) except ValueError as e: raise click.ClickException(e) # build try: s = subprocess.run( from_language.build_command, check=True, capture_output=True, text=True, cwd=from_language.working_dir, ) print(s.stderr) except subprocess.CalledProcessError as e: click.echo(">>>>>> [Error while BUILD process] >>>>>>") raise click.ClickException(e.stderr) 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: try: stdout = subprocess.run( from_language.executable_command, check=True, capture_output=True, text=True, stdin=f_in, ).stdout except subprocess.CalledProcessError as e: click.echo(">>>>>> [Error while RUNNING process] >>>>>>") click.echo(f"{e.stderr}") click.echo(f"returncode: {e.returncode}") stdout = "" continue flag: bool = False with open(f_out_path, "r", encoding="utf-8") as f_out: flag = stdout.strip() == f_out.read().strip() click.echo(f"{tc:02d}: {flag}") if verbose: click.echo("===" * 3) click.echo(stdout) click.echo("===" * 3) @click.command(name="load") @click.argument("file", type=str, nargs=1, required=True) @click.option("--to", type=str, default="") # language value @click.option("--from", "from_", type=str, required=True) @click.option("--force", "-f", is_flag=True) def load(file: str, to: str, from_: str, force: bool): """ 파일을 각 언어의 src-space로 이동시키는 명령어. 이 명령어는 다음 과정을 수행함. """ # 파일의 src-space가 어딘지 확인 to_language: Language file_name: str file_sep_ext = file.split(".") if len(file_sep_ext) == 1: file_name = file_sep_ext[0] to_language = Language.convert_name(to) # if blank, undefined elif len(file_sep_ext) == 2: file_name = file_sep_ext[0] to_language = Language.convert_name(file_sep_ext[1]) else: file_name = "" to_language = Language.UNDEFINED if to_language is Language.UNDEFINED: raise click.UsageError("LoadError: `to` or `file` argument is wrong") 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"] = {} lang_space_state: dict | None if to_language.value not in state["space"]: lang_space_state = None else: lang_space_state = state["space"][to_language.value] if not force: if lang_space_state is None or not lang_space_state: pass elif lang_space_state["file"] is None: pass else: raise click.ClickException( f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}" ) # 파일이 존재하는지 확인 # uncompleted file_is_exist: bool = False file_is_completed: bool = False file_loc: str = None if os.path.isfile( f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}" ): file_is_exist = True file_is_completed = False file_loc = ( f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}" ) elif os.path.isfile( f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}" ): file_is_exist = True file_is_completed = True file_loc = f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}" # 존재하면 move # 존재하지 않으면 빈 파일(또는 템플릿 파일)을 생성하고 # state에 기록함 if file_is_exist: with ( open(file_loc, "rb") as f_src, open( f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", "wb", ) as f_dst, ): f_dst.write(f_src.read()) os.remove(file_loc) else: if TEMPLATES_DIR and os.path.isfile( f"{TEMPLATES_DIR}/template.{to_language.value}" ): with ( open(f"{TEMPLATES_DIR}/template.{to_language.value}", "rb") as f_src, open( f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", "wb", ) as f_dst, ): f_dst.write(f_src.read()) else: with open( f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", "wb", ) as f_dst: pass with open("state.yml", "w", encoding="utf-8") as f: if lang_space_state is None: state["space"][to_language.value] = {} state["space"][to_language.value]["file"] = file_name state["space"][to_language.value]["location"] = from_ state["space"][to_language.value]["is_completed"] = file_is_completed yaml.safe_dump(state, f) if file_is_exist: click.echo( f"File {file_name}.{to_language.value} from {from_}{'/completed' if file_is_completed else ''} is successfully loaded" ) else: click.echo(f"File {file_name}.{to_language.value} is successfully created") @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(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()