Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GEMINI_API_KEY = ""
42 changes: 42 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python application

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v3
with:
python-version: "3.11"

- name: Install Poetry
uses: snok/install-poetry@v1

- name: Install dependencies
run: poetry install

- name: Lint with ruff
# Don't lint challenges for now, too many problems.
run: poetry run ruff check . --output-format github --exclude challenges/

- name: Type check with Pyright
run: pyright

- name: Test with pytest
run: pytest
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ venv/
*.log
.DS_Store

# Local settigngs
.env

# Output folders
course/
challenges/
496 changes: 440 additions & 56 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pygments = "^2.19.2"
pymdown-extensions = "^10.16.1"
flask = "^3.1.2"
pydantic = "^2.11.9"
pyright = "^1.1.405"
ruff = "^0.13.1"
google-genai = "^1.38.0"
dotenv = "^0.9.9"

[build-system]
requires = ["poetry-core"]
Expand Down
2 changes: 1 addition & 1 deletion scripts/build_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def generate_codehilite_css():


def generate_index_html(files: list[Path], output_dir: Path, style: str):
print(f"Generating index file...")
print("Generating index file...")
links = "\n".join(
f'<li><a href="{file.parent.name}/{file.name}">{file.name}</a></li>'
for file in files
Expand Down
80 changes: 80 additions & 0 deletions scripts/correct_sample_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import random
import time
from pathlib import Path
from google import genai
from dotenv import load_dotenv
from google.genai import errors

# === Configuration ===
CHALLENGES_DIR = Path("src/markdown")
MODEL = "gemini-2.5-flash"

load_dotenv()
client = genai.Client()

def correct_sample_code(challenge_text: str) -> str:
"""
Send a challenge markdown file to Gemini to correct / insert sample code.
Returns the full corrected markdown document.
"""
prompt = (
"Your job is to create or modify sample code from programming challenges. "
"Follow these instructions strictly:\n"
"1. If a sample code block already exists, verify that it is appropriate for the challenge and change as needed.\n"
"2. If a code block exists but solves the challenge, replace it with sample code only (e.g., stubs, empty functions, etc.).\n"
"3. If no sample code exists, add suitable sample code if possible.\n"
"4. All sample code must run, pass lint, and typecheck. If not possible, comment out problematic code as a last resort.\n"
"5. Non-Python sample code can be included in a separate code block, as comments in Python, or integrated (e.g. json.loads(), yaml.loads()).\n"
"6. It is acceptable to output no sample code when appropriate.\n"
"7. Response format must be Markdown and must include the complete challenge definition as provided.\n\n"
"Challenge:\n"
+ challenge_text
)

attempt = 0
initial_delay = 5
max_retries = 10
max_delay = 60

while True:
try:
response = client.models.generate_content(
model=f"models/{MODEL}",
contents=prompt,
)

if not response.text:
raise Exception("Empty response from API")

return response.text

except errors.APIError as e:
if (e.message and "429" in e.message) or (e.status and "429" in e.status):
delay = min(initial_delay * (2 ** attempt), max_delay) + random.uniform(0, 1)
print(f"Attempt {attempt + 1}/{max_retries}: Got 429 error. Retrying in {delay:.2f} seconds...")
time.sleep(delay)
attempt += 1
if attempt >= max_retries:
raise Exception("Max retries reached for 429 errors")
else:
print(f"Caught a non-429 error: {e}")
raise

def main():
for md_file in CHALLENGES_DIR.glob("**/*Challenge.md"):
print(f"Processing {md_file.name}...")

original_text = md_file.read_text(encoding="utf-8")

try:
corrected_text = correct_sample_code(original_text)
except Exception as e:
print(f"Failed to correct {md_file.name}: {e}")
continue

# Replace the challenge file with the corrected version
md_file.write_text(corrected_text, encoding="utf-8")
print(f"Updated {md_file.name}")

if __name__ == "__main__":
main()
102 changes: 102 additions & 0 deletions scripts/generate_solutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import random
import re
import time
from pathlib import Path
from google import genai
from dotenv import load_dotenv
from google.genai import errors

# === Configuration ===
CHALLENGES_DIR = Path("src/markdown")
SOLUTIONS_DIR = Path("solutions")
SOLUTIONS_DIR.mkdir(exist_ok=True, parents=True)
MODEL="gemini-2.5-flash"

load_dotenv()
client = genai.Client()

def generate_solution(challenge_text: str) -> str:
prompt = (
"You are an expert Python programmer. "
"Read the challenge below and provide a solution."
"Use any sample code from the challenge if possible. "
"Return code solutions in fenced code blocks, non-code solutions as a numbered list. "
"If the challenge cannot be solved, explain why. "
"Do not include extra text such as reasoning, explanations or similar. "
"The response format should be markdown."
"\n\nChallenge:\n" + challenge_text
)

attempt = 0
initial_delay = 5
max_retries = 10
max_delay = 60

while True:
try:
response = client.models.generate_content(
model=f"models/{MODEL}",
contents=prompt,
)

if not response.text:
raise Exception("Empty respones from API")

return response.text
except errors.APIError as e:
if e.message and "429" in e.message or e.status and "429" in e.status:
# Calculate exponential backoff with jitter
delay = min(initial_delay * (2 ** attempt), max_delay) + random.uniform(0, 1)
print(f"Attempt {attempt + 1}/{max_retries}: Got 429 error. Retrying in {delay:.2f} seconds...")
time.sleep(delay)
attempt += 1
else:
# Re-raise for other errors
print(f"Caught a non-429 error: {e}")
raise


# === Split markdown into second-level headings ===
def split_challenges(md_text: str) -> list[tuple[str, str]]:
"""
Returns a list of tuples: (heading_text, challenge_content)
Splits on ## heading lines.
"""
pattern = re.compile(r"^## (.+?)\s*\n(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
return [(m[0].strip(), m[1].strip()) for m in pattern.findall(md_text)]

# === Main script ===
def main():
for md_file in CHALLENGES_DIR.glob("**/*Challenge.md"):
solution_file = Path(md_file.parent, f"{md_file.stem} Solution.md")
if solution_file.exists():
print(f"Skipping {md_file.name}, solution already exists.")
continue

print(f"Processing {md_file.name}...")
md_text = md_file.read_text(encoding="utf-8")
challenges = split_challenges(md_text)

all_solutions = []
for heading, content in challenges:
print(f"Generating solution for '{heading}'...")

try:
solution_markdown = generate_solution(content)
except Exception as e:
solution_markdown = f"Failed to generate solution: {e}"

# Wrap solution in pymdownx.details block with indentation
wrapped_solution = f"??? solution \"{heading}\"\n"
indented_solution = "\n".join(f" {line}" if line.strip() else "" for line in solution_markdown.splitlines())
wrapped_solution += indented_solution

all_solutions.append(wrapped_solution)

# Write combined solution file
solution_file.write_text("\n\n".join(all_solutions), encoding="utf-8")
print(f"Saved solutions to {solution_file.name}")


if __name__ == "__main__":
main()
38 changes: 38 additions & 0 deletions src/markdown/Classes and oop/Descriptors Challenge Solution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
??? solution "Challenge 1: Read-Only Descriptor"
```python
class ReadOnly:
def __init__(self, value):
self._value = value

def __get__(self, instance, owner):
# If accessed via an instance (instance is not None), return the stored value.
# If accessed via the class (instance is None), return the descriptor itself.
# For a simple read-only constant, directly returning _value is fine.
return self._value

def __set__(self, instance, value):
raise AttributeError("Read-only attribute")

def __delete__(self, instance):
raise AttributeError("Cannot delete read-only attribute")

class MyClass:
const = ReadOnly(42)

# Example Usage and Verification:
# obj = MyClass()
# assert obj.const == 42
# try:
# obj.const = 10
# except AttributeError as e:
# print(f"Successfully caught expected error: {e}")
# else:
# print("Failed to catch expected AttributeError on assignment.")
#
# try:
# del obj.const
# except AttributeError as e:
# print(f"Successfully caught expected error: {e}")
# else:
# print("Failed to catch expected AttributeError on deletion.")
```
Loading