Files
CodeObject/run.py
yenru0 df8fb68a1d refactoring stage 5
- fix export command
2026-05-05 20:04:07 +09:00

1032 lines
31 KiB
Python
Executable File

#!/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: <loc>/<problem>.<lang>"
)
prob_parts = splited[1].rsplit(".", 1)
if len(prob_parts) != 2:
raise ValueError(
"Invalid target format. Expected format: <loc>/<problem>.<lang>"
)
prob_name, ext = prob_parts
lang = Language.convert_ext(ext)
if lang is Language.UNDEFINED:
raise ValueError(f"Unknown language: {ext}")
return splited[0], prob_name, lang
class StateManager:
_instance: "StateManager | None" = None
_state: dict | None = None
def __new__(cls) -> "StateManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def _load(self) -> dict:
if self._state is not None:
return self._state
try:
with open(STATE_PATH, "r", encoding="utf-8") as f:
self._state = yaml.safe_load(f) or {}
except FileNotFoundError as exc:
raise click.ClickException(f"state.yml not found at {STATE_PATH}") from exc
except yaml.YAMLError as e:
raise click.ClickException(f"state.yml parse error: {e}") from e
return self._state # type: ignore[return-value]
def save(self) -> None:
if self._state is None:
return
with open(STATE_PATH, "w", encoding="utf-8") as f:
yaml.safe_dump(self._state, f)
def check_space(self, lang: Language) -> bool:
state = self._load()
return lang.value in state.get("space", {}) and bool(state["space"][lang.value])
def get_space(self, lang: Language) -> dict:
state = self._load()
if not self.check_space(lang):
raise ValueError(f"No space found for language: {lang}")
return state["space"][lang.value]
def add_space(
self, lang: Language, file: str, location: str, is_completed: bool
) -> None:
state = self._load()
if "space" not in state:
state["space"] = {}
state["space"][lang.value] = {
"file": file,
"location": location,
"is_completed": is_completed,
}
self._state = state
def remove_space(self, lang: Language) -> None:
state = self._load()
if "space" in state and lang.value in state["space"]:
state["space"][lang.value] = {}
self._state = state
def clear_space(self, lang: Language) -> None:
state = self._load()
if "space" in state and lang.value in state["space"]:
del state["space"][lang.value]
self._state = state
def reload(self) -> None:
self._state = None
class StorageManager:
_instance: "StorageManager | None" = None
def __new__(cls) -> "StorageManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def register_location(self, location: str) -> None:
path = STORAGE_DIR / location
os.makedirs(path, exist_ok=True)
def check_location(self, location: str) -> bool:
path = STORAGE_DIR / location
return os.path.isdir(path)
def register_location_lang(self, location: str, lang: Language) -> None:
path = STORAGE_DIR / location / lang.value
os.makedirs(path, exist_ok=True)
def check_location_lang(self, location: str, lang: Language) -> bool:
path = STORAGE_DIR / location / lang.value
return os.path.isdir(path)
def check_get_file(
self, location: str, lang: Language, filename: str
) -> pathlib.Path | None:
completed_path = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
uncompleted_path = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
)
if os.path.isfile(completed_path):
return completed_path
elif os.path.isfile(uncompleted_path):
return uncompleted_path
else:
return None
def save_file(
self,
location: str,
lang: Language,
filename: str,
content: bytes,
completed: bool,
) -> None:
path = STORAGE_DIR / location / lang.value / ("completed" if completed else "")
os.makedirs(path, exist_ok=True)
with open(f"{path}/{filename}", "wb") as f:
f.write(content)
def read_file(self, location: str, lang: Language, filename: str) -> bytes:
path = self.check_get_file(location, lang, filename)
if path is None:
raise FileNotFoundError(
f"File not found in storage: {location}/{filename}.{lang.value}"
)
file_path = f"{path}/{filename}"
if not os.path.isfile(file_path):
raise FileNotFoundError(f"File not found in storage: {file_path}")
with open(file_path, "rb") as f:
return f.read()
def mark_completed(self, location: str, lang: Language, filename: str) -> None:
uncompleted_file = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
)
completed_file = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
os.makedirs(completed_file.parent, exist_ok=True)
if os.path.isfile(uncompleted_file):
os.rename(uncompleted_file, completed_file)
def unmark_completed(self, location: str, lang: Language, filename: str) -> None:
uncompleted_file = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
)
completed_file = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
os.makedirs(uncompleted_file.parent, exist_ok=True)
if os.path.isfile(completed_file):
os.rename(completed_file, uncompleted_file)
@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
# =======================
@click.group()
def cli():
pass
@click.command(name="register")
@click.argument("location", type=str, nargs=1, required=True)
def register(location: str):
"""
새로운 location을 storage에 등록하는 명령어.
"""
storage_manager = StorageManager()
if storage_manager.check_location(location):
click.secho(
f"Location '{location}' is already registered.", fg="yellow", bold=True
)
else:
storage_manager.register_location(location)
click.secho(
f"Location '{location}' registered successfully.", fg="cyan", bold=True
)
@click.command(name="create")
@click.argument("target", type=str, nargs=1, required=True)
@click.option(
"--completed/--no-completed", "-c/-nc", default=False, help="Mark as completed"
)
@click.option(
"--no-template",
"-nt",
is_flag=True,
help="Do not use template content (create empty file instead)",
)
@click.option(
"--force", "-f", is_flag=True, help="Overwrite existing file if it already exists"
)
def create(target: str, completed: bool, template: bool, force: bool):
"""
지정된 location을 storage에 생성하는 명령어
"""
loc, prob, lang = _dispatch_target(target)
if not StorageManager().check_location(loc):
raise click.ClickException(
f"Location '{loc}' is not registered. Please register it first."
)
filename = prob
existing = StorageManager().check_get_file(loc, lang, filename)
if existing:
click.secho(
f"File '{filename}.{lang.value}' already exists in location '{loc}'",
fg="yellow",
bold=True,
)
if not force:
return
if not template:
content = b""
else:
template_path = TEMPLATES_DIR / f"{lang.value}.txt"
if template_path.is_file():
content = template_path.read_bytes()
click.secho(f"Using template: {template_path}", fg="cyan")
else:
content = b""
click.secho(f"Template not found: {template_path}", fg="yellow")
StorageManager().save_file(
loc, lang, f"{filename}.{lang.value}", content, completed
)
click.secho(
f"Created '{filename}.{lang.value}' in location '{loc}'",
fg="cyan",
bold=True,
)
@click.command(name="run")
@click.argument("lang", type=str, nargs=1, required=True)
@click.option("--testcase", "-t", default=["1"], multiple=True)
@click.option("--verbose/--no-verbose", "-v/-nv", default=True)
def run(lang: str, testcase: 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."
)
# 테케 분석
testcase: list[str] = list(testcase)
tcs: list[int] = _parse_range_string_list(testcase)
# build
try:
s = subprocess.run(
target_language.build_command,
check=True,
capture_output=True,
text=True,
cwd=target_language.working_dir,
)
print(s.stderr)
except subprocess.CalledProcessError as e:
click.secho(">>>>>> [BUILD TIME ERROR] >>>>>>", fg="red", bold=True)
raise click.ClickException(e.stderr)
# execution for each test case
for tc in tcs:
if not (
os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out")
):
click.echo(f"{tc:02d}: {None}")
else:
f_in_path = f"{TC_DIR}/{tc}.in"
f_out_path = f"{TC_DIR}/{tc}.out"
with (
open(f_in_path, "r", encoding="utf-8") as f_in,
open(f_out_path, "r", encoding="utf-8") as f_out,
):
expected = f_out.read().strip()
proc = subprocess.Popen(
target_language.executable_command,
stdin=f_in,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if not verbose:
for line in proc.stdout: # type: ignore
print(line, end="")
stdout, stderr = proc.communicate()
if proc.returncode != 0:
click.secho(">>>>>> [RUN TIME ERROR] >>>>>>", fg="red", bold=True)
click.secho(f"{stderr}", fg="red")
click.secho(f"returncode: {proc.returncode}", fg="red")
continue
flag = stdout.strip() == expected
symbol = (
click.style("", fg="green") if flag else click.style("", fg="red")
)
click.echo(f"{tc:02d}: {symbol}")
if verbose:
click.secho("===" * 3, fg="bright_black")
click.secho(f"[INPUT]", fg="cyan", bold=True)
with open(f_in_path, "r") as f:
click.echo(f.read())
click.secho(f"[ACTUAL]", fg="cyan", bold=True)
click.echo(stdout.strip() if stdout else "(empty)")
click.secho("===" * 3, fg="bright_black")
@click.command(name="load")
@click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True)
def load(target: str, force: bool):
"""
Storage의 파일을 각 언어의 src-space로 이동시키는 명령.
- force는 src-space에 이미 파일이 존재할 때, 덮어쓸지 결정
"""
# 파일의 src-space가 어딘지 확인
loc, prob, lang = _dispatch_target(target)
if StateManager().check_space(lang):
if not force:
raise click.ClickException(
f"Space for language '{lang.value}' is already occupied."
)
path = StorageManager().check_get_file(loc, lang, prob)
if path is None:
raise click.ClickException(
f"File '{prob}.{lang.value}' not found in location '{loc}'. Use 'create' command to create it first."
)
dest_path = SRC_SPACE_DIR / f"src-{lang.value}" / "src" / f"main.{lang.value}"
with open(path, "rb") as f_src, open(dest_path, "wb") as f_dst:
f_dst.write(f_src.read())
StateManager().remove_space(lang)
StateManager().add_space(lang, prob, loc, False)
click.secho(
f"Loaded '{prob}.{lang.value}' from location '{loc}' to src-space.",
fg="cyan",
bold=True,
)
@click.command(name="export")
@click.argument("from", "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()
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,
)
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")
def state():
try:
with open(STATE_PATH, "r", encoding="utf-8") as f_state:
state = yaml.safe_load(f_state)
except FileNotFoundError as e:
raise click.ClickException(e)
except yaml.YAMLError as e:
raise click.ClickException(e)
if "space" not in state:
raise click.ClickException("state.yml is wrong")
elif not state["space"]:
click.secho("state.space has no member", fg="yellow", bold=True)
state["space"] = {}
active = sum(1 for v in state["space"].values() if v)
total = len(state["space"])
click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True)
click.echo()
for k, v in state["space"].items():
click.secho(f" {k}/", fg="yellow")
if not v:
click.echo(f" {click.style('', fg='bright_black')} empty")
else:
status = (
click.style("", fg="green")
if v["is_completed"]
else click.style("", fg="white")
)
click.echo(f" {status} {v['file']}.{k} @ {v['location']}")
@click.command(name="init")
@click.argument("count", type=int)
def init(count: int):
"""
init testcases with initial count and state.yml and config.yml
"""
if count < 1:
raise click.UsageError("var count should be greater than 1.")
if not os.path.exists("./" + TC_DIR):
os.mkdir(TC_DIR)
for i in range(1, count + 1):
default_in_path = "./" + TC_DIR + f"/{i}.in"
default_out_path = "./" + TC_DIR + f"/{i}.out"
if not os.path.exists(default_in_path):
with open(default_in_path, "w", encoding="utf-8") as _:
pass
if not os.path.exists(default_out_path):
with open(default_out_path, "w", encoding="utf-8") as _:
pass
click.echo(f"1. Testcases was made with count {count} successfully!")
if not os.path.isfile("./state.yml"):
click.echo("2-1. state.yml does not exist, creating...")
with open("state.yml", "w", encoding="utf-8") as f:
f.write("space:\n")
click.echo("2-1. state.yml is created successfully!")
else:
click.echo("2-1. state.yml exists")
if not os.path.isfile("./config.yml"):
click.echo("2-2. config.yml does not exist, creating...")
with open("config.yml", "w", encoding="utf-8") as f:
f.write("space:\n")
click.echo("2-2. config.yml is created successfully!")
else:
click.echo("2-2. config.yml exists")
@click.command(name="show")
@click.argument("filter", type=str, default=None, required=False)
@click.option(
"--completed/--no-completed",
"-c/-nc",
default=False,
help="Filter by completed status (default: uncompleted only)",
)
@click.option(
"--all",
"show_all",
is_flag=True,
default=False,
help="Show all problems regardless of completed status",
)
def show(filter: str | None, completed: bool, show_all: bool):
"""
Show all stored problems in storage directory.
\b
FILTER format (optional):
location e.g. zeta
location/lang e.g. zeta/rs
/lang e.g. /py
"""
location = None
lang = None
if filter:
parts = filter.split("/", 1)
if len(parts) == 2:
location = parts[0] or None
lang = parts[1] or None
else:
location = parts[0]
storage = pathlib.Path(STORAGE_DIR)
if not storage.is_dir():
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
# Collect all files
entries = [] # (location, language, filename, is_completed)
for loc_dir in sorted(storage.iterdir()):
if not loc_dir.is_dir():
continue
loc_name = loc_dir.name
if location and loc_name != location:
continue
for lang_dir in sorted(loc_dir.iterdir()):
if not lang_dir.is_dir():
continue
lang_name = lang_dir.name
if (
lang
and lang_name != Language.convert_name(lang).value
and lang_name != lang
):
continue
# uncompleted files
for f in sorted(
lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
):
if f.is_file():
entries.append((loc_name, lang_name, f.stem, False))
# completed files
completed_dir = lang_dir / "completed"
if completed_dir.is_dir():
for f in sorted(
completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
):
if f.is_file():
entries.append((loc_name, lang_name, f.stem, True))
# Filter by completed status
if not show_all:
entries = [e for e in entries if e[3] == completed]
if not entries:
click.echo("No problems found.")
return
# Display
total = len(entries)
completed_count = sum(1 for e in entries if e[3])
uncompleted_count = total - completed_count
click.secho(
f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
fg="cyan",
bold=True,
)
click.echo()
# Group by location
current_loc = None
current_lang = None
for loc_name, lang_name, file_name, is_completed in entries:
if loc_name != current_loc:
current_loc = loc_name
current_lang = None
click.secho(f"[{loc_name}]", fg="green", bold=True)
if lang_name != current_lang:
current_lang = lang_name
click.secho(f" {lang_name}/", fg="yellow")
status = (
click.style("", fg="green")
if is_completed
else click.style("", fg="white")
)
click.echo(f" {status} {file_name}.{lang_name}")
@click.command(name="find")
@click.argument("keyword", type=str)
@click.option(
"--completed/--no-completed",
"-c/-nc",
default=None,
help="Filter by completed status (default: all)",
)
def find(keyword: str, completed: bool | None):
"""
Find problems by keyword in storage.
KEYWORD format:
keyword e.g. 2447
keyword.ext e.g. 2447.py (filters by language)
.ext e.g. .py (all files of a language)
location/keyword e.g. zeta/2447
location/keyword.ext e.g. zeta/2447.py
location/.ext e.g. zeta/.py
"""
location = None
lang = None
if "/" in keyword:
parts = keyword.split("/", 1)
location = parts[0] or None
keyword = parts[1]
# Extract language from extension
if "." in keyword:
keyword_base, ext = keyword.rsplit(".", 1)
lang_candidate = Language.convert_ext(ext)
if lang_candidate is not Language.UNDEFINED:
lang = lang_candidate.value
else:
keyword_base = keyword
else:
keyword_base = keyword
storage = pathlib.Path(STORAGE_DIR)
if not storage.is_dir():
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
entries = [] # (location, language, filename, is_completed)
for loc_dir in sorted(storage.iterdir()):
if not loc_dir.is_dir():
continue
loc_name = loc_dir.name
if location and loc_name != location:
continue
for lang_dir in sorted(loc_dir.iterdir()):
if not lang_dir.is_dir():
continue
lang_name = lang_dir.name
if lang and lang_name != lang:
continue
for f in lang_dir.iterdir():
if f.is_file() and keyword_base.lower() in f.stem.lower():
entries.append((loc_name, lang_name, f.stem, False))
completed_dir = lang_dir / "completed"
if completed_dir.is_dir():
for f in completed_dir.iterdir():
if f.is_file() and keyword_base.lower() in f.stem.lower():
entries.append((loc_name, lang_name, f.stem, True))
if completed is not None:
entries = [e for e in entries if e[3] == completed]
entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2])))
if not entries:
click.echo("No problems found.")
return
total = len(entries)
completed_count = sum(1 for e in entries if e[3])
uncompleted_count = total - completed_count
click.secho(
f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
fg="cyan",
bold=True,
)
click.echo()
current_loc = None
current_lang = None
for loc_name, lang_name, file_name, is_completed in entries:
if loc_name != current_loc:
current_loc = loc_name
current_lang = None
click.secho(f"[{loc_name}]", fg="green", bold=True)
if lang_name != current_lang:
current_lang = lang_name
click.secho(f" {lang_name}/", fg="yellow")
status = (
click.style("", fg="green")
if is_completed
else click.style("", fg="white")
)
click.echo(f" {status} {file_name}.{lang_name}")
@click.command(name="fetchprob")
@click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files")
def fetchprob(target: str, force: bool):
"""(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support."""
del target, force
raise click.ClickException("fetchprob is currently disabled (BOJ closing)")
cli.add_command(register)
cli.add_command(create)
cli.add_command(run)
cli.add_command(load)
cli.add_command(init)
cli.add_command(export)
cli.add_command(state)
cli.add_command(show)
cli.add_command(find)
cli.add_command(fetchprob)
if __name__ == "__main__":
cli()