Skip to content

Commit 3327f5b

Browse files
feat: Language adapter architecture (Phase 0-2)
- Phase 0: Language context management & simplified slug format * Add language state in internal.py (get/set_current_language) * Pass language to subprocesses via CROSS_PROCESS_ATTRIBUTES * Simplify parse_slug() to 2-part format only (course/stage) * Add 36 tests covering internal & slug parsing (82 total, 100% pass) - Phase 1: Adapter pattern for multi-language support * base.py: LanguageAdapter ABC with compile/run/exists * conventions.py: Naming rules for 8 languages (C, C++, Java, Python, Go, Rust, JS, TS) * compiled.py: Concrete adapters for compiled & interpreted languages * factory.py: create_adapter() with auto-detection * Add get_adapter() convenience function in check/__init__.py * Add 41 adapter tests (123 total, 100% pass) - Phase 2: Hello check migration (unified multi-language) * Migrate cs50/hello to use adapters (22 lines vs 810 lines = 97.3% reduction) * Support C, Java, Python with single check script * Add integration tests & student code samples * All 129 tests passing (126 unit + 3 integration + 61 subtests) Key improvements: - Code reduction: 810 lines → 22 lines (97.3% less code) - Zero configuration for 80% use cases (convention over configuration) - Unified API: get_adapter().require_exists(), compile(), run().stdout().exit() - Extensible: Easy to add new languages via conventions
1 parent ae3cba6 commit 3327f5b

27 files changed

+5027
-56
lines changed

bootcs/__main__.py

Lines changed: 30 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -105,28 +105,23 @@ def main():
105105

106106
def parse_slug(slug: str):
107107
"""
108-
Parse a slug into course, language, and stage components.
108+
Parse a slug into course and stage components.
109109
110-
Supports two formats:
111-
- 2 parts: "cs50/hello" -> (course="cs50", language=None, stage="hello")
112-
- 3 parts: "cs50/c/hello" -> (course="cs50", language="c", stage="hello")
110+
MVP: Only supports 2-part format "cs50/hello"
111+
Language is determined separately via CLI flag or auto-detection.
113112
114113
Returns:
115-
tuple: (course_slug, language_from_slug, stage_slug)
116-
- language_from_slug is None if slug has 2 parts
114+
tuple: (course_slug, stage_slug)
117115
"""
118116
parts = slug.split("/")
119-
if len(parts) == 3:
120-
course_slug, lang, stage_slug = parts
121-
# Validate that middle part looks like a language
122-
if lang in SUPPORTED_LANGUAGES:
123-
return course_slug, lang, stage_slug
124-
# If middle part doesn't look like a language, treat as 2-part with nested stage
125-
return parts[0], None, "/".join(parts[1:])
126-
elif len(parts) == 2:
127-
return parts[0], None, parts[1]
117+
if len(parts) == 2:
118+
return parts[0], parts[1]
119+
elif len(parts) == 1:
120+
# Single part, treat as stage only
121+
return None, parts[0]
128122
else:
129-
return None, None, slug
123+
# Multi-part, treat first as course, last as stage
124+
return parts[0], "/".join(parts[1:])
130125

131126

132127
def detect_language(directory: Path = None, explicit: str = None) -> str:
@@ -178,19 +173,16 @@ def run_check(args):
178173
slug = args.slug
179174
force_update = getattr(args, 'update', False)
180175

181-
# Parse slug to extract language if present (e.g., "cs50/c/hello")
182-
course_slug, lang_from_slug, stage_slug = parse_slug(slug)
176+
# Parse slug (MVP: only 2-part format)
177+
course_slug, stage_slug = parse_slug(slug)
183178

184-
# Determine language: slug > explicit flag > auto-detect
179+
# Determine language via explicit flag or auto-detection
185180
explicit_lang = getattr(args, 'language', None)
186-
if lang_from_slug:
187-
language = lang_from_slug
188-
else:
189-
language = detect_language(directory=Path.cwd(), explicit=explicit_lang)
181+
language = detect_language(directory=Path.cwd(), explicit=explicit_lang)
190182

191183
# Determine check directory
192184
if args.local:
193-
# Combine local path with slug (e.g., /path/to/checks + cs50/python/hello)
185+
# Combine local path with slug (e.g., /path/to/checks + cs50/hello)
194186
check_dir = Path(args.local).resolve() / slug
195187
else:
196188
# Try remote download first, then fall back to local search
@@ -239,7 +231,7 @@ def run_check(args):
239231
targets = args.target if hasattr(args, 'target') and args.target else None
240232

