diff --git a/scripts/new_problem.py b/scripts/new_problem.py index e69de29..3ef86f3 100644 --- a/scripts/new_problem.py +++ b/scripts/new_problem.py @@ -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//-/ with a README.md built from the right +L1/L2 template (L3 also gets deep-dive.md), a solutions//main. +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()) diff --git a/scripts/tests/test_new_problem.py b/scripts/tests/test_new_problem.py new file mode 100644 index 0000000..e41fc78 --- /dev/null +++ b/scripts/tests/test_new_problem.py @@ -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() diff --git a/templates/problem-deep-dive-l3.md b/templates/problem-deep-dive-l3.md index 9e740fc..9bb1dd1 100644 --- a/templates/problem-deep-dive-l3.md +++ b/templates/problem-deep-dive-l3.md @@ -1,4 +1,4 @@ -# Deep Dive — {id} {title} +# Deep Dive: {id} {title} ## Why this problem matters {why-it-is-interesting} diff --git a/templates/problem-readme-l1.md b/templates/problem-readme-l1.md index d3a24f0..8372171 100644 --- a/templates/problem-readme-l1.md +++ b/templates/problem-readme-l1.md @@ -1,4 +1,4 @@ -# {id} — {title} +# {id}: {title} ## Link {problem-link} diff --git a/templates/problem-readme-l2.md b/templates/problem-readme-l2.md index fc0f41b..bbe42a6 100644 --- a/templates/problem-readme-l2.md +++ b/templates/problem-readme-l2.md @@ -1,4 +1,4 @@ -# {id} — {title} +# {id}: {title} ## Link {problem-link} diff --git a/trackers/backlog.md b/trackers/backlog.md index 400b5b1..6018eb3 100644 --- a/trackers/backlog.md +++ b/trackers/backlog.md @@ -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//main.`) 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//main.` 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 `-` 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) @@ -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/` 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)