diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e1d1e80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + env: + PYTHONPATH: src + run: pytest -q + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93c42f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/Binary Search.ipynb b/Binary Search.ipynb index d9d296f..8f362e6 100644 --- a/Binary Search.ipynb +++ b/Binary Search.ipynb @@ -1,131 +1,254 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "70cc70ea", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Naive search time: 2.4266116619110107 seconds\n", - "Binary search time: 0.03394174575805664 seconds\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Algorithms Visualizer\n", + "\n", + "Goal: compare and trace core algorithms (search, traversal, sorting).\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Benchmark/trace runner\n", + "4. Quick verification checks" + ], + "id": "14c6f02c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Algorithms visualizer notebook: binary search, BFS, DFS, quicksort, mergesort.\"\"\"\n", + "\n", + "from collections import deque\n", + "from time import perf_counter\n", + "import random\n", + "\n", + "\n", + "def validate_sorted(values: list[int]) -> None:\n", + " if any(values[i] > values[i + 1] for i in range(len(values) - 1)):\n", + " raise ValueError(\"Input list must be sorted.\")\n", + "\n", + "\n", + "def binary_search_iterative(values: list[int], target: int) -> tuple[int, list[tuple[int, int, int]]]:\n", + " validate_sorted(values)\n", + " low, high = 0, len(values) - 1\n", + " trace: list[tuple[int, int, int]] = []\n", + " while low <= high:\n", + " mid = (low + high) // 2\n", + " trace.append((low, mid, high))\n", + " if values[mid] == target:\n", + " return mid, trace\n", + " if values[mid] < target:\n", + " low = mid + 1\n", + " else:\n", + " high = mid - 1\n", + " return -1, trace\n", + "\n", + "\n", + "def bfs(graph: dict[str, list[str]], start: str) -> list[str]:\n", + " visited, order = {start}, []\n", + " queue = deque([start])\n", + " while queue:\n", + " node = queue.popleft()\n", + " order.append(node)\n", + " for nxt in graph.get(node, []):\n", + " if nxt not in visited:\n", + " visited.add(nxt)\n", + " queue.append(nxt)\n", + " return order\n", + "\n", + "\n", + "def dfs(graph: dict[str, list[str]], start: str) -> list[str]:\n", + " visited, order = set(), []\n", + "\n", + " def walk(node: str) -> None:\n", + " visited.add(node)\n", + " order.append(node)\n", + " for nxt in graph.get(node, []):\n", + " if nxt not in visited:\n", + " walk(nxt)\n", + "\n", + " walk(start)\n", + " return order\n", + "\n", + "\n", + "def quicksort(values: list[int]) -> list[int]:\n", + " if len(values) <= 1:\n", + " return values\n", + " pivot = values[len(values) // 2]\n", + " left = [x for x in values if x < pivot]\n", + " middle = [x for x in values if x == pivot]\n", + " right = [x for x in values if x > pivot]\n", + " return quicksort(left) + middle + quicksort(right)\n", + "\n", + "\n", + "def mergesort(values: list[int]) -> list[int]:\n", + " if len(values) <= 1:\n", + " return values\n", + " mid = len(values) // 2\n", + " left = mergesort(values[:mid])\n", + " right = mergesort(values[mid:])\n", + " merged = []\n", + " i = j = 0\n", + " while i < len(left) and j < len(right):\n", + " if left[i] <= right[j]:\n", + " merged.append(left[i]); i += 1\n", + " else:\n", + " merged.append(right[j]); j += 1\n", + " return merged + left[i:] + right[j:]\n", + "\n", + "\n", + "def benchmark(search_fn, values: list[int], targets: list[int]) -> float:\n", + " start = perf_counter()\n", + " for t in targets:\n", + " search_fn(values, t)\n", + " return perf_counter() - start\n", + "\n", + "\n", + "def linear_search(values: list[int], target: int) -> int:\n", + " for i, value in enumerate(values):\n", + " if value == target:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "# Quick verification checks\n", + "nums = [1, 4, 8, 11, 15, 20]\n", + "idx, trace = binary_search_iterative(nums, 11)\n", + "assert idx == 3 and len(trace) > 0\n", + "assert bfs({\"A\": [\"B\", \"C\"], \"B\": [\"D\"], \"C\": [], \"D\": []}, \"A\") == [\"A\", \"B\", \"C\", \"D\"]\n", + "assert mergesort([5, 2, 7, 1]) == [1, 2, 5, 7]\n", + "\n", + "\n", + "# Deterministic benchmark and trace demo\n", + "rng = random.Random(11)\n", + "values = sorted(rng.sample(range(-5000, 5000), 1200))\n", + "targets = values[:200]\n", + "time_binary = benchmark(lambda arr, t: binary_search_iterative(arr, t)[0], values, targets)\n", + "time_naive = benchmark(linear_search, values, targets)\n", + "print(\"binary_search seconds:\", round(time_binary, 6))\n", + "print(\"linear_search seconds:\", round(time_naive, 6))\n", + "print(\"sample trace for target 20:\", binary_search_iterative(nums, 20)[1])\n", + "\n", + "# We will prove that binary search is faster than naive search!\n", + "\n", + "\n", + "# Essence of binary search:\n", + "# If you have a sorted list and you want to search this array for something,\n", + "# You could go through each item in the list and ask, is this equal to what we're looking for?\n", + "# But we can make this *faster* by leveraging the fact that our array is sorted!\n", + "# Binary search ~ O(log(n)), naive search ~ O(n)\n", + "\n", + "# In these two examples, l is a list in ascending order, and target is something that we're looking for\n", + "# Return -1 if not found\n", + "\n", + "\n", + "# naive search: scan entire list and ask if its equal to the target\n", + "# if yes, return the index\n", + "# if no, then return -1\n", + "\n", + "def naive_search(l, target):\n", + " # example l = [1, 3, 10, 12]\n", + " for i in range(len(l)):\n", + " if l[i] == target:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "# binary search uses divide and conquer!\n", + "# we will leverage the fact that our list is SORTED\n", + "def binary_search(l, target, low=None, high=None):\n", + " if low is None:\n", + " low = 0\n", + " if high is None:\n", + " high = len(l) - 1\n", + "\n", + " if high < low:\n", + " return -1\n", + "\n", + " # example l = [1, 3, 5, 10, 12] # should return 3\n", + " midpoint = (low + high) // 2 # 2\n", + "\n", + " # we'll check if l[midpoint] == target, and if not, we can find out if\n", + " # target will be to the left or right of midpoint\n", + " # we know everything to the left of midpoint is smaller than the midpoint\n", + " # and everything to the right is larger\n", + " if l[midpoint] == target:\n", + " return midpoint\n", + " elif target < l[midpoint]:\n", + " return binary_search(l, target, low, midpoint-1)\n", + " else:\n", + " # target > l[midpoint]\n", + " return binary_search(l, target, midpoint+1, high)\n", + "\n", + "if __name__=='__main__':\n", + " # l = [1, 3, 5, 10, 12]\n", + " # target = 7\n", + " # print(naive_search(l, target))\n", + " # print(binary_search(l, target))\n", + "\n", + " length = 10000\n", + " # build a sorted list of length 10000\n", + " sorted_list = set()\n", + " while len(sorted_list) < length:\n", + " sorted_list.add(random.randint(-3*length, 3*length))\n", + " sorted_list = sorted(list(sorted_list))\n", + "\n", + " start = time.time()\n", + " for target in sorted_list:\n", + " naive_search(sorted_list, target)\n", + " end = time.time()\n", + " print(\"Naive search time: \", (end - start), \"seconds\")\n", + "\n", + " start = time.time()\n", + " for target in sorted_list:\n", + " binary_search(sorted_list, target)\n", + " end = time.time()\n", + " print(\"Binary search time: \", (end - start), \"seconds\")" + ], + "execution_count": 1, + "outputs": [ + { + "output_type": "stream", + "text": [ + "Naive search time: 2.4266116619110107 seconds\n", + "Binary search time: 0.03394174575805664 seconds\n" + ] + } + ], + "id": "70cc70ea" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "1750bde4" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "import random\n", - "import time\n", - "\n", - "# Implementation of binary search algorithm!!\n", - "\n", - "# We will prove that binary search is faster than naive search!\n", - "\n", - "\n", - "# Essence of binary search:\n", - "# If you have a sorted list and you want to search this array for something,\n", - "# You could go through each item in the list and ask, is this equal to what we're looking for?\n", - "# But we can make this *faster* by leveraging the fact that our array is sorted!\n", - "# Binary search ~ O(log(n)), naive search ~ O(n)\n", - "\n", - "# In these two examples, l is a list in ascending order, and target is something that we're looking for\n", - "# Return -1 if not found\n", - "\n", - "\n", - "# naive search: scan entire list and ask if its equal to the target\n", - "# if yes, return the index\n", - "# if no, then return -1\n", - "\n", - "def naive_search(l, target):\n", - " # example l = [1, 3, 10, 12]\n", - " for i in range(len(l)):\n", - " if l[i] == target:\n", - " return i\n", - " return -1\n", - "\n", - "\n", - "# binary search uses divide and conquer!\n", - "# we will leverage the fact that our list is SORTED\n", - "def binary_search(l, target, low=None, high=None):\n", - " if low is None:\n", - " low = 0\n", - " if high is None:\n", - " high = len(l) - 1\n", - "\n", - " if high < low:\n", - " return -1\n", - "\n", - " # example l = [1, 3, 5, 10, 12] # should return 3\n", - " midpoint = (low + high) // 2 # 2\n", - "\n", - " # we'll check if l[midpoint] == target, and if not, we can find out if\n", - " # target will be to the left or right of midpoint\n", - " # we know everything to the left of midpoint is smaller than the midpoint\n", - " # and everything to the right is larger\n", - " if l[midpoint] == target:\n", - " return midpoint\n", - " elif target < l[midpoint]:\n", - " return binary_search(l, target, low, midpoint-1)\n", - " else:\n", - " # target > l[midpoint]\n", - " return binary_search(l, target, midpoint+1, high)\n", - "\n", - "if __name__=='__main__':\n", - " # l = [1, 3, 5, 10, 12]\n", - " # target = 7\n", - " # print(naive_search(l, target))\n", - " # print(binary_search(l, target))\n", - "\n", - " length = 10000\n", - " # build a sorted list of length 10000\n", - " sorted_list = set()\n", - " while len(sorted_list) < length:\n", - " sorted_list.add(random.randint(-3*length, 3*length))\n", - " sorted_list = sorted(list(sorted_list))\n", - "\n", - " start = time.time()\n", - " for target in sorted_list:\n", - " naive_search(sorted_list, target)\n", - " end = time.time()\n", - " print(\"Naive search time: \", (end - start), \"seconds\")\n", - "\n", - " start = time.time()\n", - " for target in sorted_list:\n", - " binary_search(sorted_list, target)\n", - " end = time.time()\n", - " print(\"Binary search time: \", (end - start), \"seconds\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1750bde4", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/Find the month.ipynb b/Find the month.ipynb index dbcf40d..fb1d6a8 100644 --- a/Find the month.ipynb +++ b/Find the month.ipynb @@ -1,66 +1,111 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 4, - "id": "dac8d791", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "November\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Find the Month (Foundations)\n", + "\n", + "Goal: practice dictionary lookups, normalization, and safe fallback behavior.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Quick verification checks\n", + "4. Demo outputs" + ], + "id": "47252409" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Foundation notebook: safe month lookups with normalization.\"\"\"\n", + "\n", + "from difflib import get_close_matches\n", + "\n", + "MONTH_CONVERSIONS = {\n", + " \"JAN\": \"January\",\n", + " \"FEB\": \"February\",\n", + " \"MAR\": \"March\",\n", + " \"APR\": \"April\",\n", + " \"MAY\": \"May\",\n", + " \"JUN\": \"June\",\n", + " \"JUL\": \"July\",\n", + " \"AUG\": \"August\",\n", + " \"SEP\": \"September\",\n", + " \"OCT\": \"October\",\n", + " \"NOV\": \"November\",\n", + " \"DEC\": \"December\",\n", + "}\n", + "\n", + "\n", + "def normalize_month_key(raw: str) -> str:\n", + " return raw.strip().upper()[:3]\n", + "\n", + "\n", + "def expand_month(raw: str) -> str:\n", + " key = normalize_month_key(raw)\n", + " return MONTH_CONVERSIONS.get(key, \"Not a valid key\")\n", + "\n", + "\n", + "def suggest_month_key(raw: str) -> str | None:\n", + " key = normalize_month_key(raw)\n", + " matches = get_close_matches(key, MONTH_CONVERSIONS.keys(), n=1, cutoff=0.6)\n", + " return matches[0] if matches else None\n", + "\n", + "\n", + "# Quick verification checks\n", + "assert expand_month(\"Nov\") == \"November\"\n", + "assert expand_month(\" february\") == \"February\"\n", + "assert expand_month(\"Luv\") == \"Not a valid key\"\n", + "\n", + "\n", + "print(\"Valid lookup:\", expand_month(\"Nov\"))\n", + "print(\"Invalid lookup:\", expand_month(\"Luv\"))\n", + "maybe = suggest_month_key(\"Luv\")\n", + "if maybe:\n", + " print(\"Did you mean:\", MONTH_CONVERSIONS[maybe])" + ], + "execution_count": 4, + "outputs": [ + { + "output_type": "stream", + "text": [ + "November\n" + ] + } + ], + "id": "dac8d791" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "ef4aad7f" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "monthConversions = {\n", - " \"Jan\" : \"January\" ,\n", - " \"Feb\" : \"February\" ,\n", - " \"Mar\" : \"March\" ,\n", - " \"Apr\" : \"April\" ,\n", - " \"May\" : \"May\" ,\n", - " \"Jun\" : \"June\" ,\n", - " \"Jul\" : \"July\" ,\n", - " \"Aug\" : \"August\" ,\n", - " \"Sep\" : \"September\" ,\n", - " \"Oct\" : \"October\" ,\n", - " \"Nov\" : \"November\" ,\n", - " \"Dec\" : \"December\" ,\n", - "}\n", - "\n", - "print(monthConversions.get(\"Luv\" , \"Not a valid key\"))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef4aad7f", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/Guess the number.ipynb b/Guess the number.ipynb index 46b9f85..7a5f484 100644 --- a/Guess the number.ipynb +++ b/Guess the number.ipynb @@ -1,80 +1,144 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 3, - "id": "86407d0c", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Guess a number between 1 and 10: 10\n", - "Sorry, guess again. Too high.\n", - "Guess a number between 1 and 10: 5\n", - "Sorry, guess again. Too low.\n", - "Guess a number between 1 and 10: 7\n", - "Sorry, guess again. Too high.\n", - "Guess a number between 1 and 10: 6\n", - "Yay, congrats. You have guessed the number 6 correctly!!\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Guess the Number\n", + "\n", + "Goal: robust guessing game with validation and reverse mode.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Gameplay runners\n", + "4. Quick verification checks" + ], + "id": "aa37b3a0" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Guess-the-number notebook with validation, replay helpers, and reverse mode.\"\"\"\n", + "\n", + "import random\n", + "\n", + "\n", + "def parse_guess(text: str) -> int:\n", + " value = int(text.strip())\n", + " return value\n", + "\n", + "\n", + "def get_feedback(guess: int, target: int) -> str:\n", + " if guess < target:\n", + " return \"low\"\n", + " if guess > target:\n", + " return \"high\"\n", + " return \"correct\"\n", + "\n", + "\n", + "def play_guess(max_value: int, rng_seed: int = 0, input_fn=input, output_fn=print) -> int:\n", + " if max_value < 2:\n", + " raise ValueError(\"max_value must be at least 2\")\n", + " rng = random.Random(rng_seed)\n", + " target = rng.randint(1, max_value)\n", + " attempts = 0\n", + "\n", + " while True:\n", + " raw = input_fn(f\"Guess a number between 1 and {max_value}: \")\n", + " try:\n", + " guess = parse_guess(raw)\n", + " except ValueError:\n", + " output_fn(\"Please enter a whole number.\")\n", + " continue\n", + " if not (1 <= guess <= max_value):\n", + " output_fn(\"Guess out of range.\")\n", + " continue\n", + " attempts += 1\n", + " feedback = get_feedback(guess, target)\n", + " if feedback == \"correct\":\n", + " output_fn(f\"Correct! Target was {target}. Attempts: {attempts}\")\n", + " return attempts\n", + " output_fn(\"Too low.\" if feedback == \"low\" else \"Too high.\")\n", + "\n", + "\n", + "def computer_guesses(user_target: int, max_value: int) -> int:\n", + " low, high = 1, max_value\n", + " attempts = 0\n", + " while low <= high:\n", + " attempts += 1\n", + " mid = (low + high) // 2\n", + " if mid == user_target:\n", + " return attempts\n", + " if mid < user_target:\n", + " low = mid + 1\n", + " else:\n", + " high = mid - 1\n", + " raise RuntimeError(\"Target should always be found in range.\")\n", + "\n", + "\n", + "# Quick verification checks\n", + "assert get_feedback(3, 5) == \"low\"\n", + "assert get_feedback(8, 5) == \"high\"\n", + "assert computer_guesses(73, 100) <= 7\n", + "\n", + "print(\"Reverse mode demo attempts for target 42:\", computer_guesses(42, 100))" + ], + "execution_count": 3, + "outputs": [ + { + "output_type": "stream", + "text": [ + "Guess a number between 1 and 10: 10\n", + "Sorry, guess again. Too high.\n", + "Guess a number between 1 and 10: 5\n", + "Sorry, guess again. Too low.\n", + "Guess a number between 1 and 10: 7\n", + "Sorry, guess again. Too high.\n", + "Guess a number between 1 and 10: 6\n", + "Yay, congrats. You have guessed the number 6 correctly!!\n" + ] + } + ], + "id": "86407d0c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "f33aca11" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "95dd1c3c" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "import random\n", - "\n", - "def guess(x):\n", - " random_number = random.randint(1, x)\n", - " guess = 0\n", - " while guess != random_number : \n", - " guess = int(input (f'Guess a number between 1 and {x}: '))\n", - " if guess < random_number:\n", - " print('Sorry, guess again. Too low.')\n", - " elif guess > random_number:\n", - " print('Sorry, guess again. Too high.')\n", - " print(f'Yay, congrats. You have guessed the number {random_number} correctly!!')\n", - "\n", - "guess(10)\n", - " " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f33aca11", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "95dd1c3c", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/Hangman.ipynb b/Hangman.ipynb index 58961ca..b4e6226 100644 --- a/Hangman.ipynb +++ b/Hangman.ipynb @@ -1,237 +1,344 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 8, - "id": "0daac62b", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "You have 7 lives left and you have used these letters: \n", - "Current word: - - - - - - - -\n", - "Guess a letter: A\n", - "\n", - "Your letter, A is not in the word.\n", - "You have 6 lives left and you have used these letters: A\n", - "Current word: - - - - - - - -\n", - "Guess a letter: B\n", - "\n", - "Your letter, B is not in the word.\n", - "You have 5 lives left and you have used these letters: A B\n", - "Current word: - - - - - - - -\n", - "Guess a letter: E\n", - "\n", - "You have 5 lives left and you have used these letters: A E B\n", - "Current word: - - - - - - - E\n", - "Guess a letter: P\n", - "\n", - "You have 5 lives left and you have used these letters: A E P B\n", - "Current word: - - - P - - - E\n", - "Guess a letter: M\n", - "\n", - "Your letter, M is not in the word.\n", - "You have 4 lives left and you have used these letters: E A M B P\n", - "Current word: - - - P - - - E\n", - "Guess a letter: N\n", - "\n", - "Your letter, N is not in the word.\n", - "You have 3 lives left and you have used these letters: E A M B N P\n", - "Current word: - - - P - - - E\n", - "Guess a letter: C\n", - "\n", - "Your letter, C is not in the word.\n", - "You have 2 lives left and you have used these letters: E A M B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: D\n", - "\n", - "Your letter, D is not in the word.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: E\n", - "\n", - "You have already used that letter. Guess another letter.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: A\n", - "\n", - "You have already used that letter. Guess another letter.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: C\n", - "\n", - "You have already used that letter. Guess another letter.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: B\n", - "\n", - "You have already used that letter. Guess another letter.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: N\n", - "\n", - "You have already used that letter. Guess another letter.\n", - "You have 1 lives left and you have used these letters: E A M D B N P C\n", - "Current word: - - - P - - - E\n", - "Guess a letter: g\n", - "\n", - "Your letter, G is not in the word.\n", - "You died, sorry. The word was SURPRISE\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hangman\n", + "\n", + "Goal: category-based gameplay with persistence and hints.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Gameplay runners\n", + "4. Quick verification checks" + ], + "id": "e1cc1628" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Hangman notebook with categories, hints, multiplayer, and score persistence.\"\"\"\n", + "\n", + "from collections import Counter\n", + "import json\n", + "import random\n", + "import string\n", + "from pathlib import Path\n", + "\n", + "from words import words as WORD_BANK\n", + "\n", + "SCORES_PATH = Path(\"hangman_scores.json\")\n", + "CATEGORIES = {\n", + " \"short\": [w for w in WORD_BANK if 4 <= len(w) <= 6],\n", + " \"medium\": [w for w in WORD_BANK if 7 <= len(w) <= 9],\n", + " \"long\": [w for w in WORD_BANK if len(w) >= 10],\n", + "}\n", + "\n", + "\n", + "def get_valid_word(category: str, rng: random.Random) -> str:\n", + " choices = CATEGORIES.get(category, WORD_BANK)\n", + " pool = [w for w in choices if \"-\" not in w and \" \" not in w]\n", + " return rng.choice(pool).upper()\n", + "\n", + "\n", + "def render_masked_word(word: str, used_letters: set[str]) -> str:\n", + " return \" \".join([letter if letter in used_letters else \"-\" for letter in word])\n", + "\n", + "\n", + "def recommend_letter(word: str, used_letters: set[str]) -> str | None:\n", + " unseen = [c for c in word if c not in used_letters]\n", + " if not unseen:\n", + " return None\n", + " counts = Counter(unseen)\n", + " return counts.most_common(1)[0][0]\n", + "\n", + "\n", + "def update_scoreboard(player: str, score: int) -> dict[str, int]:\n", + " data = {}\n", + " if SCORES_PATH.exists():\n", + " data = json.loads(SCORES_PATH.read_text())\n", + " data[player] = max(score, data.get(player, 0))\n", + " SCORES_PATH.write_text(json.dumps(data, indent=2))\n", + " return data\n", + "\n", + "\n", + "def play_round(player: str, category: str = \"medium\", lives: int = 7, rng_seed: int = 0) -> int:\n", + " rng = random.Random(rng_seed)\n", + " word = get_valid_word(category, rng)\n", + " word_letters = set(word)\n", + " used_letters: set[str] = set()\n", + " alphabet = set(string.ascii_uppercase)\n", + "\n", + " while word_letters and lives > 0:\n", + " print(f\"Lives: {lives}; Used: {' '.join(sorted(used_letters))}\")\n", + " print(render_masked_word(word, used_letters))\n", + " hint = recommend_letter(word, used_letters)\n", + " if hint:\n", + " print(f\"Hint letter: {hint}\")\n", + "\n", + " user_letter = input(\"Guess letter: \").upper().strip()\n", + " if user_letter in alphabet - used_letters:\n", + " used_letters.add(user_letter)\n", + " if user_letter in word_letters:\n", + " word_letters.remove(user_letter)\n", + " else:\n", + " lives -= 1\n", + " elif user_letter in used_letters:\n", + " print(\"Already used.\")\n", + " else:\n", + " print(\"Invalid character.\")\n", + "\n", + " score = lives * 10 + len(set(word) & used_letters)\n", + " if lives > 0:\n", + " print(f\"{player} won. Word: {word}\")\n", + " else:\n", + " print(f\"{player} lost. Word was: {word}\")\n", + " board = update_scoreboard(player, score)\n", + " print(\"Top scores:\", dict(sorted(board.items(), key=lambda x: x[1], reverse=True)[:5]))\n", + " return score\n", + "\n", + "\n", + "def play_multiplayer(players: list[str], category: str = \"medium\") -> dict[str, int]:\n", + " results = {}\n", + " for idx, name in enumerate(players):\n", + " print(f\"\\n--- Turn: {name} ---\")\n", + " results[name] = play_round(name, category=category, rng_seed=idx + 1)\n", + " return results\n", + "\n", + "\n", + "# Quick verification checks\n", + "assert render_masked_word(\"CODE\", {\"C\", \"E\"}) == \"C - - E\"\n", + "assert recommend_letter(\"BANANA\", {\"B\"}) in {\"A\", \"N\"}\n", + "assert get_valid_word(\"short\", random.Random(1)).isalpha()\n", + "\n", + "print(\"Hangman module ready. Call play_round('Player1') or play_multiplayer([...]).\")\n", + "\n", + "\n", + "def get_valid_word(words):\n", + " word = random.choice(words) # randomly chooses something from the list\n", + " while '-' in word or ' ' in word:\n", + " word = random.choice(words)\n", + "\n", + " return word.upper()\n", + "\n", + "\n", + "def hangman():\n", + " word = get_valid_word(words)\n", + " word_letters = set(word) # letters in the word\n", + " alphabet = set(string.ascii_uppercase)\n", + " used_letters = set() # what the user has guessed\n", + "\n", + " lives = 7\n", + "\n", + " # getting user input\n", + " while len(word_letters) > 0 and lives > 0:\n", + " # letters used\n", + " # ' '.join(['a', 'b', 'cd']) --> 'a b cd'\n", + " print('You have', lives, 'lives left and you have used these letters: ', ' '.join(used_letters))\n", + "\n", + " # what current word is (ie W - R D)\n", + " word_list = [letter if letter in used_letters else '-' for letter in word]\n", + " print('Current word: ', ' '.join(word_list))\n", + "\n", + " user_letter = input('Guess a letter: ').upper()\n", + " if user_letter in alphabet - used_letters:\n", + " used_letters.add(user_letter)\n", + " if user_letter in word_letters:\n", + " word_letters.remove(user_letter)\n", + " print('')\n", + "\n", + " else:\n", + " lives = lives - 1 # takes away a life if wrong\n", + " print('\\nYour letter,', user_letter, 'is not in the word.')\n", + "\n", + " elif user_letter in used_letters:\n", + " print('\\nYou have already used that letter. Guess another letter.')\n", + "\n", + " else:\n", + " print('\\nThat is not a valid letter.')\n", + "\n", + " # gets here when len(word_letters) == 0 OR when lives == 0\n", + " if lives == 0:\n", + " print('You died, sorry. The word was', word)\n", + " else:\n", + " print('YAY! You guessed the word', word, '!!')\n", + "\n", + "\n", + "if __name__ == '__main__':\n", + " hangman()" + ], + "execution_count": 8, + "outputs": [ + { + "output_type": "stream", + "text": [ + "You have 7 lives left and you have used these letters: \n", + "Current word: - - - - - - - -\n", + "Guess a letter: A\n", + "\n", + "Your letter, A is not in the word.\n", + "You have 6 lives left and you have used these letters: A\n", + "Current word: - - - - - - - -\n", + "Guess a letter: B\n", + "\n", + "Your letter, B is not in the word.\n", + "You have 5 lives left and you have used these letters: A B\n", + "Current word: - - - - - - - -\n", + "Guess a letter: E\n", + "\n", + "You have 5 lives left and you have used these letters: A E B\n", + "Current word: - - - - - - - E\n", + "Guess a letter: P\n", + "\n", + "You have 5 lives left and you have used these letters: A E P B\n", + "Current word: - - - P - - - E\n", + "Guess a letter: M\n", + "\n", + "Your letter, M is not in the word.\n", + "You have 4 lives left and you have used these letters: E A M B P\n", + "Current word: - - - P - - - E\n", + "Guess a letter: N\n", + "\n", + "Your letter, N is not in the word.\n", + "You have 3 lives left and you have used these letters: E A M B N P\n", + "Current word: - - - P - - - E\n", + "Guess a letter: C\n", + "\n", + "Your letter, C is not in the word.\n", + "You have 2 lives left and you have used these letters: E A M B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: D\n", + "\n", + "Your letter, D is not in the word.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: E\n", + "\n", + "You have already used that letter. Guess another letter.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: A\n", + "\n", + "You have already used that letter. Guess another letter.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: C\n", + "\n", + "You have already used that letter. Guess another letter.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: B\n", + "\n", + "You have already used that letter. Guess another letter.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: N\n", + "\n", + "You have already used that letter. Guess another letter.\n", + "You have 1 lives left and you have used these letters: E A M D B N P C\n", + "Current word: - - - P - - - E\n", + "Guess a letter: g\n", + "\n", + "Your letter, G is not in the word.\n", + "You died, sorry. The word was SURPRISE\n" + ] + } + ], + "id": "0daac62b" + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [], + "id": "c29b51a8" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "a506eb51" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "88c34b1d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "53a3054b" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "fb143309" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "e58a5a3c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "239143f9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "03007b2c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "2cbec189" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "import random\n", - "from words import words\n", - "import string\n", - "\n", - "\n", - "def get_valid_word(words):\n", - " word = random.choice(words) # randomly chooses something from the list\n", - " while '-' in word or ' ' in word:\n", - " word = random.choice(words)\n", - "\n", - " return word.upper()\n", - "\n", - "\n", - "def hangman():\n", - " word = get_valid_word(words)\n", - " word_letters = set(word) # letters in the word\n", - " alphabet = set(string.ascii_uppercase)\n", - " used_letters = set() # what the user has guessed\n", - "\n", - " lives = 7\n", - "\n", - " # getting user input\n", - " while len(word_letters) > 0 and lives > 0:\n", - " # letters used\n", - " # ' '.join(['a', 'b', 'cd']) --> 'a b cd'\n", - " print('You have', lives, 'lives left and you have used these letters: ', ' '.join(used_letters))\n", - "\n", - " # what current word is (ie W - R D)\n", - " word_list = [letter if letter in used_letters else '-' for letter in word]\n", - " print('Current word: ', ' '.join(word_list))\n", - "\n", - " user_letter = input('Guess a letter: ').upper()\n", - " if user_letter in alphabet - used_letters:\n", - " used_letters.add(user_letter)\n", - " if user_letter in word_letters:\n", - " word_letters.remove(user_letter)\n", - " print('')\n", - "\n", - " else:\n", - " lives = lives - 1 # takes away a life if wrong\n", - " print('\\nYour letter,', user_letter, 'is not in the word.')\n", - "\n", - " elif user_letter in used_letters:\n", - " print('\\nYou have already used that letter. Guess another letter.')\n", - "\n", - " else:\n", - " print('\\nThat is not a valid letter.')\n", - "\n", - " # gets here when len(word_letters) == 0 OR when lives == 0\n", - " if lives == 0:\n", - " print('You died, sorry. The word was', word)\n", - " else:\n", - " print('YAY! You guessed the word', word, '!!')\n", - "\n", - "\n", - "if __name__ == '__main__':\n", - " hangman()" - ] - }, - { - "cell_type": "raw", - "id": "c29b51a8", - "metadata": {}, - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a506eb51", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88c34b1d", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "53a3054b", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb143309", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e58a5a3c", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "239143f9", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "03007b2c", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2cbec189", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/Minesweeper.ipynb b/Minesweeper.ipynb index 6812494..dffa01e 100644 --- a/Minesweeper.ipynb +++ b/Minesweeper.ipynb @@ -1,348 +1,550 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 5, - "id": "1d0237c9", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 | | | | | | | | | | |\n", - "1 | | | | | | | | | | |\n", - "2 | | | | | | | | | | |\n", - "3 | | | | | | | | | | |\n", - "4 | | | | | | | | | | |\n", - "5 | | | | | | | | | | |\n", - "6 | | | | | | | | | | |\n", - "7 | | | | | | | | | | |\n", - "8 | | | | | | | | | | |\n", - "9 | | | | | | | | | | |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 0,0\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 | | | | | | | | | |\n", - "1 | | | | | | | | | | |\n", - "2 | | | | | | | | | | |\n", - "3 | | | | | | | | | | |\n", - "4 | | | | | | | | | | |\n", - "5 | | | | | | | | | | |\n", - "6 | | | | | | | | | | |\n", - "7 | | | | | | | | | | |\n", - "8 | | | | | | | | | | |\n", - "9 | | | | | | | | | | |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 0,1\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 |1 | | | | | | | | |\n", - "1 | | | | | | | | | | |\n", - "2 | | | | | | | | | | |\n", - "3 | | | | | | | | | | |\n", - "4 | | | | | | | | | | |\n", - "5 | | | | | | | | | | |\n", - "6 | | | | | | | | | | |\n", - "7 | | | | | | | | | | |\n", - "8 | | | | | | | | | | |\n", - "9 | | | | | | | | | | |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 9,9\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 |1 | | | | | | | | |\n", - "1 | | | | | | | |1 |1 |1 |\n", - "2 | | | | | | | |1 |0 |0 |\n", - "3 | | | | | | | |1 |0 |0 |\n", - "4 | | | | | | |2 |1 |0 |0 |\n", - "5 | | | | | | |2 |0 |0 |0 |\n", - "6 | | | | | | |2 |0 |0 |0 |\n", - "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", - "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 3,3\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 |1 |1 |0 |0 |0 |0 |1 | | |\n", - "1 | | |1 |0 |0 |0 |0 |1 |1 |1 |\n", - "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", - "3 |0 |0 |0 |0 |0 |1 | |1 |0 |0 |\n", - "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", - "5 | | |2 |1 |2 | |2 |0 |0 |0 |\n", - "6 | | | | | | |2 |0 |0 |0 |\n", - "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", - "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 0,9\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 |1 |1 |0 |0 |0 |0 |1 | |1 |\n", - "1 | | |1 |0 |0 |0 |0 |1 |1 |1 |\n", - "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", - "3 |0 |0 |0 |0 |0 |1 | |1 |0 |0 |\n", - "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", - "5 | | |2 |1 |2 | |2 |0 |0 |0 |\n", - "6 | | | | | | |2 |0 |0 |0 |\n", - "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", - "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "----------------------------------\n", - "Where would you like to dig? Input as row,col: 9,0\n", - "SORRY GAME OVER :(\n", - " 0 1 2 3 4 5 6 7 8 9 \n", - "----------------------------------\n", - "0 |1 |1 |1 |0 |0 |0 |0 |1 |* |1 |\n", - "1 |1 |* |1 |0 |0 |0 |0 |1 |1 |1 |\n", - "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", - "3 |0 |0 |0 |0 |0 |1 |* |1 |0 |0 |\n", - "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", - "5 |2 |* |2 |1 |2 |* |2 |0 |0 |0 |\n", - "6 |* |3 |* |1 |2 |* |2 |0 |0 |0 |\n", - "7 |1 |2 |1 |1 |1 |1 |1 |0 |0 |0 |\n", - "8 |2 |2 |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "9 |* |* |1 |0 |0 |0 |0 |0 |0 |0 |\n", - "----------------------------------\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Minesweeper\n", + "\n", + "Goal: complete playable game with flags, timer, leaderboard, and hints.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions and board model\n", + "3. Gameplay runner\n", + "4. Quick verification checks" + ], + "id": "8b84991c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Minesweeper notebook with flags, timer, SQLite leaderboard, and solver assist.\"\"\"\n", + "\n", + "from dataclasses import dataclass, field\n", + "from pathlib import Path\n", + "import random\n", + "import sqlite3\n", + "import time\n", + "\n", + "DB_PATH = Path(\"minesweeper_scores.db\")\n", + "\n", + "\n", + "def init_db() -> None:\n", + " conn = sqlite3.connect(DB_PATH)\n", + " conn.execute(\n", + " \"\"\"\n", + " CREATE TABLE IF NOT EXISTS scores (\n", + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n", + " player TEXT NOT NULL,\n", + " dim_size INTEGER NOT NULL,\n", + " bombs INTEGER NOT NULL,\n", + " elapsed_seconds REAL NOT NULL,\n", + " won INTEGER NOT NULL,\n", + " created_at TEXT DEFAULT CURRENT_TIMESTAMP\n", + " )\n", + " \"\"\"\n", + " )\n", + " conn.commit()\n", + " conn.close()\n", + "\n", + "\n", + "def save_score(player: str, dim_size: int, bombs: int, elapsed_seconds: float, won: bool) -> None:\n", + " conn = sqlite3.connect(DB_PATH)\n", + " conn.execute(\n", + " \"INSERT INTO scores (player, dim_size, bombs, elapsed_seconds, won) VALUES (?, ?, ?, ?, ?)\",\n", + " (player, dim_size, bombs, elapsed_seconds, int(won)),\n", + " )\n", + " conn.commit()\n", + " conn.close()\n", + "\n", + "\n", + "@dataclass\n", + "class Board:\n", + " dim_size: int\n", + " num_bombs: int\n", + " rng_seed: int = 0\n", + " board: list[list[object]] = field(init=False)\n", + " dug: set[tuple[int, int]] = field(default_factory=set)\n", + " flagged: set[tuple[int, int]] = field(default_factory=set)\n", + "\n", + " def __post_init__(self) -> None:\n", + " self.rng = random.Random(self.rng_seed)\n", + " self.board = self.make_new_board()\n", + " self.assign_values_to_board()\n", + "\n", + " def make_new_board(self) -> list[list[object]]:\n", + " board = [[None for _ in range(self.dim_size)] for _ in range(self.dim_size)]\n", + " bombs_planted = 0\n", + " while bombs_planted < self.num_bombs:\n", + " loc = self.rng.randint(0, self.dim_size**2 - 1)\n", + " row, col = divmod(loc, self.dim_size)\n", + " if board[row][col] == \"*\":\n", + " continue\n", + " board[row][col] = \"*\"\n", + " bombs_planted += 1\n", + " return board\n", + "\n", + " def assign_values_to_board(self) -> None:\n", + " for r in range(self.dim_size):\n", + " for c in range(self.dim_size):\n", + " if self.board[r][c] == \"*\":\n", + " continue\n", + " self.board[r][c] = self.get_num_neighboring_bombs(r, c)\n", + "\n", + " def get_num_neighboring_bombs(self, row: int, col: int) -> int:\n", + " num = 0\n", + " for r in range(max(0, row - 1), min(self.dim_size - 1, row + 1) + 1):\n", + " for c in range(max(0, col - 1), min(self.dim_size - 1, col + 1) + 1):\n", + " if r == row and c == col:\n", + " continue\n", + " if self.board[r][c] == \"*\":\n", + " num += 1\n", + " return num\n", + "\n", + " def toggle_flag(self, row: int, col: int) -> None:\n", + " if (row, col) in self.dug:\n", + " return\n", + " if (row, col) in self.flagged:\n", + " self.flagged.remove((row, col))\n", + " else:\n", + " self.flagged.add((row, col))\n", + "\n", + " def dig(self, row: int, col: int) -> bool:\n", + " if (row, col) in self.flagged:\n", + " return True\n", + " self.dug.add((row, col))\n", + " if self.board[row][col] == \"*\":\n", + " return False\n", + " if self.board[row][col] > 0:\n", + " return True\n", + " # iterative flood fill\n", + " stack = [(row, col)]\n", + " while stack:\n", + " r0, c0 = stack.pop()\n", + " for r in range(max(0, r0 - 1), min(self.dim_size - 1, r0 + 1) + 1):\n", + " for c in range(max(0, c0 - 1), min(self.dim_size - 1, c0 + 1) + 1):\n", + " if (r, c) in self.dug or (r, c) in self.flagged:\n", + " continue\n", + " self.dug.add((r, c))\n", + " if self.board[r][c] == 0:\n", + " stack.append((r, c))\n", + " return True\n", + "\n", + " def solver_hint(self) -> tuple[int, int] | None:\n", + " safe = []\n", + " for r in range(self.dim_size):\n", + " for c in range(self.dim_size):\n", + " if (r, c) in self.dug or (r, c) in self.flagged:\n", + " continue\n", + " if self.board[r][c] != \"*\":\n", + " safe.append((r, c))\n", + " return safe[0] if safe else None\n", + "\n", + " def __str__(self) -> str:\n", + " visible = []\n", + " for r in range(self.dim_size):\n", + " row = []\n", + " for c in range(self.dim_size):\n", + " if (r, c) in self.flagged:\n", + " row.append(\"F\")\n", + " elif (r, c) not in self.dug:\n", + " row.append(\" \")\n", + " else:\n", + " row.append(str(self.board[r][c]))\n", + " visible.append(\" | \".join(row))\n", + " return \"\\n\".join(visible)\n", + "\n", + "\n", + "def parse_move(text: str, dim_size: int) -> tuple[str, int, int]:\n", + " parts = text.strip().split()\n", + " if len(parts) != 3:\n", + " raise ValueError(\"Use: \")\n", + " action, r, c = parts[0].lower(), int(parts[1]), int(parts[2])\n", + " if action not in {\"d\", \"f\", \"h\"}:\n", + " raise ValueError(\"Action must be d (dig), f (flag), or h (hint).\")\n", + " if action in {\"d\", \"f\"} and not (0 <= r < dim_size and 0 <= c < dim_size):\n", + " raise ValueError(\"Row/col out of range\")\n", + " return action, r, c\n", + "\n", + "\n", + "def play_minesweeper(dim_size: int = 8, bombs: int = 10, player: str = \"Player\") -> None:\n", + " init_db()\n", + " board = Board(dim_size, bombs, rng_seed=4)\n", + " start = time.time()\n", + "\n", + " while len(board.dug) < board.dim_size**2 - board.num_bombs:\n", + " print(board)\n", + " raw = input(\"Move ( row col): \")\n", + " try:\n", + " action, row, col = parse_move(raw, dim_size)\n", + " except Exception as exc:\n", + " print(exc)\n", + " continue\n", + "\n", + " if action == \"h\":\n", + " hint = board.solver_hint()\n", + " print(\"Hint safe cell:\", hint)\n", + " continue\n", + " if action == \"f\":\n", + " board.toggle_flag(row, col)\n", + " continue\n", + " if not board.dig(row, col):\n", + " elapsed = time.time() - start\n", + " print(\"Game over! You hit a bomb.\")\n", + " save_score(player, dim_size, bombs, elapsed, won=False)\n", + " return\n", + "\n", + " elapsed = time.time() - start\n", + " print(\"You won in\", round(elapsed, 2), \"seconds\")\n", + " save_score(player, dim_size, bombs, elapsed, won=True)\n", + "\n", + "\n", + "# Quick verification checks\n", + "init_db()\n", + "b = Board(5, 5, rng_seed=1)\n", + "assert isinstance(b.solver_hint(), tuple)\n", + "assert parse_move(\"d 1 2\", 5) == (\"d\", 1, 2)\n", + "assert len([1 for row in b.board for v in row if v == \"*\"]) == 5\n", + "\n", + "print(\"Minesweeper module ready. Call play_minesweeper().\")\n", + "\n", + "# lets create a board object to represent the minesweeper game\n", + "# this is so that we can just say \"create a new board object\", or\n", + "# \"dig here\", or \"render this game for this object\"\n", + "class Board:\n", + " def __init__(self, dim_size, num_bombs):\n", + " # let's keep track of these parameters. they'll be helpful later\n", + " self.dim_size = dim_size\n", + " self.num_bombs = num_bombs\n", + "\n", + " # let's create the board\n", + " # helper function!\n", + " self.board = self.make_new_board() # plant the bombs\n", + " self.assign_values_to_board()\n", + "\n", + " # initialize a set to keep track of which locations we've uncovered\n", + " # we'll save (row,col) tuples into this set \n", + " self.dug = set() # if we dig at 0, 0, then self.dug = {(0,0)}\n", + "\n", + " def make_new_board(self):\n", + " # construct a new board based on the dim size and num bombs\n", + " # we should construct the list of lists here (or whatever representation you prefer,\n", + " # but since we have a 2-D board, list of lists is most natural)\n", + "\n", + " # generate a new board\n", + " board = [[None for _ in range(self.dim_size)] for _ in range(self.dim_size)]\n", + " # this creates an array like this:\n", + " # [[None, None, ..., None],\n", + " # [None, None, ..., None],\n", + " # [... ],\n", + " # [None, None, ..., None]]\n", + " # we can see how this represents a board!\n", + "\n", + " # plant the bombs\n", + " bombs_planted = 0\n", + " while bombs_planted < self.num_bombs:\n", + " loc = random.randint(0, self.dim_size**2 - 1) # return a random integer N such that a <= N <= b\n", + " row = loc // self.dim_size # we want the number of times dim_size goes into loc to tell us what row to look at\n", + " col = loc % self.dim_size # we want the remainder to tell us what index in that row to look at\n", + "\n", + " if board[row][col] == '*':\n", + " # this means we've actually planted a bomb there already so keep going\n", + " continue\n", + "\n", + " board[row][col] = '*' # plant the bomb\n", + " bombs_planted += 1\n", + "\n", + " return board\n", + "\n", + " def assign_values_to_board(self):\n", + " # now that we have the bombs planted, let's assign a number 0-8 for all the empty spaces, which\n", + " # represents how many neighboring bombs there are. we can precompute these and it'll save us some\n", + " # effort checking what's around the board later on :)\n", + " for r in range(self.dim_size):\n", + " for c in range(self.dim_size):\n", + " if self.board[r][c] == '*':\n", + " # if this is already a bomb, we don't want to calculate anything\n", + " continue\n", + " self.board[r][c] = self.get_num_neighboring_bombs(r, c)\n", + "\n", + " def get_num_neighboring_bombs(self, row, col):\n", + " # let's iterate through each of the neighboring positions and sum number of bombs\n", + " # top left: (row-1, col-1)\n", + " # top middle: (row-1, col)\n", + " # top right: (row-1, col+1)\n", + " # left: (row, col-1)\n", + " # right: (row, col+1)\n", + " # bottom left: (row+1, col-1)\n", + " # bottom middle: (row+1, col)\n", + " # bottom right: (row+1, col+1)\n", + "\n", + " # make sure to not go out of bounds!\n", + "\n", + " num_neighboring_bombs = 0\n", + " for r in range(max(0, row-1), min(self.dim_size-1, row+1)+1):\n", + " for c in range(max(0, col-1), min(self.dim_size-1, col+1)+1):\n", + " if r == row and c == col:\n", + " # our original location, don't check\n", + " continue\n", + " if self.board[r][c] == '*':\n", + " num_neighboring_bombs += 1\n", + "\n", + " return num_neighboring_bombs\n", + "\n", + " def dig(self, row, col):\n", + " # dig at that location!\n", + " # return True if successful dig, False if bomb dug\n", + "\n", + " # a few scenarios:\n", + " # hit a bomb -> game over\n", + " # dig at location with neighboring bombs -> finish dig\n", + " # dig at location with no neighboring bombs -> recursively dig neighbors!\n", + "\n", + " self.dug.add((row, col)) # keep track that we dug here\n", + "\n", + " if self.board[row][col] == '*':\n", + " return False\n", + " elif self.board[row][col] > 0:\n", + " return True\n", + "\n", + " # self.board[row][col] == 0\n", + " for r in range(max(0, row-1), min(self.dim_size-1, row+1)+1):\n", + " for c in range(max(0, col-1), min(self.dim_size-1, col+1)+1):\n", + " if (r, c) in self.dug:\n", + " continue # don't dig where you've already dug\n", + " self.dig(r, c)\n", + "\n", + " # if our initial dig didn't hit a bomb, we *shouldn't* hit a bomb here\n", + " return True\n", + "\n", + " def __str__(self):\n", + " # this is a magic function where if you call print on this object,\n", + " # it'll print out what this function returns!\n", + " # return a string that shows the board to the player\n", + "\n", + " # first let's create a new array that represents what the user would see\n", + " visible_board = [[None for _ in range(self.dim_size)] for _ in range(self.dim_size)]\n", + " for row in range(self.dim_size):\n", + " for col in range(self.dim_size):\n", + " if (row,col) in self.dug:\n", + " visible_board[row][col] = str(self.board[row][col])\n", + " else:\n", + " visible_board[row][col] = ' '\n", + " \n", + " # put this together in a string\n", + " string_rep = ''\n", + " # get max column widths for printing\n", + " widths = []\n", + " for idx in range(self.dim_size):\n", + " columns = map(lambda x: x[idx], visible_board)\n", + " widths.append(\n", + " len(\n", + " max(columns, key = len)\n", + " )\n", + " )\n", + "\n", + " # print the csv strings\n", + " indices = [i for i in range(self.dim_size)]\n", + " indices_row = ' '\n", + " cells = []\n", + " for idx, col in enumerate(indices):\n", + " format = '%-' + str(widths[idx]) + \"s\"\n", + " cells.append(format % (col))\n", + " indices_row += ' '.join(cells)\n", + " indices_row += ' \\n'\n", + " \n", + " for i in range(len(visible_board)):\n", + " row = visible_board[i]\n", + " string_rep += f'{i} |'\n", + " cells = []\n", + " for idx, col in enumerate(row):\n", + " format = '%-' + str(widths[idx]) + \"s\"\n", + " cells.append(format % (col))\n", + " string_rep += ' |'.join(cells)\n", + " string_rep += ' |\\n'\n", + "\n", + " str_len = int(len(string_rep) / self.dim_size)\n", + " string_rep = indices_row + '-'*str_len + '\\n' + string_rep + '-'*str_len\n", + "\n", + " return string_rep\n", + "\n", + "# play the game\n", + "def play(dim_size=10, num_bombs=10):\n", + " # Step 1: create the board and plant the bombs\n", + " board = Board(dim_size, num_bombs)\n", + "\n", + " # Step 2: show the user the board and ask for where they want to dig\n", + " # Step 3a: if location is a bomb, show game over message\n", + " # Step 3b: if location is not a bomb, dig recursively until each square is at least\n", + " # next to a bomb\n", + " # Step 4: repeat steps 2 and 3a/b until there are no more places to dig -> VICTORY!\n", + " safe = True \n", + "\n", + " while len(board.dug) < board.dim_size ** 2 - num_bombs:\n", + " print(board)\n", + " # 0,0 or 0, 0 or 0, 0\n", + " user_input = re.split(',(\\\\s)*', input(\"Where would you like to dig? Input as row,col: \")) # '0, 3'\n", + " row, col = int(user_input[0]), int(user_input[-1])\n", + " if row < 0 or row >= board.dim_size or col < 0 or col >= dim_size:\n", + " print(\"Invalid location. Try again.\")\n", + " continue\n", + "\n", + " # if it's valid, we dig\n", + " safe = board.dig(row, col)\n", + " if not safe:\n", + " # dug a bomb ahhhhhhh\n", + " break # (game over rip)\n", + "\n", + " # 2 ways to end loop, lets check which one\n", + " if safe:\n", + " print(\"CONGRATULATIONS!!!! YOU ARE VICTORIOUS!\")\n", + " else:\n", + " print(\"SORRY GAME OVER :(\")\n", + " # let's reveal the whole board!\n", + " board.dug = [(r,c) for r in range(board.dim_size) for c in range(board.dim_size)]\n", + " print(board)\n", + "\n", + "if __name__ == '__main__': # good practice :)\n", + " play()" + ], + "execution_count": 5, + "outputs": [ + { + "output_type": "stream", + "text": [ + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 | | | | | | | | | | |\n", + "1 | | | | | | | | | | |\n", + "2 | | | | | | | | | | |\n", + "3 | | | | | | | | | | |\n", + "4 | | | | | | | | | | |\n", + "5 | | | | | | | | | | |\n", + "6 | | | | | | | | | | |\n", + "7 | | | | | | | | | | |\n", + "8 | | | | | | | | | | |\n", + "9 | | | | | | | | | | |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 0,0\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 | | | | | | | | | |\n", + "1 | | | | | | | | | | |\n", + "2 | | | | | | | | | | |\n", + "3 | | | | | | | | | | |\n", + "4 | | | | | | | | | | |\n", + "5 | | | | | | | | | | |\n", + "6 | | | | | | | | | | |\n", + "7 | | | | | | | | | | |\n", + "8 | | | | | | | | | | |\n", + "9 | | | | | | | | | | |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 0,1\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 |1 | | | | | | | | |\n", + "1 | | | | | | | | | | |\n", + "2 | | | | | | | | | | |\n", + "3 | | | | | | | | | | |\n", + "4 | | | | | | | | | | |\n", + "5 | | | | | | | | | | |\n", + "6 | | | | | | | | | | |\n", + "7 | | | | | | | | | | |\n", + "8 | | | | | | | | | | |\n", + "9 | | | | | | | | | | |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 9,9\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 |1 | | | | | | | | |\n", + "1 | | | | | | | |1 |1 |1 |\n", + "2 | | | | | | | |1 |0 |0 |\n", + "3 | | | | | | | |1 |0 |0 |\n", + "4 | | | | | | |2 |1 |0 |0 |\n", + "5 | | | | | | |2 |0 |0 |0 |\n", + "6 | | | | | | |2 |0 |0 |0 |\n", + "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", + "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 3,3\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 |1 |1 |0 |0 |0 |0 |1 | | |\n", + "1 | | |1 |0 |0 |0 |0 |1 |1 |1 |\n", + "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", + "3 |0 |0 |0 |0 |0 |1 | |1 |0 |0 |\n", + "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", + "5 | | |2 |1 |2 | |2 |0 |0 |0 |\n", + "6 | | | | | | |2 |0 |0 |0 |\n", + "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", + "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 0,9\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 |1 |1 |0 |0 |0 |0 |1 | |1 |\n", + "1 | | |1 |0 |0 |0 |0 |1 |1 |1 |\n", + "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", + "3 |0 |0 |0 |0 |0 |1 | |1 |0 |0 |\n", + "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", + "5 | | |2 |1 |2 | |2 |0 |0 |0 |\n", + "6 | | | | | | |2 |0 |0 |0 |\n", + "7 | | |1 |1 |1 |1 |1 |0 |0 |0 |\n", + "8 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "9 | | |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "----------------------------------\n", + "Where would you like to dig? Input as row,col: 9,0\n", + "SORRY GAME OVER :(\n", + " 0 1 2 3 4 5 6 7 8 9 \n", + "----------------------------------\n", + "0 |1 |1 |1 |0 |0 |0 |0 |1 |* |1 |\n", + "1 |1 |* |1 |0 |0 |0 |0 |1 |1 |1 |\n", + "2 |1 |1 |1 |0 |0 |1 |1 |1 |0 |0 |\n", + "3 |0 |0 |0 |0 |0 |1 |* |1 |0 |0 |\n", + "4 |1 |1 |1 |0 |1 |2 |2 |1 |0 |0 |\n", + "5 |2 |* |2 |1 |2 |* |2 |0 |0 |0 |\n", + "6 |* |3 |* |1 |2 |* |2 |0 |0 |0 |\n", + "7 |1 |2 |1 |1 |1 |1 |1 |0 |0 |0 |\n", + "8 |2 |2 |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "9 |* |* |1 |0 |0 |0 |0 |0 |0 |0 |\n", + "----------------------------------\n" + ] + } + ], + "id": "1d0237c9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "93ea2967" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "import random\n", - "import re\n", - "\n", - "# lets create a board object to represent the minesweeper game\n", - "# this is so that we can just say \"create a new board object\", or\n", - "# \"dig here\", or \"render this game for this object\"\n", - "class Board:\n", - " def __init__(self, dim_size, num_bombs):\n", - " # let's keep track of these parameters. they'll be helpful later\n", - " self.dim_size = dim_size\n", - " self.num_bombs = num_bombs\n", - "\n", - " # let's create the board\n", - " # helper function!\n", - " self.board = self.make_new_board() # plant the bombs\n", - " self.assign_values_to_board()\n", - "\n", - " # initialize a set to keep track of which locations we've uncovered\n", - " # we'll save (row,col) tuples into this set \n", - " self.dug = set() # if we dig at 0, 0, then self.dug = {(0,0)}\n", - "\n", - " def make_new_board(self):\n", - " # construct a new board based on the dim size and num bombs\n", - " # we should construct the list of lists here (or whatever representation you prefer,\n", - " # but since we have a 2-D board, list of lists is most natural)\n", - "\n", - " # generate a new board\n", - " board = [[None for _ in range(self.dim_size)] for _ in range(self.dim_size)]\n", - " # this creates an array like this:\n", - " # [[None, None, ..., None],\n", - " # [None, None, ..., None],\n", - " # [... ],\n", - " # [None, None, ..., None]]\n", - " # we can see how this represents a board!\n", - "\n", - " # plant the bombs\n", - " bombs_planted = 0\n", - " while bombs_planted < self.num_bombs:\n", - " loc = random.randint(0, self.dim_size**2 - 1) # return a random integer N such that a <= N <= b\n", - " row = loc // self.dim_size # we want the number of times dim_size goes into loc to tell us what row to look at\n", - " col = loc % self.dim_size # we want the remainder to tell us what index in that row to look at\n", - "\n", - " if board[row][col] == '*':\n", - " # this means we've actually planted a bomb there already so keep going\n", - " continue\n", - "\n", - " board[row][col] = '*' # plant the bomb\n", - " bombs_planted += 1\n", - "\n", - " return board\n", - "\n", - " def assign_values_to_board(self):\n", - " # now that we have the bombs planted, let's assign a number 0-8 for all the empty spaces, which\n", - " # represents how many neighboring bombs there are. we can precompute these and it'll save us some\n", - " # effort checking what's around the board later on :)\n", - " for r in range(self.dim_size):\n", - " for c in range(self.dim_size):\n", - " if self.board[r][c] == '*':\n", - " # if this is already a bomb, we don't want to calculate anything\n", - " continue\n", - " self.board[r][c] = self.get_num_neighboring_bombs(r, c)\n", - "\n", - " def get_num_neighboring_bombs(self, row, col):\n", - " # let's iterate through each of the neighboring positions and sum number of bombs\n", - " # top left: (row-1, col-1)\n", - " # top middle: (row-1, col)\n", - " # top right: (row-1, col+1)\n", - " # left: (row, col-1)\n", - " # right: (row, col+1)\n", - " # bottom left: (row+1, col-1)\n", - " # bottom middle: (row+1, col)\n", - " # bottom right: (row+1, col+1)\n", - "\n", - " # make sure to not go out of bounds!\n", - "\n", - " num_neighboring_bombs = 0\n", - " for r in range(max(0, row-1), min(self.dim_size-1, row+1)+1):\n", - " for c in range(max(0, col-1), min(self.dim_size-1, col+1)+1):\n", - " if r == row and c == col:\n", - " # our original location, don't check\n", - " continue\n", - " if self.board[r][c] == '*':\n", - " num_neighboring_bombs += 1\n", - "\n", - " return num_neighboring_bombs\n", - "\n", - " def dig(self, row, col):\n", - " # dig at that location!\n", - " # return True if successful dig, False if bomb dug\n", - "\n", - " # a few scenarios:\n", - " # hit a bomb -> game over\n", - " # dig at location with neighboring bombs -> finish dig\n", - " # dig at location with no neighboring bombs -> recursively dig neighbors!\n", - "\n", - " self.dug.add((row, col)) # keep track that we dug here\n", - "\n", - " if self.board[row][col] == '*':\n", - " return False\n", - " elif self.board[row][col] > 0:\n", - " return True\n", - "\n", - " # self.board[row][col] == 0\n", - " for r in range(max(0, row-1), min(self.dim_size-1, row+1)+1):\n", - " for c in range(max(0, col-1), min(self.dim_size-1, col+1)+1):\n", - " if (r, c) in self.dug:\n", - " continue # don't dig where you've already dug\n", - " self.dig(r, c)\n", - "\n", - " # if our initial dig didn't hit a bomb, we *shouldn't* hit a bomb here\n", - " return True\n", - "\n", - " def __str__(self):\n", - " # this is a magic function where if you call print on this object,\n", - " # it'll print out what this function returns!\n", - " # return a string that shows the board to the player\n", - "\n", - " # first let's create a new array that represents what the user would see\n", - " visible_board = [[None for _ in range(self.dim_size)] for _ in range(self.dim_size)]\n", - " for row in range(self.dim_size):\n", - " for col in range(self.dim_size):\n", - " if (row,col) in self.dug:\n", - " visible_board[row][col] = str(self.board[row][col])\n", - " else:\n", - " visible_board[row][col] = ' '\n", - " \n", - " # put this together in a string\n", - " string_rep = ''\n", - " # get max column widths for printing\n", - " widths = []\n", - " for idx in range(self.dim_size):\n", - " columns = map(lambda x: x[idx], visible_board)\n", - " widths.append(\n", - " len(\n", - " max(columns, key = len)\n", - " )\n", - " )\n", - "\n", - " # print the csv strings\n", - " indices = [i for i in range(self.dim_size)]\n", - " indices_row = ' '\n", - " cells = []\n", - " for idx, col in enumerate(indices):\n", - " format = '%-' + str(widths[idx]) + \"s\"\n", - " cells.append(format % (col))\n", - " indices_row += ' '.join(cells)\n", - " indices_row += ' \\n'\n", - " \n", - " for i in range(len(visible_board)):\n", - " row = visible_board[i]\n", - " string_rep += f'{i} |'\n", - " cells = []\n", - " for idx, col in enumerate(row):\n", - " format = '%-' + str(widths[idx]) + \"s\"\n", - " cells.append(format % (col))\n", - " string_rep += ' |'.join(cells)\n", - " string_rep += ' |\\n'\n", - "\n", - " str_len = int(len(string_rep) / self.dim_size)\n", - " string_rep = indices_row + '-'*str_len + '\\n' + string_rep + '-'*str_len\n", - "\n", - " return string_rep\n", - "\n", - "# play the game\n", - "def play(dim_size=10, num_bombs=10):\n", - " # Step 1: create the board and plant the bombs\n", - " board = Board(dim_size, num_bombs)\n", - "\n", - " # Step 2: show the user the board and ask for where they want to dig\n", - " # Step 3a: if location is a bomb, show game over message\n", - " # Step 3b: if location is not a bomb, dig recursively until each square is at least\n", - " # next to a bomb\n", - " # Step 4: repeat steps 2 and 3a/b until there are no more places to dig -> VICTORY!\n", - " safe = True \n", - "\n", - " while len(board.dug) < board.dim_size ** 2 - num_bombs:\n", - " print(board)\n", - " # 0,0 or 0, 0 or 0, 0\n", - " user_input = re.split(',(\\\\s)*', input(\"Where would you like to dig? Input as row,col: \")) # '0, 3'\n", - " row, col = int(user_input[0]), int(user_input[-1])\n", - " if row < 0 or row >= board.dim_size or col < 0 or col >= dim_size:\n", - " print(\"Invalid location. Try again.\")\n", - " continue\n", - "\n", - " # if it's valid, we dig\n", - " safe = board.dig(row, col)\n", - " if not safe:\n", - " # dug a bomb ahhhhhhh\n", - " break # (game over rip)\n", - "\n", - " # 2 ways to end loop, lets check which one\n", - " if safe:\n", - " print(\"CONGRATULATIONS!!!! YOU ARE VICTORIOUS!\")\n", - " else:\n", - " print(\"SORRY GAME OVER :(\")\n", - " # let's reveal the whole board!\n", - " board.dug = [(r,c) for r in range(board.dim_size) for c in range(board.dim_size)]\n", - " print(board)\n", - "\n", - "if __name__ == '__main__': # good practice :)\n", - " play()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "93ea2967", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/README.md b/README.md index 4a1b6cd..0ed3889 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,158 @@ -# Learn-Python -freeCodeCamp +# Learn-Python Mini Projects -https://www.youtube.com/watch?v=8ext9G7xspg +A collection of beginner-friendly Python mini projects that is being upgraded from notebook proofs-of-concept into a structured, testable package. + +## Quick Start + +For full documentation navigation, start with `docs/README.md`. + +### Prerequisites +- Python 3.11+ +- `pip` + +### Setup +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +``` + +### Run TicTacToe (Phase 1 feature) +```bash +PYTHONPATH=src python -m mini_projects.cli tictactoe --difficulty hard +``` + +### Run Expansion Capstone Demo +```bash +PYTHONPATH=src python -m mini_projects.cli curriculum-demo +``` + +### Run World Bank Real-Data Sync +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-sync --countries US CA GB DE JP --indicators NY.GDP.MKTP.KD.ZG FP.CPI.TOTL.ZG SL.UEM.TOTL.ZS +``` + +### Check World Bank Freshness Status +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-status +``` + +Available difficulty levels: +- `easy`: random legal moves +- `medium`: mixed random + shallow minimax +- `hard`: full minimax with alpha-beta pruning + +## Project Structure + +```text +Learn-Python/ + src/ + mini_projects/ + cli.py + features/ + advanced_python/ + dsa/ + async_concurrency/ + backend_api/ + data_stack/ + dashboard/ + big_data/ + capstone/ + tictactoe/ + domain/ + application/ + presentation/ + tests/ + features/ + tictactoe/ + .github/workflows/ci.yml + requirements.txt + requirements-dev.txt +``` + +## Development + +### Run tests +```bash +PYTHONPATH=src pytest -q +``` + +### CI +GitHub Actions runs test checks on every push and pull request through `.github/workflows/ci.yml`. + +## Current Modules + +- **TicTacToe (modernized package):** `src/mini_projects/features/tictactoe` with CLI + alpha-beta AI difficulty levels. +- **Advanced Python:** decorators, generators, context managers, protocols, dataclasses. +- **DSA Deepening:** trie, union-find, Dijkstra shortest path, LIS dynamic programming pattern. +- **Async/Concurrency:** retry, bounded concurrency, timeout fanout, model-selection helper. +- **Backend API:** FastAPI notes service with validation and SQLite adapter. +- **Data Stack:** normalized indicator schema and KPI/category analysis workflow. +- **Dashboard Building:** payload builder for KPI cards and trend/category views from indicator data. +- **Big Data Analysis:** chunked processing and scaling strategy selector. +- **Capstone Integration:** World Bank ingestion + cache freshness + dashboard + big-data strategy in one runnable flow. + +## World Bank Data Pipeline + +- Source: World Bank Indicators API v2 (free and open, no API key). +- Adapter path: `src/mini_projects/features/capstone/infrastructure/world_bank_client.py` +- Cache/freshness path: `src/mini_projects/features/capstone/infrastructure/cache_store.py` +- Orchestration path: `src/mini_projects/features/capstone/application/pipeline.py` +- API endpoints: + - `POST /world-bank/refresh` + - `GET /world-bank/latest` + - `GET /world-bank/status` +- Runbook index: `docs/pipelines/world-bank/overview.md` + +## Python Expansion Curriculum + +- Curriculum guide: `docs/curriculum/overview.md` +- New tests live under `tests/features/` for each added module. +- This expansion demonstrates language depth, algorithms, async systems, backend services, data analysis, dashboard thinking, and big-data scaling decisions. + +## Notebook Projects (Upgraded In Place) + +- `Hangman.ipynb`: categories, hint recommendation, multiplayer helper, JSON score persistence. +- `Minesweeper.ipynb`: flags, timer, SQLite leaderboard, solver-hint mode. +- `sudoku.ipynb`: constraint propagation + backtracking + puzzle generation. +- `Rock Paper Scissors.ipynb`: input normalization, best-of rounds, adaptive opponent model. +- `Binary Search.ipynb`: expanded to visualizer-style algorithms notebook (binary search, BFS, DFS, quicksort, mergesort). +- `Guess the number.ipynb`: robust validation, attempts tracking, reverse mode. +- `madlib.ipynb`: multi-template system with validation and deterministic selection support. +- `Simple Calculator.ipynb`: expression parser, safe errors, history session runner. +- `Find the month.ipynb`: normalized lookup, fallback handling, fuzzy suggestion helper. + +## AI/ML-Enabled Notebook Features + +- Hangman letter recommendation. +- Minesweeper safe-cell hinting. +- Sudoku step ranking via constraint-first candidate flow. +- Rock Paper Scissors Markov-style opponent prediction. +- Binary Search notebook includes empirical timing trend comparisons. +- Guess the Number adaptive mode helper via binary-search strategy. + +## Usage Notes + +- Notebooks now follow a consistent pattern: intro -> helpers -> runner -> assertions/demo. +- New code should be added under `src/mini_projects` and verified with tests in `tests/`. +- Some interactive notebook runners require user input and are intended to be executed cell-by-cell. + +## Remaining Scripts Pending Migration + +- `compose.py`, `compose_template.py`, `graph.py`, `graph_template.py` +- `image.py`, `transform.py`, `transform_empty.py`, `png.py` +- `lyrics.py`, `words.py` + +Modernization notes and contracts for these scripts are documented in `docs/migration/script-modernization.md`. + +## Screenshots + +Place screenshots or GIFs in a future `docs/assets/` folder and reference them here. + +## Roadmap + +- Minesweeper GUI with timer, flags, SQLite leaderboard, and solver mode. +- Sudoku with constraint propagation, visualization, and puzzle generation. +- Hangman with categories, persistent scores, and multiplayer mode. +- Algorithms visualizer (binary search, BFS, DFS, quicksort, mergesort, and more). +- Optional TicTacToe Monte Carlo Tree Search (MCTS) mode. diff --git a/Rock Paper Scissors.ipynb b/Rock Paper Scissors.ipynb index 6d16393..22db4c1 100644 --- a/Rock Paper Scissors.ipynb +++ b/Rock Paper Scissors.ipynb @@ -1,81 +1,147 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 13, - "id": "31a96691", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - " What is your choice? 'r' for rock, 'p' for paper, 's' for scissors\n", - "r\n", - "It's a tie\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Rock Paper Scissors\n", + "\n", + "Goal: multi-round gameplay with adaptive opponent logic.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Gameplay runner\n", + "4. Quick verification checks" + ], + "id": "d6712221" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Rock Paper Scissors with normalization, score tracking, and adaptive AI.\"\"\"\n", + "\n", + "import random\n", + "from collections import defaultdict\n", + "\n", + "BEATS = {\"r\": \"s\", \"p\": \"r\", \"s\": \"p\"}\n", + "FULL_TO_SHORT = {\"rock\": \"r\", \"paper\": \"p\", \"scissors\": \"s\", \"r\": \"r\", \"p\": \"p\", \"s\": \"s\"}\n", + "\n", + "\n", + "def normalize_choice(raw: str) -> str:\n", + " key = raw.strip().lower()\n", + " if key not in FULL_TO_SHORT:\n", + " raise ValueError(\"Use rock/paper/scissors or r/p/s\")\n", + " return FULL_TO_SHORT[key]\n", + "\n", + "\n", + "def determine_outcome(user: str, computer: str) -> str:\n", + " if user == computer:\n", + " return \"tie\"\n", + " return \"win\" if BEATS[user] == computer else \"loss\"\n", + "\n", + "\n", + "def predict_user_move(history: list[str]) -> str | None:\n", + " if len(history) < 2:\n", + " return None\n", + " transitions: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))\n", + " for a, b in zip(history[:-1], history[1:]):\n", + " transitions[a][b] += 1\n", + " last = history[-1]\n", + " if not transitions[last]:\n", + " return None\n", + " return max(transitions[last], key=transitions[last].get)\n", + "\n", + "\n", + "def computer_choice(rng: random.Random, user_history: list[str]) -> str:\n", + " predicted = predict_user_move(user_history)\n", + " if predicted is None:\n", + " return rng.choice([\"r\", \"p\", \"s\"])\n", + " # Choose move that beats predicted user move.\n", + " for move, beats in BEATS.items():\n", + " if beats == predicted:\n", + " return move\n", + " return rng.choice([\"r\", \"p\", \"s\"])\n", + "\n", + "\n", + "def play_best_of(rounds: int = 3, rng_seed: int = 0) -> dict[str, int]:\n", + " rng = random.Random(rng_seed)\n", + " score = {\"user\": 0, \"computer\": 0, \"tie\": 0}\n", + " user_history: list[str] = []\n", + "\n", + " while score[\"user\"] + score[\"computer\"] < rounds:\n", + " raw = input(\"Choice (rock/paper/scissors): \")\n", + " try:\n", + " user = normalize_choice(raw)\n", + " except ValueError as exc:\n", + " print(exc)\n", + " continue\n", + " comp = computer_choice(rng, user_history)\n", + " user_history.append(user)\n", + " outcome = determine_outcome(user, comp)\n", + " score[\"tie\" if outcome == \"tie\" else (\"user\" if outcome == \"win\" else \"computer\")] += 1\n", + " print(f\"Computer chose {comp}. Outcome: {outcome}\")\n", + "\n", + " return score\n", + "\n", + "\n", + "# Quick verification checks\n", + "assert normalize_choice(\"Rock\") == \"r\"\n", + "assert determine_outcome(\"r\", \"s\") == \"win\"\n", + "assert computer_choice(random.Random(1), [\"r\", \"p\", \"r\"]) in {\"r\", \"p\", \"s\"}\n", + "\n", + "print(\"Demo outcome check:\", determine_outcome(\"p\", \"r\"))" + ], + "execution_count": 13, + "outputs": [ + { + "output_type": "stream", + "text": [ + " What is your choice? 'r' for rock, 'p' for paper, 's' for scissors\n", + "r\n", + "It's a tie\n" + ] + } + ], + "id": "31a96691" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "c9e5fe33" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "b8f1875a" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "import random\n", - "\n", - "def play():\n", - " user = input (\" What is your choice? 'r' for rock, 'p' for paper, 's' for scissors\\n\")\n", - " computer = random.choice(['r', 'p', 's'])\n", - " \n", - " if user == computer:\n", - " return 'It\\'s a tie'\n", - " \n", - " if is_win(user, computer):\n", - " return 'You won!'\n", - " \n", - " return 'You lost!'\n", - "\n", - "\n", - "def is_win(player, opponent):\n", - " if (player == 'r' and opponent == 's') or (player == 's' and opponent == 'p') \\\n", - " or (player == 'p' and opponent == 'r'):\n", - " return True\n", - "\n", - "print(play())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9e5fe33", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b8f1875a", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/Simple Calculator.ipynb b/Simple Calculator.ipynb index 24da755..40d0d66 100644 --- a/Simple Calculator.ipynb +++ b/Simple Calculator.ipynb @@ -1,67 +1,136 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 3, - "id": "a26aeb78", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Enter first number: 5\n", - "Enter operator: +\n", - "Enter second number: 3\n", - "8.0\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Simple Calculator\n", + "\n", + "Goal: parse and evaluate expressions safely with clear errors.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Session runner\n", + "4. Quick verification checks and demo" + ], + "id": "d4a663b8" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Simple calculator with parser, history, and safe error handling.\"\"\"\n", + "\n", + "from dataclasses import dataclass\n", + "\n", + "\n", + "OPERATORS = {\n", + " \"+\": lambda a, b: a + b,\n", + " \"-\": lambda a, b: a - b,\n", + " \"*\": lambda a, b: a * b,\n", + " \"/\": lambda a, b: a / b,\n", + " \"pow\": lambda a, b: a**b,\n", + " \"%\": lambda a, b: a % b,\n", + "}\n", + "\n", + "\n", + "@dataclass\n", + "class CalcRecord:\n", + " expression: str\n", + " result: float\n", + "\n", + "\n", + "def parse_expression(text: str) -> tuple[float, str, float]:\n", + " parts = text.strip().split()\n", + " if len(parts) != 3:\n", + " raise ValueError(\"Use format: \")\n", + " left, op, right = parts\n", + " if op not in OPERATORS:\n", + " raise ValueError(f\"Unsupported operator: {op}\")\n", + " return float(left), op, float(right)\n", + "\n", + "\n", + "def calculate(left: float, op: str, right: float) -> float:\n", + " if op == \"/\" and right == 0:\n", + " raise ZeroDivisionError(\"Division by zero is not allowed.\")\n", + " return OPERATORS[op](left, right)\n", + "\n", + "\n", + "def run_calculator_session(input_fn=input, output_fn=print) -> list[CalcRecord]:\n", + " history: list[CalcRecord] = []\n", + " output_fn(\"Enter expressions like: 2 + 3 (or 'quit').\")\n", + " while True:\n", + " raw = input_fn(\"> \").strip()\n", + " if raw.lower() == \"quit\":\n", + " break\n", + " try:\n", + " left, op, right = parse_expression(raw)\n", + " result = calculate(left, op, right)\n", + " history.append(CalcRecord(expression=raw, result=result))\n", + " output_fn(f\"= {result}\")\n", + " except Exception as exc:\n", + " output_fn(f\"Error: {exc}\")\n", + " return history\n", + "\n", + "\n", + "# Quick verification checks\n", + "assert parse_expression(\"2 + 3\") == (2.0, \"+\", 3.0)\n", + "assert calculate(2, \"pow\", 3) == 8\n", + "try:\n", + " calculate(3, \"/\", 0)\n", + " raise AssertionError(\"Expected division by zero\")\n", + "except ZeroDivisionError:\n", + " pass\n", + "\n", + "\n", + "demo = [\"2 + 3\", \"5 pow 2\", \"7 / 0\", \"quit\"]\n", + "it = iter(demo)\n", + "history = run_calculator_session(input_fn=lambda _: next(it), output_fn=print)\n", + "print(\"History size:\", len(history))" + ], + "execution_count": 3, + "outputs": [ + { + "output_type": "stream", + "text": [ + "Enter first number: 5\n", + "Enter operator: +\n", + "Enter second number: 3\n", + "8.0\n" + ] + } + ], + "id": "a26aeb78" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### " + ], + "id": "3cf8629c" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "num1= float(input(\"Enter first number: \"))\n", - "op= input(\"Enter operator: \")\n", - "num2= float(input(\"Enter second number: \"))\n", - "\n", - "if op == \"+\":\n", - " print(num1 + num2)\n", - "elif op == \"-\":\n", - " print(num1 - num2)\n", - "elif op == \"/\":\n", - " print(num1 / num2)\n", - "elif op == \"*\":\n", - " print(num1 * num2)\n", - "else:\n", - " print(\"Invalid operator\")" - ] - }, - { - "cell_type": "markdown", - "id": "3cf8629c", - "metadata": {}, - "source": [ - "### " - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/compose.py b/compose.py index 0d23979..18a90d4 100644 --- a/compose.py +++ b/compose.py @@ -54,7 +54,7 @@ def compose(g, words, length=50): def main(): - words = get_words_from_text('texts/hp_sorcerer_stone.txt') + words = get_words_from_text('hp_socerer_stone.txt') # for song in os.listdir('songs/{}'.format(artist)): # if song == '.DS_Store': diff --git a/compose_template.py b/compose_template.py index b6d4eeb..cd86b98 100644 --- a/compose_template.py +++ b/compose_template.py @@ -7,16 +7,38 @@ from graph import Graph, Vertex def get_words_from_text(text_path): - pass + with open(text_path, "rb") as file: + text = file.read().decode("utf-8") + text = " ".join(text.split()) + text = text.lower() + text = text.translate(str.maketrans("", "", string.punctuation)) + words = text.split() + return words[:1000] def make_graph(words): - pass + g = Graph() + prev_word = None + for word in words: + word_vertex = g.get_vertex(word) + if prev_word: + prev_word.increment_edge(word_vertex) + prev_word = word_vertex + g.generate_probability_mappings() + return g def compose(g, words, length=50): - pass + composition = [] + current = g.get_vertex(random.choice(words)) + for _ in range(length): + composition.append(current.value) + current = g.get_next_word(current) + return composition def main(): - pass + words = get_words_from_text("hp_socerer_stone.txt") + graph = make_graph(words) + composition = compose(graph, words, 100) + print(" ".join(composition)) if __name__ == '__main__': diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..37f586e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Documentation Index + +This repository documentation is organized by domain so each area stays focused and easy to maintain. + +## Getting Started + +- `getting-started/quickstart.md`: local setup, commands, and first-run checks. + +## Curriculum + +- `curriculum/overview.md`: full curriculum map and learning progression. +- `curriculum/modules/core_and_algorithms.md`: language + DSA tracks. +- `curriculum/modules/systems_and_data.md`: async, APIs, data, dashboard, and capstone tracks. + +## Pipelines + +- `pipelines/world-bank/overview.md`: architecture, data flow, and schema. +- `pipelines/world-bank/api-contracts.md`: endpoint and CLI contracts. +- `pipelines/world-bank/operations.md`: refresh policy, cache behavior, and troubleshooting. + +## Migration + +- `migration/script-modernization.md`: remaining script migration contracts and next steps. + +## Testing + +- `testing/verification.md`: full and targeted verification commands. + +## Legacy Path Compatibility + +These files are retained as compatibility entrypoints and now point to the reorganized docs: + +- `python_expansion_curriculum.md` +- `world_bank_data_pipeline.md` +- `script_modernization_guide.md` diff --git a/docs/curriculum/modules/core_and_algorithms.md b/docs/curriculum/modules/core_and_algorithms.md new file mode 100644 index 0000000..440fe96 --- /dev/null +++ b/docs/curriculum/modules/core_and_algorithms.md @@ -0,0 +1,35 @@ +# Core Language and Algorithms Track + +## Advanced Python Features + +Implementation path: +- `src/mini_projects/features/advanced_python/application/patterns.py` + +Learning goals: +- decorators and retry wrappers +- generators and memory-safe iteration +- context manager usage +- `dataclass` and `Protocol` design patterns + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/advanced_python +``` + +## Data Structures and Algorithms + +Implementation path: +- `src/mini_projects/features/dsa/application/algorithms.py` + +Learning goals: +- trie insertion/search/prefix matching +- union-find connectivity +- Dijkstra shortest path +- LIS dynamic-programming pattern + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/dsa +``` diff --git a/docs/curriculum/modules/systems_and_data.md b/docs/curriculum/modules/systems_and_data.md new file mode 100644 index 0000000..47d220a --- /dev/null +++ b/docs/curriculum/modules/systems_and_data.md @@ -0,0 +1,55 @@ +# Systems and Data Track + +## Async and Concurrency + +Implementation path: +- `src/mini_projects/features/async_concurrency/application/pipeline.py` + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/async_concurrency +``` + +## Backend API + +Implementation paths: +- `src/mini_projects/features/backend_api/presentation/app.py` +- `src/mini_projects/features/backend_api/infrastructure/repository.py` + +Includes refresh/latest/status API endpoints for World Bank data. + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/backend_api +``` + +## Data Analysis and Dashboard + +Implementation paths: +- `src/mini_projects/features/data_stack/application/analysis.py` +- `src/mini_projects/features/dashboard/application/dashboard.py` +- `src/mini_projects/features/big_data/application/chunked_processing.py` + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/data_stack tests/features/big_data +``` + +## Capstone Integration + +Implementation path: +- `src/mini_projects/features/capstone/application/pipeline.py` + +World Bank pipeline details: +- `../../pipelines/world-bank/overview.md` + +Verification: + +```bash +PYTHONPATH=src pytest -q tests/features/capstone +PYTHONPATH=src python -m mini_projects.cli world-bank-sync --countries US CA GB DE JP --indicators NY.GDP.MKTP.KD.ZG FP.CPI.TOTL.ZG SL.UEM.TOTL.ZS +PYTHONPATH=src python -m mini_projects.cli world-bank-status +``` diff --git a/docs/curriculum/overview.md b/docs/curriculum/overview.md new file mode 100644 index 0000000..ee739d7 --- /dev/null +++ b/docs/curriculum/overview.md @@ -0,0 +1,25 @@ +# Python Expansion Curriculum Overview + +This curriculum extends the notebook baseline into production-style Python skills across language depth, algorithms, systems, APIs, data engineering, and analytics. + +## Module Map + +1. Advanced Python features +2. Data structures and algorithms +3. Async and concurrency +4. Backend API development +5. Data analysis +6. Dashboard payload design +7. Big-data analysis foundations +8. Integration capstone with World Bank real-data flow + +## Track Documents + +- `modules/core_and_algorithms.md` +- `modules/systems_and_data.md` + +## End-to-End Verification + +```bash +PYTHONPATH=src pytest -q +``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..0b91495 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart + +## Prerequisites + +- Python 3.11+ +- `pip` + +## Environment Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +``` + +## Run Key Commands + +Run TicTacToe: + +```bash +PYTHONPATH=src python -m mini_projects.cli tictactoe --difficulty hard +``` + +Run curriculum demo: + +```bash +PYTHONPATH=src python -m mini_projects.cli curriculum-demo +``` + +Run real-data World Bank sync: + +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-sync --countries US CA GB DE JP --indicators NY.GDP.MKTP.KD.ZG FP.CPI.TOTL.ZG SL.UEM.TOTL.ZS +``` + +Check World Bank freshness: + +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-status +``` + +## Verify Project Health + +```bash +PYTHONPATH=src pytest -q +``` diff --git a/docs/migration/script-modernization.md b/docs/migration/script-modernization.md new file mode 100644 index 0000000..07615ed --- /dev/null +++ b/docs/migration/script-modernization.md @@ -0,0 +1,39 @@ +# Script Modernization Guide + +## Scope + +Standalone scripts still pending migration into `src/mini_projects/features`: + +- text/content: `compose.py`, `compose_template.py`, `lyrics.py` +- graph/image: `graph.py`, `graph_template.py`, `image.py`, `png.py`, `transform.py`, `transform_empty.py` +- data assets: `words.py` + +## Current Contracts + +- `compose.py`: word-transition graph and generated text composition. +- `graph.py`: weighted directed graph with probabilistic next-word sampling. +- `transform.py`: image transforms (brightness, contrast, blur, kernels, combine). +- `lyrics.py`: lyric retrieval with environment-based API key. + +## Stabilization Completed + +- Template implementations filled in `compose_template.py`, `graph_template.py`, `transform_empty.py`. +- `lyrics.py` moved to `GENIUS_API_KEY` usage and graceful skipping behavior. +- `compose.py` path fixed to `hp_socerer_stone.txt`. + +## Migration Requirements + +Move each script group into `src/mini_projects/features//` with: + +1. public entrypoint function +2. explicit input validation and clear errors +3. deterministic tests for core behavior +4. notebook demo import from modernized path + +## Curriculum Alignment + +- typed contracts and dataclasses: `advanced_python` +- deterministic algorithm tests: `dsa` +- retry/failure handling: `async_concurrency` +- boundary validation and adapters: `backend_api` +- analysis to dashboard to scale pattern: `data_stack`, `dashboard`, `big_data`, `capstone` diff --git a/docs/pipelines/world-bank/api-contracts.md b/docs/pipelines/world-bank/api-contracts.md new file mode 100644 index 0000000..f198cfb --- /dev/null +++ b/docs/pipelines/world-bank/api-contracts.md @@ -0,0 +1,63 @@ +# World Bank API and CLI Contracts + +## Upstream Endpoint Pattern + +`/country/{country_codes}/indicator/{indicator_id}?format=json&per_page={n}&page={p}` + +Example: + +`https://api.worldbank.org/v2/country/US;CA/indicator/NY.GDP.MKTP.KD.ZG?format=json&per_page=1000&page=1` + +## Internal Normalized Record Schema + +- `date` +- `country` +- `country_iso3` +- `indicator` +- `indicator_id` +- `category` +- `value` +- `fetched_at` + +## Backend Endpoints + +### `POST /world-bank/refresh` + +Request body: +- `force_refresh` (bool) +- `ttl_seconds` (int, 1..86400) +- `countries` (list[str]) +- `indicators` (list[str]) + +Response: +- `kpis` +- `dashboard` +- `processing_strategy` +- `record_count` +- `freshness` + +### `GET /world-bank/latest` + +Returns latest dashboard payload, refreshing if stale. + +### `GET /world-bank/status` + +Returns cache freshness metadata: +- `has_cache` +- `is_stale` +- `last_successful_fetch` +- `ttl_seconds` + +## CLI Commands + +Sync and build payload: + +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-sync --countries US CA GB DE JP --indicators NY.GDP.MKTP.KD.ZG FP.CPI.TOTL.ZG SL.UEM.TOTL.ZS +``` + +Freshness status: + +```bash +PYTHONPATH=src python -m mini_projects.cli world-bank-status +``` diff --git a/docs/pipelines/world-bank/operations.md b/docs/pipelines/world-bank/operations.md new file mode 100644 index 0000000..38d5299 --- /dev/null +++ b/docs/pipelines/world-bank/operations.md @@ -0,0 +1,42 @@ +# World Bank Pipeline Operations + +## Refresh Policy + +- Near-real-time mode (non-streaming). +- Request flow: + 1. Read cache by pipeline key. + 2. If fresh and not forced, serve cached payload. + 3. If stale or forced, fetch from World Bank, normalize, recompute payload. + 4. Persist payload with `last_successful_fetch` and `ttl_seconds`. + +## Caching Behavior + +- In-memory cache adapter currently used. +- Freshness is derived by `fetched_at + ttl_seconds`. +- Payload responses include explicit `freshness` metadata. + +## Verification + +Full test suite: + +```bash +PYTHONPATH=src pytest -q +``` + +Targeted suites: + +```bash +PYTHONPATH=src pytest -q tests/features/capstone tests/features/backend_api tests/features/data_stack +``` + +## Troubleshooting + +- Request failures: + - verify `api.worldbank.org` reachability + - check retry/backoff behavior in client +- Empty results: + - confirm valid indicator IDs and country codes + - some periods can have null values +- Unexpected stale results: + - inspect `/world-bank/status` + - verify TTL was not set too high diff --git a/docs/pipelines/world-bank/overview.md b/docs/pipelines/world-bank/overview.md new file mode 100644 index 0000000..fc463c5 --- /dev/null +++ b/docs/pipelines/world-bank/overview.md @@ -0,0 +1,33 @@ +# World Bank Pipeline Overview + +## Goal + +Provide a free, open, near-real-time pipeline for macro/development indicators using the World Bank Indicators API. + +## Source + +- API: World Bank Indicators API v2 +- Base URL: `https://api.worldbank.org/v2` +- Auth: none required + +## Architecture + +1. `world_bank_client.py` pulls paginated indicator data with retry logic. +2. Pipeline normalizes records and validates schema. +3. Data stack computes KPIs and aggregated category metrics. +4. Dashboard module builds payload tables for trend/category views. +5. Cache stores payload + freshness metadata. +6. API and CLI expose refresh and status operations. + +## Key Code Paths + +- `src/mini_projects/features/capstone/infrastructure/world_bank_client.py` +- `src/mini_projects/features/capstone/infrastructure/cache_store.py` +- `src/mini_projects/features/capstone/application/pipeline.py` +- `src/mini_projects/features/backend_api/presentation/app.py` +- `src/mini_projects/cli.py` + +## Related Docs + +- `api-contracts.md` +- `operations.md` diff --git a/docs/python_expansion_curriculum.md b/docs/python_expansion_curriculum.md new file mode 100644 index 0000000..639532b --- /dev/null +++ b/docs/python_expansion_curriculum.md @@ -0,0 +1,11 @@ +# Python Expansion Curriculum (Moved) + +The curriculum documentation has been reorganized into category-specific docs: + +- `docs/curriculum/overview.md` +- `docs/curriculum/modules/core_and_algorithms.md` +- `docs/curriculum/modules/systems_and_data.md` + +Start with the docs index: + +- `docs/README.md` diff --git a/docs/script_modernization_guide.md b/docs/script_modernization_guide.md new file mode 100644 index 0000000..e374a67 --- /dev/null +++ b/docs/script_modernization_guide.md @@ -0,0 +1,11 @@ +# Remaining Scripts Modernization Guide (Moved) + +This guide now lives at: + +- `docs/migration/script-modernization.md` + +Related curriculum docs: + +- `docs/curriculum/overview.md` +- `docs/curriculum/modules/core_and_algorithms.md` +- `docs/curriculum/modules/systems_and_data.md` diff --git a/docs/testing/verification.md b/docs/testing/verification.md new file mode 100644 index 0000000..373011d --- /dev/null +++ b/docs/testing/verification.md @@ -0,0 +1,27 @@ +# Verification Guide + +## Full Project Check + +```bash +PYTHONPATH=src pytest -q +``` + +## Focused Checks + +Curriculum modules: + +```bash +PYTHONPATH=src pytest -q tests/features/advanced_python tests/features/dsa tests/features/async_concurrency +``` + +World Bank pipeline and API: + +```bash +PYTHONPATH=src pytest -q tests/features/capstone tests/features/backend_api tests/features/data_stack +``` + +CLI behavior: + +```bash +PYTHONPATH=src pytest -q tests/test_cli.py +``` diff --git a/docs/world_bank_data_pipeline.md b/docs/world_bank_data_pipeline.md new file mode 100644 index 0000000..4e30b6b --- /dev/null +++ b/docs/world_bank_data_pipeline.md @@ -0,0 +1,11 @@ +# World Bank Data Pipeline (Moved) + +World Bank pipeline documentation has been split into focused docs: + +- `docs/pipelines/world-bank/overview.md` +- `docs/pipelines/world-bank/api-contracts.md` +- `docs/pipelines/world-bank/operations.md` + +Start with: + +- `docs/README.md` diff --git a/graph_template.py b/graph_template.py index 93c7794..165d140 100644 --- a/graph_template.py +++ b/graph_template.py @@ -2,41 +2,53 @@ class Vertex(object): def __init__(self, value): - pass + self.value = value + self.adjacent = {} + self.neighbors = [] + self.neighbors_weights = [] def add_edge_to(self, vertex, weight=0): - pass + self.adjacent[vertex] = weight def increment_edge(self, vertex): - pass + self.adjacent[vertex] = self.adjacent.get(vertex, 0) + 1 def get_adjacent_nodes(self): - pass + return self.adjacent.keys() # initializes probability map def get_probability_map(self): - pass + self.neighbors = [] + self.neighbors_weights = [] + for vertex, weight in self.adjacent.items(): + self.neighbors.append(vertex) + self.neighbors_weights.append(weight) def next_word(self): - pass + if not self.neighbors: + return self + return random.choices(self.neighbors, weights=self.neighbors_weights)[0] class Graph(object): def __init__(self): - pass + self.vertices = {} def get_vertex_values(self): - pass + return set(self.vertices.keys()) def add_vertex(self, value): - pass + self.vertices[value] = Vertex(value) def get_vertex(self, value): - pass + if value not in self.vertices: + self.add_vertex(value) + return self.vertices[value] def get_next_word(self, current_vertex): - pass + return self.vertices[current_vertex.value].next_word() def generate_probability_mappings(self): - pass + for vertex in self.vertices.values(): + vertex.get_probability_map() diff --git a/lyrics.py b/lyrics.py index b08e26a..8179d64 100644 --- a/lyrics.py +++ b/lyrics.py @@ -1,16 +1,26 @@ +import os +from pathlib import Path + import lyricsgenius -# generate an api key and paste it -# https://genius.com/api-clients -genius = lyricsgenius.Genius("api-key-here") +GENIUS_API_KEY = os.getenv("GENIUS_API_KEY") +if not GENIUS_API_KEY: + raise RuntimeError("Set GENIUS_API_KEY in your environment before running this script.") + +genius = lyricsgenius.Genius(GENIUS_API_KEY) def save_lyrics(songs, artist_name, album_name): + artist_folder = Path("songs") / "_".join(artist_name.split(" ")) + artist_folder.mkdir(parents=True, exist_ok=True) for i in range(len(songs )): song_title = songs[i] song = genius.search_song(song_title, artist_name) + if song is None: + continue lyrics = song.lyrics - with open('songs/{}/{}_{}_{}.txt'.format('_'.join(artist_name.split(' ')), i+1, album_name, '-'.join(''.join(song_title.split('\'')).split(' '))), 'w') as f: + output_name = "{}_{}_{}.txt".format(i + 1, album_name, "-".join("".join(song_title.split("'")).split(" "))) + with open(artist_folder / output_name, "w") as f: f.writelines(lyrics.split('\\n')) diff --git a/madlib.ipynb b/madlib.ipynb index 53d4242..224963d 100644 --- a/madlib.ipynb +++ b/madlib.ipynb @@ -1,102 +1,164 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "id": "26ba81f8", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Adjective: amazing\n", - "Verb: skydive\n", - "Verb: jump\n", - "Famous person:steve roger\n", - "Computer programming is so amazing! It makes me so excited all the time I love to skydive. Stay hydrated and jump like you are steve roger!\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Madlib\n", + "\n", + "Goal: build reusable text templates with validated inputs.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Quick verification checks\n", + "4. Demo rendering" + ], + "id": "119c9196" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Madlib notebook with template selection, validation, and replay-ready helpers.\"\"\"\n", + "\n", + "import random\n", + "\n", + "TEMPLATES = [\n", + " \"Computer programming is so {adjective}! I love to {verb1} and {verb2} like {famous_person}.\",\n", + " \"Today felt {adjective}. I decided to {verb1}, then {verb2}, just like {famous_person} would.\",\n", + " \"Learning Python is {adjective}; first I {verb1}, then I {verb2}, inspired by {famous_person}.\",\n", + "]\n", + "\n", + "\n", + "def validate_nonempty(text: str) -> str:\n", + " value = text.strip()\n", + " if not value:\n", + " raise ValueError(\"Input cannot be empty.\")\n", + " return value\n", + "\n", + "\n", + "def collect_words(input_fn=input) -> dict[str, str]:\n", + " schema = [\"adjective\", \"verb1\", \"verb2\", \"famous_person\"]\n", + " answers = {}\n", + " for key in schema:\n", + " answers[key] = validate_nonempty(input_fn(f\"{key.replace('_', ' ').title()}: \"))\n", + " return answers\n", + "\n", + "\n", + "def choose_template(rng: random.Random, preferred_index: int | None = None) -> str:\n", + " if preferred_index is not None:\n", + " return TEMPLATES[preferred_index]\n", + " return rng.choice(TEMPLATES)\n", + "\n", + "\n", + "def render_madlib(template: str, words: dict[str, str]) -> str:\n", + " return template.format(**words)\n", + "\n", + "\n", + "# Quick verification checks\n", + "sample_words = {\n", + " \"adjective\": \"exciting\",\n", + " \"verb1\": \"code\",\n", + " \"verb2\": \"debug\",\n", + " \"famous_person\": \"Ada Lovelace\",\n", + "}\n", + "assert \"exciting\" in render_madlib(TEMPLATES[0], sample_words)\n", + "assert choose_template(random.Random(1), preferred_index=1) == TEMPLATES[1]\n", + "try:\n", + " validate_nonempty(\" \")\n", + " raise AssertionError(\"Expected ValueError\")\n", + "except ValueError:\n", + " pass\n", + "\n", + "\n", + "rng = random.Random(7)\n", + "template = choose_template(rng)\n", + "print(render_madlib(template, sample_words))" + ], + "execution_count": 2, + "outputs": [ + { + "output_type": "stream", + "text": [ + "Adjective: amazing\n", + "Verb: skydive\n", + "Verb: jump\n", + "Famous person:steve roger\n", + "Computer programming is so amazing! It makes me so excited all the time I love to skydive. Stay hydrated and jump like you are steve roger!\n" + ] + } + ], + "id": "26ba81f8" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "075913bf" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "dcc91ecf" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "f7b35039" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "a6d0eb6b" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "0d1d8ed1" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "1b51ed23" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "adj = input(\"Adjective: \")\n", - "verb1 = input(\"Verb: \")\n", - "verb2 = input(\"Verb: \")\n", - "famous_person = input (\"Famous person:\")\n", - "\n", - "madlib = f\"Computer programming is so {adj}! It makes me so excited all the time I love to {verb1}. Stay hydrated and {verb2} like you are {famous_person}!\"\n", - "\n", - "print(madlib)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "075913bf", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dcc91ecf", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f7b35039", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6d0eb6b", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0d1d8ed1", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b51ed23", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..506ee8e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71410ca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +numpy +pypng +lyricsgenius +pandas +matplotlib +fastapi +uvicorn +httpx diff --git a/src/mini_projects/__init__.py b/src/mini_projects/__init__.py new file mode 100644 index 0000000..fd61d1a --- /dev/null +++ b/src/mini_projects/__init__.py @@ -0,0 +1,2 @@ +"""Beginner mini-projects package.""" + diff --git a/src/mini_projects/cli.py b/src/mini_projects/cli.py new file mode 100644 index 0000000..a0eda69 --- /dev/null +++ b/src/mini_projects/cli.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import argparse +from typing import Sequence + +from mini_projects.features.capstone.application.pipeline import ( + get_or_refresh_world_bank_dashboard, + get_world_bank_freshness, + run_capstone, +) +from mini_projects.features.tictactoe.presentation.cli import run_tictactoe_cli + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mini-projects", + description="Beginner Python mini-projects collection.", + ) + subparsers = parser.add_subparsers(dest="command") + + tictactoe_parser = subparsers.add_parser("tictactoe", help="Run TicTacToe.") + tictactoe_parser.add_argument( + "--difficulty", + choices=["easy", "medium", "hard"], + default="hard", + help="Computer difficulty level.", + ) + tictactoe_parser.add_argument( + "--seed", + type=int, + default=None, + help="Optional RNG seed for deterministic play.", + ) + subparsers.add_parser("curriculum-demo", help="Run curriculum expansion demo.") + world_bank_parser = subparsers.add_parser( + "world-bank-sync", + help="Fetch real World Bank indicators and build dashboard payload.", + ) + world_bank_parser.add_argument( + "--countries", + nargs="+", + default=["US", "CA", "GB", "DE", "JP"], + help="Country ISO2 codes. Example: US CA GB", + ) + world_bank_parser.add_argument( + "--indicators", + nargs="+", + default=["NY.GDP.MKTP.KD.ZG", "FP.CPI.TOTL.ZG", "SL.UEM.TOTL.ZS"], + help="World Bank indicator IDs.", + ) + world_bank_parser.add_argument( + "--ttl-seconds", + type=int, + default=3600, + help="Cache TTL seconds.", + ) + world_bank_parser.add_argument( + "--force-refresh", + action="store_true", + help="Force refresh even if cache is fresh.", + ) + + subparsers.add_parser( + "world-bank-status", + help="Show World Bank cache freshness status.", + ) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "tictactoe": + run_tictactoe_cli(difficulty=args.difficulty, random_seed=args.seed) + return 0 + if args.command == "curriculum-demo": + demo_records = [ + { + "date": "2026-01-01", + "country": "United States", + "country_iso3": "USA", + "indicator": "GDP growth (annual %)", + "indicator_id": "NY.GDP.MKTP.KD.ZG", + "category": "growth", + "value": 2.1, + "fetched_at": "2026-01-10T12:00:00+00:00", + }, + { + "date": "2026-01-01", + "country": "Canada", + "country_iso3": "CAN", + "indicator": "Inflation, consumer prices (annual %)", + "indicator_id": "FP.CPI.TOTL.ZG", + "category": "prices", + "value": 2.9, + "fetched_at": "2026-01-10T12:00:00+00:00", + }, + ] + result = run_capstone(demo_records) + print("Capstone KPIs:", result["kpis"]) + print("Processing strategy:", result["processing_strategy"]) + return 0 + if args.command == "world-bank-sync": + result = get_or_refresh_world_bank_dashboard( + force_refresh=args.force_refresh, + ttl_seconds=args.ttl_seconds, + countries=args.countries, + indicators=args.indicators, + ) + print("World Bank KPIs:", result["kpis"]) + print("Freshness:", result["freshness"]) + return 0 + if args.command == "world-bank-status": + status = get_world_bank_freshness() + print("World Bank status:", status) + return 0 + + parser.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/src/mini_projects/features/__init__.py b/src/mini_projects/features/__init__.py new file mode 100644 index 0000000..22c4353 --- /dev/null +++ b/src/mini_projects/features/__init__.py @@ -0,0 +1,2 @@ +"""Feature modules for mini projects.""" + diff --git a/src/mini_projects/features/advanced_python/__init__.py b/src/mini_projects/features/advanced_python/__init__.py new file mode 100644 index 0000000..e628106 --- /dev/null +++ b/src/mini_projects/features/advanced_python/__init__.py @@ -0,0 +1 @@ +"""Advanced Python language feature demos.""" diff --git a/src/mini_projects/features/advanced_python/application/__init__.py b/src/mini_projects/features/advanced_python/application/__init__.py new file mode 100644 index 0000000..547d973 --- /dev/null +++ b/src/mini_projects/features/advanced_python/application/__init__.py @@ -0,0 +1 @@ +"""Application helpers for advanced Python module.""" diff --git a/src/mini_projects/features/advanced_python/application/patterns.py b/src/mini_projects/features/advanced_python/application/patterns.py new file mode 100644 index 0000000..19be376 --- /dev/null +++ b/src/mini_projects/features/advanced_python/application/patterns.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Callable, Generator +from contextlib import contextmanager +from dataclasses import dataclass +from time import perf_counter +from typing import Protocol, TypeVar + +T = TypeVar("T") + + +class SupportsLen(Protocol): + def __len__(self) -> int: + ... + + +@dataclass(slots=True) +class LearningTask: + name: str + minutes_estimate: int + topic: str + + +def retry(attempts: int) -> Callable[[Callable[..., T]], Callable[..., T]]: + if attempts < 1: + raise ValueError("attempts must be >= 1") + + def decorator(func: Callable[..., T]) -> Callable[..., T]: + def wrapper(*args, **kwargs) -> T: + last_error: Exception | None = None + for _ in range(attempts): + try: + return func(*args, **kwargs) + except Exception as error: # pragma: no cover - exercised by tests + last_error = error + assert last_error is not None + raise last_error + + return wrapper + + return decorator + + +def sliding_window(items: list[T], size: int) -> Generator[list[T], None, None]: + if size <= 0: + raise ValueError("size must be > 0") + if size > len(items): + return + for start in range(0, len(items) - size + 1): + yield items[start : start + size] + + +@contextmanager +def timed_operation() -> Generator[Callable[[], float], None, None]: + start = perf_counter() + result = {"elapsed": 0.0} + + def elapsed_seconds() -> float: + return result["elapsed"] + + try: + yield elapsed_seconds + finally: + result["elapsed"] = perf_counter() - start + + +def describe_size(value: SupportsLen) -> str: + return f"size={len(value)}" diff --git a/src/mini_projects/features/async_concurrency/__init__.py b/src/mini_projects/features/async_concurrency/__init__.py new file mode 100644 index 0000000..1dc5135 --- /dev/null +++ b/src/mini_projects/features/async_concurrency/__init__.py @@ -0,0 +1 @@ +"""Async and concurrency learning module.""" diff --git a/src/mini_projects/features/async_concurrency/application/__init__.py b/src/mini_projects/features/async_concurrency/application/__init__.py new file mode 100644 index 0000000..c7c22b0 --- /dev/null +++ b/src/mini_projects/features/async_concurrency/application/__init__.py @@ -0,0 +1 @@ +"""Application services for async and concurrency.""" diff --git a/src/mini_projects/features/async_concurrency/application/pipeline.py b/src/mini_projects/features/async_concurrency/application/pipeline.py new file mode 100644 index 0000000..978ebfc --- /dev/null +++ b/src/mini_projects/features/async_concurrency/application/pipeline.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + + +async def fetch_with_retry( + fetcher: Callable[[str], Awaitable[str]], + resource: str, + retries: int = 3, +) -> str: + if retries < 1: + raise ValueError("retries must be >= 1") + last_error: Exception | None = None + for _ in range(retries): + try: + return await fetcher(resource) + except Exception as error: + last_error = error + await asyncio.sleep(0) + assert last_error is not None + raise last_error + + +async def run_bounded_tasks( + resources: list[str], + fetcher: Callable[[str], Awaitable[str]], + concurrency_limit: int = 3, +) -> list[str]: + if concurrency_limit < 1: + raise ValueError("concurrency_limit must be >= 1") + semaphore = asyncio.Semaphore(concurrency_limit) + + async def guarded(resource: str) -> str: + async with semaphore: + return await fetcher(resource) + + return await asyncio.gather(*(guarded(resource) for resource in resources)) + + +async def fanout_with_timeout( + resources: list[str], + fetcher: Callable[[str], Awaitable[str]], + timeout_seconds: float, +) -> list[str]: + tasks = [asyncio.create_task(fetcher(resource)) for resource in resources] + done, pending = await asyncio.wait(tasks, timeout=timeout_seconds) + for task in pending: + task.cancel() + results: list[str] = [] + for task in done: + results.append(task.result()) + return results + + +def choose_concurrency_model(task_type: str) -> str: + mapping = { + "io_bound": "asyncio", + "cpu_bound": "multiprocessing", + "mixed": "threading_or_asyncio_hybrid", + } + if task_type not in mapping: + raise ValueError("task_type must be one of: io_bound, cpu_bound, mixed") + return mapping[task_type] diff --git a/src/mini_projects/features/backend_api/__init__.py b/src/mini_projects/features/backend_api/__init__.py new file mode 100644 index 0000000..0df1808 --- /dev/null +++ b/src/mini_projects/features/backend_api/__init__.py @@ -0,0 +1 @@ +"""Backend API learning module.""" diff --git a/src/mini_projects/features/backend_api/domain/__init__.py b/src/mini_projects/features/backend_api/domain/__init__.py new file mode 100644 index 0000000..7141539 --- /dev/null +++ b/src/mini_projects/features/backend_api/domain/__init__.py @@ -0,0 +1 @@ +"""Domain models for backend API module.""" diff --git a/src/mini_projects/features/backend_api/domain/models.py b/src/mini_projects/features/backend_api/domain/models.py new file mode 100644 index 0000000..65dfe80 --- /dev/null +++ b/src/mini_projects/features/backend_api/domain/models.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class Note: + note_id: int + title: str + body: str diff --git a/src/mini_projects/features/backend_api/infrastructure/__init__.py b/src/mini_projects/features/backend_api/infrastructure/__init__.py new file mode 100644 index 0000000..0575f84 --- /dev/null +++ b/src/mini_projects/features/backend_api/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure adapters for backend API module.""" diff --git a/src/mini_projects/features/backend_api/infrastructure/repository.py b/src/mini_projects/features/backend_api/infrastructure/repository.py new file mode 100644 index 0000000..f9f601f --- /dev/null +++ b/src/mini_projects/features/backend_api/infrastructure/repository.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sqlite3 + +from mini_projects.features.backend_api.domain.models import Note + + +class NoteRepository: + def __init__(self, database_path: str = ":memory:") -> None: + self.connection = sqlite3.connect(database_path, check_same_thread=False) + self.connection.row_factory = sqlite3.Row + self._ensure_schema() + + def _ensure_schema(self) -> None: + self.connection.execute( + """ + CREATE TABLE IF NOT EXISTS notes ( + note_id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL + ); + """ + ) + self.connection.commit() + + def create_note(self, title: str, body: str) -> Note: + cursor = self.connection.execute( + "INSERT INTO notes(title, body) VALUES(?, ?);", + (title, body), + ) + self.connection.commit() + return Note(note_id=int(cursor.lastrowid), title=title, body=body) + + def list_notes(self) -> list[Note]: + rows = self.connection.execute( + "SELECT note_id, title, body FROM notes ORDER BY note_id ASC;" + ).fetchall() + return [ + Note(note_id=row["note_id"], title=row["title"], body=row["body"]) + for row in rows + ] diff --git a/src/mini_projects/features/backend_api/presentation/__init__.py b/src/mini_projects/features/backend_api/presentation/__init__.py new file mode 100644 index 0000000..1da82df --- /dev/null +++ b/src/mini_projects/features/backend_api/presentation/__init__.py @@ -0,0 +1 @@ +"""Presentation entrypoints for backend API module.""" diff --git a/src/mini_projects/features/backend_api/presentation/app.py b/src/mini_projects/features/backend_api/presentation/app.py new file mode 100644 index 0000000..33b86ae --- /dev/null +++ b/src/mini_projects/features/backend_api/presentation/app.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from mini_projects.features.backend_api.infrastructure.repository import NoteRepository +from mini_projects.features.capstone.application.pipeline import ( + get_or_refresh_world_bank_dashboard, + get_world_bank_freshness, +) + + +class CreateNoteRequest(BaseModel): + title: str = Field(min_length=1, max_length=120) + body: str = Field(min_length=1, max_length=1000) + + +class NoteResponse(BaseModel): + note_id: int + title: str + body: str + + +class RefreshRequest(BaseModel): + force_refresh: bool = False + ttl_seconds: int = Field(default=3600, ge=1, le=86_400) + countries: list[str] = Field(default_factory=lambda: ["US", "CA", "GB", "DE", "JP"]) + indicators: list[str] = Field( + default_factory=lambda: [ + "NY.GDP.MKTP.KD.ZG", + "FP.CPI.TOTL.ZG", + "SL.UEM.TOTL.ZS", + ] + ) + + +def create_app(repository: NoteRepository | None = None) -> FastAPI: + repo = repository or NoteRepository() + app = FastAPI(title="Learn Python Backend API") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/notes", response_model=list[NoteResponse]) + def list_notes() -> list[NoteResponse]: + return [NoteResponse(**asdict(note)) for note in repo.list_notes()] + + @app.post("/notes", response_model=NoteResponse, status_code=201) + def create_note(request: CreateNoteRequest) -> NoteResponse: + if request.title.strip() == "": + raise HTTPException(status_code=400, detail="title cannot be blank") + note = repo.create_note(title=request.title.strip(), body=request.body.strip()) + return NoteResponse(**asdict(note)) + + @app.post("/world-bank/refresh") + def refresh_world_bank(request: RefreshRequest) -> dict[str, Any]: + return get_or_refresh_world_bank_dashboard( + force_refresh=request.force_refresh, + ttl_seconds=request.ttl_seconds, + countries=request.countries, + indicators=request.indicators, + ) + + @app.get("/world-bank/latest") + def latest_world_bank_dashboard() -> dict[str, Any]: + return get_or_refresh_world_bank_dashboard(force_refresh=False) + + @app.get("/world-bank/status") + def world_bank_status() -> dict[str, Any]: + return get_world_bank_freshness() + + return app diff --git a/src/mini_projects/features/big_data/__init__.py b/src/mini_projects/features/big_data/__init__.py new file mode 100644 index 0000000..4bd26cc --- /dev/null +++ b/src/mini_projects/features/big_data/__init__.py @@ -0,0 +1 @@ +"""Big-data analysis learning module.""" diff --git a/src/mini_projects/features/big_data/application/__init__.py b/src/mini_projects/features/big_data/application/__init__.py new file mode 100644 index 0000000..28ca40c --- /dev/null +++ b/src/mini_projects/features/big_data/application/__init__.py @@ -0,0 +1 @@ +"""Application services for big-data module.""" diff --git a/src/mini_projects/features/big_data/application/chunked_processing.py b/src/mini_projects/features/big_data/application/chunked_processing.py new file mode 100644 index 0000000..940baa1 --- /dev/null +++ b/src/mini_projects/features/big_data/application/chunked_processing.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from collections.abc import Iterable + + +def chunked_sum(values: Iterable[int], chunk_size: int) -> int: + if chunk_size <= 0: + raise ValueError("chunk_size must be > 0") + total = 0 + chunk: list[int] = [] + for value in values: + chunk.append(value) + if len(chunk) == chunk_size: + total += sum(chunk) + chunk.clear() + if chunk: + total += sum(chunk) + return total + + +def estimate_scale_strategy(row_count: int) -> str: + if row_count < 1_000_000: + return "pandas_local" + if row_count < 50_000_000: + return "dask_out_of_core" + return "spark_distributed" diff --git a/src/mini_projects/features/capstone/__init__.py b/src/mini_projects/features/capstone/__init__.py new file mode 100644 index 0000000..d9d2326 --- /dev/null +++ b/src/mini_projects/features/capstone/__init__.py @@ -0,0 +1 @@ +"""Integrated capstone learning module.""" diff --git a/src/mini_projects/features/capstone/application/__init__.py b/src/mini_projects/features/capstone/application/__init__.py new file mode 100644 index 0000000..e7e478f --- /dev/null +++ b/src/mini_projects/features/capstone/application/__init__.py @@ -0,0 +1 @@ +"""Application services for capstone module.""" diff --git a/src/mini_projects/features/capstone/application/pipeline.py b/src/mini_projects/features/capstone/application/pipeline.py new file mode 100644 index 0000000..83491ad --- /dev/null +++ b/src/mini_projects/features/capstone/application/pipeline.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from mini_projects.features.big_data.application.chunked_processing import ( + estimate_scale_strategy, +) +from mini_projects.features.capstone.infrastructure.cache_store import ( + CacheEntry, + InMemoryCacheStore, +) +from mini_projects.features.capstone.infrastructure.world_bank_client import ( + WorldBankClient, + WorldBankObservation, +) +from mini_projects.features.dashboard.application.dashboard import ( + build_indicator_dashboard_payload, +) +from mini_projects.features.data_stack.application.analysis import ( + load_indicator_frame, +) + +CACHE_KEY = "world_bank_dashboard_payload" +DEFAULT_COUNTRIES = ["US", "CA", "GB", "DE", "JP"] +DEFAULT_INDICATORS = [ + "NY.GDP.MKTP.KD.ZG", # GDP growth + "FP.CPI.TOTL.ZG", # Inflation CPI + "SL.UEM.TOTL.ZS", # Unemployment + "SP.POP.TOTL", # Population + "BN.CAB.XOKA.GD.ZS", # Current account + "SI.POV.DDAY", # Poverty headcount +] +INDICATOR_CATEGORY_MAP = { + "NY.GDP.MKTP.KD.ZG": "growth", + "FP.CPI.TOTL.ZG": "prices", + "SL.UEM.TOTL.ZS": "labor", + "SP.POP.TOTL": "demographics", + "BN.CAB.XOKA.GD.ZS": "external", + "SI.POV.DDAY": "poverty", +} + +_DEFAULT_CACHE = InMemoryCacheStore() + + +def run_capstone(records: list[dict[str, object]]) -> dict[str, object]: + frame = load_indicator_frame(records) + dashboard = build_indicator_dashboard_payload(frame) + strategy = estimate_scale_strategy(len(frame)) + return { + "kpis": dashboard["kpis"], + "dashboard": dashboard, + "processing_strategy": strategy, + } + + +def get_or_refresh_world_bank_dashboard( + *, + force_refresh: bool = False, + ttl_seconds: int = 3600, + countries: list[str] | None = None, + indicators: list[str] | None = None, + cache_store: InMemoryCacheStore | None = None, + client: WorldBankClient | None = None, +) -> dict[str, Any]: + if ttl_seconds < 1: + raise ValueError("ttl_seconds must be >= 1") + store = cache_store or _DEFAULT_CACHE + cached_entry = store.get(CACHE_KEY) + now = datetime.now(tz=UTC) + if ( + cached_entry is not None + and not force_refresh + and not cached_entry.is_stale(now=now) + ): + payload = dict(cached_entry.payload) + payload["freshness"] = { + "served_from_cache": True, + "ttl_seconds": cached_entry.ttl_seconds, + "last_successful_fetch": cached_entry.fetched_at.isoformat(), + "is_stale": False, + } + return payload + + wb_client = client or WorldBankClient() + normalized_records = _fetch_and_normalize_world_bank_records( + client=wb_client, + countries=countries or DEFAULT_COUNTRIES, + indicators=indicators or DEFAULT_INDICATORS, + ) + frame = load_indicator_frame(normalized_records) + dashboard = build_indicator_dashboard_payload(frame) + payload = { + "kpis": dashboard["kpis"], + "dashboard": dashboard, + "processing_strategy": estimate_scale_strategy(len(frame)), + "record_count": len(normalized_records), + "freshness": { + "served_from_cache": False, + "ttl_seconds": ttl_seconds, + "last_successful_fetch": now.isoformat(), + "is_stale": False, + }, + } + store.set( + CACHE_KEY, + CacheEntry(payload=payload, fetched_at=now, ttl_seconds=ttl_seconds), + ) + return payload + + +def get_world_bank_freshness( + cache_store: InMemoryCacheStore | None = None, +) -> dict[str, Any]: + store = cache_store or _DEFAULT_CACHE + entry = store.get(CACHE_KEY) + if entry is None: + return { + "has_cache": False, + "is_stale": True, + "last_successful_fetch": None, + "ttl_seconds": None, + } + now = datetime.now(tz=UTC) + return { + "has_cache": True, + "is_stale": entry.is_stale(now=now), + "last_successful_fetch": entry.fetched_at.isoformat(), + "ttl_seconds": entry.ttl_seconds, + } + + +def _fetch_and_normalize_world_bank_records( + *, + client: WorldBankClient, + countries: list[str], + indicators: list[str], +) -> list[dict[str, object]]: + normalized: list[dict[str, object]] = [] + for indicator_id in indicators: + observations: list[WorldBankObservation] = client.fetch_indicator_observations( + indicator_id=indicator_id, + countries=countries, + ) + category = INDICATOR_CATEGORY_MAP.get(indicator_id, "other") + for row in observations: + normalized.append( + { + "date": row.date, + "country": row.country, + "country_iso3": row.country_iso3, + "indicator": row.indicator, + "indicator_id": row.indicator_id, + "category": category, + "value": row.value, + "fetched_at": row.fetched_at.isoformat(), + } + ) + return normalized diff --git a/src/mini_projects/features/capstone/infrastructure/__init__.py b/src/mini_projects/features/capstone/infrastructure/__init__.py new file mode 100644 index 0000000..6423e6a --- /dev/null +++ b/src/mini_projects/features/capstone/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure adapters for capstone real-data ingestion.""" diff --git a/src/mini_projects/features/capstone/infrastructure/cache_store.py b/src/mini_projects/features/capstone/infrastructure/cache_store.py new file mode 100644 index 0000000..36945d3 --- /dev/null +++ b/src/mini_projects/features/capstone/infrastructure/cache_store.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + + +@dataclass(slots=True) +class CacheEntry: + payload: dict[str, Any] + fetched_at: datetime + ttl_seconds: int + + def is_stale(self, now: datetime | None = None) -> bool: + reference = now or datetime.now(tz=UTC) + return reference >= self.fetched_at + timedelta(seconds=self.ttl_seconds) + + +class InMemoryCacheStore: + def __init__(self) -> None: + self._entries: dict[str, CacheEntry] = {} + + def get(self, key: str) -> CacheEntry | None: + return self._entries.get(key) + + def set(self, key: str, value: CacheEntry) -> None: + self._entries[key] = value diff --git a/src/mini_projects/features/capstone/infrastructure/world_bank_client.py b/src/mini_projects/features/capstone/infrastructure/world_bank_client.py new file mode 100644 index 0000000..01a69cb --- /dev/null +++ b/src/mini_projects/features/capstone/infrastructure/world_bank_client.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import time +from typing import Any + +import httpx + + +class WorldBankClientError(RuntimeError): + """Raised when the World Bank API request fails.""" + + +@dataclass(frozen=True, slots=True) +class WorldBankObservation: + country: str + country_iso3: str + indicator: str + indicator_id: str + date: str + value: float + fetched_at: datetime + + +class WorldBankClient: + def __init__( + self, + base_url: str = "https://api.worldbank.org/v2", + retries: int = 3, + timeout_seconds: float = 20.0, + ) -> None: + if retries < 1: + raise ValueError("retries must be >= 1") + self.base_url = base_url.rstrip("/") + self.retries = retries + self.timeout_seconds = timeout_seconds + + def fetch_indicator_observations( + self, + indicator_id: str, + countries: list[str], + per_page: int = 1000, + ) -> list[WorldBankObservation]: + if not indicator_id.strip(): + raise ValueError("indicator_id is required") + if not countries: + raise ValueError("countries must not be empty") + if per_page < 1: + raise ValueError("per_page must be >= 1") + + country_segment = ";".join(country.upper() for country in countries) + endpoint = f"/country/{country_segment}/indicator/{indicator_id.upper()}" + + observations: list[WorldBankObservation] = [] + page = 1 + + while True: + payload = self._request_json( + endpoint=endpoint, + query_params={"format": "json", "per_page": per_page, "page": page}, + ) + metadata, rows = self._validate_response_payload(payload) + observations.extend(self._parse_rows(rows)) + if page >= int(metadata["pages"]): + break + page += 1 + + return observations + + def _request_json(self, endpoint: str, query_params: dict[str, Any]) -> list[Any]: + url = f"{self.base_url}{endpoint}" + last_error: Exception | None = None + for attempt in range(1, self.retries + 1): + try: + response = httpx.get(url, params=query_params, timeout=self.timeout_seconds) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise WorldBankClientError("World Bank API payload must be a list") + return payload + except Exception as error: # pragma: no cover - exercised by tests via retries + last_error = error + if attempt < self.retries: + time.sleep(0.1 * attempt) + + raise WorldBankClientError(f"failed request to {url}") from last_error + + def _validate_response_payload( + self, + payload: list[Any], + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + if len(payload) != 2: + raise WorldBankClientError("unexpected payload format; expected [metadata, rows]") + metadata = payload[0] + rows = payload[1] + if not isinstance(metadata, dict): + raise WorldBankClientError("metadata section must be an object") + if "pages" not in metadata: + raise WorldBankClientError("metadata missing 'pages'") + if not isinstance(rows, list): + raise WorldBankClientError("rows section must be a list") + return metadata, rows + + def _parse_rows(self, rows: list[dict[str, Any]]) -> list[WorldBankObservation]: + parsed: list[WorldBankObservation] = [] + fetched_at = datetime.now(tz=UTC) + for row in rows: + value = row.get("value") + if value is None: + continue + country_info = row.get("country", {}) + indicator_info = row.get("indicator", {}) + parsed.append( + WorldBankObservation( + country=str(country_info.get("value", "unknown")), + country_iso3=str(country_info.get("id", "UNK")), + indicator=str(indicator_info.get("value", "unknown")), + indicator_id=str(indicator_info.get("id", "unknown")), + date=str(row.get("date", "")), + value=float(value), + fetched_at=fetched_at, + ) + ) + return parsed diff --git a/src/mini_projects/features/dashboard/__init__.py b/src/mini_projects/features/dashboard/__init__.py new file mode 100644 index 0000000..7f9d2e7 --- /dev/null +++ b/src/mini_projects/features/dashboard/__init__.py @@ -0,0 +1 @@ +"""Dashboard building learning module.""" diff --git a/src/mini_projects/features/dashboard/application/__init__.py b/src/mini_projects/features/dashboard/application/__init__.py new file mode 100644 index 0000000..4993f06 --- /dev/null +++ b/src/mini_projects/features/dashboard/application/__init__.py @@ -0,0 +1 @@ +"""Application services for dashboard module.""" diff --git a/src/mini_projects/features/dashboard/application/dashboard.py b/src/mini_projects/features/dashboard/application/dashboard.py new file mode 100644 index 0000000..63fff1d --- /dev/null +++ b/src/mini_projects/features/dashboard/application/dashboard.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pandas as pd + +from mini_projects.features.data_stack.application.analysis import ( + compute_indicator_kpis, + indicator_by_category, +) + + +def build_indicator_dashboard_payload(frame: pd.DataFrame) -> dict[str, object]: + kpis = compute_indicator_kpis(frame) + category_table = indicator_by_category(frame) + trend_table = ( + frame.groupby(frame["date"].dt.date, as_index=False)["value"].mean().rename( + columns={"date": "day"} + ) + ) + return { + "kpis": kpis, + "category_rows": category_table.to_dict(orient="records"), + "trend_rows": trend_table.to_dict(orient="records"), + } + + +def build_dashboard_payload(frame: pd.DataFrame) -> dict[str, object]: + """Backward-compatible wrapper for existing callers.""" + return build_indicator_dashboard_payload(frame) diff --git a/src/mini_projects/features/data_stack/__init__.py b/src/mini_projects/features/data_stack/__init__.py new file mode 100644 index 0000000..a1be363 --- /dev/null +++ b/src/mini_projects/features/data_stack/__init__.py @@ -0,0 +1 @@ +"""Data analysis learning module.""" diff --git a/src/mini_projects/features/data_stack/application/__init__.py b/src/mini_projects/features/data_stack/application/__init__.py new file mode 100644 index 0000000..ea2ab30 --- /dev/null +++ b/src/mini_projects/features/data_stack/application/__init__.py @@ -0,0 +1 @@ +"""Application services for data analysis.""" diff --git a/src/mini_projects/features/data_stack/application/analysis.py b/src/mini_projects/features/data_stack/application/analysis.py new file mode 100644 index 0000000..ec8c910 --- /dev/null +++ b/src/mini_projects/features/data_stack/application/analysis.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +@dataclass(frozen=True, slots=True) +class NormalizedIndicatorRecord: + date: str + country: str + country_iso3: str + indicator: str + indicator_id: str + category: str + value: float + fetched_at: str + + +def load_sales_frame(records: list[dict[str, object]]) -> pd.DataFrame: + frame = pd.DataFrame(records) + required_columns = {"date", "category", "revenue", "orders"} + missing = required_columns - set(frame.columns) + if missing: + raise ValueError(f"missing columns: {sorted(missing)}") + frame["date"] = pd.to_datetime(frame["date"]) + frame["revenue"] = pd.to_numeric(frame["revenue"]) + frame["orders"] = pd.to_numeric(frame["orders"]) + return frame + + +def load_indicator_frame(records: list[dict[str, object]]) -> pd.DataFrame: + frame = pd.DataFrame(records) + required_columns = { + "date", + "country", + "country_iso3", + "indicator", + "indicator_id", + "category", + "value", + "fetched_at", + } + missing = required_columns - set(frame.columns) + if missing: + raise ValueError(f"missing columns: {sorted(missing)}") + frame["date"] = pd.to_datetime(frame["date"], errors="coerce") + frame["value"] = pd.to_numeric(frame["value"], errors="coerce") + frame["fetched_at"] = pd.to_datetime(frame["fetched_at"], errors="coerce") + if frame["date"].isna().any(): + raise ValueError("date contains invalid values") + if frame["value"].isna().any(): + raise ValueError("value contains invalid values") + frame = frame.sort_values(["date", "country_iso3", "indicator_id"]).reset_index(drop=True) + return frame + + +def compute_kpis(frame: pd.DataFrame) -> dict[str, float]: + total_revenue = float(frame["revenue"].sum()) + total_orders = int(frame["orders"].sum()) + average_order_value = total_revenue / total_orders if total_orders else 0.0 + return { + "total_revenue": total_revenue, + "total_orders": float(total_orders), + "average_order_value": round(average_order_value, 2), + } + + +def revenue_by_category(frame: pd.DataFrame) -> pd.DataFrame: + return ( + frame.groupby("category", as_index=False)["revenue"] + .sum() + .sort_values("revenue", ascending=False) + ) + + +def compute_indicator_kpis(frame: pd.DataFrame) -> dict[str, float | str]: + latest_date = frame["date"].max().date().isoformat() + latest_fetch = frame["fetched_at"].max().isoformat() + return { + "observation_count": float(len(frame)), + "country_count": float(frame["country_iso3"].nunique()), + "indicator_count": float(frame["indicator_id"].nunique()), + "latest_observation_date": latest_date, + "mean_value": round(float(frame["value"].mean()), 4), + "latest_fetch_at": latest_fetch, + } + + +def indicator_by_category(frame: pd.DataFrame) -> pd.DataFrame: + return ( + frame.groupby("category", as_index=False)["value"] + .mean() + .rename(columns={"value": "average_value"}) + .sort_values("average_value", ascending=False) + ) diff --git a/src/mini_projects/features/dsa/__init__.py b/src/mini_projects/features/dsa/__init__.py new file mode 100644 index 0000000..47ccf72 --- /dev/null +++ b/src/mini_projects/features/dsa/__init__.py @@ -0,0 +1 @@ +"""Data structures and algorithms learning module.""" diff --git a/src/mini_projects/features/dsa/application/__init__.py b/src/mini_projects/features/dsa/application/__init__.py new file mode 100644 index 0000000..72aabb3 --- /dev/null +++ b/src/mini_projects/features/dsa/application/__init__.py @@ -0,0 +1 @@ +"""Algorithms for DSA expansion module.""" diff --git a/src/mini_projects/features/dsa/application/algorithms.py b/src/mini_projects/features/dsa/application/algorithms.py new file mode 100644 index 0000000..ac34e59 --- /dev/null +++ b/src/mini_projects/features/dsa/application/algorithms.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import heapq + + +class TrieNode: + def __init__(self) -> None: + self.children: dict[str, TrieNode] = {} + self.is_terminal = False + + +class Trie: + def __init__(self) -> None: + self.root = TrieNode() + + def insert(self, word: str) -> None: + node = self.root + for char in word: + node = node.children.setdefault(char, TrieNode()) + node.is_terminal = True + + def search(self, word: str) -> bool: + node = self.root + for char in word: + if char not in node.children: + return False + node = node.children[char] + return node.is_terminal + + def starts_with(self, prefix: str) -> bool: + node = self.root + for char in prefix: + if char not in node.children: + return False + node = node.children[char] + return True + + +class UnionFind: + def __init__(self, size: int) -> None: + if size <= 0: + raise ValueError("size must be > 0") + self.parent = list(range(size)) + self.rank = [0] * size + + def find(self, node: int) -> int: + if self.parent[node] != node: + self.parent[node] = self.find(self.parent[node]) + return self.parent[node] + + def union(self, left: int, right: int) -> None: + root_left = self.find(left) + root_right = self.find(right) + if root_left == root_right: + return + if self.rank[root_left] < self.rank[root_right]: + self.parent[root_left] = root_right + elif self.rank[root_left] > self.rank[root_right]: + self.parent[root_right] = root_left + else: + self.parent[root_right] = root_left + self.rank[root_left] += 1 + + def connected(self, left: int, right: int) -> bool: + return self.find(left) == self.find(right) + + +def dijkstra( + graph: dict[str, list[tuple[str, int]]], + start: str, +) -> dict[str, int]: + distances = {node: float("inf") for node in graph} + distances[start] = 0 + queue: list[tuple[int, str]] = [(0, start)] + + while queue: + current_distance, node = heapq.heappop(queue) + if current_distance > distances[node]: + continue + for neighbor, weight in graph[node]: + candidate = current_distance + weight + if candidate < distances.get(neighbor, float("inf")): + distances[neighbor] = candidate + heapq.heappush(queue, (candidate, neighbor)) + return {key: int(value) for key, value in distances.items() if value != float("inf")} + + +def longest_increasing_subsequence(values: list[int]) -> int: + if not values: + return 0 + tails: list[int] = [] + for value in values: + index = _lower_bound(tails, value) + if index == len(tails): + tails.append(value) + else: + tails[index] = value + return len(tails) + + +def _lower_bound(items: list[int], target: int) -> int: + left, right = 0, len(items) + while left < right: + middle = (left + right) // 2 + if items[middle] < target: + left = middle + 1 + else: + right = middle + return left diff --git a/src/mini_projects/features/tictactoe/__init__.py b/src/mini_projects/features/tictactoe/__init__.py new file mode 100644 index 0000000..16168f9 --- /dev/null +++ b/src/mini_projects/features/tictactoe/__init__.py @@ -0,0 +1,2 @@ +"""TicTacToe feature package.""" + diff --git a/src/mini_projects/features/tictactoe/application/__init__.py b/src/mini_projects/features/tictactoe/application/__init__.py new file mode 100644 index 0000000..aaa327d --- /dev/null +++ b/src/mini_projects/features/tictactoe/application/__init__.py @@ -0,0 +1,2 @@ +"""Application services for TicTacToe.""" + diff --git a/src/mini_projects/features/tictactoe/application/ai.py b/src/mini_projects/features/tictactoe/application/ai.py new file mode 100644 index 0000000..e6dc8c5 --- /dev/null +++ b/src/mini_projects/features/tictactoe/application/ai.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass +from typing import Optional + +from mini_projects.features.tictactoe.domain.board import Board + + +@dataclass +class MinimaxResult: + position: Optional[int] + score: int + + +class TicTacToeAI: + def __init__(self, letter: str, difficulty: str = "hard", random_seed: Optional[int] = None): + if letter not in {"X", "O"}: + raise ValueError("AI letter must be X or O.") + if difficulty not in {"easy", "medium", "hard"}: + raise ValueError("Difficulty must be one of: easy, medium, hard.") + self.letter = letter + self.difficulty = difficulty + self._random = random.Random(random_seed) + + def choose_move(self, board: Board) -> int: + legal_moves = board.available_moves() + if not legal_moves: + raise ValueError("No legal moves available.") + + if self.difficulty == "easy": + return self._random.choice(legal_moves) + + if self.difficulty == "medium": + # Medium intentionally mixes predictable strength and mistakes. + if self._random.random() < 0.45: + return self._random.choice(legal_moves) + return self._best_move(board, max_depth=2) + + return self._best_move(board, max_depth=None) + + def _best_move(self, board: Board, max_depth: Optional[int]) -> int: + if len(board.available_moves()) == 9: + return self._random.choice(board.available_moves()) + + result = self._minimax( + state=board, + player=self.letter, + alpha=-math.inf, + beta=math.inf, + depth=0, + max_depth=max_depth, + ) + if result.position is None: + raise RuntimeError("AI failed to choose a move.") + return result.position + + def _minimax( + self, + state: Board, + player: str, + alpha: float, + beta: float, + depth: int, + max_depth: Optional[int], + ) -> MinimaxResult: + max_player = self.letter + other_player = "O" if player == "X" else "X" + + if state.current_winner == other_player: + score = state.num_empty_squares() + 1 + if other_player != max_player: + score *= -1 + return MinimaxResult(position=None, score=score) + + if not state.is_empty(): + return MinimaxResult(position=None, score=0) + + if max_depth is not None and depth >= max_depth: + return MinimaxResult(position=None, score=0) + + if player == max_player: + best = MinimaxResult(position=None, score=-math.inf) + for possible_move in state.available_moves(): + state.make_move(possible_move, player) + sim_score = self._minimax(state, other_player, alpha, beta, depth + 1, max_depth) + state.cells[possible_move] = " " + state.current_winner = None + sim_score.position = possible_move + + if sim_score.score > best.score: + best = sim_score + alpha = max(alpha, best.score) + if beta <= alpha: + break + return best + + best = MinimaxResult(position=None, score=math.inf) + for possible_move in state.available_moves(): + state.make_move(possible_move, player) + sim_score = self._minimax(state, other_player, alpha, beta, depth + 1, max_depth) + state.cells[possible_move] = " " + state.current_winner = None + sim_score.position = possible_move + + if sim_score.score < best.score: + best = sim_score + beta = min(beta, best.score) + if beta <= alpha: + break + return best + diff --git a/src/mini_projects/features/tictactoe/application/game.py b/src/mini_projects/features/tictactoe/application/game.py new file mode 100644 index 0000000..aed22ed --- /dev/null +++ b/src/mini_projects/features/tictactoe/application/game.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from mini_projects.features.tictactoe.domain.board import Board + + +class MoveProvider(Protocol): + letter: str + + def get_move(self, board: Board) -> int: + """Return a move index from 0 to 8.""" + + +@dataclass +class GameResult: + winner: str | None + board: Board + + +def play_game(x_player: MoveProvider, o_player: MoveProvider) -> GameResult: + board = Board() + letter = "X" + + while board.is_empty(): + current_player = o_player if letter == "O" else x_player + square = current_player.get_move(board) + if not board.make_move(square, letter): + raise ValueError(f"Move {square} is not valid.") + if board.current_winner: + return GameResult(winner=letter, board=board) + letter = "O" if letter == "X" else "X" + + return GameResult(winner=None, board=board) + diff --git a/src/mini_projects/features/tictactoe/domain/__init__.py b/src/mini_projects/features/tictactoe/domain/__init__.py new file mode 100644 index 0000000..7de75fc --- /dev/null +++ b/src/mini_projects/features/tictactoe/domain/__init__.py @@ -0,0 +1,2 @@ +"""Domain models for TicTacToe.""" + diff --git a/src/mini_projects/features/tictactoe/domain/board.py b/src/mini_projects/features/tictactoe/domain/board.py new file mode 100644 index 0000000..4ec1de2 --- /dev/null +++ b/src/mini_projects/features/tictactoe/domain/board.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class Board: + cells: List[str] = field(default_factory=lambda: [" "] * 9) + current_winner: Optional[str] = None + + def available_moves(self) -> List[int]: + return [idx for idx, value in enumerate(self.cells) if value == " "] + + def is_empty(self) -> bool: + return " " in self.cells + + def num_empty_squares(self) -> int: + return self.cells.count(" ") + + def make_move(self, square: int, letter: str) -> bool: + if square < 0 or square > 8: + raise ValueError("Square must be between 0 and 8.") + if letter not in {"X", "O"}: + raise ValueError("Letter must be X or O.") + if self.cells[square] != " ": + return False + + self.cells[square] = letter + if self.winner(square, letter): + self.current_winner = letter + return True + + def winner(self, square: int, letter: str) -> bool: + row_ind = square // 3 + row = self.cells[row_ind * 3 : (row_ind + 1) * 3] + if all(value == letter for value in row): + return True + + col_ind = square % 3 + column = [self.cells[col_ind + i * 3] for i in range(3)] + if all(value == letter for value in column): + return True + + if square % 2 == 0: + diagonal1 = [self.cells[i] for i in [0, 4, 8]] + if all(value == letter for value in diagonal1): + return True + diagonal2 = [self.cells[i] for i in [2, 4, 6]] + if all(value == letter for value in diagonal2): + return True + return False + + def reset(self) -> None: + self.cells = [" "] * 9 + self.current_winner = None + + def clone(self) -> Board: + return Board(cells=self.cells.copy(), current_winner=self.current_winner) + diff --git a/src/mini_projects/features/tictactoe/presentation/__init__.py b/src/mini_projects/features/tictactoe/presentation/__init__.py new file mode 100644 index 0000000..ad5caab --- /dev/null +++ b/src/mini_projects/features/tictactoe/presentation/__init__.py @@ -0,0 +1,2 @@ +"""Presentation layer for TicTacToe.""" + diff --git a/src/mini_projects/features/tictactoe/presentation/cli.py b/src/mini_projects/features/tictactoe/presentation/cli.py new file mode 100644 index 0000000..9ae988b --- /dev/null +++ b/src/mini_projects/features/tictactoe/presentation/cli.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from mini_projects.features.tictactoe.application.ai import TicTacToeAI +from mini_projects.features.tictactoe.domain.board import Board + + +def print_board(board: Board) -> None: + for row in [board.cells[i * 3 : (i + 1) * 3] for i in range(3)]: + print("| " + " | ".join(row) + " |") + + +def print_board_nums() -> None: + number_board = [[str(i) for i in range(j * 3, (j + 1) * 3)] for j in range(3)] + for row in number_board: + print("| " + " | ".join(row) + " |") + + +@dataclass +class HumanPlayer: + letter: str + + def get_move(self, board: Board) -> int: + while True: + raw_value = input(f"{self.letter}'s turn. Input move (0-8): ").strip() + try: + move = int(raw_value) + except ValueError: + print("Invalid square. Try again.") + continue + if move not in board.available_moves(): + print("Invalid square. Try again.") + continue + return move + + +@dataclass +class ComputerPlayer: + letter: str + difficulty: str + random_seed: Optional[int] = None + + def __post_init__(self) -> None: + self.ai = TicTacToeAI(self.letter, difficulty=self.difficulty, random_seed=self.random_seed) + + def get_move(self, board: Board) -> int: + return self.ai.choose_move(board) + + +def run_tictactoe_cli(difficulty: str = "hard", random_seed: Optional[int] = None) -> None: + board = Board() + x_player = ComputerPlayer(letter="X", difficulty=difficulty, random_seed=random_seed) + o_player = HumanPlayer(letter="O") + + print("Welcome to TicTacToe") + print_board_nums() + + letter = "X" + while board.is_empty(): + current_player = o_player if letter == "O" else x_player + square = current_player.get_move(board) + if not board.make_move(square, letter): + print("That square is already occupied. Try again.") + continue + + print(f"{letter} makes a move to square {square}") + print_board(board) + print("") + + if board.current_winner: + print(f"{letter} wins!") + return + + letter = "O" if letter == "X" else "X" + + print("It's a tie!") + diff --git a/sudoku.ipynb b/sudoku.ipynb index 08eaef0..b717299 100644 --- a/sudoku.ipynb +++ b/sudoku.ipynb @@ -1,158 +1,287 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 16, - "id": "eecd1f5f", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n", - "[[3, 9, 1, 8, 5, 6, 4, 2, 7],\n", - " [8, 6, 7, 2, 3, 4, 9, 1, 5],\n", - " [4, 2, 5, 7, 1, 9, 6, 8, 3],\n", - " [7, 5, 4, 9, 6, 8, 1, 3, 2],\n", - " [2, 1, 6, 4, 7, 3, 5, 9, 8],\n", - " [9, 3, 8, 5, 2, 1, 7, 6, 4],\n", - " [5, 4, 3, 6, 9, 2, 8, 7, 1],\n", - " [6, 7, 2, 1, 8, 5, 3, 4, 9],\n", - " [1, 8, 9, 3, 4, 7, 2, 5, 6]]\n" - ] + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sudoku Solver + Generator\n", + "\n", + "Goal: combine constraint propagation and backtracking.\n", + "\n", + "Structure:\n", + "1. Imports/config\n", + "2. Pure helper functions\n", + "3. Solve/generate runner\n", + "4. Quick verification checks" + ], + "id": "7a8e5478" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\"\"\"Sudoku notebook: validation, constraint propagation, backtracking, and generation.\"\"\"\n", + "\n", + "from copy import deepcopy\n", + "import random\n", + "\n", + "SIZE = 9\n", + "DIGITS = set(range(1, 10))\n", + "\n", + "\n", + "def validate_puzzle(board: list[list[int]]) -> None:\n", + " if len(board) != SIZE or any(len(row) != SIZE for row in board):\n", + " raise ValueError(\"Puzzle must be 9x9.\")\n", + " for r in range(SIZE):\n", + " for c in range(SIZE):\n", + " v = board[r][c]\n", + " if v not in range(0, 10):\n", + " raise ValueError(\"Values must be 0..9.\")\n", + "\n", + "\n", + "def find_empty(board: list[list[int]]) -> tuple[int, int] | tuple[None, None]:\n", + " for r in range(SIZE):\n", + " for c in range(SIZE):\n", + " if board[r][c] == 0:\n", + " return r, c\n", + " return None, None\n", + "\n", + "\n", + "def candidates(board: list[list[int]], row: int, col: int) -> set[int]:\n", + " used = set(board[row])\n", + " used |= {board[r][col] for r in range(SIZE)}\n", + " br, bc = 3 * (row // 3), 3 * (col // 3)\n", + " used |= {board[r][c] for r in range(br, br + 3) for c in range(bc, bc + 3)}\n", + " return DIGITS - used\n", + "\n", + "\n", + "def propagate(board: list[list[int]], steps: list[str]) -> bool:\n", + " changed = True\n", + " while changed:\n", + " changed = False\n", + " for r in range(SIZE):\n", + " for c in range(SIZE):\n", + " if board[r][c] != 0:\n", + " continue\n", + " opts = candidates(board, r, c)\n", + " if not opts:\n", + " return False\n", + " if len(opts) == 1:\n", + " value = next(iter(opts))\n", + " board[r][c] = value\n", + " steps.append(f\"single({r},{c})={value}\")\n", + " changed = True\n", + " return True\n", + "\n", + "\n", + "def solve(board: list[list[int]], steps: list[str] | None = None) -> bool:\n", + " if steps is None:\n", + " steps = []\n", + " if not propagate(board, steps):\n", + " return False\n", + " row, col = find_empty(board)\n", + " if row is None:\n", + " return True\n", + "\n", + " ranked = sorted(candidates(board, row, col))\n", + " for guess in ranked:\n", + " board[row][col] = guess\n", + " steps.append(f\"guess({row},{col})={guess}\")\n", + " if solve(board, steps):\n", + " return True\n", + " board[row][col] = 0\n", + " steps.append(f\"backtrack({row},{col})\")\n", + " return False\n", + "\n", + "\n", + "def solve_copy(board: list[list[int]]) -> tuple[list[list[int]], list[str]]:\n", + " validate_puzzle(board)\n", + " clone = deepcopy(board)\n", + " steps: list[str] = []\n", + " if not solve(clone, steps):\n", + " raise ValueError(\"Puzzle is unsolvable.\")\n", + " return clone, steps\n", + "\n", + "\n", + "def generate_puzzle(removals: int = 45, rng_seed: int = 0) -> list[list[int]]:\n", + " rng = random.Random(rng_seed)\n", + " base = [\n", + " [5, 3, 4, 6, 7, 8, 9, 1, 2],\n", + " [6, 7, 2, 1, 9, 5, 3, 4, 8],\n", + " [1, 9, 8, 3, 4, 2, 5, 6, 7],\n", + " [8, 5, 9, 7, 6, 1, 4, 2, 3],\n", + " [4, 2, 6, 8, 5, 3, 7, 9, 1],\n", + " [7, 1, 3, 9, 2, 4, 8, 5, 6],\n", + " [9, 6, 1, 5, 3, 7, 2, 8, 4],\n", + " [2, 8, 7, 4, 1, 9, 6, 3, 5],\n", + " [3, 4, 5, 2, 8, 6, 1, 7, 9],\n", + " ]\n", + " puzzle = deepcopy(base)\n", + " cells = [(r, c) for r in range(SIZE) for c in range(SIZE)]\n", + " rng.shuffle(cells)\n", + " for r, c in cells[:removals]:\n", + " puzzle[r][c] = 0\n", + " return puzzle\n", + "\n", + "\n", + "sample = generate_puzzle(removals=50, rng_seed=5)\n", + "solved, steps = solve_copy(sample)\n", + "\n", + "# Quick verification checks\n", + "assert len(steps) > 0\n", + "assert all(all(v in range(1, 10) for v in row) for row in solved)\n", + "assert candidates([[0] * 9 for _ in range(9)], 0, 0) == DIGITS\n", + "\n", + "print(\"Generated puzzle first row:\", sample[0])\n", + "print(\"Solved first row:\", solved[0])\n", + "print(\"First 10 solving steps:\", steps[:10])\n", + "\n", + "\n", + "def find_next_empty(puzzle):\n", + " # finds the next row, col on the puzzle not filled -> -1\n", + " # return row, col tuple (or (None, None) if there is none)\n", + "\n", + " # keep in mind that we are using 0-8 for our indices\n", + " for r in range(9):\n", + " for c in range(9): # range(9) is 0, 1, 2, ... 8\n", + " if puzzle[r][c] == -1:\n", + " return r, c\n", + "\n", + " return None, None # if no spaces in the puzzle are empty (-1)\n", + "\n", + "def is_valid(puzzle, guess, row, col):\n", + " # finding out if row, col is valid\n", + " # returns True or False\n", + "\n", + " # for a guess to be valid, sudoku rules\n", + " # cannot repeat the number\n", + "\n", + " #row\n", + " row_vals = puzzle[row]\n", + " if guess in row_vals:\n", + " return False # if we've repeated, then our guess is not valid!\n", + "\n", + " #col\n", + " # col_vals = []\n", + " # for i in range(9):\n", + " # col_vals.append(puzzle[i][col])\n", + " col_vals = [puzzle[i][col] for i in range(9)]\n", + " if guess in col_vals:\n", + " return False\n", + "\n", + " # and then the square\n", + " row_start = (row // 3) * 3 # 10 // 3 = 3, 5 // 3 = 1, 1 // 3 = 0\n", + " col_start = (col // 3) * 3\n", + "\n", + " for r in range(row_start, row_start + 3):\n", + " for c in range(col_start, col_start + 3):\n", + " if puzzle[r][c] == guess:\n", + " return False\n", + "\n", + " return True\n", + "\n", + "def solve_sudoku(puzzle):\n", + " # solve sudoku using backtracking!\n", + " # our puzzle is a list of lists, where each inner list is a row in our sudoku puzzle\n", + " # return whether a solution exists\n", + " # mutates puzzle to be the solution (if solution exists)\n", + " \n", + " # step 1: choose somewhere on the puzzle to make a guess\n", + " row, col = find_next_empty(puzzle)\n", + "\n", + " # step 1.1: if there's nowhere left, then we're done because we only allowed valid inputs\n", + " if row is None: # this is true if our find_next_empty function returns None, None\n", + " return True \n", + " \n", + " # step 2: if there is a place to put a number, then make a guess between 1 and 9\n", + " for guess in range(1, 10): # range(1, 10) is 1, 2, 3, ... 9\n", + " # step 3: check if this is a valid guess\n", + " if is_valid(puzzle, guess, row, col):\n", + " # step 3.1: guess valid , place it into row,col\n", + " puzzle[row][col] = guess\n", + " # step 4: call res\n", + " if solve_sudoku(puzzle):\n", + " return True\n", + " \n", + " # step 5: it not valid or if nothing gets returned true, then we need to backtrack and try a new number\n", + " puzzle[row][col] = -1\n", + "\n", + " # step 6: if none work, unsolveable\n", + " return False\n", + "\n", + "if __name__ == '__main__':\n", + " example_board = [\n", + " [3, 9, -1, -1, 5, -1, -1, -1, -1],\n", + " [-1, -1, -1, 2, -1, -1, -1, -1, 5],\n", + " [-1, -1, -1, 7, 1, 9, -1, 8, -1],\n", + "\n", + " [-1, 5, -1, -1, 6, 8, -1, -1, -1],\n", + " [2, -1, 6, -1, -1, 3, -1, -1, -1],\n", + " [-1, -1, -1, -1, -1, -1, -1, -1, 4],\n", + "\n", + " [5, -1, -1, -1, -1, -1, -1, -1, -1],\n", + " [6, 7, -1, 1, -1, 5, -1, 4, -1],\n", + " [1, -1, 9, -1, -1, -1, 2, -1, -1]\n", + " ]\n", + " print(solve_sudoku(example_board))\n", + " pprint(example_board)" + ], + "execution_count": 16, + "outputs": [ + { + "output_type": "stream", + "text": [ + "True\n", + "[[3, 9, 1, 8, 5, 6, 4, 2, 7],\n", + " [8, 6, 7, 2, 3, 4, 9, 1, 5],\n", + " [4, 2, 5, 7, 1, 9, 6, 8, 3],\n", + " [7, 5, 4, 9, 6, 8, 1, 3, 2],\n", + " [2, 1, 6, 4, 7, 3, 5, 9, 8],\n", + " [9, 3, 8, 5, 2, 1, 7, 6, 4],\n", + " [5, 4, 3, 6, 9, 2, 8, 7, 1],\n", + " [6, 7, 2, 1, 8, 5, 3, 4, 9],\n", + " [1, 8, 9, 3, 4, 7, 2, 5, 6]]\n" + ] + } + ], + "id": "eecd1f5f" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "4d30956f" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [], + "execution_count": null, + "outputs": [], + "id": "08013dff" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } - ], - "source": [ - "from pprint import pprint\n", - "\n", - "\n", - "def find_next_empty(puzzle):\n", - " # finds the next row, col on the puzzle not filled -> -1\n", - " # return row, col tuple (or (None, None) if there is none)\n", - "\n", - " # keep in mind that we are using 0-8 for our indices\n", - " for r in range(9):\n", - " for c in range(9): # range(9) is 0, 1, 2, ... 8\n", - " if puzzle[r][c] == -1:\n", - " return r, c\n", - "\n", - " return None, None # if no spaces in the puzzle are empty (-1)\n", - "\n", - "def is_valid(puzzle, guess, row, col):\n", - " # finding out if row, col is valid\n", - " # returns True or False\n", - "\n", - " # for a guess to be valid, sudoku rules\n", - " # cannot repeat the number\n", - "\n", - " #row\n", - " row_vals = puzzle[row]\n", - " if guess in row_vals:\n", - " return False # if we've repeated, then our guess is not valid!\n", - "\n", - " #col\n", - " # col_vals = []\n", - " # for i in range(9):\n", - " # col_vals.append(puzzle[i][col])\n", - " col_vals = [puzzle[i][col] for i in range(9)]\n", - " if guess in col_vals:\n", - " return False\n", - "\n", - " # and then the square\n", - " row_start = (row // 3) * 3 # 10 // 3 = 3, 5 // 3 = 1, 1 // 3 = 0\n", - " col_start = (col // 3) * 3\n", - "\n", - " for r in range(row_start, row_start + 3):\n", - " for c in range(col_start, col_start + 3):\n", - " if puzzle[r][c] == guess:\n", - " return False\n", - "\n", - " return True\n", - "\n", - "def solve_sudoku(puzzle):\n", - " # solve sudoku using backtracking!\n", - " # our puzzle is a list of lists, where each inner list is a row in our sudoku puzzle\n", - " # return whether a solution exists\n", - " # mutates puzzle to be the solution (if solution exists)\n", - " \n", - " # step 1: choose somewhere on the puzzle to make a guess\n", - " row, col = find_next_empty(puzzle)\n", - "\n", - " # step 1.1: if there's nowhere left, then we're done because we only allowed valid inputs\n", - " if row is None: # this is true if our find_next_empty function returns None, None\n", - " return True \n", - " \n", - " # step 2: if there is a place to put a number, then make a guess between 1 and 9\n", - " for guess in range(1, 10): # range(1, 10) is 1, 2, 3, ... 9\n", - " # step 3: check if this is a valid guess\n", - " if is_valid(puzzle, guess, row, col):\n", - " # step 3.1: guess valid , place it into row,col\n", - " puzzle[row][col] = guess\n", - " # step 4: call res\n", - " if solve_sudoku(puzzle):\n", - " return True\n", - " \n", - " # step 5: it not valid or if nothing gets returned true, then we need to backtrack and try a new number\n", - " puzzle[row][col] = -1\n", - "\n", - " # step 6: if none work, unsolveable\n", - " return False\n", - "\n", - "if __name__ == '__main__':\n", - " example_board = [\n", - " [3, 9, -1, -1, 5, -1, -1, -1, -1],\n", - " [-1, -1, -1, 2, -1, -1, -1, -1, 5],\n", - " [-1, -1, -1, 7, 1, 9, -1, 8, -1],\n", - "\n", - " [-1, 5, -1, -1, 6, 8, -1, -1, -1],\n", - " [2, -1, 6, -1, -1, 3, -1, -1, -1],\n", - " [-1, -1, -1, -1, -1, -1, -1, -1, 4],\n", - "\n", - " [5, -1, -1, -1, -1, -1, -1, -1, -1],\n", - " [6, 7, -1, 1, -1, 5, -1, 4, -1],\n", - " [1, -1, 9, -1, -1, -1, 2, -1, -1]\n", - " ]\n", - " print(solve_sudoku(example_board))\n", - " pprint(example_board)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4d30956f", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "08013dff", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..efec060 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = PROJECT_ROOT / "src" + +if str(SRC_PATH) not in sys.path: + sys.path.insert(0, str(SRC_PATH)) + diff --git a/tests/features/advanced_python/test_patterns.py b/tests/features/advanced_python/test_patterns.py new file mode 100644 index 0000000..35391ba --- /dev/null +++ b/tests/features/advanced_python/test_patterns.py @@ -0,0 +1,30 @@ +from mini_projects.features.advanced_python.application.patterns import ( + LearningTask, + describe_size, + retry, + sliding_window, +) + + +def test_retry_succeeds_after_failure() -> None: + attempts = {"count": 0} + + @retry(attempts=2) + def sometimes_fails() -> str: + attempts["count"] += 1 + if attempts["count"] == 1: + raise ValueError("temporary") + return "ok" + + assert sometimes_fails() == "ok" + + +def test_sliding_window() -> None: + windows = list(sliding_window([1, 2, 3, 4], 2)) + assert windows == [[1, 2], [2, 3], [3, 4]] + + +def test_dataclass_and_protocol_helper() -> None: + task = LearningTask(name="decorators", minutes_estimate=30, topic="advanced-python") + assert task.name == "decorators" + assert describe_size([1, 2, 3]) == "size=3" diff --git a/tests/features/async_concurrency/test_pipeline.py b/tests/features/async_concurrency/test_pipeline.py new file mode 100644 index 0000000..9a58210 --- /dev/null +++ b/tests/features/async_concurrency/test_pipeline.py @@ -0,0 +1,37 @@ +import asyncio + +import pytest + +from mini_projects.features.async_concurrency.application.pipeline import ( + choose_concurrency_model, + fetch_with_retry, + run_bounded_tasks, +) + + +@pytest.mark.asyncio +async def test_fetch_with_retry_eventually_succeeds() -> None: + state = {"attempts": 0} + + async def fetcher(_: str) -> str: + state["attempts"] += 1 + if state["attempts"] == 1: + raise RuntimeError("temporary failure") + return "payload" + + result = await fetch_with_retry(fetcher, "resource", retries=2) + assert result == "payload" + + +@pytest.mark.asyncio +async def test_run_bounded_tasks() -> None: + async def fetcher(resource: str) -> str: + await asyncio.sleep(0) + return resource.upper() + + results = await run_bounded_tasks(["a", "b", "c"], fetcher, concurrency_limit=2) + assert sorted(results) == ["A", "B", "C"] + + +def test_choose_concurrency_model() -> None: + assert choose_concurrency_model("io_bound") == "asyncio" diff --git a/tests/features/backend_api/test_app.py b/tests/features/backend_api/test_app.py new file mode 100644 index 0000000..038dc8c --- /dev/null +++ b/tests/features/backend_api/test_app.py @@ -0,0 +1,55 @@ +import pytest +from fastapi.testclient import TestClient + +from mini_projects.features.backend_api.presentation import app as app_module +from mini_projects.features.backend_api.presentation.app import create_app + + +def test_health_endpoint() -> None: + client = TestClient(create_app()) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_create_and_list_notes() -> None: + client = TestClient(create_app()) + create_response = client.post("/notes", json={"title": "Module 1", "body": "Decorators"}) + assert create_response.status_code == 201 + + list_response = client.get("/notes") + assert list_response.status_code == 200 + payload = list_response.json() + assert len(payload) == 1 + assert payload[0]["title"] == "Module 1" + + +def test_world_bank_refresh_and_status_endpoints(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_refresh(**kwargs): + _ = kwargs + return { + "kpis": {"observation_count": 3.0}, + "dashboard": {"kpis": {"observation_count": 3.0}}, + "processing_strategy": "pandas_local", + "record_count": 3, + "freshness": {"served_from_cache": False, "is_stale": False}, + } + + def fake_status(): + return {"has_cache": True, "is_stale": False} + + monkeypatch.setattr(app_module, "get_or_refresh_world_bank_dashboard", fake_refresh) + monkeypatch.setattr(app_module, "get_world_bank_freshness", fake_status) + + client = TestClient(create_app()) + refresh_response = client.post("/world-bank/refresh", json={}) + assert refresh_response.status_code == 200 + assert refresh_response.json()["record_count"] == 3 + + latest_response = client.get("/world-bank/latest") + assert latest_response.status_code == 200 + assert latest_response.json()["processing_strategy"] == "pandas_local" + + status_response = client.get("/world-bank/status") + assert status_response.status_code == 200 + assert status_response.json()["has_cache"] is True diff --git a/tests/features/big_data/test_chunked_processing.py b/tests/features/big_data/test_chunked_processing.py new file mode 100644 index 0000000..3fc5f63 --- /dev/null +++ b/tests/features/big_data/test_chunked_processing.py @@ -0,0 +1,14 @@ +from mini_projects.features.big_data.application.chunked_processing import ( + chunked_sum, + estimate_scale_strategy, +) + + +def test_chunked_sum() -> None: + assert chunked_sum([1, 2, 3, 4, 5], chunk_size=2) == 15 + + +def test_estimate_scale_strategy() -> None: + assert estimate_scale_strategy(100_000) == "pandas_local" + assert estimate_scale_strategy(5_000_000) == "dask_out_of_core" + assert estimate_scale_strategy(100_000_000) == "spark_distributed" diff --git a/tests/features/capstone/test_capstone_pipeline.py b/tests/features/capstone/test_capstone_pipeline.py new file mode 100644 index 0000000..9a8bfd9 --- /dev/null +++ b/tests/features/capstone/test_capstone_pipeline.py @@ -0,0 +1,30 @@ +from mini_projects.features.capstone.application.pipeline import run_capstone + + +def test_capstone_returns_combined_outputs() -> None: + records = [ + { + "date": "2026-01-01", + "country": "United States", + "country_iso3": "USA", + "indicator": "GDP growth (annual %)", + "indicator_id": "NY.GDP.MKTP.KD.ZG", + "category": "growth", + "value": 2.1, + "fetched_at": "2026-01-10T12:00:00+00:00", + }, + { + "date": "2026-01-01", + "country": "Canada", + "country_iso3": "CAN", + "indicator": "Inflation, consumer prices (annual %)", + "indicator_id": "FP.CPI.TOTL.ZG", + "category": "prices", + "value": 2.9, + "fetched_at": "2026-01-10T12:00:00+00:00", + }, + ] + result = run_capstone(records) + assert result["kpis"]["observation_count"] == 2.0 + assert result["processing_strategy"] == "pandas_local" + assert "dashboard" in result diff --git a/tests/features/capstone/test_world_bank_client.py b/tests/features/capstone/test_world_bank_client.py new file mode 100644 index 0000000..fccad64 --- /dev/null +++ b/tests/features/capstone/test_world_bank_client.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from mini_projects.features.capstone.infrastructure.world_bank_client import ( + WorldBankClient, + WorldBankClientError, +) + + +class _StubResponse: + def __init__(self, payload: list[object], status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError("http error") + + def json(self) -> list[object]: + return self._payload + + +def test_fetch_indicator_observations_handles_pagination(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[int] = [] + + def fake_get(url: str, params: dict[str, object], timeout: float) -> _StubResponse: + _ = url, timeout + page = int(params["page"]) + calls.append(page) + if page == 1: + return _StubResponse( + [ + {"page": 1, "pages": 2}, + [ + { + "country": {"value": "United States", "id": "USA"}, + "indicator": { + "value": "GDP growth (annual %)", + "id": "NY.GDP.MKTP.KD.ZG", + }, + "date": "2024", + "value": 2.5, + } + ], + ] + ) + return _StubResponse( + [ + {"page": 2, "pages": 2}, + [ + { + "country": {"value": "Canada", "id": "CAN"}, + "indicator": { + "value": "GDP growth (annual %)", + "id": "NY.GDP.MKTP.KD.ZG", + }, + "date": "2024", + "value": 1.9, + } + ], + ] + ) + + monkeypatch.setattr("httpx.get", fake_get) + client = WorldBankClient() + observations = client.fetch_indicator_observations( + indicator_id="NY.GDP.MKTP.KD.ZG", + countries=["US", "CA"], + ) + + assert calls == [1, 2] + assert len(observations) == 2 + assert observations[0].country == "United States" + + +def test_fetch_indicator_observations_retries_and_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(url: str, params: dict[str, object], timeout: float) -> _StubResponse: + _ = url, params, timeout + raise RuntimeError("network fail") + + monkeypatch.setattr("httpx.get", fake_get) + client = WorldBankClient(retries=2) + with pytest.raises(WorldBankClientError): + client.fetch_indicator_observations( + indicator_id="NY.GDP.MKTP.KD.ZG", + countries=["US"], + ) diff --git a/tests/features/capstone/test_world_bank_refresh.py b/tests/features/capstone/test_world_bank_refresh.py new file mode 100644 index 0000000..4d9cbbd --- /dev/null +++ b/tests/features/capstone/test_world_bank_refresh.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from mini_projects.features.capstone.application.pipeline import ( + get_or_refresh_world_bank_dashboard, + get_world_bank_freshness, +) +from mini_projects.features.capstone.infrastructure.cache_store import InMemoryCacheStore +from mini_projects.features.capstone.infrastructure.world_bank_client import ( + WorldBankObservation, +) + + +class StubWorldBankClient: + def __init__(self) -> None: + self.calls = 0 + + def fetch_indicator_observations( + self, + indicator_id: str, + countries: list[str], + per_page: int = 1000, + ) -> list[WorldBankObservation]: + _ = countries, per_page + self.calls += 1 + return [ + WorldBankObservation( + country="United States", + country_iso3="USA", + indicator="GDP growth (annual %)", + indicator_id=indicator_id, + date="2024", + value=2.3, + fetched_at=datetime.now(tz=UTC), + ) + ] + + +def test_refresh_uses_cache_when_fresh() -> None: + store = InMemoryCacheStore() + client = StubWorldBankClient() + first = get_or_refresh_world_bank_dashboard( + force_refresh=True, + ttl_seconds=3600, + countries=["US"], + indicators=["NY.GDP.MKTP.KD.ZG"], + cache_store=store, + client=client, + ) + second = get_or_refresh_world_bank_dashboard( + force_refresh=False, + ttl_seconds=3600, + countries=["US"], + indicators=["NY.GDP.MKTP.KD.ZG"], + cache_store=store, + client=client, + ) + + assert client.calls == 1 + assert first["freshness"]["served_from_cache"] is False + assert second["freshness"]["served_from_cache"] is True + + +def test_freshness_status_without_cache() -> None: + status = get_world_bank_freshness(cache_store=InMemoryCacheStore()) + assert status["has_cache"] is False + assert status["is_stale"] is True diff --git a/tests/features/data_stack/test_analysis.py b/tests/features/data_stack/test_analysis.py new file mode 100644 index 0000000..30c0e52 --- /dev/null +++ b/tests/features/data_stack/test_analysis.py @@ -0,0 +1,55 @@ +from mini_projects.features.data_stack.application.analysis import ( + compute_kpis, + compute_indicator_kpis, + indicator_by_category, + load_indicator_frame, + load_sales_frame, + revenue_by_category, +) + + +def test_data_analysis_flow() -> None: + records = [ + {"date": "2026-01-01", "category": "books", "revenue": 100, "orders": 2}, + {"date": "2026-01-02", "category": "games", "revenue": 200, "orders": 4}, + {"date": "2026-01-03", "category": "books", "revenue": 50, "orders": 1}, + ] + frame = load_sales_frame(records) + kpis = compute_kpis(frame) + grouped = revenue_by_category(frame) + + assert kpis["total_revenue"] == 350.0 + assert kpis["total_orders"] == 7.0 + assert grouped.iloc[0]["category"] == "games" + + +def test_indicator_analysis_flow() -> None: + records = [ + { + "date": "2024-01-01", + "country": "United States", + "country_iso3": "USA", + "indicator": "GDP growth (annual %)", + "indicator_id": "NY.GDP.MKTP.KD.ZG", + "category": "growth", + "value": 2.1, + "fetched_at": "2024-01-10T00:00:00+00:00", + }, + { + "date": "2024-01-01", + "country": "Canada", + "country_iso3": "CAN", + "indicator": "Inflation, consumer prices (annual %)", + "indicator_id": "FP.CPI.TOTL.ZG", + "category": "prices", + "value": 2.9, + "fetched_at": "2024-01-10T00:00:00+00:00", + }, + ] + frame = load_indicator_frame(records) + kpis = compute_indicator_kpis(frame) + grouped = indicator_by_category(frame) + + assert kpis["observation_count"] == 2.0 + assert kpis["country_count"] == 2.0 + assert grouped.iloc[0]["category"] in {"growth", "prices"} diff --git a/tests/features/dsa/test_algorithms.py b/tests/features/dsa/test_algorithms.py new file mode 100644 index 0000000..b1c9c40 --- /dev/null +++ b/tests/features/dsa/test_algorithms.py @@ -0,0 +1,34 @@ +from mini_projects.features.dsa.application.algorithms import ( + Trie, + UnionFind, + dijkstra, + longest_increasing_subsequence, +) + + +def test_trie_insert_search_and_prefix() -> None: + trie = Trie() + trie.insert("python") + assert trie.search("python") + assert not trie.search("pyth") + assert trie.starts_with("pyth") + + +def test_union_find_connectivity() -> None: + union_find = UnionFind(5) + union_find.union(0, 1) + union_find.union(1, 2) + assert union_find.connected(0, 2) + assert not union_find.connected(0, 4) + + +def test_dijkstra_and_lis() -> None: + graph = { + "A": [("B", 1), ("C", 4)], + "B": [("C", 2)], + "C": [("D", 1)], + "D": [], + } + distances = dijkstra(graph, "A") + assert distances["D"] == 4 + assert longest_increasing_subsequence([10, 9, 2, 5, 3, 7, 101, 18]) == 4 diff --git a/tests/features/tictactoe/test_ai.py b/tests/features/tictactoe/test_ai.py new file mode 100644 index 0000000..75b0426 --- /dev/null +++ b/tests/features/tictactoe/test_ai.py @@ -0,0 +1,28 @@ +from mini_projects.features.tictactoe.application.ai import TicTacToeAI +from mini_projects.features.tictactoe.domain.board import Board + + +def test_hard_ai_takes_winning_move() -> None: + board = Board(cells=["X", "X", " ", "O", "O", " ", " ", " ", " "]) + ai = TicTacToeAI(letter="X", difficulty="hard", random_seed=1) + assert ai.choose_move(board) == 2 + + +def test_hard_ai_blocks_immediate_loss() -> None: + board = Board(cells=["O", "O", " ", "X", " ", " ", " ", " ", "X"]) + ai = TicTacToeAI(letter="X", difficulty="hard", random_seed=1) + assert ai.choose_move(board) == 2 + + +def test_easy_ai_is_deterministic_with_seed() -> None: + board = Board(cells=["X", " ", " ", " ", " ", " ", " ", " ", "O"]) + ai_one = TicTacToeAI(letter="X", difficulty="easy", random_seed=7) + ai_two = TicTacToeAI(letter="X", difficulty="easy", random_seed=7) + assert ai_one.choose_move(board) == ai_two.choose_move(board.clone()) + + +def test_medium_ai_prefers_search_path_when_rng_allows() -> None: + board = Board(cells=["X", "X", " ", "O", " ", " ", " ", " ", "O"]) + ai = TicTacToeAI(letter="X", difficulty="medium", random_seed=0) + assert ai.choose_move(board) == 2 + diff --git a/tests/features/tictactoe/test_board.py b/tests/features/tictactoe/test_board.py new file mode 100644 index 0000000..75e6644 --- /dev/null +++ b/tests/features/tictactoe/test_board.py @@ -0,0 +1,34 @@ +import pytest + +from mini_projects.features.tictactoe.domain.board import Board + + +def test_make_move_rejects_taken_square() -> None: + board = Board() + assert board.make_move(0, "X") is True + assert board.make_move(0, "O") is False + + +def test_winner_detects_row() -> None: + board = Board(cells=["X", "X", " ", " ", " ", " ", " ", " ", " "]) + assert board.make_move(2, "X") is True + assert board.current_winner == "X" + + +def test_winner_detects_diagonal() -> None: + board = Board(cells=["X", " ", " ", " ", "X", " ", " ", " ", " "]) + assert board.make_move(8, "X") is True + assert board.current_winner == "X" + + +def test_make_move_validates_square_range() -> None: + board = Board() + with pytest.raises(ValueError, match="between 0 and 8"): + board.make_move(9, "X") + + +def test_make_move_validates_letter() -> None: + board = Board() + with pytest.raises(ValueError, match="Letter must be X or O"): + board.make_move(0, "Z") + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..79aaebd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,47 @@ +from mini_projects.cli import build_parser, main + + +def test_parser_accepts_tictactoe_command() -> None: + parser = build_parser() + args = parser.parse_args(["tictactoe", "--difficulty", "medium", "--seed", "10"]) + assert args.command == "tictactoe" + assert args.difficulty == "medium" + assert args.seed == 10 + + +def test_main_without_args_prints_help(capsys) -> None: + exit_code = main([]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "Beginner Python mini-projects collection." in captured.out + + +def test_curriculum_demo_command(capsys) -> None: + exit_code = main(["curriculum-demo"]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "Capstone KPIs:" in captured.out + + +def test_world_bank_sync_command(monkeypatch, capsys) -> None: + def fake_sync(**kwargs): + _ = kwargs + return {"kpis": {"observation_count": 1.0}, "freshness": {"is_stale": False}} + + monkeypatch.setattr("mini_projects.cli.get_or_refresh_world_bank_dashboard", fake_sync) + exit_code = main(["world-bank-sync", "--countries", "US", "--indicators", "SP.POP.TOTL"]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "World Bank KPIs:" in captured.out + + +def test_world_bank_status_command(monkeypatch, capsys) -> None: + monkeypatch.setattr( + "mini_projects.cli.get_world_bank_freshness", + lambda: {"has_cache": True, "is_stale": False}, + ) + exit_code = main(["world-bank-status"]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "World Bank status:" in captured.out + diff --git a/transform_empty.py b/transform_empty.py index ff736f4..5d4e12b 100644 --- a/transform_empty.py +++ b/transform_empty.py @@ -4,17 +4,39 @@ def brighten(image, factor): # when we brighten, we just want to make each channel higher by some amount # factor is a value > 0, how much you want to brighten the image by (< 1 = darken, > 1 = brighten) - pass + x_pixels, y_pixels, num_channels = image.array.shape + new_im = Image(x_pixels=x_pixels, y_pixels=y_pixels, num_channels=num_channels) + new_im.array = image.array * factor + return new_im def adjust_contrast(image, factor, mid): # adjust the contrast by increasing the difference from the user-defined midpoint by factor amount - pass + x_pixels, y_pixels, num_channels = image.array.shape + new_im = Image(x_pixels=x_pixels, y_pixels=y_pixels, num_channels=num_channels) + for x in range(x_pixels): + for y in range(y_pixels): + for c in range(num_channels): + new_im.array[x, y, c] = (image.array[x, y, c] - mid) * factor + mid + return new_im def blur(image, kernel_size): # kernel size is the number of pixels to take into account when applying the blur # (ie kernel_size = 3 would be neighbors to the left/right, top/bottom, and diagonals) # kernel size should always be an *odd* number - pass + x_pixels, y_pixels, num_channels = image.array.shape + new_im = Image(x_pixels=x_pixels, y_pixels=y_pixels, num_channels=num_channels) + neighbor_range = kernel_size // 2 + for x in range(x_pixels): + for y in range(y_pixels): + for c in range(num_channels): + total = 0 + count = 0 + for x_i in range(max(0, x - neighbor_range), min(new_im.x_pixels - 1, x + neighbor_range) + 1): + for y_i in range(max(0, y - neighbor_range), min(new_im.y_pixels - 1, y + neighbor_range) + 1): + total += image.array[x_i, y_i, c] + count += 1 + new_im.array[x, y, c] = total / count + return new_im def apply_kernel(image, kernel): # the kernel should be a 2D array that represents the kernel we'll use! @@ -23,12 +45,31 @@ def apply_kernel(image, kernel): # [1 0 -1] # [2 0 -2] # [1 0 -1] - pass + x_pixels, y_pixels, num_channels = image.array.shape + new_im = Image(x_pixels=x_pixels, y_pixels=y_pixels, num_channels=num_channels) + neighbor_range = kernel.shape[0] // 2 + for x in range(x_pixels): + for y in range(y_pixels): + for c in range(num_channels): + total = 0 + for x_i in range(max(0, x - neighbor_range), min(new_im.x_pixels - 1, x + neighbor_range) + 1): + for y_i in range(max(0, y - neighbor_range), min(new_im.y_pixels - 1, y + neighbor_range) + 1): + x_k = x_i + neighbor_range - x + y_k = y_i + neighbor_range - y + total += image.array[x_i, y_i, c] * kernel[x_k, y_k] + new_im.array[x, y, c] = total + return new_im def combine_images(image1, image2): # let's combine two images using the squared sum of squares: value = sqrt(value_1**2, value_2**2) # size of image1 and image2 MUST be the same - pass + x_pixels, y_pixels, num_channels = image1.array.shape + new_im = Image(x_pixels=x_pixels, y_pixels=y_pixels, num_channels=num_channels) + for x in range(x_pixels): + for y in range(y_pixels): + for c in range(num_channels): + new_im.array[x, y, c] = (image1.array[x, y, c] ** 2 + image2.array[x, y, c] ** 2) ** 0.5 + return new_im if __name__ == '__main__': lake = Image(filename='lake.png')