241233
try:
242-
with CheckRunner(checks_file, list(included)) as runner:
234+
with CheckRunner(checks_file, list(included), language=language) as runner:
243235
results = runner.run(targets=targets)
244236
except Exception as e:
245237
termcolor.cprint(f"Error running checks: {e}", "red", file=sys.stderr)
@@ -341,47 +333,34 @@ def find_check_dir(slug, language: str = "c", force_update: bool = False):
341333
"""
342334
Find the check directory for a given slug.
343335
344-
Supports two slug formats:
345-
- 2 parts: "cs50/hello" (language auto-detected or passed as parameter)
346-
- 3 parts: "cs50/c/hello" (language extracted from slug)
336+
MVP: Only supports 2-part format "cs50/hello"
337+
Language is determined separately and not part of the path.
347338
348339
Priority:
349340
1. BOOTCS_CHECKS_PATH environment variable (for evaluator)
350341
2. Remote API download (with local cache)
351342
3. Local directories (for development)
352343
"""
353-
# Use parse_slug to extract components
354-
course_slug, lang_from_slug, stage_name = parse_slug(slug)
355-
356-
# If slug contains language, use it; otherwise use provided parameter
357-
if lang_from_slug:
358-
language = lang_from_slug
344+
# Parse slug (MVP: only 2-part format)
345+
course_slug, stage_name = parse_slug(slug)
359346

360347
# 1. Check environment variable first (used by evaluator)
361348
if "BOOTCS_CHECKS_PATH" in os.environ:
362349
checks_path = Path(os.environ["BOOTCS_CHECKS_PATH"])
363-
# Try with course/language/stage structure (e.g., checks/cs50/c/hello)
350+
# Try with course/stage structure (e.g., checks/cs50/hello)
364351
if course_slug:
365-
path = checks_path / course_slug / language / stage_name
352+
path = checks_path / course_slug / stage_name
366353
if path.exists():
367354
return path
368-
# Try with language/stage structure (e.g., checks/c/hello)
369-
path = checks_path / language / stage_name
370-
if path.exists():
371-
return path
372-
# Try with full slug under language
355+
# Fallback: try with full slug
373356
if course_slug:
374-
path = checks_path / language / slug
357+
path = checks_path / slug
375358
if path.exists():
376359
return path
377-
# Fallback: try without language prefix
360+
# Try stage name only
378361
path = checks_path / stage_name
379362
if path.exists():
380363
return path
381-
if course_slug:
382-
path = checks_path / slug
383-
if path.exists():
384-
return path
385364

386365
# 2. Try remote download (if slug has course/stage format)
387366
if course_slug:
@@ -531,15 +510,12 @@ def run_submit(args):
531510

532511
slug = args.slug
533512

534-
# Parse slug to extract language if present (e.g., "cs50/c/hello")
535-
course_slug, lang_from_slug, stage_slug = parse_slug(slug)
513+
# Parse slug (MVP: only 2-part format)
514+
course_slug, stage_slug = parse_slug(slug)
536515

537-
# Determine language: slug > explicit flag > auto-detect
516+
# Determine language via explicit flag or auto-detection
538517
explicit_lang = getattr(args, 'language', None)
539-
if lang_from_slug:
540-
language = lang_from_slug
541-
else:
542-
language = detect_language(directory=Path.cwd(), explicit=explicit_lang)
518+
language = detect_language(directory=Path.cwd(), explicit=explicit_lang)
543519

544520
# Check if logged in
545521
if not is_logged_in():

bootcs/check/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ def _(s):
3232
from .runner import check
3333
from pexpect import EOF
3434

35+
# Phase 1: Language adapters
36+
from .adapters import LanguageAdapter, create_adapter
37+
38+
39+
def get_adapter():
40+
"""
41+
Get language adapter for current check context.
42+
43+
Automatically retrieves problem name and language from internal state.
44+
Convenience function to avoid repetition in check implementations.
45+
46+
Returns:
47+
LanguageAdapter: Configured adapter for current problem/language
48+
"""
49+
problem = internal.get_problem_name()
50+
language = internal.get_current_language()
51+
return create_adapter(problem, language)
52+
53+
3554
__all__ = ["import_checks", "data", "exists", "hash", "include", "regex",
3655
"run", "log", "Failure", "Mismatch", "Missing", "check", "EOF",
37-
"c", "java", "internal", "hidden"]
56+
"c", "java", "internal", "hidden",
57+
# Phase 1: Adapters
58+
"LanguageAdapter", "create_adapter", "get_adapter"]

