Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions scripts/new_problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Scaffold a new problem folder with the right structure and metadata.

Usage:
python scripts/new_problem.py 3485 "Some Title" \\
--category "Data Structures and Libraries" --difficulty 3 \\
--topics "binary search tree,recursion" --lang cpp

python scripts/new_problem.py 2602 "Total Spent" --category SQL \\
--difficulty 2 --topics aggregation --lang sql --slug total-spent

Creates problems/<range>/<id>-<slug>/ with a README.md built from the right
L1/L2 template (L3 also gets deep-dive.md), a solutions/<lang>/main.<ext>
skeleton copied from templates/solutions/, and an empty tests/ folder.

Only the mechanical metadata (id, title, link, category, difficulty, topics,
language, status) is filled in. The authorial sections (summary, approach,
what you learned...) are left as the template's own {placeholders} for you
to write after actually solving the problem.

Exit code: 0 = created, 1 = invalid input or the folder already exists.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

from repo_lib import CATEGORIES, DOC_LEVELS, LANGUAGES, PRIORITIES, SLUG_RE, find_language

REPO_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES = REPO_ROOT / "templates"


def slugify(title: str) -> str:
""""Binary Search!" -> "binary-search". Non-English titles need --slug."""
text = re.sub(r"[^a-z0-9]+", "-", title.lower())
return text.strip("-")


def resolve_slug(title: str, override: str | None) -> str:
if override:
slug = override.strip().lower()
else:
# A non-ASCII title (accents, other scripts) would have its special
# characters silently dropped by slugify(), producing a mangled slug
# instead of an error, so this case always needs an explicit --slug.
if not title.isascii():
raise ValueError(f"title '{title}' is not in English; pass --slug explicitly")
slug = slugify(title)
if not SLUG_RE.match(slug):
raise ValueError(
f"slug '{slug}' is not lowercase-english-hyphenated; pass --slug explicitly"
)
return slug


def range_folder(problem_id: int) -> str:
start = problem_id // 100 * 100
return f"{start:04d}-{start + 99:04d}"


def fill_metadata(text: str, values: dict[str, str]) -> str:
for key, value in values.items():
text = text.replace("{" + key + "}", value)
return text


def fill_topics(text: str, topics: list[str]) -> str:
return text.replace("{topic-1}, {topic-2}", ", ".join(topics))


def force_unsolved_status(text: str) -> str:
"""Scaffolding happens before the problem is solved, whatever the template's own example says."""
text = re.sub(r"(?m)^- Status:.*$", "- Status: In Progress", text, count=1)
return text.replace("- [x] Solved", "- [ ] Solved")


def build_readme(level: str, values: dict[str, str], topics: list[str]) -> str:
template_name = "problem-readme-l1.md" if level == "L1" else "problem-readme-l2.md"
text = (TEMPLATES / template_name).read_text(encoding="utf-8")
text = fill_topics(text, topics)
text = fill_metadata(text, values)
text = force_unsolved_status(text)
if level != "L1" and level != "L2":
text = text.replace("- Documentation Level: L2", f"- Documentation Level: {level}")
return text


def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Scaffold a new problem folder.")
parser.add_argument("id", type=int, help="Beecrowd problem id, e.g. 3485")
parser.add_argument("title", help="problem title, used for the README heading")
parser.add_argument("--category", required=True, choices=CATEGORIES)
parser.add_argument("--difficulty", required=True, type=int, choices=range(1, 11), metavar="1-10")
parser.add_argument("--topics", required=True, help="comma separated, e.g. 'binary search,recursion'")
parser.add_argument("--lang", required=True, dest="language", help="primary language, e.g. cpp, C++, python")
parser.add_argument("--slug", help="override the slug derived from the title (needed for non-English titles)")
parser.add_argument("--level", default="L1", choices=DOC_LEVELS)
parser.add_argument("--priority", default="Medium", choices=PRIORITIES)
parser.add_argument("--link", help="defaults to the standard Beecrowd URL for this id")
parser.add_argument("--notes", action="store_true", help="also scaffold notes.md")
parser.add_argument("--root", type=Path, default=REPO_ROOT)
return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
root: Path = args.root.resolve()

display = find_language(args.language)
if display is None:
print(f"error: unknown language '{args.language}' (known: {', '.join(LANGUAGES)})", file=sys.stderr)
return 1
folder, ext = LANGUAGES[display]

category = args.category
if (display == "SQL") != (category == "SQL"):
print("error: SQL is reserved for the SQL category, and the SQL category must use SQL", file=sys.stderr)
return 1

try:
slug = resolve_slug(args.title, args.slug)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

