From b9a8ea6badef094f001186f9df40530d9038565a Mon Sep 17 00:00:00 2001 From: yenru0 Date: Sat, 2 May 2026 07:59:08 +0900 Subject: [PATCH 01/13] update run.py inorder to ensure there is folder to store file --- run.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/run.py b/run.py index 93dee37..08b5ed3 100755 --- a/run.py +++ b/run.py @@ -45,6 +45,7 @@ class Language(Enum): - Kotlin: kt - Lua: lua """ + PYTHON = "py" C = "c" CPP = "cpp" @@ -107,11 +108,11 @@ class Language(Enum): Language.KOTLIN: "kt.jar", Language.LUA: "lua.luac", }[self] - + @property def executable_command(self): """ - 각 언어에 대한 실행 명령어. + 각 언어에 대한 실행 명령어. """ return { Language.PYTHON: ["python", f"{BUILD_DIR}/{self.build_executable}"], @@ -173,6 +174,7 @@ 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]: """ 범위 문자열을 정수 리스트로 변환. @@ -334,7 +336,9 @@ def _guess_image_mime(src_url: str, content_type: str | None) -> str: return ext_to_mime.get(ext, "image/png") -def _download_image_for_offline(problem_id: str, src_url: str, seq: int, force: bool) -> str | None: +def _download_image_for_offline( + problem_id: str, src_url: str, seq: int, force: bool +) -> str | None: req = urllib.request.Request( src_url, headers={ @@ -387,7 +391,9 @@ def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) - return f"{prefix}{quote}{local_src}{quote}" counter["i"] += 1 - local_src = _download_image_for_offline(problem_id, abs_url, counter["i"], force=force) + local_src = _download_image_for_offline( + problem_id, abs_url, counter["i"], force=force + ) if not local_src: return m.group(0) @@ -455,7 +461,9 @@ def _render_math_expressions(html_fragment: str) -> str: return m.group(0) temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL) - temp = re.sub(r"(? st blocks.append( "\n".join( [ - "
", + '
', f"

{fallback_label}

", f"{localized_content}", "
", @@ -503,7 +511,10 @@ def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> st ) ) - for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")): + for sample_type, sample_label in ( + ("sampleinput", "예제 입력"), + ("sampleoutput", "예제 출력"), + ): sample_pattern = rf"]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)" sample_matches = list( re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE) @@ -540,7 +551,7 @@ def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> st blocks.append( "\n".join( [ - "
", + '
', f"

{h2}

", pre_html, "
", @@ -550,7 +561,7 @@ def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> st if not blocks: body_fallback = ( - "
" + '
' "

원본 페이지

" "

문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.

" f"
{escape(raw_html[:100000])}
" @@ -979,11 +990,15 @@ def export(from_: str, completed: bool, copy: bool): 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}" + 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()) @@ -1367,7 +1382,6 @@ def fetchprob(target: str, force: bool): ) - cli.add_command(run) cli.add_command(load) cli.add_command(init) From 21d51e4af9e546f657470cf6c9e0d367bb875910 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Sun, 3 May 2026 01:09:31 +0900 Subject: [PATCH 02/13] refactoring stage 1 - add StorageManager StateManager - use pathlib Co-authored-by: Copilot --- run.py | 828 +++++++++++++++------------------------------------------ 1 file changed, 220 insertions(+), 608 deletions(-) diff --git a/run.py b/run.py index 08b5ed3..86a5aa0 100755 --- a/run.py +++ b/run.py @@ -1,37 +1,89 @@ #!/usr/bin/env python3 -import os -import sys -import pathlib import base64 -from enum import Enum, unique, auto -from dataclasses import dataclass +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 yaml import click +import yaml try: from latex2mathml.converter import convert as latex_to_mathml except ImportError: latex_to_mathml = None -CFG_PATH = "./config.yml" -STATE_PATH = "./state.yml" +__version__ = "2.0.0" # new version -TC_DIR = "./_testcases" +CFG_PATH = pathlib.Path("./config.yml") +STATE_PATH = pathlib.Path("./state.yml") -TEMPLATES_DIR = "./templates" +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") -SRC_SPACE_DIR = "./space" -STORAGE_DIR = "./storage" +# ====== +# Helper Functions +# ====== -BUILD_DIR = "./build" + +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 @@ -170,551 +222,155 @@ class Language(Enum): return Language.UNDEFINED -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)] +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 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 parse_fetchprob_target(target: str) -> tuple[str, str | None]: - """ - fetchprob target parser. - - zeta/: single mode - - zeta: batch mode - """ - parts = target.split("/", 1) - location = parts[0].strip() - - if location != "zeta": - raise click.UsageError("fetchprob target must start with 'zeta'") - - if len(parts) == 1: - return location, None - - problem_id = parts[1].strip() - if not problem_id.isdigit(): - raise click.UsageError("problem id must be numeric (e.g. zeta/2447)") - - return location, problem_id - - -def extract_problem_id_from_stem(stem: str) -> str | None: - """ - Extract BOJ numeric id from file stem. - Accepted forms: , _, - - """ - m = re.match(r"^(\d+)(?:[_-].*)?$", stem) - return m.group(1) if m else None - - -def collect_zeta_problem_ids() -> list[str]: - """ - Collect problem ids from storage/zeta/* and storage/zeta/*/completed. - """ - zeta_dir = pathlib.Path(STORAGE_DIR) / "zeta" - if not zeta_dir.is_dir(): - raise click.ClickException(f"Storage location '{zeta_dir}' not found") - - ids: set[str] = set() - for lang_dir in sorted(zeta_dir.iterdir()): - if not lang_dir.is_dir() or lang_dir.name.startswith("_"): - continue - - for f in lang_dir.iterdir(): - if f.is_file(): - problem_id = extract_problem_id_from_stem(f.stem) - if problem_id: - ids.add(problem_id) - - completed_dir = lang_dir / "completed" - if completed_dir.is_dir(): - for f in completed_dir.iterdir(): - if f.is_file(): - problem_id = extract_problem_id_from_stem(f.stem) - if problem_id: - ids.add(problem_id) - - return sorted(ids, key=int) - - -def fetch_boj_problem_html(problem_id: str, timeout: int = 10) -> str: - """ - Download BOJ problem page raw HTML. - """ - url = f"https://www.acmicpc.net/problem/{problem_id}" - req = urllib.request.Request( - url, - headers={ - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ) - }, - ) - - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - status = getattr(resp, "status", 200) - if status != 200: - raise click.ClickException( - f"failed to fetch problem {problem_id}: HTTP {status}" - ) - return resp.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as e: - raise click.ClickException( - f"failed to fetch problem {problem_id}: HTTP {e.code}" - ) from e - except urllib.error.URLError as e: - raise click.ClickException( - f"network error while fetching problem {problem_id}: {e.reason}" - ) from e - - -def _problem_static_html_path(problem_id: str) -> pathlib.Path: - return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / f"{problem_id}.html" - - -def _problem_static_assets_dir(problem_id: str) -> pathlib.Path: - return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / "assets" / problem_id - - -def _guess_image_mime(src_url: str, content_type: str | None) -> str: - if content_type: - mime = content_type.split(";", 1)[0].strip().lower() - if mime.startswith("image/"): - return mime - - parsed = urllib.parse.urlparse(src_url) - ext = pathlib.Path(parsed.path).suffix.lower() - ext_to_mime = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - } - return ext_to_mime.get(ext, "image/png") - - -def _download_image_for_offline( - problem_id: str, src_url: str, seq: int, force: bool -) -> str | None: - req = urllib.request.Request( - src_url, - headers={ - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ), - "Referer": f"https://www.acmicpc.net/problem/{problem_id}", - }, - ) - - try: - with urllib.request.urlopen(req, timeout=10) as resp: - status = getattr(resp, "status", 200) - if status != 200: - return None - content_type = resp.headers.get("Content-Type") - image_bytes = resp.read() - except (urllib.error.HTTPError, urllib.error.URLError): - return None - - mime = _guess_image_mime(src_url, content_type) - encoded = base64.b64encode(image_bytes).decode("ascii") - return f"data:{mime};base64,{encoded}" - - -def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) -> str: - base_url = f"https://www.acmicpc.net/problem/{problem_id}" - counter = {"i": 0} - cache: dict[str, str] = {} - - pattern = re.compile( - r'(]*?\bsrc\s*=\s*)(["\']?)([^"\'>\s]+)(["\']?)', - flags=re.IGNORECASE, - ) - - def repl(m: re.Match) -> str: - prefix = m.group(1) - q1 = m.group(2) - src = m.group(3) - - if src.startswith("data:"): - return m.group(0) - - abs_url = urllib.parse.urljoin(base_url, src) - if abs_url in cache: - local_src = cache[abs_url] - quote = q1 if q1 else '"' - return f"{prefix}{quote}{local_src}{quote}" - - counter["i"] += 1 - local_src = _download_image_for_offline( - problem_id, abs_url, counter["i"], force=force - ) - if not local_src: - return m.group(0) - - cache[abs_url] = local_src - quote = q1 if q1 else '"' - return f"{prefix}{quote}{local_src}{quote}" - - return pattern.sub(repl, html_fragment) - - -def _extract_html_by_id(raw_html: str, tag: str, element_id: str) -> str | None: - pattern = rf"<{tag}[^>]*id=\"{re.escape(element_id)}\"[^>]*>(.*?)" - m = re.search(pattern, raw_html, flags=re.DOTALL | re.IGNORECASE) - if not m: - return None - return m.group(1).strip() - - -def _strip_tags(html_text: str) -> str: - text = re.sub(r"<[^>]+>", "", html_text, flags=re.DOTALL) - return " ".join(text.split()) - - -def _render_math_expressions(html_fragment: str) -> str: - """ - Convert TeX math delimiters to MathML for offline rendering. - - Inline: $...$ - - Block: $$...$$ - """ - if latex_to_mathml is None: - return html_fragment - - protected_blocks: list[str] = [] - - def protect(m: re.Match) -> str: - protected_blocks.append(m.group(0)) - return f"@@PROTECTED_{len(protected_blocks) - 1}@@" - - # Do not touch code/pre blocks. - temp = re.sub( - r"<(pre|code)\b[^>]*>.*?", - protect, - html_fragment, - flags=re.DOTALL | re.IGNORECASE, - ) - - def repl_block(m: re.Match) -> str: - expr = unescape(m.group(1).strip()) - if not expr: - return m.group(0) + def load(self) -> dict: + if self._state is not None: + return self._state try: - mathml = latex_to_mathml(expr) - return f'
{mathml}
' - except Exception: - return m.group(0) + 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 repl_inline(m: re.Match) -> str: - expr = unescape(m.group(1).strip()) - if not expr: - return m.group(0) - try: - mathml = latex_to_mathml(expr) - return f'{mathml}' - except Exception: - return m.group(0) + 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) - temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL) - temp = re.sub( - r"(? dict | None: + state = self.load() + return state.get("space", {}).get(lang) - for i, block in enumerate(protected_blocks): - temp = temp.replace(f"@@PROTECTED_{i}@@", block) + 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 - return temp + 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 -def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str: - """ - Build a self-contained offline-friendly HTML page from BOJ raw HTML. - """ - title = _extract_html_by_id(raw_html, "span", "problem_title") - if not title: - title = f"BOJ {problem_id}" +class StorageManager: + _instance: "StorageManager | None" = None - blocks: list[str] = [] - core_specs = [ - ("problem_description", "문제"), - ("problem_input", "입력"), - ("problem_output", "출력"), - ("problem_limit", "제한"), - ("problem_hint", "힌트"), - ] + def __new__(cls) -> "StorageManager": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance - for content_id, fallback_label in core_specs: - content = _extract_html_by_id(raw_html, "div", content_id) - if not content or not content.strip(): - continue + def register_location(self, location: str) -> None: + path = STORAGE_DIR / location + os.makedirs(path, exist_ok=True) - localized_content = _localize_images_in_html( - problem_id, - content, - force=force, + 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}" ) - localized_content = _render_math_expressions(localized_content) - - blocks.append( - "\n".join( - [ - '
', - f"

{fallback_label}

", - f"{localized_content}", - "
", - ] - ) + uncompleted_path = ( + STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" ) - for sample_type, sample_label in ( - ("sampleinput", "예제 입력"), - ("sampleoutput", "예제 출력"), - ): - sample_pattern = rf"]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)" - sample_matches = list( - re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE) + 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}" ) - sample_matches.sort(key=lambda m: int(m.group(1))) - - for m in sample_matches: - idx = m.group(1) - section_html = m.group(2) - pre_match = re.search( - r"(]*>.*?)", - section_html, - flags=re.DOTALL | re.IGNORECASE, - ) - if not pre_match: - continue - - pre_html = _localize_images_in_html( - problem_id, - pre_match.group(1), - force=force, - ) - - h2_match = re.search( - r"]*>(.*?)", - section_html, - flags=re.DOTALL | re.IGNORECASE, - ) - if h2_match: - h2 = _strip_tags(h2_match.group(1)) - else: - h2 = f"{sample_label} {idx}" - - blocks.append( - "\n".join( - [ - '
', - f"