bootcs/check/adapters/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Language adapters for bootcs check system.
3+
4+
This module provides language-agnostic adapters that abstract away
5+
language-specific details for compilation, execution, and testing.
6+
7+
Based on check50 by CS50 (https://github.com/cs50/check50)
8+
Licensed under GPL-3.0
9+
"""
10+
11+
from .base import LanguageAdapter
12+
from .factory import create_adapter
13+
14+
__all__ = [
15+
"LanguageAdapter",
16+
"create_adapter",
17+
]

bootcs/check/adapters/base.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Base language adapter for bootcs check system.
3+
4+
Provides the abstract interface that all language adapters must implement.
5+
6+
Based on check50 by CS50 (https://github.com/cs50/check50)
7+
Licensed under GPL-3.0
8+
"""
9+
10+
from abc import ABC, abstractmethod
11+
from pathlib import Path
12+
from typing import Optional, List
13+
14+
15+
class LanguageAdapter(ABC):
16+
"""
17+
Abstract base class for language adapters.
18+
19+
Each language adapter abstracts the details of compiling, running,
20+
and testing programs in a specific programming language.
21+
22+
Attributes:
23+
problem (str): The problem name (e.g., "hello", "mario-less")
24+
language (str): The programming language (e.g., "c", "python", "java")
25+
"""
26+
27+
def __init__(self, problem: str, language: str = None):
28+
"""
29+
Initialize the language adapter.
30+
31+
Args:
32+
problem: Problem name (e.g., "hello")
33+
language: Programming language (auto-detected if None)
34+
"""
35+
self.problem = problem
36+
self.language = language or self._detect_language()
37+
38+
@abstractmethod
39+
def _detect_language(self) -> str:
40+
"""
41+
Detect the programming language from available files.
42+
43+
Returns:
44+
Detected language string (e.g., "c", "python", "java")
45+
"""
46+
pass
47+
48+
@abstractmethod
49+
def get_source_file(self) -> Path:
50+
"""
51+
Get the main source file path for this problem.
52+
53+
Returns:
54+
Path to the source file (e.g., hello.c, Hello.java, hello.py)
55+
56+
Raises:
57+
FileNotFoundError: If source file doesn't exist
58+
"""
59+
pass
60+
61+
@abstractmethod
62+
def compile(self, *args, **kwargs):
63+
"""
64+
Compile the source code (if needed for this language).
65+
66+
For interpreted languages, this may be a no-op or perform validation.
67+
For compiled languages, this compiles the source to executable/bytecode.
68+
69+
Args:
70+
*args: Language-specific compilation arguments
71+
**kwargs: Language-specific compilation options
72+
73+
Raises:
74+
CompileError: If compilation fails
75+
"""
76+
pass
77+
78+
@abstractmethod
79+
def run(self, *args, stdin=None, **kwargs):
80+
"""
81+
Run the compiled/interpreted program.
82+
83+
Args:
84+
*args: Command-line arguments to pass to the program
85+
stdin: Standard input to provide to the program
86+
**kwargs: Language-specific execution options
87+
88+
Returns:
89+
The result object from the execution (depends on implementation)
90+
"""
91+
pass
92+
93+
def exists(self) -> bool:
94+
"""
95+
Check if the source file exists.
96+
97+
Returns:
98+
True if source file exists, False otherwise
99+
"""
100+
try:
101+
source = self.get_source_file()
102+
return source.exists()
103+
except FileNotFoundError:
104+
return False
105+
106+
def require_exists(self):
107+
"""
108+
Ensure source file exists, raise Failure if not.
109+
110+
This is a convenience method for check implementations.
111+
Instead of: if not adapter.exists(): raise Failure(...)
112+
Use: adapter.require_exists()
113+
114+
Raises:
115+
Failure: If source file doesn't exist
116+
"""
117+
from .. import Failure
118+
if not self.exists():
119+
raise Failure("Expected source file")
120+
121+
def __repr__(self):
122+
"""String representation of the adapter."""
123+
return f"{self.__class__.__name__}(problem={self.problem!r}, language={self.language!r})"

0 commit comments

Comments
 (0)