1101 lines
34 KiB
Python
Executable File
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()
|