problem_dir = root / "problems" / range_folder(args.id) / f"{args.id}-{slug}"
if problem_dir.exists():
print(f"error: {problem_dir.relative_to(root)} already exists", file=sys.stderr)
return 1

topics = [t.strip() for t in args.topics.split(",") if t.strip()]
link = args.link or f"https://judge.beecrowd.com/en/problems/view/{args.id}"
values = {
"id": str(args.id),
"title": args.title,
"problem-link": link,
"category": category,
"difficulty-level": str(args.difficulty),
"primary-language": display,
"low-medium-high": args.priority,
"language-1": display,
"language-folder": folder,
"ext": ext,
}

readme = build_readme(args.level, values, topics)

(problem_dir / "solutions" / folder).mkdir(parents=True)
(problem_dir / "tests").mkdir()
(problem_dir / "README.md").write_text(readme, encoding="utf-8")

skeleton = (TEMPLATES / "solutions" / f"main.{ext}").read_text(encoding="utf-8")
(problem_dir / "solutions" / folder / f"main.{ext}").write_text(skeleton, encoding="utf-8")

if args.level == "L3":
deep_dive = fill_metadata(
(TEMPLATES / "problem-deep-dive-l3.md").read_text(encoding="utf-8"),
{"id": values["id"], "title": values["title"]},
)
(problem_dir / "deep-dive.md").write_text(deep_dive, encoding="utf-8")

if args.notes:
(problem_dir / "notes.md").write_text(
(TEMPLATES / "notes-template.md").read_text(encoding="utf-8"), encoding="utf-8"
)

rel = problem_dir.relative_to(root).as_posix()
print(f"Created {rel}")
print(" README.md: fill in the {...} sections after solving")
print(f" solutions/{folder}/main.{ext}: replace the skeleton with the real solution")
print(" tests/: add sampleN.in/.out and edge-*.in/.out")
print(f"\nValidate with: python scripts/validate_structure.py {rel}")
return 0


