#!/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 @staticmethod def languages() -> list["Language"]: return [ Language.PYTHON, Language.C, Language.CPP, Language.RUST, Language.KOTLIN, Language.LUA, ] 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) @staticmethod def _auto_save(func): def wrapper(self, *args, **kwargs): result = func(self, *args, **kwargs) self.save() return result return wrapper 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] @_auto_save 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 @_auto_save 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 @_auto_save 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 @_auto_save def set_completed(self, lang: Language, is_completed: bool) -> None: state = self._load() if lang.value not in state.get("space", {}): raise ValueError(f"No space found for language: {lang}") if not state["space"].get(lang.value): raise ValueError(f"No space found for language: {lang}") state["space"][lang.value]["is_completed"] = is_completed self._state = state 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(path / f"{filename}.{lang.value}", "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 = path / f"{filename}.{lang.value}" if not file_path.is_file(): raise FileNotFoundError(f"File not found in storage: {file_path}") with open(file_path, "rb") as f: return f.read() def remove_file(self, location: str, lang: Language, filename: str) -> None: path = self.check_get_file(location, lang, filename) if path is None: raise FileNotFoundError( f"File not found in storage: {location}/{filename}.{lang.value}" ) os.remove(path) 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) @staticmethod def construct_target_path( location: str, lang: Language, filename: str, completed: bool ) -> pathlib.Path: return ( STORAGE_DIR / location / lang.value / ("completed" if completed else "") / f"{filename}.{lang.value}" ) def list_entries( self, location: str | None = None, lang: Language | None = None, completed: bool | None = None, ) -> list[tuple[str, str, str, bool]]: """저장된 파일 목록을 반환합니다. Args: location: location 필터 (None이면 전체) lang: language 필터 (None이면 전체) completed: completed 필터 (None이면 both, True이면 completed만, False이면 uncompleted만) Returns: list[tuple[str, str, str, bool]]: (location, lang, filename, is_completed) """ result: list[tuple[str, str, str, bool]] = [] if not STORAGE_DIR.is_dir(): return result for loc_dir in sorted(STORAGE_DIR.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 Language.convert_ext(lang_name) is Language.UNDEFINED: continue if lang and lang_name != lang.value: continue for f in sorted( lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) ): if f.is_file(): result.append((loc_name, lang_name, f.stem, False)) 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(): result.append((loc_name, lang_name, f.stem, True)) if completed is not None: result = [e for e in result if e[3] == completed] return result # ======================= # 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, no_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 no_template: content = b"" else: template_path = TEMPLATES_DIR / f"template.{lang.value}" 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, filename, 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: tuple[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(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("[INPUT]", fg="cyan", bold=True) with open(f_in_path, "r") as f: click.echo(f.read()) click.secho("[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) StorageManager().remove_file(loc, lang, prob) click.secho( f"Loaded '{prob}.{lang.value}' from location '{loc}' to src-space.", fg="cyan", bold=True, ) @click.command(name="export") @click.argument("from_", type=str, nargs=-1) @click.option( "--all", "all_", default=False, is_flag=True, help="Export all files in the space" ) @click.option( "--force", "-f", is_flag=True, help="Overwrite existing file in storage if it already exists", ) def export(from_: tuple[str, ...], all_: bool, force: bool): """ space에 있는 파일을 storage의 각 location으로 이동시키는 명령. """ def _export_sub_command(from_language: Language): space = StateManager().get_space(from_language) s_file, s_loc, s_is_completed = ( space["file"], space["location"], space["is_completed"], ) path = StorageManager.construct_target_path( s_loc, from_language, s_file, s_is_completed ) if path.is_file() and not force: raise click.ClickException( f"File '{path.name}' already exists in location '{s_loc}'. Use --force to overwrite." ) src_path = ( SRC_SPACE_DIR / f"src-{from_language.value}" / "src" / f"main.{from_language.value}" ) with open(src_path, "rb") as f_src: content = f_src.read() os.remove(src_path) StorageManager().save_file( s_loc, from_language, s_file, content, s_is_completed ) StateManager().clear_space(from_language) click.secho( f"Exported '{s_file}.{from_language.value}' to location '{s_loc}'", fg="cyan", bold=True, ) if all_: for l in Language.languages(): if StateManager().check_space(l): _export_sub_command(l) else: froms = map(lambda x: Language.convert_name(x), from_) for l in froms: if not StateManager().check_space(l): click.secho( f"No loaded file for language '{l.value}' in space. Skipping export for this language.", fg="yellow", ) continue _export_sub_command(l) @click.group(name="state") def state(): """State management commands""" pass @state.command(name="show") def state_show(): """Show current space state""" click.secho("Current Space State:", fg="cyan", bold=True) for lang in Language.languages(): space = ( StateManager().get_space(lang) if StateManager().check_space(lang) else None ) if space: status_symbol = ( click.style("✓", fg="green") if space.get("is_completed") else click.style("○", fg="white") ) click.echo( f"{status_symbol} {lang.value}: {space['file']} (Location: {space['location']})" ) else: click.echo(f"{click.style('○', fg='white')} {lang.value}: No file loaded") @state.command(name="complete") @click.argument("lang", type=str) def state_complete(lang: str): """Mark language space as completed""" lang_obj = Language.convert_name(lang) if lang_obj is Language.UNDEFINED: raise click.ClickException(f"Unknown language: {lang}") state_manager = StateManager() try: state_manager.set_completed(lang_obj, True) state_manager.save() except ValueError as e: raise click.ClickException(str(e)) click.secho(f"Language '{lang}' marked as completed.", fg="green") @state.command(name="uncomplete") @click.argument("lang", type=str) def state_uncomplete(lang: str): """Mark language space as not completed""" lang_obj = Language.convert_name(lang) if lang_obj is Language.UNDEFINED: raise click.ClickException(f"Unknown language: {lang}") state_manager = StateManager() try: state_manager.set_completed(lang_obj, False) state_manager.save() except ValueError as e: raise click.ClickException(str(e)) click.secho(f"Language '{lang}' marked as not completed.", fg="green") @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("count should be greater than 0") TC_DIR.mkdir(exist_ok=True) for i in range(1, count + 1): (TC_DIR / f"{i}.in").touch() (TC_DIR / f"{i}.out").touch() click.secho(f"1. Testcases created with count {count}", fg="cyan") click.secho("2. Checking state.yml...", fg="cyan") if not STATE_PATH.is_file(): click.secho("2. state.yml does not exist, creating...", fg="yellow") STATE_PATH.write_text("space:\n", encoding="utf-8") click.secho("2. state.yml created", fg="cyan") else: click.secho("2. state.yml exists", fg="green") @click.command(name="show") @click.argument("filter_", type=str, default=None, required=False) @click.option("--completed", "completed_mode", flag_value="completed", default=True) @click.option("--uncompleted", "completed_mode", flag_value="uncompleted") @click.option("--both-completed", "completed_mode", flag_value="both") def show(filter_: str | None, completed_mode: str): """Show all stored problems in storage directory. FILTER format (optional): location e.g. zeta location/lang e.g. zeta/rs /lang e.g. /py FILTER MODE (default: --completed): --completed show completed only --uncompleted show uncompleted only --both-completed show both completed and uncompleted """ completed = {"completed": True, "uncompleted": False, "both": None}[completed_mode] location = lang = None if filter_: parts = filter_.split("/", 1) location = parts[0] if parts[0].strip() else None lang = parts[1] if len(parts) == 2 else None entries = StorageManager().list_entries( location=location, lang=Language.convert_name(lang) if lang else None, completed=completed, ) if not entries: click.echo(" - ") return 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() 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("filter_", type=str) @click.option( "--completed/--no-completed", "-c/-nc", default=None, help="Filter by completed status (default: all)", ) def find(filter_: str, completed: bool | None): """ Find problems by filter in storage. FILTER format: filter e.g. 2447 filter.ext e.g. 2447.py (filters by language) .ext e.g. .py (all files of a language) location/filter e.g. zeta/2447 location/filter.ext e.g. zeta/2447.py location/.ext e.g. zeta/.py """ location: str | None = None filter_base: str | None = None lang: Language | None = None if "/" in filter_: parts = filter_.split("/", 1) location = parts[0] or None filter_ = parts[1] else: location = None if "." in filter_: filter_base, ext = filter_.rsplit(".", 1) lang_candidate = Language.convert_ext(ext) if lang_candidate is not Language.UNDEFINED: lang = lang_candidate filter_base = None if filter_base == "" else filter_base else: filter_base = filter_ else: filter_base = filter_ entries = StorageManager().list_entries( location=location, lang=lang, completed=completed ) if filter_base is not None: entries = [e for e in entries if filter_base.lower() in e[2].lower()] entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2]))) if not entries: click.echo("No problems found.") return total_count = len(entries) completed_count = sum(1 for e in entries if e[3]) uncompleted_count = sum(1 for e in entries if not e[3]) click.secho( f"Found: {total_count} (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="version") def version(): """Show version information.""" click.echo(f"CodeObject version {__version__}") @click.command(name="status") def status(): """Show storage statistics.""" all_entries = StorageManager().list_entries() if not all_entries: click.echo("No problems in storage.") return stats: dict[str, dict[str, dict[str, int]]] = {} for loc, lang, _, is_completed in all_entries: if loc not in stats: stats[loc] = {} if lang not in stats[loc]: stats[loc][lang] = {"total": 0, "completed": 0} stats[loc][lang]["total"] += 1 if is_completed: stats[loc][lang]["completed"] += 1 total_problems = len(all_entries) total_completed = sum(1 for _, _, _, c in all_entries if c) click.secho("Storage Status", fg="cyan", bold=True) click.echo() for loc_name, langs in sorted(stats.items()): loc_total = sum(c["total"] for c in langs.values()) loc_completed = sum(c["completed"] for c in langs.values()) loc_uncompleted = loc_total - loc_completed click.secho(f"[{loc_name}]", fg="green", bold=True, nl=False) click.secho(f" {loc_total}", fg="yellow", bold=False, nl=False) click.secho(" (", fg="white", bold=False, nl=False) click.secho(f"{loc_completed}", fg="green", bold=False, nl=False) click.secho(" completed, ", fg="cyan", bold=False, nl=False) click.secho(f"{loc_uncompleted}", fg="red", bold=False, nl=False) click.secho(" not completed", fg="cyan", bold=False, nl=False) click.secho(")", fg="white", bold=False) for lang_name, counts in sorted(langs.items()): click.echo(f" {lang_name}: {counts['completed']}/{counts['total']}") click.echo() click.secho( f"Total: {total_problems} (completed: {total_completed}, " f"uncompleted: {total_problems - total_completed})", fg="cyan", ) @click.command(name="fetchprob") @click.argument("target", type=str, nargs=1, required=True) @click.option("--force", "-f", is_flag=True, help="Overwrite existing static 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(version) cli.add_command(status) cli.add_command(fetchprob) if __name__ == "__main__": cli()