{h2}

", - pre_html, - "
", - ] - ) - ) - - if not blocks: - body_fallback = ( - '
' - "

원본 페이지

" - "

문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.

" - f"
{escape(raw_html[:100000])}
" - "
" + completed_file = ( + STORAGE_DIR + / location + / lang.value + / "completed" + / f"{filename}.{lang.value}" ) - blocks.append(body_fallback) - source_url = f"https://www.acmicpc.net/problem/{problem_id}" - content_html = "\n".join(blocks) + os.makedirs(completed_file.parent, exist_ok=True) + if os.path.isfile(uncompleted_file): + os.rename(uncompleted_file, completed_file) - return f""" - - - - - BOJ {problem_id} - Offline - - - -
-
-

{title}

-
- {content_html} -
- - -""" + 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) -def save_problem_html(problem_id: str, html: str, force: bool) -> str: - """ - Save html to storage/zeta/_static/.html - Return: fetched | skipped - """ - static_dir = pathlib.Path(STORAGE_DIR) / "zeta" / "_static" - static_dir.mkdir(parents=True, exist_ok=True) - - dest = _problem_static_html_path(problem_id) - if dest.exists() and not force: - return "skipped" - - dest.write_text(html, encoding="utf-8") - return "fetched" +# ======================= +# MAIN CLI LOGIC +# ======================= @click.group() @@ -722,14 +378,29 @@ 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.option("--from", "from_", type=str, required=True) -@click.option("--target", "-t", default=["1"], multiple=True) +@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(from_: str, target: str, verbose: bool): +def run(target: str, testcase: str, verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ + loc, prob = _dispatch_target(target) + # Language 확인 from_language: Language = Language.convert_name(from_) @@ -760,7 +431,7 @@ def run(from_: str, target: str, verbose: bool): # 테케 분석 try: - tcs: list[int] = parse_range_string_list(target) + tcs: list[int] = _parse_range_string_list(target) except ValueError as e: raise click.ClickException(e) @@ -1148,7 +819,9 @@ def show(filter: str | None, completed: bool, show_all: bool): continue # uncompleted files - for f in sorted(lang_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)): + 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)) @@ -1156,7 +829,7 @@ def show(filter: str | None, completed: bool, show_all: bool): 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) + completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) ): if f.is_file(): entries.append((loc_name, lang_name, f.stem, True)) @@ -1275,7 +948,7 @@ def find(keyword: str, completed: bool | None): 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]))) + entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2]))) if not entries: click.echo("No problems found.") @@ -1316,70 +989,9 @@ def find(keyword: str, completed: bool | None): @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): - """ - Fetch BOJ problem HTML into storage/zeta/_static. - - TARGET: - zeta/ Fetch one problem - zeta Fetch all detected problem ids under storage/zeta - """ - location, problem_id = parse_fetchprob_target(target) - if location != "zeta": - raise click.UsageError("only 'zeta' location is supported") - - if problem_id is not None: - if _problem_static_html_path(problem_id).exists() and not force: - click.echo(f"{problem_id}: skipped (already exists)") - return - - raw_html = fetch_boj_problem_html(problem_id) - offline_html = make_offline_problem_html(problem_id, raw_html, force=force) - result = save_problem_html(problem_id, offline_html, force=force) - if result == "skipped": - click.echo(f"{problem_id}: skipped (already exists)") - else: - click.echo(f"{problem_id}: fetched (offline processed + images)") - return - - ids = collect_zeta_problem_ids() - if not ids: - click.echo("No problem ids found in storage/zeta") - return - - attempted = len(ids) - fetched = 0 - skipped = 0 - failed = 0 - - for pid in ids: - try: - if _problem_static_html_path(pid).exists() and not force: - skipped += 1 - click.echo(f"{pid}: skipped") - continue - - raw_html = fetch_boj_problem_html(pid) - offline_html = make_offline_problem_html(pid, raw_html, force=force) - result = save_problem_html(pid, offline_html, force=force) - if result == "skipped": - skipped += 1 - click.echo(f"{pid}: skipped") - else: - fetched += 1 - click.echo(f"{pid}: fetched (offline processed + images)") - except click.ClickException as e: - failed += 1 - click.echo(f"{pid}: failed ({e.message})") - - click.echo() - click.secho( - ( - f"Summary - attempted: {attempted}, fetched: {fetched}, " - f"skipped: {skipped}, failed: {failed}" - ), - fg="cyan", - bold=True, - ) + """(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) From 63ecdae57b8367607bdd82488be96561cc847338 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Sun, 3 May 2026 12:40:23 +0900 Subject: [PATCH 03/13] refactoring stage 2 --- run.py | 48 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/run.py b/run.py index 86a5aa0..dc14023 100755 --- a/run.py +++ b/run.py @@ -79,11 +79,21 @@ def _parse_range_string_list(str_list) -> list[int]: return list(result) -def _dispatch_target(target: str) -> tuple[str, str]: +def _dispatch_target(target: str) -> tuple[str, str, Language]: splited = target.split("/") if len(splited) != 2: - raise ValueError("Invalid target format. Expected format: /") - return splited[0], splited[1] + 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 @unique @@ -391,6 +401,27 @@ def register(location: str): storage_manager.register_location(location) click.echo(f"Location '{location}' registered successfully.") +@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") +def create(target: str, completed: bool): + """ + 지정된 target을 storage에 생성하는 명령어 + """ + loc, prob, lang = _dispatch_target(target) + storage_manager = StorageManager() + if not storage_manager.check_location(loc): + raise click.ClickException(f"Location '{loc}' is not registered. Please register it first.") + filename = prob + if storage_manager.check_location_lang(loc, lang): + existing_file = storage_manager.check_get_file(loc, lang, filename) + if existing_file: + click.echo(f"File '{filename}.{lang.value}' already exists in location '{loc}'.") + return + storage_manager.save_file(loc, lang.value, f"{filename}.{lang.value}", b"", completed) # create empty file + click.echo(f"Problem '{filename}.{lang.value}' created successfully in location '{loc}' with completed status: {completed}.") + + @click.command(name="run") @click.argument("target", type=str, nargs=1, required=True) @click.option("--testcase", "-t", default=["1"], multiple=True) @@ -399,10 +430,7 @@ def run(target: str, testcase: str, verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ - loc, prob = _dispatch_target(target) - - # Language 확인 - from_language: Language = Language.convert_name(from_) + loc, prob, from_language = _dispatch_target(target) if from_language is Language.UNDEFINED: raise click.ClickException("Undefined Language Exception") @@ -492,9 +520,7 @@ def run(target: str, testcase: str, verbose: bool): @click.option("--force", "-f", is_flag=True) def load(file: str, to: str, from_: str, force: bool): """ - 파일을 각 언어의 src-space로 이동시키는 명령어. - 이 명령어는 다음 과정을 수행함. - + Storage의 파일을 각 언어의 src-space로 이동시키는 명령. """ # 파일의 src-space가 어딘지 확인 @@ -994,6 +1020,8 @@ def fetchprob(target: str, force: bool): 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) From 843f2ef321751086231a81203c4baef3faa6a9de Mon Sep 17 00:00:00 2001 From: yenru0 Date: Tue, 5 May 2026 15:22:53 +0900 Subject: [PATCH 04/13] refactoring stage 3 - run command --- run.py | 140 +++++++++++++++++++++++++++------------------------------ 1 file changed, 67 insertions(+), 73 deletions(-) diff --git a/run.py b/run.py index dc14023..3c7afcc 100755 --- a/run.py +++ b/run.py @@ -1,16 +1,9 @@ #!/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 +from enum import Enum, unique import click import yaml @@ -68,7 +61,7 @@ def _parse_range_string(range_str: str) -> list[int]: raise ValueError("Invalid range format") -def _parse_range_string_list(str_list) -> list[int]: +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] @@ -241,7 +234,7 @@ class StateManager: cls._instance = super().__new__(cls) return cls._instance - def load(self) -> dict: + def _load(self) -> dict: if self._state is not None: return self._state try: @@ -260,18 +253,18 @@ class StateManager: yaml.safe_dump(self._state, f) def get_space(self, lang: str) -> dict | None: - state = self.load() + state = self._load() return state.get("space", {}).get(lang) def set_space(self, lang: str, data: dict) -> None: - state = self.load() + 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() + state = self._load() if "space" in state and lang in state["space"]: del state["space"][lang] self._state = state @@ -396,17 +389,22 @@ def register(location: str): """ storage_manager = StorageManager() if storage_manager.check_location(location): - click.echo(f"Location '{location}' is already registered.") + click.secho( + f"Location '{location}' is already registered.", fg="yellow", bold=True + ) else: storage_manager.register_location(location) - click.echo(f"Location '{location}' registered successfully.") + 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") def create(target: str, completed: bool): """ - 지정된 target을 storage에 생성하는 명령어 + 지정된 location을 storage에 생성하는 명령어 """ loc, prob, lang = _dispatch_target(target) storage_manager = StorageManager() @@ -423,62 +421,47 @@ def create(target: str, completed: bool): @click.command(name="run") -@click.argument("target", type=str, nargs=1, required=True) +@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(target: str, testcase: str, verbose: bool): +def run(lang: str, testcase: list[str], verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ - loc, prob, from_language = _dispatch_target(target) - if from_language is Language.UNDEFINED: + # Language 확인 + target_language: Language = Language.convert_name(lang) + + if target_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"] = {} + # load된 state가 있는지 확인 + space = StateManager().get_space(target_language.value) - 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") + if space is None: + raise click.ClickException( + f"No loaded state for language '{target_language.value}'" + ) # 테케 분석 - try: - tcs: list[int] = _parse_range_string_list(target) - except ValueError as e: - raise click.ClickException(e) + tcs: list[int] = _parse_range_string_list(testcase) # build - try: s = subprocess.run( - from_language.build_command, + target_language.build_command, check=True, capture_output=True, text=True, - cwd=from_language.working_dir, + cwd=target_language.working_dir, ) print(s.stderr) except subprocess.CalledProcessError as e: - click.echo(">>>>>> [Error while BUILD process] >>>>>>") + 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") @@ -487,30 +470,43 @@ def run(target: str, testcase: str, verbose: bool): 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}") + + 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.echo("===" * 3) - click.echo(stdout) - click.echo("===" * 3) + 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") @@ -687,9 +683,7 @@ def export(from_: str, completed: bool, copy: bool): 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}" - ) + 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}" ) From 97f1636c57f0572d6e5e7cb4711df85fc400f81e Mon Sep 17 00:00:00 2001 From: yenru0 Date: Tue, 5 May 2026 17:42:51 +0900 Subject: [PATCH 05/13] refactoring stage 4 - modify StateManager to accept Language - fix load command - modify create command --- run.py | 280 ++++++++++++++++++++++++++------------------------------- 1 file changed, 127 insertions(+), 153 deletions(-) diff --git a/run.py b/run.py index 3c7afcc..089e389 100755 --- a/run.py +++ b/run.py @@ -72,23 +72,6 @@ def _parse_range_string_list(str_list: list[str]) -> list[int]: return list(result) -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 - - @unique class Language(Enum): """ @@ -225,6 +208,27 @@ class Language(Enum): 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 @@ -252,21 +256,39 @@ class StateManager: with open(STATE_PATH, "w", encoding="utf-8") as f: yaml.safe_dump(self._state, f) - def get_space(self, lang: str) -> dict | None: + def check_space(self, lang: Language) -> bool: state = self._load() - return state.get("space", {}).get(lang) + return lang.value in state.get("space", {}) and bool(state["space"][lang.value]) - def set_space(self, lang: str, data: dict) -> None: + 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] = data + state["space"][lang.value] = { + "file": file, + "location": location, + "is_completed": is_completed, + } self._state = state - def clear_space(self, lang: str) -> None: + def remove_space(self, lang: Language) -> None: state = self._load() - if "space" in state and lang in state["space"]: - del state["space"][lang] + 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: @@ -319,9 +341,9 @@ class StorageManager: return None def save_file( - self, location: str, lang: str, filename: str, content: bytes, completed: bool + self, location: str, lang: Language, filename: str, content: bytes, completed: bool ) -> None: - path = STORAGE_DIR / location / lang / ("completed" if completed else "") + 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) @@ -401,23 +423,59 @@ def register(location: str): @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") -def create(target: str, completed: bool): +@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) - storage_manager = StorageManager() - if not storage_manager.check_location(loc): - raise click.ClickException(f"Location '{loc}' is not registered. Please register it first.") + + if not StorageManager().check_location(loc): + raise click.ClickException( + f"Location '{loc}' is not registered. Please register it first." + ) + filename = prob - if storage_manager.check_location_lang(loc, lang): - existing_file = storage_manager.check_get_file(loc, lang, filename) - if existing_file: - click.echo(f"File '{filename}.{lang.value}' already exists in location '{loc}'.") + 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 - storage_manager.save_file(loc, lang.value, f"{filename}.{lang.value}", b"", completed) # create empty file - click.echo(f"Problem '{filename}.{lang.value}' created successfully in location '{loc}' with completed status: {completed}.") + + 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") @@ -436,11 +494,9 @@ def run(lang: str, testcase: list[str], verbose: bool): raise click.ClickException("Undefined Language Exception") # load된 state가 있는지 확인 - space = StateManager().get_space(target_language.value) - - if space is None: + if not StateManager().check_space(target_language): raise click.ClickException( - f"No loaded state for language '{target_language.value}'" + f"No loaded file for language '{target_language.value}'. Please load a file first." ) # 테케 분석 @@ -471,8 +527,10 @@ def run(lang: str, testcase: list[str], verbose: bool): 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: + 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( @@ -496,7 +554,9 @@ def run(lang: str, testcase: list[str], verbose: bool): continue flag = stdout.strip() == expected - symbol = click.style("✓", fg="green") if flag else click.style("✗", fg="red") + symbol = ( + click.style("✓", fg="green") if flag else click.style("✗", fg="red") + ) click.echo(f"{tc:02d}: {symbol}") if verbose: @@ -510,127 +570,41 @@ def run(lang: str, testcase: list[str], verbose: bool): @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.argument("target", type=str, nargs=1, required=True) @click.option("--force", "-f", is_flag=True) -def load(file: str, to: str, from_: str, force: bool): +def load(target: str, force: bool): """ Storage의 파일을 각 언어의 src-space로 이동시키는 명령. + - force는 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 + loc, prob, lang = _dispatch_target(target) - 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: + if StateManager().check_space(lang): + if not force: raise click.ClickException( - f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}" + f"Space for language '{lang.value}' is already occupied." ) - # 파일이 존재하는지 확인 - # 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 + path = StorageManager().check_get_file(loc, lang, prob) - 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" + if path is None: + raise click.ClickException( + f"File '{prob}.{lang.value}' not found in location '{loc}'. Use 'create' command to create it first." ) - else: - click.echo(f"File {file_name}.{to_language.value} is successfully created") + + 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") From df8fb68a1d3ae817e8ad5584c4d33850035e91c9 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Tue, 5 May 2026 20:04:07 +0900 Subject: [PATCH 06/13] refactoring stage 5 - fix export command --- run.py | 144 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 86 insertions(+), 58 deletions(-) diff --git a/run.py b/run.py index 089e389..8b1c6a5 100755 --- a/run.py +++ b/run.py @@ -207,6 +207,17 @@ class Language(Enum): 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("/") @@ -341,7 +352,12 @@ class StorageManager: return None def save_file( - self, location: str, lang: Language, filename: str, content: bytes, completed: bool + 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) @@ -392,6 +408,18 @@ class StorageManager: 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}" + ) + # ======================= # MAIN CLI LOGIC @@ -482,7 +510,7 @@ def create(target: str, completed: bool, template: bool, force: bool): @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): +def run(lang: str, testcase: tuple[str, ...], verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ @@ -500,6 +528,7 @@ def run(lang: str, testcase: list[str], verbose: bool): ) # 테케 분석 + testcase: list[str] = list(testcase) tcs: list[int] = _parse_range_string_list(testcase) # build @@ -608,73 +637,72 @@ def load(target: str, force: bool): @click.command(name="export") -@click.option("--from", "from_", type=str, required=True) +@click.argument("from", "from_", type=str, nargs=-1) @click.option( - "--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None + "--all", "all_", default=False, is_flag=True, help="Export all files in the space" ) -@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_) +@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으로 이동시키는 명령. + """ - 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) + def _export_sub_command(from_language: Language): + space = StateManager().get_space(from_language) - 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" + s_file, s_loc, s_is_completed = ( + space["file"], + space["location"], + space["is_completed"], ) - if not s_loc: - raise click.ClickException(f"Export Exception: Location {s_loc} is not defined") + path = StorageManager.construct_target_path( + s_loc, from_language, s_file, s_is_completed + ) - if completed: - s_is_completed = True + if path.is_file() and not force: + raise click.ClickException( + f"File '{path.name}' already exists in location '{s_loc}'. Use --force to overwrite." + ) - 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}" - ) + src_path = ( + SRC_SPACE_DIR + / f"src-{from_language.value}" + / "src" + / f"main.{from_language.value}" + ) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with open(src_path, "rb") as f_src: + content = f_src.read() - with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst: - f_dst.write(f_src.read()) + StorageManager().save_file( + s_loc, from_language, s_file, content, s_is_completed + ) + click.secho( + f"Exported '{s_file}.{from_language.value}' to location '{s_loc}'", + fg="cyan", + bold=True, + ) - 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) + 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.command(name="state") From 7706721eb1a041260e5331e9c52b2626908822c2 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 00:57:45 +0900 Subject: [PATCH 07/13] refactoring stage 6 - enhance state command - enhance init --- run.py | 138 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 84 insertions(+), 54 deletions(-) diff --git a/run.py b/run.py index 8b1c6a5..0655cf5 100755 --- a/run.py +++ b/run.py @@ -305,6 +305,15 @@ class StateManager: def reload(self) -> None: self._state = None + 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 @@ -376,6 +385,16 @@ class StorageManager: 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}" + ) + file_path = f"{path}/{filename}" + if os.path.isfile(file_path): + os.remove(file_path) + def mark_completed(self, location: str, lang: Language, filename: str) -> None: uncompleted_file = ( STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" @@ -629,6 +648,7 @@ def load(target: str, force: bool): 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", @@ -705,38 +725,65 @@ def export(from_: tuple[str, ...], all_: bool, force: bool): _export_sub_command(l) -@click.command(name="state") +@click.group(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) + """State management commands""" + pass - 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 = ( +@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 v["is_completed"] + if space.get("is_completed") else click.style("○", fg="white") ) - click.echo(f" {status} {v['file']}.{k} @ {v['location']}") + 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") @@ -746,36 +793,20 @@ 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.") + raise click.UsageError("count should be greater than 0") - if not os.path.exists("./" + TC_DIR): - os.mkdir(TC_DIR) + TC_DIR.mkdir(exist_ok=True) 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!") + (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.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.secho("2. state.yml exists", fg="green") @click.command(name="show") @@ -797,7 +828,6 @@ 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 From d4aaefa130de343266106d4de239d9b22855d727 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 02:22:28 +0900 Subject: [PATCH 08/13] refactoring stage 7 - implement StorageManager list_entries - fix show - fix find - fix some minor --- run.py | 255 ++++++++++++++++++++++++++------------------------------- 1 file changed, 118 insertions(+), 137 deletions(-) diff --git a/run.py b/run.py index 0655cf5..05610bd 100755 --- a/run.py +++ b/run.py @@ -439,6 +439,65 @@ class StorageManager: / 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 @@ -547,8 +606,7 @@ def run(lang: str, testcase: tuple[str, ...], verbose: bool): ) # 테케 분석 - testcase: list[str] = list(testcase) - tcs: list[int] = _parse_range_string_list(testcase) + tcs: list[int] = _parse_range_string_list(list(testcase)) # build try: @@ -657,7 +715,7 @@ def load(target: str, force: bool): @click.command(name="export") -@click.argument("from", "from_", type=str, nargs=-1) +@click.argument("from_", type=str, nargs=-1) @click.option( "--all", "all_", default=False, is_flag=True, help="Export all files in the space" ) @@ -810,91 +868,41 @@ def init(count: int): @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. +@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 """ - 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] + completed = {"completed": True, "uncompleted": False, "both": None}[completed_mode] - storage = pathlib.Path(STORAGE_DIR) - if not storage.is_dir(): - raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found") + 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 - # 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] + entries = StorageManager().list_entries( + location=location, + lang=Language.convert_name(lang) if lang else None, + completed=completed, + ) if not entries: - click.echo("No problems found.") + click.echo(" - ") return - # Display total = len(entries) completed_count = sum(1 for e in entries if e[3]) uncompleted_count = total - completed_count @@ -906,7 +914,6 @@ def show(filter: str | None, completed: bool, show_all: bool): ) click.echo() - # Group by location current_loc = None current_lang = None for loc_name, lang_name, file_name, is_completed in entries: @@ -928,90 +935,64 @@ def show(filter: str | None, completed: bool, show_all: bool): @click.command(name="find") -@click.argument("keyword", type=str) +@click.argument("filter_", 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): +def find(filter_: str, completed: bool | None): """ - Find problems by keyword in storage. + Find problems by filter 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 + 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 = None - lang = None - if "/" in keyword: - parts = keyword.split("/", 1) + 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 - keyword = parts[1] + filter_ = parts[1] + else: + location = None - # Extract language from extension - if "." in keyword: - keyword_base, ext = keyword.rsplit(".", 1) + 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.value + lang = lang_candidate + filter_base = None if filter_base == "" else filter_base else: - keyword_base = keyword + filter_base = filter_ 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] + 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 = len(entries) + total_count = len(entries) completed_count = sum(1 for e in entries if e[3]) - uncompleted_count = total - completed_count + uncompleted_count = sum(1 for e in entries if not e[3]) click.secho( - f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})", + f"Found: {total_count} (completed: {completed_count}, uncompleted: {uncompleted_count})", fg="cyan", bold=True, ) @@ -1039,7 +1020,7 @@ def find(keyword: str, completed: bool | None): @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") +@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 From 34eac82c7aeb8137cf9a48441824b20ff7ac5d27 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 02:36:37 +0900 Subject: [PATCH 09/13] refactoring stage 8 - minor refactoring - version - status --- run.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/run.py b/run.py index 05610bd..0eac34d 100755 --- a/run.py +++ b/run.py @@ -667,10 +667,10 @@ def run(lang: str, testcase: tuple[str, ...], verbose: bool): if verbose: click.secho("===" * 3, fg="bright_black") - click.secho(f"[INPUT]", fg="cyan", bold=True) + click.secho("[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.secho("[ACTUAL]", fg="cyan", bold=True) click.echo(stdout.strip() if stdout else "(empty)") click.secho("===" * 3, fg="bright_black") @@ -1018,6 +1018,62 @@ def find(filter_: str, completed: bool | None): 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") @@ -1036,6 +1092,8 @@ 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__": From bfb79e573daec18bd870dfa81f0dc1b74a262afa Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 02:40:41 +0900 Subject: [PATCH 10/13] fix create --- run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/run.py b/run.py index 0eac34d..45a176d 100755 --- a/run.py +++ b/run.py @@ -541,7 +541,7 @@ def register(location: str): @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): +def create(target: str, completed: bool, no_template: bool, force: bool): """ 지정된 location을 storage에 생성하는 명령어 """ @@ -563,10 +563,10 @@ def create(target: str, completed: bool, template: bool, force: bool): if not force: return - if not template: + if no_template: content = b"" else: - template_path = TEMPLATES_DIR / f"{lang.value}.txt" + 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") From b15648a17c3e91b7988d2fdddbc9362e4a6ef144 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 02:51:41 +0900 Subject: [PATCH 11/13] fix StateManager to save the state --- run.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/run.py b/run.py index 45a176d..8ef6ceb 100755 --- a/run.py +++ b/run.py @@ -267,6 +267,14 @@ class StateManager: 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]) @@ -277,6 +285,7 @@ class StateManager: 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: @@ -290,12 +299,14 @@ class StateManager: } 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"]: @@ -305,6 +316,7 @@ class StateManager: 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", {}): @@ -391,9 +403,7 @@ class StorageManager: raise FileNotFoundError( f"File not found in storage: {location}/{filename}.{lang.value}" ) - file_path = f"{path}/{filename}" - if os.path.isfile(file_path): - os.remove(file_path) + os.remove(path) def mark_completed(self, location: str, lang: Language, filename: str) -> None: uncompleted_file = ( @@ -1062,9 +1072,7 @@ def status(): 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(f" {lang_name}: {counts['completed']}/{counts['total']}") click.echo() click.secho( From b3cd28471e3b159c061201c7e45d9a7461bdd528 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 02:53:30 +0900 Subject: [PATCH 12/13] fix export command to clear space state --- run.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/run.py b/run.py index 8ef6ceb..bbe3376 100755 --- a/run.py +++ b/run.py @@ -273,6 +273,7 @@ class StateManager: result = func(self, *args, **kwargs) self.save() return result + return wrapper def check_space(self, lang: Language) -> bool: @@ -771,6 +772,7 @@ def export(from_: tuple[str, ...], all_: bool, force: bool): 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", From b4dff1d3445e39c42d2fb3caf855e524f76b248b Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 6 May 2026 03:02:05 +0900 Subject: [PATCH 13/13] fix StorageManager save, read expression and operation fix create fix export; not remove src space --- run.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/run.py b/run.py index bbe3376..dfb831b 100755 --- a/run.py +++ b/run.py @@ -383,7 +383,7 @@ class StorageManager: ) -> 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: + with open(path / f"{filename}.{lang.value}", "wb") as f: f.write(content) def read_file(self, location: str, lang: Language, filename: str) -> bytes: @@ -392,8 +392,8 @@ class StorageManager: raise FileNotFoundError( f"File not found in storage: {location}/{filename}.{lang.value}" ) - file_path = f"{path}/{filename}" - if not os.path.isfile(file_path): + 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() @@ -586,7 +586,7 @@ def create(target: str, completed: bool, no_template: bool, force: bool): click.secho(f"Template not found: {template_path}", fg="yellow") StorageManager().save_file( - loc, lang, f"{filename}.{lang.value}", content, completed + loc, lang, filename, content, completed ) click.secho( f"Created '{filename}.{lang.value}' in location '{loc}'", @@ -768,7 +768,7 @@ def export(from_: tuple[str, ...], all_: bool, force: bool): 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 )