if __name__ == "__main__":
sys.exit(main())
144 changes: 144 additions & 0 deletions scripts/tests/test_new_problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Tests for scripts/new_problem.py."""

from __future__ import annotations

import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import new_problem as np # noqa: E402
import validate_structure as vs # noqa: E402


class NewProblemTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name)

def tearDown(self) -> None:
self._tmp.cleanup()

def create(self, *args: str) -> int:
return np.main([*args, "--root", str(self.root)])

def problem_dir(self, range_name: str, folder_name: str) -> Path:
return self.root / "problems" / range_name / folder_name

def test_creates_expected_structure(self) -> None:
code = self.create(
"3485", "Divided Class Two",
"--category", "Data Structures and Libraries",
"--difficulty", "3",
"--topics", "binary search tree,recursion",
"--lang", "cpp",
)
self.assertEqual(code, 0)

problem = self.problem_dir("3400-3499", "3485-divided-class-two")
readme = (problem / "README.md").read_text(encoding="utf-8")

self.assertTrue((problem / "solutions" / "cpp" / "main.cpp").is_file())
self.assertTrue((problem / "tests").is_dir())
self.assertIn("# 3485: Divided Class Two", readme)
self.assertIn("- Category: Data Structures and Libraries", readme)
self.assertIn("- Difficulty Level: 3", readme)
self.assertIn("- Topics: binary search tree, recursion", readme)
self.assertIn("- Primary Language: C++", readme)
self.assertIn("- Status: In Progress", readme)
self.assertIn("- [ ] Solved", readme)
self.assertNotIn("- [x] Solved", readme)
self.assertIn("solutions/cpp/main.cpp", readme)

def test_range_folder_computed_correctly(self) -> None:
self.create(
"1050", "Example", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "python",
)
self.assertTrue(self.problem_dir("1000-1099", "1050-example").is_dir())

def test_refuses_to_overwrite_existing_folder(self) -> None:
args = (
"3485", "Divided Class Two", "--category", "Data Structures and Libraries",
"--difficulty", "3", "--topics", "trees", "--lang", "cpp",
)
self.assertEqual(self.create(*args), 0)
self.assertEqual(self.create(*args), 1)

def test_sql_requires_sql_category(self) -> None:
code = self.create(
"2700", "Some Query", "--category", "Beginner", "--difficulty", "2",
"--topics", "aggregation", "--lang", "sql",
)
self.assertEqual(code, 1)

def test_sql_category_requires_sql_language(self) -> None:
code = self.create(
"2700", "Some Query", "--category", "SQL", "--difficulty", "2",
"--topics", "aggregation", "--lang", "cpp",
)
self.assertEqual(code, 1)

def test_sql_problem_created_correctly(self) -> None:
code = self.create(
"2700", "Some Query", "--category", "SQL", "--difficulty", "2",
"--topics", "aggregation", "--lang", "sql",
)
self.assertEqual(code, 0)
problem = self.problem_dir("2700-2799", "2700-some-query")
self.assertTrue((problem / "solutions" / "sql" / "main.sql").is_file())

def test_unknown_language_rejected(self) -> None:
code = self.create(
"3486", "Another One", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "cobol",
)
self.assertEqual(code, 1)

def test_non_ascii_title_requires_explicit_slug(self) -> None:
code = self.create(
"3487", "Divisão de Turma", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "cpp",
)
self.assertEqual(code, 1)

def test_explicit_slug_overrides_derivation(self) -> None:
code = self.create(
"3487", "Divisão de Turma", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "cpp", "--slug", "class-split",
)
self.assertEqual(code, 0)
self.assertTrue(self.problem_dir("3400-3499", "3487-class-split").is_dir())

def test_level_l3_also_creates_deep_dive(self) -> None:
self.create(
"3488", "Hard One", "--category", "Graphs", "--difficulty", "8",
"--topics", "flood fill", "--lang", "cpp", "--level", "L3",
)
problem = self.problem_dir("3400-3499", "3488-hard-one")
self.assertIn("- Documentation Level: L3", (problem / "README.md").read_text(encoding="utf-8"))
deep_dive = (problem / "deep-dive.md").read_text(encoding="utf-8")
self.assertIn("3488", deep_dive)
self.assertIn("Hard One", deep_dive)

def test_notes_flag_creates_notes_file(self) -> None:
self.create(
"3489", "Needs Notes", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "cpp", "--notes",
)
self.assertTrue(self.problem_dir("3400-3499", "3489-needs-notes").joinpath("notes.md").is_file())

def test_output_validates_with_only_a_warning(self) -> None:
self.create(
"3490", "Fresh Scaffold", "--category", "Beginner", "--difficulty", "1",
"--topics", "io", "--lang", "cpp",
)
issues = vs.validate_repository(self.root)
errors = [i for i in issues if i.level == "error"]
self.assertEqual(errors, [], errors)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion templates/problem-deep-dive-l3.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Deep Dive — {id} {title}
# Deep Dive: {id} {title}

## Why this problem matters
{why-it-is-interesting}
Expand Down
2 changes: 1 addition & 1 deletion templates/problem-readme-l1.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# {id} — {title}
# {id}: {title}

## Link
{problem-link}
Expand Down
2 changes: 1 addition & 1 deletion templates/problem-readme-l2.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# {id} — {title}
# {id}: {title}

## Link
{problem-link}
Expand Down
4 changes: 2 additions & 2 deletions trackers/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This file is the single prioritized TODO for the repository. Sections are ordere

## 2. Automation scripts (empty stubs today, needed before scaling migration)

- [ ] implement `scripts/new_problem.py`: scaffolds a problem folder (README from the right L1/L2/L3 template, `notes.md`, `solutions/<lang>/main.<ext>`) from the problem ID, title, and category
- [x] implement `scripts/new_problem.py`: scaffolds a problem folder (README from the right L1/L2 template, L3 also gets `deep-dive.md`, `solutions/<lang>/main.<ext>` from the language skeleton) from the problem ID, title, and category
- [x] implement `scripts/validate_structure.py`: fails if a `solutions/*/main.*` file is empty, a metadata field is missing, checkbox syntax is malformed, or a problem folder doesn't match `<id>-<slug>` naming
- [ ] implement `scripts/sync_index.py`: regenerates `trackers/solved-problems.md` and `docs/indexes/*.md` from each problem's metadata block, so indexes can never drift from reality again
- [ ] implement `scripts/generate_views.py`: optional dashboard/summary view (counts by language, category, difficulty)
Expand All @@ -40,7 +40,7 @@ This file is the single prioritized TODO for the repository. Sections are ordere

- [ ] confirm rough volume and categories of already-solved problems to size the effort realistically
- [ ] do not bulk-import: each migrated problem goes through the full review: style-guide pass, correct L1/L2/L3 documentation level, metadata filled, indexes updated (per `docs/repository-philosophy.md` migration principle and `CONTRIBUTING.md`)
- [ ] use `scripts/new_problem.py` (once built) to scaffold each migrated problem consistently
- [ ] use `scripts/new_problem.py` to scaffold each migrated problem consistently
- [ ] batch migrations under `migration/<batch-name>` branches, one PR per coherent batch (not one PR per problem, not one giant PR)
- [ ] prioritize breadth first: cover most Beecrowd categories at least once before grinding depth in a single category, since that's a better signal for a portfolio than volume in one topic
- [ ] follow `trackers/learning-path.md` as the concrete driver for problem selection order (category × difficulty tier spiral)
Expand Down
Loading