Files
CodeObject/run.py
yenru0 34eac82c7a refactoring stage 8
- minor refactoring
- version
- status
2026-05-06 02:36:37 +09:00

1101 lines
34 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
def set_completed(self, lang: Language, is_completed: bool) -> None:
state = self._load()
if lang.value not in state.get("space", {}):
raise ValueError(f"No space found for language: {lang}")
if not state["space"].get(lang.value):
raise ValueError(f"No space found for language: {lang}")
state["space"][lang.value]["is_completed"] = is_completed
self._state = state
class StorageManager:
_instance: "StorageManager | None" = None
def __new__(cls) -> "StorageManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def register_location(self, location: str) -> None:
path = STORAGE_DIR / location
os.makedirs(path, exist_ok=True)
def check_location(self, location: str) -> bool:
path = STORAGE_DIR / location
return os.path.isdir(path)
def register_location_lang(self, location: str, lang: Language) -> None:
path = STORAGE_DIR / location / lang.value
os.makedirs(path, exist_ok=True)
def check_location_lang(self, location: str, lang: Language) -> bool:
path = STORAGE_DIR / location / lang.value
return os.path.isdir(path)
def check_get_file(
self, location: str, lang: Language, filename: str
) -> pathlib.Path | None:
completed_path = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
uncompleted_path = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
)
if os.path.isfile(completed_path):
return completed_path
elif os.path.isfile(uncompleted_path):
return uncompleted_path
else:
return None
def save_file(
self,
location: str,
lang: Language,
filename: str,
content: bytes,
completed: bool,
) -> None:
path = STORAGE_DIR / location / lang.value / ("completed" if completed else "")
os.makedirs(path, exist_ok=True)
with open(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 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}"
)
completed_file = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
os.makedirs(completed_file.parent, exist_ok=True)
if os.path.isfile(uncompleted_file):
os.rename(uncompleted_file, completed_file)
def unmark_completed(self, location: str, lang: Language, filename: str) -> None:
uncompleted_file = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
)
completed_file = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
)
os.makedirs(uncompleted_file.parent, exist_ok=True)
if os.path.isfile(completed_file):
os.rename(completed_file, uncompleted_file)
@staticmethod
def construct_target_path(
location: str, lang: Language, filename: str, completed: bool
) -> pathlib.Path:
return (
STORAGE_DIR
/ location
/ lang.value
/ ("completed" if completed else "")
/ f"{filename}.{lang.value}"
)
def list_entries(
self,
location: str | None = None,
lang: Language | None = None,
completed: bool | None = None,
) -> list[tuple[str, str, str, bool]]:
"""저장된 파일 목록을 반환합니다.
Args:
location: location 필터 (None이면 전체)
lang: language 필터 (None이면 전체)
completed: completed 필터 (None이면 both, True이면 completed만, False이면 uncompleted만)
Returns:
list[tuple[str, str, str, bool]]: (location, lang, filename, is_completed)
"""
result: list[tuple[str, str, str, bool]] = []
if not STORAGE_DIR.is_dir():
return result
for loc_dir in sorted(STORAGE_DIR.iterdir()):
if not loc_dir.is_dir():
continue
loc_name = loc_dir.name
if location and loc_name != location:
continue
for lang_dir in sorted(loc_dir.iterdir()):
if not lang_dir.is_dir():
continue
lang_name = lang_dir.name
if Language.convert_ext(lang_name) is Language.UNDEFINED:
continue
if lang and lang_name != lang.value:
continue
for f in sorted(
lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
):
if f.is_file():
result.append((loc_name, lang_name, f.stem, False))
completed_dir = lang_dir / "completed"
if completed_dir.is_dir():
for f in sorted(
completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
):
if f.is_file():
result.append((loc_name, lang_name, f.stem, True))
if completed is not None:
result = [e for e in result if e[3] == completed]
return result
# =======================
# MAIN CLI LOGIC
# =======================
@click.group()
def cli():
pass
@click.command(name="register")
@click.argument("location", type=str, nargs=1, required=True)
def register(location: str):
"""
새로운 location을 storage에 등록하는 명령어.
"""
storage_manager = StorageManager()
if storage_manager.check_location(location):
click.secho(
f"Location '{location}' is already registered.", fg="yellow", bold=True
)
else:
storage_manager.register_location(location)
click.secho(
f"Location '{location}' registered successfully.", fg="cyan", bold=True
)
@click.command(name="create")
@click.argument("target", type=str, nargs=1, required=True)
@click.option(
"--completed/--no-completed", "-c/-nc", default=False, help="Mark as completed"
)
@click.option(
"--no-template",
"-nt",
is_flag=True,
help="Do not use template content (create empty file instead)",
)
@click.option(
"--force", "-f", is_flag=True, help="Overwrite existing file if it already exists"
)
def create(target: str, completed: bool, 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."
)
# 테케 분석
tcs: list[int] = _parse_range_string_list(list(testcase))
# build
try:
s = subprocess.run(
target_language.build_command,
check=True,
capture_output=True,
text=True,
cwd=target_language.working_dir,
)
print(s.stderr)
except subprocess.CalledProcessError as e:
click.secho(">>>>>> [BUILD TIME ERROR] >>>>>>", fg="red", bold=True)
raise click.ClickException(e.stderr)
# execution for each test case
for tc in tcs:
if not (
os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out")
):
click.echo(f"{tc:02d}: {None}")
else:
f_in_path = f"{TC_DIR}/{tc}.in"
f_out_path = f"{TC_DIR}/{tc}.out"
with (
open(f_in_path, "r", encoding="utf-8") as f_in,
open(f_out_path, "r", encoding="utf-8") as f_out,
):
expected = f_out.read().strip()
proc = subprocess.Popen(
target_language.executable_command,
stdin=f_in,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if not verbose:
for line in proc.stdout: # type: ignore
print(line, end="")
stdout, stderr = proc.communicate()
if proc.returncode != 0:
click.secho(">>>>>> [RUN TIME ERROR] >>>>>>", fg="red", bold=True)
click.secho(f"{stderr}", fg="red")
click.secho(f"returncode: {proc.returncode}", fg="red")
continue
flag = stdout.strip() == expected
symbol = (
click.style("", fg="green") if flag else click.style("", fg="red")
)
click.echo(f"{tc:02d}: {symbol}")
if verbose:
click.secho("===" * 3, fg="bright_black")
click.secho("[INPUT]", fg="cyan", bold=True)
with open(f_in_path, "r") as f:
click.echo(f.read())
click.secho("[ACTUAL]", fg="cyan", bold=True)
click.echo(stdout.strip() if stdout else "(empty)")
click.secho("===" * 3, fg="bright_black")
@click.command(name="load")
@click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True)
def load(target: str, force: bool):
"""
Storage의 파일을 각 언어의 src-space로 이동시키는 명령.
- force는 src-space에 이미 파일이 존재할 때, 덮어쓸지 결정
"""
# 파일의 src-space가 어딘지 확인
loc, prob, lang = _dispatch_target(target)
if StateManager().check_space(lang):
if not force:
raise click.ClickException(
f"Space for language '{lang.value}' is already occupied."
)
path = StorageManager().check_get_file(loc, lang, prob)
if path is None:
raise click.ClickException(
f"File '{prob}.{lang.value}' not found in location '{loc}'. Use 'create' command to create it first."
)
dest_path = SRC_SPACE_DIR / f"src-{lang.value}" / "src" / f"main.{lang.value}"
with open(path, "rb") as f_src, open(dest_path, "wb") as f_dst:
f_dst.write(f_src.read())
StateManager().remove_space(lang)
StateManager().add_space(lang, prob, loc, False)
StorageManager().remove_file(loc, lang, prob)
click.secho(
f"Loaded '{prob}.{lang.value}' from location '{loc}' to src-space.",
fg="cyan",
bold=True,
)
@click.command(name="export")
@click.argument("from_", type=str, nargs=-1)
@click.option(
"--all", "all_", default=False, is_flag=True, help="Export all files in the space"
)
@click.option(
"--force",
"-f",
is_flag=True,
help="Overwrite existing file in storage if it already exists",
)
def export(from_: tuple[str, ...], all_: bool, force: bool):
"""
space에 있는 파일을 storage의 각 location으로 이동시키는 명령.
"""
def _export_sub_command(from_language: Language):
space = StateManager().get_space(from_language)
s_file, s_loc, s_is_completed = (
space["file"],
space["location"],
space["is_completed"],
)
path = StorageManager.construct_target_path(
s_loc, from_language, s_file, s_is_completed
)
if path.is_file() and not force:
raise click.ClickException(
f"File '{path.name}' already exists in location '{s_loc}'. Use --force to overwrite."
)
src_path = (
SRC_SPACE_DIR
/ f"src-{from_language.value}"
/ "src"
/ f"main.{from_language.value}"
)
with open(src_path, "rb") as f_src:
content = f_src.read()
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.group(name="state")
def state():
"""State management commands"""
pass
@state.command(name="show")
def state_show():
"""Show current space state"""
click.secho("Current Space State:", fg="cyan", bold=True)
for lang in Language.languages():
space = (
StateManager().get_space(lang) if StateManager().check_space(lang) else None
)
if space:
status_symbol = (
click.style("", fg="green")
if space.get("is_completed")
else click.style("", fg="white")
)
click.echo(
f"{status_symbol} {lang.value}: {space['file']} (Location: {space['location']})"
)
else:
click.echo(f"{click.style('', fg='white')} {lang.value}: No file loaded")
@state.command(name="complete")
@click.argument("lang", type=str)
def state_complete(lang: str):
"""Mark language space as completed"""
lang_obj = Language.convert_name(lang)
if lang_obj is Language.UNDEFINED:
raise click.ClickException(f"Unknown language: {lang}")
state_manager = StateManager()
try:
state_manager.set_completed(lang_obj, True)
state_manager.save()
except ValueError as e:
raise click.ClickException(str(e))
click.secho(f"Language '{lang}' marked as completed.", fg="green")
@state.command(name="uncomplete")
@click.argument("lang", type=str)
def state_uncomplete(lang: str):
"""Mark language space as not completed"""
lang_obj = Language.convert_name(lang)
if lang_obj is Language.UNDEFINED:
raise click.ClickException(f"Unknown language: {lang}")
state_manager = StateManager()
try:
state_manager.set_completed(lang_obj, False)
state_manager.save()
except ValueError as e:
raise click.ClickException(str(e))
click.secho(f"Language '{lang}' marked as not completed.", fg="green")
@click.command(name="init")
@click.argument("count", type=int)
def init(count: int):
"""
init testcases with initial count and state.yml and config.yml
"""
if count < 1:
raise click.UsageError("count should be greater than 0")
TC_DIR.mkdir(exist_ok=True)
for i in range(1, count + 1):
(TC_DIR / f"{i}.in").touch()
(TC_DIR / f"{i}.out").touch()
click.secho(f"1. Testcases created with count {count}", fg="cyan")
click.secho("2. Checking state.yml...", fg="cyan")
if not STATE_PATH.is_file():
click.secho("2. state.yml does not exist, creating...", fg="yellow")
STATE_PATH.write_text("space:\n", encoding="utf-8")
click.secho("2. state.yml created", fg="cyan")
else:
click.secho("2. state.yml exists", fg="green")
@click.command(name="show")
@click.argument("filter_", type=str, default=None, required=False)
@click.option("--completed", "completed_mode", flag_value="completed", default=True)
@click.option("--uncompleted", "completed_mode", flag_value="uncompleted")
@click.option("--both-completed", "completed_mode", flag_value="both")
def show(filter_: str | None, completed_mode: str):
"""Show all stored problems in storage directory.
FILTER format (optional):
location e.g. zeta
location/lang e.g. zeta/rs
/lang e.g. /py
FILTER MODE (default: --completed):
--completed show completed only
--uncompleted show uncompleted only
--both-completed show both completed and uncompleted
"""
completed = {"completed": True, "uncompleted": False, "both": None}[completed_mode]
location = lang = None
if filter_:
parts = filter_.split("/", 1)
location = parts[0] if parts[0].strip() else None
lang = parts[1] if len(parts) == 2 else None
entries = StorageManager().list_entries(
location=location,
lang=Language.convert_name(lang) if lang else None,
completed=completed,
)
if not entries:
click.echo(" - ")
return
total = len(entries)
completed_count = sum(1 for e in entries if e[3])
uncompleted_count = total - completed_count
click.secho(
f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
fg="cyan",
bold=True,
)
click.echo()
current_loc = None
current_lang = None
for loc_name, lang_name, file_name, is_completed in entries:
if loc_name != current_loc:
current_loc = loc_name
current_lang = None
click.secho(f"[{loc_name}]", fg="green", bold=True)
if lang_name != current_lang:
current_lang = lang_name
click.secho(f" {lang_name}/", fg="yellow")
status = (
click.style("", fg="green")
if is_completed
else click.style("", fg="white")
)
click.echo(f" {status} {file_name}.{lang_name}")
@click.command(name="find")
@click.argument("filter_", type=str)
@click.option(
"--completed/--no-completed",
"-c/-nc",
default=None,
help="Filter by completed status (default: all)",
)
def find(filter_: str, completed: bool | None):
"""
Find problems by filter in storage.
FILTER format:
filter e.g. 2447
filter.ext e.g. 2447.py (filters by language)
.ext e.g. .py (all files of a language)
location/filter e.g. zeta/2447
location/filter.ext e.g. zeta/2447.py
location/.ext e.g. zeta/.py
"""
location: str | None = None
filter_base: str | None = None
lang: Language | None = None
if "/" in filter_:
parts = filter_.split("/", 1)
location = parts[0] or None
filter_ = parts[1]
else:
location = None
if "." in filter_:
filter_base, ext = filter_.rsplit(".", 1)
lang_candidate = Language.convert_ext(ext)
if lang_candidate is not Language.UNDEFINED:
lang = lang_candidate
filter_base = None if filter_base == "" else filter_base
else:
filter_base = filter_
else:
filter_base = filter_
entries = StorageManager().list_entries(
location=location, lang=lang, completed=completed
)
if filter_base is not None:
entries = [e for e in entries if filter_base.lower() in e[2].lower()]
entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2])))
if not entries:
click.echo("No problems found.")
return
total_count = len(entries)
completed_count = sum(1 for e in entries if e[3])
uncompleted_count = sum(1 for e in entries if not e[3])
click.secho(
f"Found: {total_count} (completed: {completed_count}, uncompleted: {uncompleted_count})",
fg="cyan",
bold=True,
)
click.echo()
current_loc = None
current_lang = None
for loc_name, lang_name, file_name, is_completed in entries:
if loc_name != current_loc:
current_loc = loc_name
current_lang = None
click.secho(f"[{loc_name}]", fg="green", bold=True)
if lang_name != current_lang:
current_lang = lang_name
click.secho(f" {lang_name}/", fg="yellow")
status = (
click.style("", fg="green")
if is_completed
else click.style("", fg="white")
)
click.echo(f" {status} {file_name}.{lang_name}")
@click.command(name="version")
def version():
"""Show version information."""
click.echo(f"CodeObject version {__version__}")
@click.command(name="status")
def status():
"""Show storage statistics."""
all_entries = StorageManager().list_entries()
if not all_entries:
click.echo("No problems in storage.")
return
stats: dict[str, dict[str, dict[str, int]]] = {}
for loc, lang, _, is_completed in all_entries:
if loc not in stats:
stats[loc] = {}
if lang not in stats[loc]:
stats[loc][lang] = {"total": 0, "completed": 0}
stats[loc][lang]["total"] += 1
if is_completed:
stats[loc][lang]["completed"] += 1
total_problems = len(all_entries)
total_completed = sum(1 for _, _, _, c in all_entries if c)
click.secho("Storage Status", fg="cyan", bold=True)
click.echo()
for loc_name, langs in sorted(stats.items()):
loc_total = sum(c["total"] for c in langs.values())
loc_completed = sum(c["completed"] for c in langs.values())
loc_uncompleted = loc_total - loc_completed
click.secho(f"[{loc_name}]", fg="green", bold=True, nl=False)
click.secho(f" {loc_total}", fg="yellow", bold=False, nl=False)
click.secho(" (", fg="white", bold=False, nl=False)
click.secho(f"{loc_completed}", fg="green", bold=False, nl=False)
click.secho(" completed, ", fg="cyan", bold=False, nl=False)
click.secho(f"{loc_uncompleted}", fg="red", bold=False, nl=False)
click.secho(" not completed", fg="cyan", bold=False, nl=False)
click.secho(")", fg="white", bold=False)
for lang_name, counts in sorted(langs.items()):
click.echo(
f" {lang_name}: {counts['completed']}/{counts['total']}"
)
click.echo()
click.secho(
f"Total: {total_problems} (completed: {total_completed}, "
f"uncompleted: {total_problems - total_completed})",
fg="cyan",
)
@click.command(name="fetchprob")
@click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True, help="Overwrite existing static files")
def fetchprob(target: str, force: bool):
"""(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support."""
del target, force
raise click.ClickException("fetchprob is currently disabled (BOJ closing)")
cli.add_command(register)
cli.add_command(create)
cli.add_command(run)
cli.add_command(load)
cli.add_command(init)
cli.add_command(export)
cli.add_command(state)
cli.add_command(show)
cli.add_command(find)
cli.add_command(version)
cli.add_command(status)
cli.add_command(fetchprob)
if __name__ == "__main__":
cli()