- modify StateManager to accept Language - fix load command - modify create command
1004 lines
31 KiB
Python
Executable File
1004 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
|
|
|
|
|
|
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)
|
|
|
|
|
|
# =======================
|
|
# 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: list[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(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.option("--from", "from_", type=str, required=True)
|
|
@click.option(
|
|
"--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None
|
|
)
|
|
@click.option("--copy", is_flag=True, help="is copy from state or move")
|
|
def export(from_: str, completed: bool, copy: bool):
|
|
# Language 알기
|
|
from_language = Language.convert_name(from_)
|
|
|
|
if from_language is Language.UNDEFINED:
|
|
raise click.ClickException("Undefined Language Exception")
|
|
try:
|
|
with open("state.yml", "r", encoding="utf-8") as f:
|
|
state = yaml.safe_load(f)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("State(state.yml) Exception")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
lang_space_state: dict | None
|
|
if from_language.value not in state["space"]:
|
|
lang_space_state = None
|
|
else:
|
|
lang_space_state = state["space"][from_language.value]
|
|
|
|
if lang_space_state is None or not lang_space_state:
|
|
raise click.ClickException("Export Exception: There are no pre-defined state")
|
|
|
|
s_file = lang_space_state["file"]
|
|
s_loc = lang_space_state["location"]
|
|
s_is_completed = lang_space_state["is_completed"]
|
|
|
|
if not s_file:
|
|
raise click.ClickException(
|
|
f"Export Exception: There is no file in {from_language.value} space"
|
|
)
|
|
|
|
if not s_loc:
|
|
raise click.ClickException(f"Export Exception: Location {s_loc} is not defined")
|
|
|
|
if completed:
|
|
s_is_completed = True
|
|
|
|
dest_path: str = f"{STORAGE_DIR}/{s_loc}/{from_language.value}{'/completed' if s_is_completed else ''}/{s_file}.{from_language.value}"
|
|
source_path: str = (
|
|
f"{SRC_SPACE_DIR}/src-{from_language.value}/src/main.{from_language.value}"
|
|
)
|
|
|
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
|
|
with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst:
|
|
f_dst.write(f_src.read())
|
|
|
|
click.echo(f"File {s_file}.{from_language.value} successfully ")
|
|
|
|
if not copy:
|
|
with open("state.yml", "w", encoding="utf-8") as f:
|
|
state["space"][from_language.value] = {}
|
|
yaml.safe_dump(state, f)
|
|
|
|
os.remove(source_path)
|
|
|
|
|
|
@click.command(name="state")
|
|
def state():
|
|
try:
|
|
with open(STATE_PATH, "r", encoding="utf-8") as f_state:
|
|
state = yaml.safe_load(f_state)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("state.yml is wrong")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
active = sum(1 for v in state["space"].values() if v)
|
|
total = len(state["space"])
|
|
click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True)
|
|
click.echo()
|
|
|
|
for k, v in state["space"].items():
|
|
click.secho(f" {k}/", fg="yellow")
|
|
if not v:
|
|
click.echo(f" {click.style('—', fg='bright_black')} empty")
|
|
else:
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if v["is_completed"]
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {v['file']}.{k} @ {v['location']}")
|
|
|
|
|
|
@click.command(name="init")
|
|
@click.argument("count", type=int)
|
|
def init(count: int):
|
|
"""
|
|
init testcases with initial count and state.yml and config.yml
|
|
"""
|
|
if count < 1:
|
|
raise click.UsageError("var count should be greater than 1.")
|
|
|
|
if not os.path.exists("./" + TC_DIR):
|
|
os.mkdir(TC_DIR)
|
|
for i in range(1, count + 1):
|
|
default_in_path = "./" + TC_DIR + f"/{i}.in"
|
|
default_out_path = "./" + TC_DIR + f"/{i}.out"
|
|
if not os.path.exists(default_in_path):
|
|
with open(default_in_path, "w", encoding="utf-8") as _:
|
|
pass
|
|
if not os.path.exists(default_out_path):
|
|
with open(default_out_path, "w", encoding="utf-8") as _:
|
|
pass
|
|
|
|
click.echo(f"1. Testcases was made with count {count} successfully!")
|
|
|
|
if not os.path.isfile("./state.yml"):
|
|
click.echo("2-1. state.yml does not exist, creating...")
|
|
with open("state.yml", "w", encoding="utf-8") as f:
|
|
f.write("space:\n")
|
|
click.echo("2-1. state.yml is created successfully!")
|
|
else:
|
|
click.echo("2-1. state.yml exists")
|
|
if not os.path.isfile("./config.yml"):
|
|
click.echo("2-2. config.yml does not exist, creating...")
|
|
with open("config.yml", "w", encoding="utf-8") as f:
|
|
f.write("space:\n")
|
|
click.echo("2-2. config.yml is created successfully!")
|
|
else:
|
|
click.echo("2-2. config.yml exists")
|
|
|
|
|
|
@click.command(name="show")
|
|
@click.argument("filter", type=str, default=None, required=False)
|
|
@click.option(
|
|
"--completed/--no-completed",
|
|
"-c/-nc",
|
|
default=False,
|
|
help="Filter by completed status (default: uncompleted only)",
|
|
)
|
|
@click.option(
|
|
"--all",
|
|
"show_all",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Show all problems regardless of completed status",
|
|
)
|
|
def show(filter: str | None, completed: bool, show_all: bool):
|
|
"""
|
|
Show all stored problems in storage directory.
|
|
|
|
\b
|
|
FILTER format (optional):
|
|
location e.g. zeta
|
|
location/lang e.g. zeta/rs
|
|
/lang e.g. /py
|
|
"""
|
|
location = None
|
|
lang = None
|
|
if filter:
|
|
parts = filter.split("/", 1)
|
|
if len(parts) == 2:
|
|
location = parts[0] or None
|
|
lang = parts[1] or None
|
|
else:
|
|
location = parts[0]
|
|
|
|
storage = pathlib.Path(STORAGE_DIR)
|
|
if not storage.is_dir():
|
|
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
|
|
|
|
# Collect all files
|
|
entries = [] # (location, language, filename, is_completed)
|
|
|
|
for loc_dir in sorted(storage.iterdir()):
|
|
if not loc_dir.is_dir():
|
|
continue
|
|
loc_name = loc_dir.name
|
|
|
|
if location and loc_name != location:
|
|
continue
|
|
|
|
for lang_dir in sorted(loc_dir.iterdir()):
|
|
if not lang_dir.is_dir():
|
|
continue
|
|
lang_name = lang_dir.name
|
|
|
|
if (
|
|
lang
|
|
and lang_name != Language.convert_name(lang).value
|
|
and lang_name != lang
|
|
):
|
|
continue
|
|
|
|
# uncompleted files
|
|
for f in sorted(
|
|
lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
|
|
):
|
|
if f.is_file():
|
|
entries.append((loc_name, lang_name, f.stem, False))
|
|
|
|
# completed files
|
|
completed_dir = lang_dir / "completed"
|
|
if completed_dir.is_dir():
|
|
for f in sorted(
|
|
completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
|
|
):
|
|
if f.is_file():
|
|
entries.append((loc_name, lang_name, f.stem, True))
|
|
|
|
# Filter by completed status
|
|
if not show_all:
|
|
entries = [e for e in entries if e[3] == completed]
|
|
|
|
if not entries:
|
|
click.echo("No problems found.")
|
|
return
|
|
|
|
# Display
|
|
total = len(entries)
|
|
completed_count = sum(1 for e in entries if e[3])
|
|
uncompleted_count = total - completed_count
|
|
|
|
click.secho(
|
|
f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
|
|
fg="cyan",
|
|
bold=True,
|
|
)
|
|
click.echo()
|
|
|
|
# Group by location
|
|
current_loc = None
|
|
current_lang = None
|
|
for loc_name, lang_name, file_name, is_completed in entries:
|
|
if loc_name != current_loc:
|
|
current_loc = loc_name
|
|
current_lang = None
|
|
click.secho(f"[{loc_name}]", fg="green", bold=True)
|
|
|
|
if lang_name != current_lang:
|
|
current_lang = lang_name
|
|
click.secho(f" {lang_name}/", fg="yellow")
|
|
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if is_completed
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {file_name}.{lang_name}")
|
|
|
|
|
|
@click.command(name="find")
|
|
@click.argument("keyword", type=str)
|
|
@click.option(
|
|
"--completed/--no-completed",
|
|
"-c/-nc",
|
|
default=None,
|
|
help="Filter by completed status (default: all)",
|
|
)
|
|
def find(keyword: str, completed: bool | None):
|
|
"""
|
|
Find problems by keyword in storage.
|
|
|
|
KEYWORD format:
|
|
keyword e.g. 2447
|
|
keyword.ext e.g. 2447.py (filters by language)
|
|
.ext e.g. .py (all files of a language)
|
|
location/keyword e.g. zeta/2447
|
|
location/keyword.ext e.g. zeta/2447.py
|
|
location/.ext e.g. zeta/.py
|
|
"""
|
|
location = None
|
|
lang = None
|
|
if "/" in keyword:
|
|
parts = keyword.split("/", 1)
|
|
location = parts[0] or None
|
|
keyword = parts[1]
|
|
|
|
# Extract language from extension
|
|
if "." in keyword:
|
|
keyword_base, ext = keyword.rsplit(".", 1)
|
|
lang_candidate = Language.convert_ext(ext)
|
|
if lang_candidate is not Language.UNDEFINED:
|
|
lang = lang_candidate.value
|
|
else:
|
|
keyword_base = keyword
|
|
else:
|
|
keyword_base = keyword
|
|
|
|
storage = pathlib.Path(STORAGE_DIR)
|
|
if not storage.is_dir():
|
|
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
|
|
|
|
entries = [] # (location, language, filename, is_completed)
|
|
|
|
for loc_dir in sorted(storage.iterdir()):
|
|
if not loc_dir.is_dir():
|
|
continue
|
|
loc_name = loc_dir.name
|
|
|
|
if location and loc_name != location:
|
|
continue
|
|
|
|
for lang_dir in sorted(loc_dir.iterdir()):
|
|
if not lang_dir.is_dir():
|
|
continue
|
|
lang_name = lang_dir.name
|
|
|
|
if lang and lang_name != lang:
|
|
continue
|
|
|
|
for f in lang_dir.iterdir():
|
|
if f.is_file() and keyword_base.lower() in f.stem.lower():
|
|
entries.append((loc_name, lang_name, f.stem, False))
|
|
|
|
completed_dir = lang_dir / "completed"
|
|
if completed_dir.is_dir():
|
|
for f in completed_dir.iterdir():
|
|
if f.is_file() and keyword_base.lower() in f.stem.lower():
|
|
entries.append((loc_name, lang_name, f.stem, True))
|
|
|
|
if completed is not None:
|
|
entries = [e for e in entries if e[3] == completed]
|
|
|
|
entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2])))
|
|
|
|
if not entries:
|
|
click.echo("No problems found.")
|
|
return
|
|
|
|
total = len(entries)
|
|
completed_count = sum(1 for e in entries if e[3])
|
|
uncompleted_count = total - completed_count
|
|
|
|
click.secho(
|
|
f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
|
|
fg="cyan",
|
|
bold=True,
|
|
)
|
|
click.echo()
|
|
|
|
current_loc = None
|
|
current_lang = None
|
|
for loc_name, lang_name, file_name, is_completed in entries:
|
|
if loc_name != current_loc:
|
|
current_loc = loc_name
|
|
current_lang = None
|
|
click.secho(f"[{loc_name}]", fg="green", bold=True)
|
|
|
|
if lang_name != current_lang:
|
|
current_lang = lang_name
|
|
click.secho(f" {lang_name}/", fg="yellow")
|
|
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if is_completed
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {file_name}.{lang_name}")
|
|
|
|
|
|
@click.command(name="fetchprob")
|
|
@click.argument("target", type=str, nargs=1, required=True)
|
|
@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files")
|
|
def fetchprob(target: str, force: bool):
|
|
"""(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support."""
|
|
del target, force
|
|
raise click.ClickException("fetchprob is currently disabled (BOJ closing)")
|
|
|
|
|
|
cli.add_command(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()
|