diff --git a/.github/workflows/sync_roadmap_status.yml b/.github/workflows/sync_roadmap_status.yml new file mode 100644 index 0000000..5194817 --- /dev/null +++ b/.github/workflows/sync_roadmap_status.yml @@ -0,0 +1,47 @@ +name: Sync Roadmap Status + +on: + issues: + types: [assigned, unassigned, closed, reopened] + # pull_request_target (not pull_request) so the workflow gets write + # access even for PRs opened from forks. Safe here because we never + # check out or run the fork's code — we only read the PR body + # (already in the event payload) and write to ROADMAP.md on main. + pull_request_target: + types: [opened, ready_for_review, closed] + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Update ROADMAP.md + id: update + run: python scripts/roadmap/sync_roadmap.py + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + + - name: Commit ROADMAP.md + if: steps.update.outputs.changed == 'true' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: sync roadmap status [skip ci]" + file_pattern: ROADMAP.md + + - name: Sync Discord + if: steps.update.outputs.changed == 'true' + run: python scripts/roadmap/sync_discord.py + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_MESSAGE_IDS: ${{ vars.DISCORD_MESSAGE_IDS }} \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 9f01df4..5ade97d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,49 +3,90 @@ This may be subject to change. ## Legend `[ ]` - no work has been done -`[-]` - somebody is working on this but it has not yet been completed. There should be a related PR. +`[ ] 🚧` - work in progress (assigned / open PR) `[x]` - lesson completed ## Roadmap 1. Introduction * [x] How course works? * [x] Motivation and Mindset - * [ ] Asking for help - * [ ] Join the Community - * [x] Computers and Programming Languages (CS Basics) -2. Prerequisites - * [x] Installation of required software - * [x] Text Editor basics - * [x] Command Line basics - * [x] Python - What is it? REPL vs Scripts + * [ ] Asking for Help (WIP) — #32 + * [ ] Join the Community — #33 + * [x] Computer Science basics, Programming and Python +2. Prerequisites + * [x] Software Installation + * [x] Text and Code Editors + * [x] Command Line Basics + * [x] Python - REPL vs Scripts 3. Git Basics * [x] Introduction to Git * [x] Setting up Git and Github - * [x] Git basics -4. Python Foundations - * [x] Output (print), Variables, Core Datatypes - * [x] Math, String operations, Comparisons - * [x] User input, Conditionals + * [x] Git Basics +4. Foundations + * [x] Output, Variables, Core Datatypes + * [x] Arithmetic and String Operations, Comparisons + * [x] User Input and Conditionals * [x] Loops - * [-] Data Structures - * [-] List - * [-] Tuple - * [-] Set - * [-] Dictionary - * [ ] Functions - * [ ] Project 1 -5. Modules, Working with Files and Error Handling - * [ ] Working with multiple files (`__init.py__`, directory structure, etc...) - * [ ] Opening, Closing and Manipulating files - * [ ] Error Handling (`try-except-else-finally`) - * [ ] Custom Exceptions - * [ ] Defensive Coding patterns - * [ ] Working with structured data (`json`, `csv`, etc...) - * [ ] Project 2 -6. OOP, Virtual Environments and third party libraries, Working with APIs - * [ ] Classes and OOP - * [ ] Usage and setup of virtual environments - * [ ] Installation of third party libraries - * [ ] Working with APIs - * [ ] Project 3 - *probably: * weather app -7. Working on Projects, Solving bugs, Reading errors \ No newline at end of file +5. Data Structures + * [ ] Lists — #57 + * [ ] Tuples — #58 + * [ ] Sets — #59 + * [ ] Dictionaries — #60 + * [ ] Coprehensions (`list`, `dict` `set`) — #61 +6. Code Organization + * [ ] Functions — #62 + * [ ] Scope and Namespaces (LEGB rule) — #63 + * [ ] Type Hints — #64 + * [ ] Modules — #65 +7. Capstone Project 1 + * [ ] Problem Solving — #66 + * [ ] Problem Solving - Fizz-Buzz Example — #67 + * [ ] Capstone Project - **Hangman game** — #68 +8. Errors, Debugging, I/O + * [ ] Understanding Errors and Error Handling + * [ ] `logging` module + * [ ] Debugging tehniques + * [ ] File Handling +9. Capstone Project 2 + * [ ] Git Workflow: Branches and Pull Requests + * [ ] Revisiting Project 1 (hangman game): + * Creating branch, adding features, merging back to `main` + * Loading *words* from external file + * Catching and logging errors + * [ ] Capstone Project +10. Intermediate Python + * [ ] Decorators + * [ ] Generators + * [ ] Context Managers +11. Capstone Project 3 + * [ ] Capstone Project +12. Built-in and Modules + * [ ] `math` module + * [ ] `json` module + * [ ] `random` module + * [ ] `datetime` module + * [ ] `pathlib` / `os` module + * [ ] `csv` module + * [ ] `sys` module + * [ ] `re` module +13. Third-party Modules + * [ ] Virtual Environments + * [ ] Using `pip` and `requirements.txt` + * [ ] Creating python package - `pyproject.toml` file + * [ ] ? Linting, Formatting, Type Checking (`ruff`, `mypy`) ? +14. Capstone Project 4 + * [ ] Clean Code Priciples + * [ ] Capstone Project + * [ ] Testing Your Code (`pytest`) + * [ ] Revisit Project 4 + * Develop a testing suite for the project +15. Object Oriented Programming - Part 1 + * [ ] Introduction to classes and instances + * [ ] Methods (instance, class, static) + * [ ] Dunder Methods (`__str__`, `__repr__`, etc...) +16. Capstone Project 5 + * [ ] Capstone Project +17. Object Oriented Programming - Part 2 + * [ ] Four Pillars of Object-Oriented Programming (Encapsulation, Abstraction, Inheritance, Polymorphism) +18. Capstone Project 6 + * [ ] Capstone Project \ No newline at end of file diff --git a/bot_challenges/01-inventory-system.md b/bot_challenges/01-inventory-system.md deleted file mode 100644 index 98267c0..0000000 --- a/bot_challenges/01-inventory-system.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -id: "01-inventory-system" -title: "RPG Inventory System" -web_lesson: "https://pythonledger.com/docs/lists" -total_stages: 2 ---- - -# Stage 1 -Create a list named `inventory` containing 3 strings: `"potion"`, `"sword"`, and `"shield"`. - -- HINT: Make sure item names are wrapped in quotes and separated by commas. -- DONE: Print the entire list using `print(inventory)`. - ----STAGE--- - -# Stage 2 -Print only the **second** item from your `inventory` list. - -- HINT: Python lists are 0-indexed (0 = first item, 1 = second item). -- DONE: Console output displays only the string `"sword"`. \ No newline at end of file diff --git a/scripts/roadmap/sync_discord.py b/scripts/roadmap/sync_discord.py new file mode 100644 index 0000000..57a9f67 --- /dev/null +++ b/scripts/roadmap/sync_discord.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Renders ROADMAP.md into Discord embeds and PATCHes a set of existing +webhook messages, so a Discord channel mirrors the roadmap's current +state. Uses only the standard library — no extra dependency to install +in the workflow. + +Requires: + DISCORD_WEBHOOK_URL The webhook's execute URL + (https://discord.com/api/webhooks//) + DISCORD_MESSAGE_IDS JSON array of message IDs to update, one per + "page" of phases (see PHASES_PER_MESSAGE below) + +One-time setup: send an initial message through the webhook manually +(any content) for each page you'll need, note the returned message +IDs, and store them as DISCORD_MESSAGE_IDS. This script only edits +those messages — it never creates new ones — so the roadmap always +lives at the same spot in the channel. +""" + +import json +import os +import re +import sys +import urllib.request + +ROADMAP_PATH = "ROADMAP.md" +PHASES_PER_MESSAGE = ( + 9 # 18 phases / 9 = 2 messages; keeps each embed well under Discord's limits +) + +STATE_EMOJI = {" ": "⬜", "-": "🚧", "x": "✅"} +WIP_BADGE = "🚧" + +PHASE_RE = re.compile(r"^\d+\.\s+(.+)$") +ITEM_RE = re.compile( + rf"^\s*\*\s*\[(?P[ x])\]\s*(?P{WIP_BADGE}\s*)?(?P.+?)\s*$" +) + + +def item_state(check: str, badge: str | None) -> str: + if check == "x": + return "x" + return "-" if badge else " " + + +Phase = tuple[str, list[tuple[str, str]]] + + +def parse_roadmap(text: str) -> list[Phase]: + phases: list[Phase] = [] + current_items: list[tuple[str, str]] = [] + current_title: str | None = None + + for raw_line in text.splitlines(): + phase_match = PHASE_RE.match(raw_line) + if phase_match: + if current_title is not None: + phases.append((current_title, current_items)) + current_title = phase_match.group(1) + current_items = [] + continue + + item_match = ITEM_RE.match(raw_line) + if item_match and current_title is not None: + state = item_state(item_match.group("check"), item_match.group("badge")) + current_items.append((state, item_match.group("text"))) + + if current_title is not None: + phases.append((current_title, current_items)) + + return phases + + +def build_embed(phases: list[Phase]) -> dict: + fields = [] + for title, items in phases: + lines = [f"{STATE_EMOJI[state]} {text}" for state, text in items] + value = "\n".join(lines) or "—" + fields.append({"name": title[:256], "value": value[:1024], "inline": False}) + + return { + "title": "📍 Curriculum Roadmap", + "color": 0x3776AB, + "fields": fields[:25], + } + + +def patch_message(webhook_url: str, message_id: str, embed: dict) -> None: + url = f"{webhook_url}/messages/{message_id}" + payload = json.dumps({"embeds": [embed]}).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + method="PATCH", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as resp: + if resp.status not in (200, 204): + print( + f"Unexpected status {resp.status} updating message {message_id}", + file=sys.stderr, + ) + + +def main() -> None: + webhook_url = os.environ["DISCORD_WEBHOOK_URL"] + message_ids = json.loads(os.environ["DISCORD_MESSAGE_IDS"]) + + with open(ROADMAP_PATH, encoding="utf-8") as f: + phases = parse_roadmap(f.read()) + + chunks = [ + phases[i : i + PHASES_PER_MESSAGE] + for i in range(0, len(phases), PHASES_PER_MESSAGE) + ] + + if len(chunks) != len(message_ids): + print( + f"Expected {len(chunks)} message(s) for {len(phases)} phases, " + f"but DISCORD_MESSAGE_IDS has {len(message_ids)}. Update the " + f"variable or PHASES_PER_MESSAGE so they match.", + file=sys.stderr, + ) + sys.exit(1) + + for message_id, chunk in zip(message_ids, chunks): + patch_message(webhook_url, message_id, build_embed(chunk)) + print(f"Updated message {message_id} ({len(chunk)} phases)") + + +if __name__ == "__main__": + main() diff --git a/scripts/roadmap/sync_roadmap.py b/scripts/roadmap/sync_roadmap.py new file mode 100644 index 0000000..45291ba --- /dev/null +++ b/scripts/roadmap/sync_roadmap.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Reads the GitHub event that triggered this workflow run (an issue or PR +change) and updates the matching line in ROADMAP.md to one of three +states: + + [ ] Lists — #34 not started + [ ] 🚧 Lists — #34 in progress (assigned / open PR) + [x] Lists — #34 done (merged / issue closed) + +GitHub's task-list rendering only recognizes [ ] and [x] as real, +interactive checkboxes — anything else (like a bare [-]) is just +literal text with no checkbox widget. So the checkbox itself stays +strictly boolean (done / not done), and "in progress" is layered on +as a 🚧 badge in the visible text instead of a third bracket state. + +This only touches roadmap lines that already reference an issue, e.g.: + + * [ ] Lists — #34 + +Lines with no "— #N" suffix are left alone — link an issue to a lesson +by adding that suffix, and the automation picks it up from then on. +""" + +import json +import os +import re + +ROADMAP_PATH = "ROADMAP.md" +WIP_BADGE = "🚧" + +# Internal state codes (unchanged from before): " " = not started, +# "-" = in progress, "x" = done. Only how they're RENDERED changes. +STATE_RENDER = { + " ": {"check": " ", "badge": ""}, + "-": {"check": " ", "badge": f"{WIP_BADGE} "}, + "x": {"check": "x", "badge": ""}, +} + +# Matches e.g. " * [ ] 🚧 Lists — #34" and captures the checkbox, +# an optional WIP badge, and the issue number — everything else on +# the line is left untouched. +LINE_RE = re.compile( + r"^(?P\s*\*\s*\[)(?P[ x])(?P\]\s*)" + rf"(?P{WIP_BADGE}\s*)?" + r"(?P.*?—\s*#)(?P\d+)(?P.*)$" +) + + +def current_state(check: str, badge: str | None) -> str: + if check == "x": + return "x" + return "-" if badge else " " + + +# Matches "Closes #12", "Fixes #34", "Resolves #7", etc. (repeatable, case-insensitive) +ISSUE_REF_RE = re.compile( + r"(?:clos(?:e[sd]?)|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE +) + + +def marker_for_issue_event(event: dict) -> dict[int, str]: + action = event["action"] + issue_number = event["issue"]["number"] + + if action == "assigned": + return {issue_number: "-"} + if action == "unassigned": + return {issue_number: " "} + if action == "closed": + return {issue_number: "x"} + if action == "reopened": + has_assignee = bool(event["issue"].get("assignees")) + return {issue_number: "-" if has_assignee else " "} + return {} + + +def marker_for_pr_event(event: dict) -> dict[int, str]: + action = event["action"] + pr = event["pull_request"] + body = pr.get("body") or "" + issue_numbers = {int(n) for n in ISSUE_REF_RE.findall(body)} + if not issue_numbers: + return {} + + if action in ("opened", "ready_for_review"): + marker = "-" + elif action == "closed": + marker = "x" if pr.get("merged") else "-" + else: + return {} + + return {n: marker for n in issue_numbers} + + +def load_event() -> dict: + with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f: + return json.load(f) + + +def compute_updates() -> dict[int, str]: + event_name = os.environ["GITHUB_EVENT_NAME"] + event = load_event() + + if event_name == "issues": + return marker_for_issue_event(event) + if event_name == "pull_request" or event_name == "pull_request_target": + return marker_for_pr_event(event) + return {} + + +def apply_updates(updates: dict[int, str]) -> bool: + with open(ROADMAP_PATH, encoding="utf-8") as f: + lines = f.readlines() + + changed = False + for i, line in enumerate(lines): + m = LINE_RE.match(line.rstrip("\n")) + if not m: + continue + + issue = int(m.group("issue")) + if issue not in updates: + continue + + new_state = updates[issue] + old_state = current_state(m.group("check"), m.group("badge")) + if old_state == new_state: + continue + + render = STATE_RENDER[new_state] + lines[i] = ( + m.group("prefix") + + render["check"] + + m.group("mid") + + render["badge"] + + m.group("body") + + m.group("issue") + + m.group("rest") + + "\n" + ) + changed = True + print(f"#{issue}: [{old_state}] -> [{new_state}]") + + if changed: + with open(ROADMAP_PATH, "w", encoding="utf-8") as f: + f.writelines(lines) + + return changed + + +def main() -> None: + updates = compute_updates() + if not updates: + print("No linked roadmap issue affected by this event — nothing to do.") + changed = False + else: + changed = apply_updates(updates) + if not changed: + print("Matching roadmap line(s) already up to date.") + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"changed={'true' if changed else 'false'}\n") + + +if __name__ == "__main__": + main()