Files
CodeObject/run.py
2026-04-27 09:43:47 +09:00

1382 lines
42 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import sys
import pathlib
import base64
from enum import Enum, unique, auto
from dataclasses import dataclass
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from html import escape, unescape
import yaml
import click
try:
from latex2mathml.converter import convert as latex_to_mathml
except ImportError:
latex_to_mathml = None
CFG_PATH = "./config.yml"
STATE_PATH = "./state.yml"
TC_DIR = "./_testcases"
TEMPLATES_DIR = "./templates"
SRC_SPACE_DIR = "./space"
STORAGE_DIR = "./storage"
BUILD_DIR = "./build"
@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 natural_sort_key(s: str):
"""Natural sort key: splits string into text/number chunks for proper ordering."""
return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)]
def parse_range_string(range_str: str) -> list[int]:
"""
범위 문자열을 정수 리스트로 변환.
* ..N: 1부터 N까지
* M..N: M부터 N까지
* N: 단일 숫자
"""
if range_str.startswith(".."):
end_str = range_str[2:]
if not end_str.isdigit():
raise ValueError("Invalid range format")
return list(range(1, int(end_str) + 1))
parts = range_str.split("..")
if len(parts) == 1:
if not parts[0].isdigit():
raise ValueError("Invalid range format")
return [int(parts[0])]
elif len(parts) == 2:
if not parts[0].isdigit() or not parts[1].isdigit():
raise ValueError("Invalid range format")
return list(range(int(parts[0]), int(parts[1]) + 1))
else:
raise ValueError("Invalid range format")
def parse_range_string_list(str_list) -> list[int]:
"""
여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함.
* e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9]
"""
result = set()
for s in str_list:
result.update(parse_range_string(s))
return list(result)
def parse_fetchprob_target(target: str) -> tuple[str, str | None]:
"""
fetchprob target parser.
- zeta/<id>: single mode
- zeta: batch mode
"""
parts = target.split("/", 1)
location = parts[0].strip()
if location != "zeta":
raise click.UsageError("fetchprob target must start with 'zeta'")
if len(parts) == 1:
return location, None
problem_id = parts[1].strip()
if not problem_id.isdigit():
raise click.UsageError("problem id must be numeric (e.g. zeta/2447)")
return location, problem_id
def extract_problem_id_from_stem(stem: str) -> str | None:
"""
Extract BOJ numeric id from file stem.
Accepted forms: <id>, <id>_<suffix>, <id>-<suffix>
"""
m = re.match(r"^(\d+)(?:[_-].*)?$", stem)
return m.group(1) if m else None
def collect_zeta_problem_ids() -> list[str]:
"""
Collect problem ids from storage/zeta/* and storage/zeta/*/completed.
"""
zeta_dir = pathlib.Path(STORAGE_DIR) / "zeta"
if not zeta_dir.is_dir():
raise click.ClickException(f"Storage location '{zeta_dir}' not found")
ids: set[str] = set()
for lang_dir in sorted(zeta_dir.iterdir()):
if not lang_dir.is_dir() or lang_dir.name.startswith("_"):
continue
for f in lang_dir.iterdir():
if f.is_file():
problem_id = extract_problem_id_from_stem(f.stem)
if problem_id:
ids.add(problem_id)
completed_dir = lang_dir / "completed"
if completed_dir.is_dir():
for f in completed_dir.iterdir():
if f.is_file():
problem_id = extract_problem_id_from_stem(f.stem)
if problem_id:
ids.add(problem_id)
return sorted(ids, key=int)
def fetch_boj_problem_html(problem_id: str, timeout: int = 10) -> str:
"""
Download BOJ problem page raw HTML.
"""
url = f"https://www.acmicpc.net/problem/{problem_id}"
req = urllib.request.Request(
url,
headers={
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
)
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
status = getattr(resp, "status", 200)
if status != 200:
raise click.ClickException(
f"failed to fetch problem {problem_id}: HTTP {status}"
)
return resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
raise click.ClickException(
f"failed to fetch problem {problem_id}: HTTP {e.code}"
) from e
except urllib.error.URLError as e:
raise click.ClickException(
f"network error while fetching problem {problem_id}: {e.reason}"
) from e
def _problem_static_html_path(problem_id: str) -> pathlib.Path:
return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / f"{problem_id}.html"
def _problem_static_assets_dir(problem_id: str) -> pathlib.Path:
return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / "assets" / problem_id
def _guess_image_mime(src_url: str, content_type: str | None) -> str:
if content_type:
mime = content_type.split(";", 1)[0].strip().lower()
if mime.startswith("image/"):
return mime
parsed = urllib.parse.urlparse(src_url)
ext = pathlib.Path(parsed.path).suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
".ico": "image/x-icon",
}
return ext_to_mime.get(ext, "image/png")
def _download_image_for_offline(problem_id: str, src_url: str, seq: int, force: bool) -> str | None:
req = urllib.request.Request(
src_url,
headers={
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
),
"Referer": f"https://www.acmicpc.net/problem/{problem_id}",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
status = getattr(resp, "status", 200)
if status != 200:
return None
content_type = resp.headers.get("Content-Type")
image_bytes = resp.read()
except (urllib.error.HTTPError, urllib.error.URLError):
return None
mime = _guess_image_mime(src_url, content_type)
encoded = base64.b64encode(image_bytes).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) -> str:
base_url = f"https://www.acmicpc.net/problem/{problem_id}"
counter = {"i": 0}
cache: dict[str, str] = {}
pattern = re.compile(
r'(<img\b[^>]*?\bsrc\s*=\s*)(["\']?)([^"\'>\s]+)(["\']?)',
flags=re.IGNORECASE,
)
def repl(m: re.Match) -> str:
prefix = m.group(1)
q1 = m.group(2)
src = m.group(3)
if src.startswith("data:"):
return m.group(0)
abs_url = urllib.parse.urljoin(base_url, src)
if abs_url in cache:
local_src = cache[abs_url]
quote = q1 if q1 else '"'
return f"{prefix}{quote}{local_src}{quote}"
counter["i"] += 1
local_src = _download_image_for_offline(problem_id, abs_url, counter["i"], force=force)
if not local_src:
return m.group(0)
cache[abs_url] = local_src
quote = q1 if q1 else '"'
return f"{prefix}{quote}{local_src}{quote}"
return pattern.sub(repl, html_fragment)
def _extract_html_by_id(raw_html: str, tag: str, element_id: str) -> str | None:
pattern = rf"<{tag}[^>]*id=\"{re.escape(element_id)}\"[^>]*>(.*?)</{tag}>"
m = re.search(pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
if not m:
return None
return m.group(1).strip()
def _strip_tags(html_text: str) -> str:
text = re.sub(r"<[^>]+>", "", html_text, flags=re.DOTALL)
return " ".join(text.split())
def _render_math_expressions(html_fragment: str) -> str:
"""
Convert TeX math delimiters to MathML for offline rendering.
- Inline: $...$
- Block: $$...$$
"""
if latex_to_mathml is None:
return html_fragment
protected_blocks: list[str] = []
def protect(m: re.Match) -> str:
protected_blocks.append(m.group(0))
return f"@@PROTECTED_{len(protected_blocks) - 1}@@"
# Do not touch code/pre blocks.
temp = re.sub(
r"<(pre|code)\b[^>]*>.*?</\1>",
protect,
html_fragment,
flags=re.DOTALL | re.IGNORECASE,
)
def repl_block(m: re.Match) -> str:
expr = unescape(m.group(1).strip())
if not expr:
return m.group(0)
try:
mathml = latex_to_mathml(expr)
return f'<div class="math-block">{mathml}</div>'
except Exception:
return m.group(0)
def repl_inline(m: re.Match) -> str:
expr = unescape(m.group(1).strip())
if not expr:
return m.group(0)
try:
mathml = latex_to_mathml(expr)
return f'<span class="math-inline">{mathml}</span>'
except Exception:
return m.group(0)
temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL)
temp = re.sub(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", repl_inline, temp, flags=re.DOTALL)
for i, block in enumerate(protected_blocks):
temp = temp.replace(f"@@PROTECTED_{i}@@", block)
return temp
def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str:
"""
Build a self-contained offline-friendly HTML page from BOJ raw HTML.
"""
title = _extract_html_by_id(raw_html, "span", "problem_title")
if not title:
title = f"BOJ {problem_id}"
blocks: list[str] = []
core_specs = [
("problem_description", "문제"),
("problem_input", "입력"),
("problem_output", "출력"),
("problem_limit", "제한"),
("problem_hint", "힌트"),
]
for content_id, fallback_label in core_specs:
content = _extract_html_by_id(raw_html, "div", content_id)
if not content or not content.strip():
continue
localized_content = _localize_images_in_html(
problem_id,
content,
force=force,
)
localized_content = _render_math_expressions(localized_content)
blocks.append(
"\n".join(
[
"<article class=\"section\">",
f"<h2>{fallback_label}</h2>",
f"{localized_content}",
"</article>",
]
)
)
for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")):
sample_pattern = rf"<section[^>]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)</section>"
sample_matches = list(
re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
)
sample_matches.sort(key=lambda m: int(m.group(1)))
for m in sample_matches:
idx = m.group(1)
section_html = m.group(2)
pre_match = re.search(
r"(<pre[^>]*>.*?</pre>)",
section_html,
flags=re.DOTALL | re.IGNORECASE,
)
if not pre_match:
continue
pre_html = _localize_images_in_html(
problem_id,
pre_match.group(1),
force=force,
)
h2_match = re.search(
r"<h2[^>]*>(.*?)</h2>",
section_html,
flags=re.DOTALL | re.IGNORECASE,
)
if h2_match:
h2 = _strip_tags(h2_match.group(1))
else:
h2 = f"{sample_label} {idx}"
blocks.append(
"\n".join(
[
"<article class=\"section\">",
f"<h2>{h2}</h2>",
pre_html,
"</article>",
]
)
)
if not blocks:
body_fallback = (
"<article class=\"section\">"
"<h2>원본 페이지</h2>"
"<p>문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.</p>"
f"<pre>{escape(raw_html[:100000])}</pre>"
"</article>"
)
blocks.append(body_fallback)
source_url = f"https://www.acmicpc.net/problem/{problem_id}"
content_html = "\n".join(blocks)
return f"""<!DOCTYPE html>
<html lang=\"ko\">
<head>
<meta charset=\"UTF-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />
<title>BOJ {problem_id} - Offline</title>
<style>
:root {{
--bg: #fafaf8;
--paper: #ffffff;
--ink: #1e1f24;
--muted: #6a6d75;
--line: #d8dce3;
--accent: #0d6e6e;
--code-bg: #f4f6fb;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
background:
radial-gradient(circle at 15% 0%, #f0efe9 0%, transparent 42%),
radial-gradient(circle at 85% 20%, #e7f1f2 0%, transparent 38%),
var(--bg);
color: var(--ink);
font-family: "Noto Sans KR", "Pretendard", "Apple SD Gothic Neo", sans-serif;
line-height: 1.65;
}}
main {{
max-width: 980px;
margin: 0 auto;
padding: 24px 16px 56px;
}}
.header {{
background: var(--paper);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 18px;
}}
.header h1 {{ margin: 0 0 6px; font-size: 1.5rem; }}
.header p {{ margin: 0; color: var(--muted); font-size: 0.95rem; }}
.header a {{ color: var(--accent); text-decoration: none; }}
.section {{
background: var(--paper);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px 18px;
margin-bottom: 14px;
overflow-x: auto;
}}
h2 {{
margin: 0 0 10px;
font-size: 1.05rem;
color: var(--accent);
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
}}
pre, code {{
font-family: "JetBrains Mono", "Fira Code", monospace;
background: var(--code-bg);
}}
pre {{
padding: 12px;
border-radius: 10px;
border: 1px solid #e7ebf2;
overflow: auto;
}}
blockquote {{
margin: 14px 0;
padding: 16px 16px 14px 22px;
border-left: 4px solid var(--accent);
border-radius: 10px;
background: linear-gradient(90deg, #eef8f8 0%, #f9fdfd 100%);
color: #24313a;
font-weight: 600;
position: relative;
}}
blockquote::before {{
content: "";
position: absolute;
left: 8px;
top: 2px;
font-size: 1.35rem;
line-height: 1;
color: #0b5f5f;
opacity: 0.7;
}}
blockquote > :first-child {{ margin-top: 0; }}
blockquote > :last-child {{ margin-bottom: 0; }}
q {{
color: #114f50;
font-weight: 700;
background: #edf8f8;
border-radius: 6px;
padding: 0 4px;
}}
.math-inline math {{
font-size: 1em;
vertical-align: middle;
}}
.math-block {{
margin: 10px 0;
padding: 8px 10px;
overflow-x: auto;
background: #f8fbff;
border: 1px solid #e2ecf8;
border-radius: 8px;
}}
.math-block math {{
font-size: 1.04em;
display: block;
}}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid var(--line); padding: 6px 8px; }}
img {{ max-width: 100%; height: auto; }}
</style>
</head>
<body>
<main>
<header class=\"header\">
<h1>{title}</h1>
</header>
{content_html}
</main>
</body>
</html>
"""
def save_problem_html(problem_id: str, html: str, force: bool) -> str:
"""
Save html to storage/zeta/_static/<id>.html
Return: fetched | skipped
"""
static_dir = pathlib.Path(STORAGE_DIR) / "zeta" / "_static"
static_dir.mkdir(parents=True, exist_ok=True)
dest = _problem_static_html_path(problem_id)
if dest.exists() and not force:
return "skipped"
dest.write_text(html, encoding="utf-8")
return "fetched"
@click.group()
def cli():
pass
@click.command(name="run")
@click.option("--from", "from_", type=str, required=True)
@click.option("--target", "-t", default=["1"], multiple=True)
@click.option("--verbose/--no-verbose", "-v/-nv", default=True)
def run(from_: str, target: str, verbose: bool):
"""
지정된 언어로 빌드 및 실행하는 명령어
"""
# Language 확인
from_language: 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("Run Exception: There are no pre-defined state")
# 테케 분석
try:
tcs: list[int] = parse_range_string_list(target)
except ValueError as e:
raise click.ClickException(e)
# build
try:
s = subprocess.run(
from_language.build_command,
check=True,
capture_output=True,
text=True,
cwd=from_language.working_dir,
)
print(s.stderr)
except subprocess.CalledProcessError as e:
click.echo(">>>>>> [Error while BUILD process] >>>>>>")
raise click.ClickException(e.stderr)
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:
try:
stdout = subprocess.run(
from_language.executable_command,
check=True,
capture_output=True,
text=True,
stdin=f_in,
).stdout
except subprocess.CalledProcessError as e:
click.echo(">>>>>> [Error while RUNNING process] >>>>>>")
click.echo(f"{e.stderr}")
click.echo(f"returncode: {e.returncode}")
stdout = ""
continue
flag: bool = False
with open(f_out_path, "r", encoding="utf-8") as f_out:
flag = stdout.strip() == f_out.read().strip()
click.echo(f"{tc:02d}: {flag}")
if verbose:
click.echo("===" * 3)
click.echo(stdout)
click.echo("===" * 3)
@click.command(name="load")
@click.argument("file", type=str, nargs=1, required=True)
@click.option("--to", type=str, default="") # language value
@click.option("--from", "from_", type=str, required=True)
@click.option("--force", "-f", is_flag=True)
def load(file: str, to: str, from_: str, force: bool):
"""
파일을 각 언어의 src-space로 이동시키는 명령어.
이 명령어는 다음 과정을 수행함.
"""
# 파일의 src-space가 어딘지 확인
to_language: Language
file_name: str
file_sep_ext = file.split(".")
if len(file_sep_ext) == 1:
file_name = file_sep_ext[0]
to_language = Language.convert_name(to) # if blank, undefined
elif len(file_sep_ext) == 2:
file_name = file_sep_ext[0]
to_language = Language.convert_name(file_sep_ext[1])
else:
file_name = ""
to_language = Language.UNDEFINED
if to_language is Language.UNDEFINED:
raise click.UsageError("LoadError: `to` or `file` argument is wrong")
try:
with open(STATE_PATH, "r", encoding="utf-8") as f_state:
state = yaml.safe_load(f_state)
except FileNotFoundError as e:
raise click.ClickException(e)
except yaml.YAMLError as e:
raise click.ClickException(e)
if "space" not in state:
raise click.ClickException("state.yml is wrong")
elif not state["space"]:
click.secho("state.space has no member", fg="yellow", bold=True)
state["space"] = {}
lang_space_state: dict | None
if to_language.value not in state["space"]:
lang_space_state = None
else:
lang_space_state = state["space"][to_language.value]
if not force:
if lang_space_state is None or not lang_space_state:
pass
elif lang_space_state["file"] is None:
pass
else:
raise click.ClickException(
f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}"
)
# 파일이 존재하는지 확인
# uncompleted
file_is_exist: bool = False
file_is_completed: bool = False
file_loc: str = None
if os.path.isfile(
f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
):
file_is_exist = True
file_is_completed = False
file_loc = (
f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
)
elif os.path.isfile(
f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
):
file_is_exist = True
file_is_completed = True
file_loc = f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
# 존재하면 move
# 존재하지 않으면 빈 파일(또는 템플릿 파일)을 생성하고
# state에 기록함
if file_is_exist:
with (
open(file_loc, "rb") as f_src,
open(
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
"wb",
) as f_dst,
):
f_dst.write(f_src.read())
os.remove(file_loc)
else:
if TEMPLATES_DIR and os.path.isfile(
f"{TEMPLATES_DIR}/template.{to_language.value}"
):
with (
open(f"{TEMPLATES_DIR}/template.{to_language.value}", "rb") as f_src,
open(
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
"wb",
) as f_dst,
):
f_dst.write(f_src.read())
else:
with open(
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
"wb",
) as f_dst:
pass
with open("state.yml", "w", encoding="utf-8") as f:
if lang_space_state is None:
state["space"][to_language.value] = {}
state["space"][to_language.value]["file"] = file_name
state["space"][to_language.value]["location"] = from_
state["space"][to_language.value]["is_completed"] = file_is_completed
yaml.safe_dump(state, f)
if file_is_exist:
click.echo(
f"File {file_name}.{to_language.value} from {from_}{'/completed' if file_is_completed else ''} is successfully loaded"
)
else:
click.echo(f"File {file_name}.{to_language.value} is successfully created")
@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}"
)
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):
"""
Fetch BOJ problem HTML into storage/zeta/_static.
TARGET:
zeta/<id> Fetch one problem
zeta Fetch all detected problem ids under storage/zeta
"""
location, problem_id = parse_fetchprob_target(target)
if location != "zeta":
raise click.UsageError("only 'zeta' location is supported")
if problem_id is not None:
if _problem_static_html_path(problem_id).exists() and not force:
click.echo(f"{problem_id}: skipped (already exists)")
return
raw_html = fetch_boj_problem_html(problem_id)
offline_html = make_offline_problem_html(problem_id, raw_html, force=force)
result = save_problem_html(problem_id, offline_html, force=force)
if result == "skipped":
click.echo(f"{problem_id}: skipped (already exists)")
else:
click.echo(f"{problem_id}: fetched (offline processed + images)")
return
ids = collect_zeta_problem_ids()
if not ids:
click.echo("No problem ids found in storage/zeta")
return
attempted = len(ids)
fetched = 0
skipped = 0
failed = 0
for pid in ids:
try:
if _problem_static_html_path(pid).exists() and not force:
skipped += 1
click.echo(f"{pid}: skipped")
continue
raw_html = fetch_boj_problem_html(pid)
offline_html = make_offline_problem_html(pid, raw_html, force=force)
result = save_problem_html(pid, offline_html, force=force)
if result == "skipped":
skipped += 1
click.echo(f"{pid}: skipped")
else:
fetched += 1
click.echo(f"{pid}: fetched (offline processed + images)")
except click.ClickException as e:
failed += 1
click.echo(f"{pid}: failed ({e.message})")
click.echo()
click.secho(
(
f"Summary - attempted: {attempted}, fetched: {fetched}, "
f"skipped: {skipped}, failed: {failed}"
),
fg="cyan",
bold=True,
)
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()