From 75d4235cc19c4cddf1a27d87564110c5a07d28a5 Mon Sep 17 00:00:00 2001 From: edoardob90 Date: Wed, 12 Jul 2023 13:44:30 +0200 Subject: [PATCH 1/3] Don't treat comments in code blocks as headings --- tutorial/toc.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tutorial/toc.py b/tutorial/toc.py index 14b46075..4c3fb77b 100755 --- a/tutorial/toc.py +++ b/tutorial/toc.py @@ -3,13 +3,18 @@ import argparse as ap import pathlib import re -from collections import namedtuple +from typing import NamedTuple import nbformat from nbformat import NotebookNode -TocEntry = namedtuple("TocEntry", ["level", "text", "anchor"]) +class TocEntry(NamedTuple): + """Table of contents entry""" + + level: int + text: str + anchor: str def extract_markdown_cells(notebook: NotebookNode) -> str: @@ -22,29 +27,32 @@ def extract_markdown_cells(notebook: NotebookNode) -> str: def extract_toc(notebook: str) -> list[TocEntry]: """Extract the table of contents from a markdown string""" toc = [] - line_re = re.compile(r"(#+)\s+(.+)") - for line in notebook.splitlines(): - if groups := re.match(line_re, line): - heading, text, *_ = groups.groups() - level = len(heading) + + # Regex trick: use a capture group to match the heading level discarding code blocks + line_re = re.compile(r"```py.*\n#|^(#{1,6})\s+(.+)", re.MULTILINE) + + for match in re.findall(line_re, notebook): + if all(match): + level, text = match anchor = "-".join(text.replace("`", "").split()) - toc.append(TocEntry(level, text, anchor)) + toc.append(TocEntry(len(level), text, anchor)) + return toc def markdown_toc(toc: list[TocEntry]) -> str: """Build a string representation of the toc as a nested markdown list""" - lines = [] - for entry in toc: - line = f"{' ' * entry.level}- [{entry.text}](#{entry.anchor})" - lines.append(line) - return "\n".join(lines) + return "\n".join( + f"{' ' * entry.level}- [{entry.text}](#{entry.anchor})" for entry in toc + ) def build_toc(nb_path: pathlib.Path, placeholder: str = "[TOC]") -> NotebookNode: """Build a table of contents for a notebook and insert it at the location of a placeholder""" # Read the notebook nb_obj: NotebookNode = nbformat.read(nb_path, nbformat.NO_CONVERT) + + # Extract markdown cells md_cells = extract_markdown_cells(nb_obj) # Build tree From 4ed72947c3a02a4d69dd3c3ff04f5ea432251292 Mon Sep 17 00:00:00 2001 From: edoardob90 Date: Wed, 12 Jul 2023 17:57:29 +0200 Subject: [PATCH 2/3] Add backlink to each header --- tutorial/toc.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tutorial/toc.py b/tutorial/toc.py index 4c3fb77b..ab4896a7 100755 --- a/tutorial/toc.py +++ b/tutorial/toc.py @@ -28,10 +28,7 @@ def extract_toc(notebook: str) -> list[TocEntry]: """Extract the table of contents from a markdown string""" toc = [] - # Regex trick: use a capture group to match the heading level discarding code blocks - line_re = re.compile(r"```py.*\n#|^(#{1,6})\s+(.+)", re.MULTILINE) - - for match in re.findall(line_re, notebook): + for match in re.findall(r"```py.*\n#|^(#{1,6})\s+(.+)", notebook, re.MULTILINE): if all(match): level, text = match anchor = "-".join(text.replace("`", "").split()) @@ -47,13 +44,15 @@ def markdown_toc(toc: list[TocEntry]) -> str: ) -def build_toc(nb_path: pathlib.Path, placeholder: str = "[TOC]") -> NotebookNode: - """Build a table of contents for a notebook and insert it at the location of a placeholder""" - # Read the notebook - nb_obj: NotebookNode = nbformat.read(nb_path, nbformat.NO_CONVERT) +def add_toc_and_backlinks( + notebook_path: pathlib.Path, placeholder: str = "[TOC]" +) -> NotebookNode: + """Replace a `placeholder` cell with a table of contents and add backlinks to each header""" + # Read notebook + notebook: NotebookNode = nbformat.read(notebook_path, nbformat.NO_CONVERT) # Extract markdown cells - md_cells = extract_markdown_cells(nb_obj) + md_cells = extract_markdown_cells(notebook) # Build tree toc_tree = extract_toc(md_cells) @@ -64,12 +63,19 @@ def build_toc(nb_path: pathlib.Path, placeholder: str = "[TOC]") -> NotebookNode # Insert it a the location of a placeholder toc_header = "# Table of Contents" - for cell in nb_obj.cells: + for cell in notebook.cells: + # Add backlinks + if cell.cell_type == "markdown": + cell.source = re.sub( + r"^(#{1,6})\s+(.+)", r"\1 \2 [↩](#Table-of-Contents)", cell.source + ) + + # Replace placeholder with toc if cell.source.startswith((placeholder, toc_header)): cell.source = f"{toc_header}\n{toc_repr}" cell.cell_type = "markdown" - return nb_obj + return notebook def main(): @@ -99,7 +105,7 @@ def main(): output_nb = pathlib.Path(args.output) with output_nb.open("w", encoding="utf-8") as file: - nbformat.write(build_toc(input_nb), file) + nbformat.write(add_toc_and_backlinks(input_nb), file) if args.force: input_nb.unlink() From 8977566d606d73f9de699add65d730fa3e9f299d Mon Sep 17 00:00:00 2001 From: edoardob90 Date: Mon, 23 Oct 2023 16:14:24 +0200 Subject: [PATCH 3/3] Squash-merge branch 'main' --- .flake8 | 1 + .github/workflows/build-docker-image.yml | 30 ++ .gitignore | 1 + .mypy.ini | 10 + .pre-commit-config.yaml | 12 +- README.md | 61 ++- binder/environment.yml | 2 +- binder/ipython_config.py | 2 +- magic_example.ipynb | 7 +- tutorial/tests/test_control_flow.py | 32 +- tutorial/tests/test_functional_programming.py | 43 +- tutorial/tests/test_functions.py | 30 +- tutorial/tests/test_input_output.py | 65 +-- tutorial/tests/test_magic_example.py | 52 ++- tutorial/tests/testsuite.py | 274 +++---------- tutorial/tests/testsuite_helpers.py | 380 ++++++++++++++++++ 16 files changed, 665 insertions(+), 337 deletions(-) create mode 100644 .github/workflows/build-docker-image.yml create mode 100644 .mypy.ini create mode 100644 tutorial/tests/testsuite_helpers.py diff --git a/.flake8 b/.flake8 index f3272664..c84a0d1b 100644 --- a/.flake8 +++ b/.flake8 @@ -3,5 +3,6 @@ ignore = E501 W503 E203 + TRY301 per-file-ignores = binder/ipython_config.py:E266 diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml new file mode 100644 index 00000000..41d4da70 --- /dev/null +++ b/.github/workflows/build-docker-image.yml @@ -0,0 +1,30 @@ +--- +name: Build Tutorial Container +on: + push: + branches: + - main + paths-ignore: + - '*.md' + - slides/** + - images/** + - .gitignore + workflow_dispatch: + +jobs: + repo2docker: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: checkout files in repo + uses: actions/checkout@main + + - name: update jupyter dependencies with repo2docker + uses: jupyterhub/repo2docker-action@master + with: + DOCKER_USERNAME: ${{ github.actor }} + DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + DOCKER_REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + FORCE_REPO2DOCKER_VERSION: jupyter-repo2docker==2023.06.0 diff --git a/.gitignore b/.gitignore index fcfe0c1d..e4bffd81 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dmypy.json .DS_Store *_files/ *.html +.idea/ diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 00000000..cd94234a --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,10 @@ +[mypy] +; Do not warn about missing return statements +warn_no_return = False +; Show error messages in context +show_error_context = True +; Allow untyped definitions and calls +disallow_untyped_defs = False +disallow_untyped_calls = False +; Disable import errors +disable_error_code = import diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 46b3d820..31f62117 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,21 +17,21 @@ repos: - id: isort args: [--profile, black, --filter-files] - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 + rev: v2.2.1 hooks: - id: autoflake - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.14.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black language_version: python3 # Should be a command that runs python3.6+ - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 args: [--count, --show-source, --statistics] @@ -45,7 +45,7 @@ repos: - pyflakes - tryceratops - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 + rev: v1.5.1 hooks: - id: mypy additional_dependencies: @@ -58,7 +58,7 @@ repos: hooks: - id: yamlfmt - repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.4.0 + rev: v2.5.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/kynan/nbstripout diff --git a/README.md b/README.md index 721f2d41..170cc98f 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@ # python-tutorial -[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/empa-scientific-it/python-tutorial.git/main?labpath=index.ipynb) - +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/empa-scientific-it/python-tutorial.git/main?labpath=index.ipynb) [![Build Docker container](https://github.com/empa-scientific-it/python-tutorial/actions/workflows/build-docker-image.yml/badge.svg)](https://github.com/empa-scientific-it/python-tutorial/actions/workflows/build-docker-image.yml) ## Run the tutorial on your computer -### 0. Prerequisites +You have two ways in which you can run the tutorial **locally**. + +### 1. With a `conda` environment + +#### 0. Prerequisites To run the tutorial locally, you should first install [conda](https://docs.conda.io/en/latest/miniconda.html) (or [mamba](https://mamba.readthedocs.io/en/latest/installation.html)). It is also suggested that you have a recent version of `git`. Check out [how to install `git`](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) on your operating system. -### 1. Download the material +#### 1. Download the material Go to the directory on your machine where you want to download the material and clone the repository: @@ -25,7 +28,7 @@ Alternatively, you can manually download a ZIP archive with the latest version o Extract the archive in a directory of your choice. -### 2. Create a dedicated environment +#### 2. Create a dedicated environment Enter the tutorial folder with @@ -45,6 +48,7 @@ conda env create -f binder/environment.yml > If you are on Windows and using Command Prompt or the PowerShell, please make sure to adjust the paths in the commands above accordingly. Then activate the environment with + ```console conda activate python-tutorial ``` @@ -55,9 +59,54 @@ You can update the existing environment (that is, downloading the latest version conda env update -f binder/environment.yml ``` -### 3. Launch the tutorial via Jupyter +#### 3. Launch the tutorial via Jupyter Finally, launch JupyterLab with + ```console jupyter lab ``` + +To update the existing environment, run + +```console +conda env update -f environment.yml +``` + +### 2. With Docker + +> **Note** +> +> The following instructions are for Windows. With minor changes, the steps work on macOS or Linux as well. + +1. Install Docker Desktop: First, you need to install Docker Desktop on your Windows machine. You can download it from the official Docker website: https://www.docker.com/products/docker-desktop. + +2. Create a folder: Open File Explorer and create a new folder where you want to save the tutorial's materials. For example, you could create a folder called "python-tutorial" on your Desktop. + +3. Open PowerShell: Once Docker Desktop is installed, open PowerShell on your Windows machine. You can do this by pressing the "Windows" key and typing "PowerShell" in the search bar. + +4. Pull the Docker image: In PowerShell, run the following command to pull the "empascientificit/python-tutorial" Docker image: + +```console +docker pull ghcr.io/empa-scientific-it/python-tutorial:latest +``` + +5. Run the Docker container: Once the image is downloaded, run the following command to start a Docker container from the image: + +```console +docker run -p 8888:8888 --name python_tutorial -v /path/to/python-tutorial:/home/jovyan/work ghcr.io/empa-scientific-it/python-tutorial:latest jupyter lab --ip 0.0.0.0 --no-browser +``` + +Replace `/path/to/python-tutorial` with the path to the folder you created in step 2, for example `C:/Users/yourusername/Desktop/python-tutorial`. + +> **Note** +> +> The above command will **mirror** the content of your local folder (e.g., `C:/Users/yourusername/Desktop/python-tutorial`) to the `work/` folder **inside the container**. In this way, every file or folder you copy or create into `work/` will be saved on your machine, and will remain there **even if you stop Docker**. + +6. Access the Jupyter Notebook: Open a web browser and navigate to `http://localhost:8888/lab`. You should see the Jupyter Notebook interface. Enter the token provided in the PowerShell console to access the notebook. Alternatively, you can directly click on the link that appears in the PowerShell after the container has started. + +You can now use the Jupyter in the Docker container to run the python-tutorial. When you're done, you can stop the container by pressing `Ctrl+C` in the PowerShell console. + +> **Note** +> +> If you want to restart the container, you can simply run the command `docker container start python_tutorial`. diff --git a/binder/environment.yml b/binder/environment.yml index 16c7d522..9674f4c0 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -3,7 +3,7 @@ name: python-tutorial channels: - conda-forge dependencies: - - python + - python=3.10 - pip - pip: - numpy diff --git a/binder/ipython_config.py b/binder/ipython_config.py index 18ae5ab3..a1e1b5f2 100644 --- a/binder/ipython_config.py +++ b/binder/ipython_config.py @@ -1,6 +1,6 @@ # Configuration file for ipython. -c = get_config() # noqa +c = get_config() # noqa # type: ignore # ------------------------------------------------------------------------------ # InteractiveShellApp(Configurable) configuration diff --git a/magic_example.ipynb b/magic_example.ipynb index 874b91a8..d2603bc9 100644 --- a/magic_example.ipynb +++ b/magic_example.ipynb @@ -22,13 +22,8 @@ "%%ipytest\n", "# or %%ipytest test_module_name\n", "\n", - "len('a')\n", "def solution_power2(x: int) -> int:\n", - " print('hi')\n", - " len('b')\n", - " print(len('bb'))\n", - " return x ** 2\n", - "len('aaa')" + " return x * 2" ] }, { diff --git a/tutorial/tests/test_control_flow.py b/tutorial/tests/test_control_flow.py index 7e071d2b..84affc45 100644 --- a/tutorial/tests/test_control_flow.py +++ b/tutorial/tests/test_control_flow.py @@ -140,7 +140,7 @@ def is_prime(num: int) -> bool: return True -def reference_solution_find_factors(num: int) -> List[int]: +def reference_find_factors(num: int) -> List[int]: """Dumb way to find the factors of an integer""" if is_prime(num): return [1, num] @@ -149,7 +149,7 @@ def reference_solution_find_factors(num: int) -> List[int]: @pytest.mark.parametrize("num", [350, 487, 965, 816, 598, 443, 13, 17, 211]) def test_find_factors(num: int, function_to_test) -> None: - assert function_to_test(num) == reference_solution_find_factors(num) + assert function_to_test(num) == reference_find_factors(num) # @@ -162,7 +162,7 @@ def test_find_factors(num: int, function_to_test) -> None: ) -def reference_solution_find_pair(nums: List[int]) -> int: +def reference_find_pair(nums: List[int]) -> int: """Reference solution (part 1)""" complements = {} for num in nums: @@ -173,10 +173,10 @@ def reference_solution_find_pair(nums: List[int]) -> int: @pytest.mark.parametrize("nums", [nums_1, nums_2]) def test_find_pair(nums: List[int], function_to_test) -> None: - assert function_to_test(nums) == reference_solution_find_pair(nums) + assert function_to_test(nums) == reference_find_pair(nums) -def reference_solution_find_triplet_slow(nums: List[int]) -> int: +def reference_find_triplet_slow(nums: List[int]) -> int: """Reference solution (part 2), O(n^3)""" n = len(nums) for i in range(n - 2): @@ -186,7 +186,7 @@ def reference_solution_find_triplet_slow(nums: List[int]) -> int: return nums[i] * nums_2[j] * nums[k] -def reference_solution_find_triplet(nums: List[int]) -> int: +def reference_find_triplet(nums: List[int]) -> int: """Reference solution (part 2), O(n^2)""" n = len(nums) for i in range(n - 1): @@ -201,7 +201,7 @@ def reference_solution_find_triplet(nums: List[int]) -> int: @pytest.mark.parametrize("nums", [nums_1, nums_2]) def test_find_triplet(nums: List[int], function_to_test) -> None: - assert function_to_test(nums) == reference_solution_find_triplet(nums) + assert function_to_test(nums) == reference_find_triplet(nums) # @@ -209,7 +209,7 @@ def test_find_triplet(nums: List[int], function_to_test) -> None: # -def reference_solution_cats_with_hats() -> int: +def reference_cats_with_hats() -> int: """Solution with dictionaries""" cats = {i: False for i in range(1, 101)} @@ -222,7 +222,7 @@ def reference_solution_cats_with_hats() -> int: def test_cats_with_hats(function_to_test) -> None: - assert function_to_test() == reference_solution_cats_with_hats() + assert function_to_test() == reference_cats_with_hats() # @@ -241,9 +241,7 @@ def parse_data(filename: str) -> List[List[int]]: trees_1, trees_2 = (parse_data(f"trees_{num}.txt") for num in (1, 2)) -def reference_solution_toboggan_p1( - trees_map: List[List[int]], right: int, down: int -) -> int: +def reference_toboggan_p1(trees_map: List[List[int]], right: int, down: int) -> int: """Reference solution (part 1)""" start, trees, depth, width = [0, 0], 0, len(trees_map), len(trees_map[0]) while start[0] < depth: @@ -262,18 +260,16 @@ def reference_solution_toboggan_p1( def test_toboggan_p1( trees_map: List[List[int]], right: int, down: int, function_to_test ) -> None: - assert function_to_test(trees_map, right, down) == reference_solution_toboggan_p1( + assert function_to_test(trees_map, right, down) == reference_toboggan_p1( trees_map, right, down ) -def reference_solution_toboggan_p2( - trees_map: List[List[int]], slopes: Tuple[Tuple[int]] -) -> int: +def reference_toboggan_p2(trees_map: List[List[int]], slopes: Tuple[Tuple[int]]) -> int: """Reference solution (part 2)""" total = 1 for right, down in slopes: - total *= reference_solution_toboggan_p1(trees_map, right, down) + total *= reference_toboggan_p1(trees_map, right, down) return total @@ -293,6 +289,6 @@ def reference_solution_toboggan_p2( def test_toboggan_p2( trees_map: List[List[int]], slopes: Tuple[Tuple[int]], function_to_test ) -> None: - assert function_to_test(trees_map, slopes) == reference_solution_toboggan_p2( + assert function_to_test(trees_map, slopes) == reference_toboggan_p2( trees_map, slopes ) diff --git a/tutorial/tests/test_functional_programming.py b/tutorial/tests/test_functional_programming.py index d3d83ee8..371b4b80 100644 --- a/tutorial/tests/test_functional_programming.py +++ b/tutorial/tests/test_functional_programming.py @@ -11,7 +11,7 @@ from numpy.typing import NDArray -def reference_solution_exercise2(my_list: List[int], k: int): +def reference_exercise2(my_list: List[int], k: int) -> List[int]: return [i for i in my_list if i % k == 0] @@ -24,7 +24,7 @@ def check_for_loop_in_body(fun: Callable) -> bool: return False -def reference_solution_filter_even(my_list: "list[int]") -> "list[int]": +def reference_filter_even(my_list: List[int]) -> List[int]: return list(filter(lambda x: x % 2 == 0, my_list)) @@ -39,7 +39,7 @@ def reference_solution_filter_even(my_list: "list[int]") -> "list[int]": def test_filter_even(function_to_test: Callable, my_list: List[int]): res = function_to_test(my_list) assert type(res) == list, "The function you wrote does not return a list" - assert res == reference_solution_filter_even( + assert res == reference_filter_even( my_list ), "The list you return is not equal to the expected solution" assert not check_for_loop_in_body( @@ -47,7 +47,7 @@ def test_filter_even(function_to_test: Callable, my_list: List[int]): ), "You are not allowed to use a for loop in this exercise" -def reference_solution_add_one(my_list: List[int]) -> List[int]: +def reference_add_one(my_list: List[int]) -> List[int]: return list(map(lambda x: x + 1, my_list)) # noqa: C417 @@ -60,7 +60,7 @@ def reference_solution_add_one(my_list: List[int]) -> List[int]: ], ) def test_add_one(function_to_test: Callable, my_list: List[int]): - assert function_to_test(my_list) == reference_solution_add_one( + assert function_to_test(my_list) == reference_add_one( my_list ), "The list you return is not equal to the expected solution" assert not check_for_loop_in_body( @@ -76,14 +76,14 @@ def test_add_one(function_to_test: Callable, my_list: List[int]): ], ) def test_exercise2( - function_to_test: Callable[[int, int], int], + function_to_test: Callable[[List[int]], int], my_list: List[int], k: int, ): - assert function_to_test(my_list, k) == reference_solution_exercise2(my_list, k) + assert function_to_test(my_list, k) == reference_exercise2(my_list, k) -def reference_solution_exercise3(x: List[List[int]]) -> List[List[int]]: +def reference_exercise3(x: List[List[int]]) -> List[List[int]]: return [list(i) for i in zip(*x)] @@ -96,28 +96,27 @@ def test_exercise3( ): res = function_to_test(my_input.tolist()) assert ( - res == reference_solution_exercise3(my_input.tolist()) + res == reference_exercise3(my_input.tolist()) and res == my_input.transpose().tolist() ) -def reference_solution_exercise4(my_list: List[List[Any]]) -> List[Any]: +def reference_exercise4(my_list: List[List[Any]]) -> List[Any]: return functools.reduce(lambda x, y: x + y, my_list) @pytest.mark.parametrize( - "my_input, reference_func", + "my_input", [ - ([[1, 2, 3, 4], [4, 5, 5], [4, 5, 6]], reference_solution_exercise4), - ([["a", "b", "c"], ["d", "f", "e"], ["another"]], reference_solution_exercise4), + [[1, 2, 3, 4], [4, 5, 5], [4, 5, 6]], + [["a", "b", "c"], ["d", "f", "e"], ["another"]], ], ) def test_exercise4( - function_to_test: Callable[[List[List[any]]], List[Any]], + function_to_test: Callable[[List[List[Any]]], List[Any]], my_input: List[List[Any]], - reference_func: Callable, ): - assert function_to_test(my_input) == reference_func(my_input) + assert function_to_test(my_input) == reference_exercise4(my_input) @functools.lru_cache @@ -126,7 +125,7 @@ def get_data_exercise5() -> List[str]: return words.splitlines() -def reference_solution_exercise5(w: List[str]) -> List[Tuple[str, int]]: +def reference_exercise5(w: List[str]) -> List[Tuple[str, int]]: return [ (k, len(list(v))) for k, v in itertools.groupby(sorted(w, key=lambda x: x[0]), key=lambda x: x[0]) @@ -135,12 +134,10 @@ def reference_solution_exercise5(w: List[str]) -> List[Tuple[str, int]]: def test_exercise5(function_to_test: Callable[[List[str]], List[Tuple[str, int]]]): data = get_data_exercise5() - assert function_to_test(data) == reference_solution_exercise5(data) + assert function_to_test(data) == reference_exercise5(data) -def reference_solution_exercise6( - my_list: List[Tuple[str, int]] -) -> List[Tuple[str, float]]: +def reference_exercise6(my_list: List[Tuple[str, int]]) -> List[Tuple[str, float]]: total = sum(map(lambda x: x[1], my_list)) # noqa: C417 return [(letter, freq / total) for letter, freq in my_list] @@ -148,8 +145,8 @@ def reference_solution_exercise6( def test_exercise6( function_to_test: Callable[[List[Tuple[str, int]]], List[Tuple[str, float]]] ): - input_data = reference_solution_exercise5(get_data_exercise5()) - assert function_to_test(input_data) == reference_solution_exercise6(input_data) + input_data = reference_exercise5(get_data_exercise5()) + assert function_to_test(input_data) == reference_exercise6(input_data) def reference_function_exercise7(my_list: List[str]) -> List[str]: diff --git a/tutorial/tests/test_functions.py b/tutorial/tests/test_functions.py index 5dea3ac3..32ec1574 100644 --- a/tutorial/tests/test_functions.py +++ b/tutorial/tests/test_functions.py @@ -20,7 +20,7 @@ def read_data(name: str, data_dir: str = "data") -> pathlib.Path: # -def reference_solution_longest_sequence(nums: List[int]) -> int: +def reference_longest_sequence(nums: List[int]) -> int: """ Find the longest consecutive sequence of integers @@ -52,9 +52,7 @@ def reference_solution_longest_sequence(nums: List[int]) -> int: ], ) def test_longest_sequence(input_nums: List[int], function_to_test) -> None: - assert function_to_test(input_nums) == reference_solution_longest_sequence( - input_nums - ) + assert function_to_test(input_nums) == reference_longest_sequence(input_nums) @pytest.mark.timeout(60) @@ -65,9 +63,7 @@ def test_longest_sequence(input_nums: List[int], function_to_test) -> None: ], ) def test_longest_sequence_best(input_nums: List[int], function_to_test) -> None: - assert function_to_test(input_nums) == reference_solution_longest_sequence( - input_nums - ) + assert function_to_test(input_nums) == reference_longest_sequence(input_nums) # @@ -75,7 +71,7 @@ def test_longest_sequence_best(input_nums: List[int], function_to_test) -> None: # -def reference_solution_password_validator1(start: int, end: int) -> int: +def reference_password_validator1(start: int, end: int) -> int: """Password validator reference solution (part 1)""" count = 0 for pwd in range(start, end + 1): @@ -85,7 +81,7 @@ def reference_solution_password_validator1(start: int, end: int) -> int: return count -def reference_solution_password_validator2(start: int, end: int) -> int: +def reference_password_validator2(start: int, end: int) -> int: """Password validator reference solution (part 2)""" count = 0 for pwd in range(start, end + 1): @@ -105,9 +101,7 @@ def reference_solution_password_validator2(start: int, end: int) -> int: ], ) def test_password_validator1(pwd_range: Tuple[int], function_to_test) -> None: - assert function_to_test(*pwd_range) == reference_solution_password_validator1( - *pwd_range - ) + assert function_to_test(*pwd_range) == reference_password_validator1(*pwd_range) @pytest.mark.parametrize( @@ -118,9 +112,7 @@ def test_password_validator1(pwd_range: Tuple[int], function_to_test) -> None: ], ) def test_password_validator2(pwd_range: Tuple[int], function_to_test) -> None: - assert function_to_test(*pwd_range) == reference_solution_password_validator2( - *pwd_range - ) + assert function_to_test(*pwd_range) == reference_password_validator2(*pwd_range) # @@ -131,7 +123,7 @@ def test_password_validator2(pwd_range: Tuple[int], function_to_test) -> None: buckets_1, buckets_2 = (read_data(f"buckets_{num}.txt") for num in (1, 2)) -def reference_solution_buckets1(buckets: pathlib.Path) -> int: +def reference_buckets1(buckets: pathlib.Path) -> int: """Reference solution (part 1)""" data = buckets.read_text().splitlines() total = 0 @@ -143,7 +135,7 @@ def reference_solution_buckets1(buckets: pathlib.Path) -> int: return total -def reference_solution_buckets2(buckets: pathlib.Path) -> int: +def reference_buckets2(buckets: pathlib.Path) -> int: """Reference solution (part 2)""" data = buckets.read_text().splitlines() total = 0 @@ -160,7 +152,7 @@ def reference_solution_buckets2(buckets: pathlib.Path) -> int: [buckets_1, buckets_2], ) def test_buckets1(buckets: pathlib.Path, function_to_test): - assert function_to_test(buckets) == reference_solution_buckets1(buckets) + assert function_to_test(buckets) == reference_buckets1(buckets) @pytest.mark.parametrize( @@ -168,4 +160,4 @@ def test_buckets1(buckets: pathlib.Path, function_to_test): [buckets_1, buckets_2], ) def test_buckets2(buckets: pathlib.Path, function_to_test): - assert function_to_test(buckets) == reference_solution_buckets2(buckets) + assert function_to_test(buckets) == reference_buckets2(buckets) diff --git a/tutorial/tests/test_input_output.py b/tutorial/tests/test_input_output.py index 8fd18970..5bcfe12b 100644 --- a/tutorial/tests/test_input_output.py +++ b/tutorial/tests/test_input_output.py @@ -4,17 +4,18 @@ import pathlib as pl from collections import Counter from io import StringIO +from typing import Dict, List, Tuple import pytest -from .. import prepare_magic_file +from tutorial.prepare_magic_file import decode_secret_message def get_data(name: str, data_dir: str = "data") -> pl.Path: return (pl.Path.cwd() / f"tutorial/tests/{data_dir}/{name}").resolve() -def reference_solution_print_odd(num: int) -> None: +def reference_print_odd(num: int) -> None: for i in range(num): if i % 2 != 0: print(i) @@ -27,30 +28,30 @@ def test_print_odd(function_to_test, num: int): function_to_test(num) with contextlib.redirect_stdout(reference_stdout): - reference_solution_print_odd(num) + reference_print_odd(num) assert reference_stdout.getvalue() == solution_stdout.getvalue() -def reference_solution_find_all_files(f: pl.Path) -> "list[pl.Path]": +def reference_find_all_files(f: pl.Path) -> List[pl.Path]: return list(f.parent.iterdir()) def test_find_all_files(function_to_test): f = pl.Path("data/") - assert function_to_test(f) == reference_solution_find_all_files(f) + assert function_to_test(f) == reference_find_all_files(f) -def reference_solution_count_dirs(path: pl.Path) -> int: +def reference_count_dirs(path: pl.Path) -> int: return len([obj for obj in path.glob("*") if obj.is_dir()]) def test_count_dirs(function_to_test): path = pl.Path.cwd() - assert function_to_test(path) == reference_solution_count_dirs(path) + assert function_to_test(path) == reference_count_dirs(path) -def reference_solution_read_file(file: pl.Path) -> "list[str]": +def reference_read_file(file: pl.Path) -> List[str]: with file.open("r") as lines: return list(lines.readlines()) @@ -58,10 +59,10 @@ def reference_solution_read_file(file: pl.Path) -> "list[str]": def test_read_file(function_to_test): for file in ["lines.txt", "example.csv"]: data = get_data(file) - assert function_to_test(data) == reference_solution_read_file(data) + assert function_to_test(data) == reference_read_file(data) -def reference_solution_write_file(file: pl.Path) -> None: +def reference_write_file(file: pl.Path) -> None: file.write_text("python tutorial 2023") @@ -70,7 +71,7 @@ def test_write_file(function_to_test, tmp_path: pl.Path): tmp_test = tmp_path / "test_write_file.txt" function_to_test(tmp_user) - reference_solution_write_file(tmp_test) + reference_write_file(tmp_test) if not tmp_user.exists(): pytest.fail("Cannot read from inexistent file.") @@ -78,9 +79,7 @@ def test_write_file(function_to_test, tmp_path: pl.Path): assert tmp_user.read_text() == tmp_test.read_text() -def reference_solution_read_write_file( - input_file: pl.Path, output_file: pl.Path -) -> None: +def reference_read_write_file(input_file: pl.Path, output_file: pl.Path) -> None: with input_file.open("r") as read_file, output_file.open("w") as write_file: for line in read_file.readlines(): write_file.write("{}, {}\n".format(line.strip("\n\r"), len(line))) @@ -92,12 +91,12 @@ def test_read_write_file(function_to_test, tmp_path: pl.Path): test_output_file = tmp_path / "test_output_file.txt" function_to_test(input_file, output_file) - reference_solution_read_write_file(input_file, test_output_file) + reference_read_write_file(input_file, test_output_file) assert output_file.read_text() == test_output_file.read_text() -def reference_solution_exercise1(file: pl.Path) -> dict[str, list[str]]: +def reference_exercise1(file: pl.Path) -> Dict[str, List[str]]: with file.open("r") as lines: reader = csv.reader(lines) headers = next(reader) @@ -108,37 +107,41 @@ def reference_solution_exercise1(file: pl.Path) -> dict[str, list[str]]: def test_exercise1(function_to_test): f = get_data("example.csv") - assert function_to_test(f) == reference_solution_exercise1(f) + assert function_to_test(f) == reference_exercise1(f) -def reference_solution_exercise2(file: pl.Path) -> int: +def reference_exercise2(file: pl.Path) -> int: with file.open("r") as lines: return len( - list(itertools.chain.from_iterable([l.split() for l in lines.readlines()])) + list( + itertools.chain.from_iterable( + [line.split() for line in lines.readlines()] + ) + ) ) def test_exercise2(function_to_test): f = get_data("lines.txt") - assert function_to_test(f) == reference_solution_exercise2(f) + assert function_to_test(f) == reference_exercise2(f) -def reference_solution_exercise3(file: pl.Path) -> "dict[str, int]": +def reference_exercise3(file: pl.Path) -> Dict[str, int]: with file.open("r") as lines: res = sorted( - l for l in itertools.chain.from_iterable(lines.readlines()) if l.isalpha() + line + for line in itertools.chain.from_iterable(lines.readlines()) + if line.isalpha() ) return Counter(res) def test_exercise3(function_to_test): f = get_data("lines.txt") - assert function_to_test(f) == reference_solution_exercise3(f) + assert function_to_test(f) == reference_exercise3(f) -def reference_solution_exercise4( - english: pl.Path, dictionary: pl.Path -) -> list[tuple[str, str]]: +def reference_exercise4(english: pl.Path, dictionary: pl.Path) -> List[Tuple[str, str]]: english_words = english.read_text().splitlines() with dictionary.open("r") as dict_file: @@ -154,15 +157,13 @@ def reference_solution_exercise4( def test_exercise4(function_to_test): words = get_data("english.txt") dictionary = get_data("dict.csv") - assert function_to_test(words, dictionary) == reference_solution_exercise4( - words, dictionary - ) + assert function_to_test(words, dictionary) == reference_exercise4(words, dictionary) -def reference_solution_exercise5(secret_file: pl.Path) -> str: - return prepare_magic_file.decode_secret_message(secret_file) +def reference_exercise5(secret_file: pl.Path) -> str: + return decode_secret_message(secret_file) def test_exercise5(function_to_test): message = get_data("secret_message.dat") - assert function_to_test(message) == reference_solution_exercise5(message) + assert function_to_test(message) == reference_exercise5(message) diff --git a/tutorial/tests/test_magic_example.py b/tutorial/tests/test_magic_example.py index 13998b27..d99cfeb5 100644 --- a/tutorial/tests/test_magic_example.py +++ b/tutorial/tests/test_magic_example.py @@ -1,27 +1,69 @@ import pytest -def reference_magic_example(num: int, power: int) -> int: - """Compute num ^ power""" +def my_num() -> int: + return 1 + + +def my_test2(x: str) -> None: + print(x) + + +def last_test() -> str: + return "this has been a success" + + +def my_test(x: str) -> str: + my_test2(last_test()) + return x + + +def my_str() -> str: + return "this is a string" + + +def my_print(text: str) -> None: + print(my_test(text)) + + +def my_calc(num: int, power: int) -> int: return num**power +def reference_power2(num: int) -> int: + """Compute num ^ 2""" + if my_num(): + print("ok") + my_print(my_str()) + return my_calc(num, 2) + + +def reference_power3(num: int) -> int: + """Compute num ^ 3""" + return num**3 + + +def reference_power4(num: int) -> int: + """Compute num ^ 4""" + return num**4 + + input_args = [1, 2, 3, 4, 32] @pytest.mark.parametrize("input_arg", input_args) def test_power2(input_arg, function_to_test): """The test case(s)""" - assert function_to_test(input_arg) == reference_magic_example(input_arg, 2) + assert function_to_test(input_arg) == reference_power2(input_arg) @pytest.mark.parametrize("input_arg", input_args) def test_power3(input_arg, function_to_test): """The test case(s)""" - assert function_to_test(input_arg) == reference_magic_example(input_arg, 3) + assert function_to_test(input_arg) == reference_power3(input_arg) @pytest.mark.parametrize("input_arg", input_args) def test_power4(input_arg, function_to_test): """The test case(s)""" - assert function_to_test(input_arg) == reference_magic_example(input_arg, 4) + assert function_to_test(input_arg) == reference_power4(input_arg) diff --git a/tutorial/tests/testsuite.py b/tutorial/tests/testsuite.py index 9b1af80a..e1c5751c 100644 --- a/tutorial/tests/testsuite.py +++ b/tutorial/tests/testsuite.py @@ -3,20 +3,27 @@ import pathlib import re from contextlib import redirect_stderr, redirect_stdout -from dataclasses import dataclass -from typing import Callable, Dict, List +from typing import Dict, Optional import ipynbname -import ipywidgets import pytest -from IPython.core.display import HTML, Javascript +from IPython.core.display import Javascript +from IPython.core.getipython import get_ipython from IPython.core.interactiveshell import InteractiveShell from IPython.core.magic import Magics, cell_magic, magics_class from IPython.display import display -from nbconvert import filters +from .testsuite_helpers import ( + AstParser, + FunctionInjectionPlugin, + FunctionNotFoundError, + InstanceNotFoundError, + ResultCollector, + TestResultOutput, +) -def _name_from_line(line: str = None): + +def _name_from_line(line: str = ""): return line.strip().removesuffix(".py") if line else None @@ -29,12 +36,14 @@ def _name_from_ipynbname() -> str | None: def _name_from_globals(globals_dict: Dict) -> str | None: """Find the name of the test module from the globals dictionary if working in VSCode""" + module_path = globals_dict.get("__vsc_ipynb_file__") if globals_dict else None return pathlib.Path(module_path).stem if module_path else None -def get_module_name(line: str, globals_dict: Dict = None) -> str: +def get_module_name(line: str, globals_dict: Dict) -> str: """Fetch the test module name""" + module_name = ( _name_from_line(line) or _name_from_ipynbname() @@ -42,192 +51,25 @@ def get_module_name(line: str, globals_dict: Dict = None) -> str: ) if not module_name: - raise RuntimeError( - "Test module is undefined. Did you provide an argument to %%ipytest?" - ) + raise ModuleNotFoundError(module_name) return module_name -class FunctionInjectionPlugin: - """A class to inject a function to test""" - - def __init__(self, function_to_test: Callable) -> None: - self.function_to_test = function_to_test - - def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None: - """Override the abstract `function_to_test` fixture function""" - if "function_to_test" in metafunc.fixturenames: - metafunc.parametrize("function_to_test", [self.function_to_test]) - - -@dataclass -class TestResult: - """Container class to store the test results when we collect them""" - - stdout: str - stderr: str - test_name: str - success: bool - - -@dataclass -class OutputConfig: - """Container class to store the information to display in the test output""" - - style: str - name: str - result: str - - -def format_success_failure( - syntax_error: bool, success: bool, name: str -) -> OutputConfig: - """ - Depending on the test results, returns a fragment that represents - either an error message, a success message, or a syntax error warning - """ - if syntax_error: - return OutputConfig( - "alert-warning", - "Tests COULD NOT RUN for this cell.", - "🤔 Careful, looks like you have a syntax error.", - ) - - if not success: - return OutputConfig( - "alert-danger", - f"Tests FAILED for the function {name}", - "😱 Your solution was not correct!", - ) - - return OutputConfig( - "alert-success", - f"Tests PASSED for the function {name}", - "🙌 Congratulations, your solution was correct!", - ) - - -def format_long_stdout(text: str) -> str: - """ - Format a long test stdout as a HTML by using the
element - """ - - stdout_body = re.split(r"_\s{3,}", text)[-1] - stdout_filtered = list( - filter(re.compile(r".*>E\s").match, stdout_body.splitlines()) - ) - html_body = "".join(f"

{line}

" for line in stdout_filtered) - - test_runs = f"""
Click here to expand
{html_body}
""" - return test_runs - - -class TestResultOutput(ipywidgets.VBox): - """Class to display the test results in a structured way""" - - def __init__( - self, - name: str = "", - syntax_error: bool = False, - success: bool = False, - test_outputs: List[TestResult] = None, - ): - output_config = format_success_failure(syntax_error, success, name) - output_cell = ipywidgets.Output() - - with output_cell: - custom_div_style = '"border: 1px solid; border-color: lightgray; background-color: whitesmoke; margin: 5px; padding: 10px;"' - display(HTML("

Test results

")) - display( - HTML( - f"""

{output_config.name}

{output_config.result}
""" - ) - ) - - if not syntax_error: - if len(test_outputs) > 0 and test_outputs[0].stdout: - display( - HTML( - f"

Code output:

{test_outputs[0].stdout}
" - ) - ) - - display( - HTML( - f""" -

We tested your solution solution_{name} with {'1 input' if len(test_outputs) == 1 else str(len(test_outputs)) + ' different inputs'}. - {"All tests passed!

" if success else "Below you find the details for each test run:"} - """ - ) - ) - - if not success: - for test in test_outputs: - test_name = test.test_name - if match := re.search(r"\[.*?\]", test_name): - test_name = re.sub(r"\[|\]", "", match.group()) - - display( - HTML( - f""" -
-
{"✔" if test.success else "❌"} Test {test_name}
- {format_long_stdout(filters.ansi.ansi2html(test.stderr)) if not test.success else ""} -
- """ - ) - ) - else: - display( - HTML( - "

Your code cannot run because of the following error:

" - ) - ) - - super().__init__(children=[output_cell]) - - -class ResultCollector: - """A class that will collect the result of a test. If behaves a bit like a visitor pattern""" - - def __init__(self) -> None: - self.tests: Dict[str, TestResult] = {} - - def pytest_runtest_logreport(self, report: pytest.TestReport): - # Only collect the results if it did not fail - if report.when == "teardown" and report.nodeid not in self.tests: - self.tests[report.nodeid] = TestResult( - report.capstdout, report.capstderr, report.nodeid, not report.failed - ) - - def pytest_exception_interact( - self, node: pytest.Item, call: pytest.CallInfo, report: pytest.TestReport - ): - # We need to collect the results and the stderr if the test failed - if report.failed: - self.tests[node.nodeid] = TestResult( - report.capstdout, - str(call.excinfo.getrepr() if call.excinfo else ""), - report.nodeid, - False, - ) - - -@pytest.fixture -def function_to_test(): - """Function to test, overridden at runtime by the cell magic""" - - @magics_class class TestMagic(Magics): """Class to add the test cell magic""" - shell: InteractiveShell + shell: Optional[InteractiveShell] # type: ignore + cells: Dict[str, int] = {} @cell_magic def ipytest(self, line: str, cell: str): """The `%%ipytest` cell magic""" + # Check that the magic is called from a notebook + if not self.shell: + raise InstanceNotFoundError("InteractiveShell") + # Get the module containing the test(s) module_name = get_module_name(line, self.shell.user_global_ns) @@ -253,7 +95,21 @@ def ipytest(self, line: str, cell: str): functions_to_run[name.removeprefix("solution_")] = function if not functions_to_run: - raise ValueError("No function to test defined in the cell") + raise FunctionNotFoundError + + # Store execution count information for each cell + if (ipython := get_ipython()) is None: + raise InstanceNotFoundError("IPython") + + cell_id = ipython.parent_header["metadata"]["cellId"] + if cell_id in self.cells: + self.cells[cell_id] += 1 + else: + self.cells[cell_id] = 1 + + # Parse the AST tree of the file containing the test functions, + # to extract and store all information of function definitions and imports + ast_parser = AstParser(module_file) outputs = [] for name, function in functions_to_run.items(): @@ -277,12 +133,19 @@ def ipytest(self, line: str, cell: str): pytest_stdout.getvalue() pytest_stderr.getvalue() + # reset execution count on success + success = result == pytest.ExitCode.OK + if success: + self.cells[cell_id] = 0 + outputs.append( TestResultOutput( + list(result_collector.tests.values()), name, False, - result == pytest.ExitCode.OK, - list(result_collector.tests.values()), + success, + self.cells[cell_id], + ast_parser.get_solution_code(name), ) ) @@ -292,31 +155,16 @@ def ipytest(self, line: str, cell: str): display( Javascript( """ - var output_divs = document.querySelectorAll(".jp-OutputArea-executeResult"); - for (let div of output_divs) { - div.setAttribute("style", "display: none;"); - } - """ - ) - ) - - # remove syntax error styling - display( - Javascript( + var output_divs = document.querySelectorAll(".jp-OutputArea-executeResult"); + for (let div of output_divs) { + div.setAttribute("style", "display: none;"); + } """ - var output_divs = document.querySelectorAll(".jp-Cell-outputArea"); - for (let div of output_divs) { - var div_str = String(div.innerHTML); - if (div_str.includes("alert-success") | div_str.includes("alert-danger")) { - div.setAttribute("style", "padding-bottom: 0;"); - } - } - """ ) ) - except Exception: - # Catches syntax errors and creates a custom warning + except SyntaxError: + # Catches syntax errors display( TestResultOutput( syntax_error=True, @@ -324,21 +172,6 @@ def ipytest(self, line: str, cell: str): ) ) - display( - Javascript( - """ - var syntax_error_containers = document.querySelectorAll('div[data-mime-type="application/vnd.jupyter.stderr"]'); - for (let container of syntax_error_containers) { - var syntax_error_div = container.parentNode; - var container_div = syntax_error_div.parentNode; - const container_style = "position: relative; padding-bottom: " + syntax_error_div.clientHeight + "px;"; - container_div.setAttribute("style", container_style); - syntax_error_div.setAttribute("style", "position: absolute; bottom: 10px;"); - } - """ - ) - ) - def load_ipython_extension(ipython): """ @@ -346,4 +179,5 @@ def load_ipython_extension(ipython): can be loaded via `%load_ext module.path` or be configured to be autoloaded by IPython at startup time. """ + ipython.register_magics(TestMagic) diff --git a/tutorial/tests/testsuite_helpers.py b/tutorial/tests/testsuite_helpers.py new file mode 100644 index 00000000..7e52cd36 --- /dev/null +++ b/tutorial/tests/testsuite_helpers.py @@ -0,0 +1,380 @@ +import ast +import pathlib +import re +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional, Set + +import ipywidgets +import pytest +from IPython.core.display import HTML, Javascript +from IPython.display import Code, display +from nbconvert import filters + + +@dataclass +class TestResult: + """Container class to store the test results when we collect them""" + + stdout: str + stderr: str + test_name: str + success: bool + + +@dataclass +class OutputConfig: + """Container class to store the information to display in the test output""" + + style: str + name: str + result: str + + +def format_success_failure( + syntax_error: bool, success: bool, name: str +) -> OutputConfig: + """ + Depending on the test results, returns a fragment that represents + either an error message, a success message, or a syntax error warning + """ + + if syntax_error: + return OutputConfig( + "alert-warning", + "Tests COULD NOT RUN for this cell.", + "🤔 Careful, looks like you have a syntax error.", + ) + + if not success: + return OutputConfig( + "alert-danger", + f"Tests FAILED for the function {name}", + "😱 Your solution was not correct!", + ) + + return OutputConfig( + "alert-success", + f"Tests PASSED for the function {name}", + "🙌 Congratulations, your solution was correct!", + ) + + +def format_long_stdout(text: str) -> str: + """ + Format the error message lines of a long test stdout + as an HTML that expands, by using the
element + """ + + stdout_body = re.split(r"_\s{3,}", text)[-1] + stdout_filtered = list( + filter(re.compile(r".*>E\s").match, stdout_body.splitlines()) + ) + stdout_str = "".join(f"

{line}

" for line in stdout_filtered) + stdout_edited = re.sub(r"E\s+[\+\s]*", "", stdout_str) + stdout_edited = re.sub( + r"\bfunction\ssolution_[\w\s\d]*", "your_solution", stdout_edited + ) + stdout_edited = re.sub(r"\breference_\w+\(", "reference_solution(", stdout_edited) + + test_runs = f""" +
+ Click here to expand +
{stdout_edited}
+
+ """ + return test_runs + + +class TestResultOutput(ipywidgets.VBox): + """Class to display the test results in a structured way""" + + def __init__( + self, + test_outputs: Optional[List[TestResult]] = None, + name: str = "", + syntax_error: bool = False, + success: bool = False, + cell_exec_count: int = 0, + solution_body: str = "", + ): + reveal_solution = cell_exec_count > 2 or success + output_config = format_success_failure(syntax_error, success, name) + output_cell = ipywidgets.Output() + + # For each test, create an alert box with the appropriate message, + # print the code output and display code errors in case of failure + with output_cell: + custom_div_style = '"border: 1px solid; border-color: lightgray; background-color: #FAFAFA; margin: 5px; padding: 10px;"' + display(HTML("

Test results

")) + display( + HTML( + f"""

{output_config.name}

{output_config.result}
""" + ) + ) + + if not syntax_error and isinstance(test_outputs, List): + if len(test_outputs) > 0 and test_outputs[0].stdout: + display( + HTML( + f""" +

👉 Code output:

+
{test_outputs[0].stdout}
+ """ + ) + ) + + display( + HTML( + f""" +

👉 We tested your solution solution_{name} with {'1 input' if len(test_outputs) == 1 else str(len(test_outputs)) + ' different inputs'}. + {"All tests passed!

" if success else "Below you find the details for each test run:"} + """ + ) + ) + + if not success: + for test in test_outputs: + test_name = test.test_name + if match := re.search(r"\[.*?\]", test_name): + test_name = re.sub(r"\[|\]", "", match.group()) + + display( + HTML( + f""" +
+
{"✔" if test.success else "❌"} Test {test_name}
+ {format_long_stdout(filters.ansi.ansi2html(test.stderr)) if not test.success else ""} +
+ """ + ) + ) + + if not reveal_solution: + display( + HTML( + f"

📝 A proposed solution will appear after {3 - cell_exec_count} more failed attempt{'s' if cell_exec_count < 2 else ''}.

" + ) + ) + else: + # display syntax error custom alert + display( + HTML( + "

👉 Your code cannot run because of the following error:

" + ) + ) + + # fix syntax error styling + display( + Javascript( + """ + var syntax_error_containers = document.querySelectorAll('div[data-mime-type="application/vnd.jupyter.stderr"]'); + for (let container of syntax_error_containers) { + var syntax_error_div = container.parentNode; + var container_div = syntax_error_div.parentNode; + const container_style = "position: relative; padding-bottom: " + syntax_error_div.clientHeight + "px;"; + container_div.setAttribute("style", container_style); + syntax_error_div.setAttribute("style", "position: absolute; bottom: 0;"); + } + """ + ) + ) + + # fix css styling + display( + Javascript( + """ + var divs = document.querySelectorAll(".jupyter-widget-Collapse-contents"); + for (let div of divs) { + div.setAttribute("style", "padding: 0"); + } + divs = document.querySelectorAll(".widget-vbox"); + for (let div of divs) { + div.setAttribute("style", "background: #EAF0FB"); + } + """ + ) + ) + + display( + Javascript( + """ + var output_divs = document.querySelectorAll(".jp-Cell-outputArea"); + for (let div of output_divs) { + var div_str = String(div.innerHTML); + if (div_str.includes("alert-success") | div_str.includes("alert-danger")) { + div.setAttribute("style", "padding-bottom: 0;"); + } + } + """ + ) + ) + + # After 3 failed attempts or on success, reveal the proposed solution + # using a Code box inside an Accordion to display the str containing all code + solution_output = ipywidgets.Output() + with solution_output: + display(HTML("

👉 Proposed solution:

")) + + solution_code = ipywidgets.Output() + with solution_code: + display(Code(language="python", data=f"{solution_body}")) + + solution_accordion = ipywidgets.Accordion( + titles=("Click here to reveal",), children=[solution_code] + ) + + solution_box = ipywidgets.Box( + children=[solution_output, solution_accordion], + layout={ + "display": "block" if reveal_solution else "none", + "padding": "0 20px 0 0", + }, + ) + + super().__init__(children=[output_cell, solution_box]) + + +@pytest.fixture +def function_to_test(): + """Function to test, overridden at runtime by the cell magic""" + + +class FunctionInjectionPlugin: + """A class to inject a function to test""" + + def __init__(self, function_to_test: Callable) -> None: + self.function_to_test = function_to_test + + def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None: + # Override the abstract `function_to_test` fixture function + if "function_to_test" in metafunc.fixturenames: + metafunc.parametrize("function_to_test", [self.function_to_test]) + + +class ResultCollector: + """A class that will collect the result of a test. If behaves a bit like a visitor pattern""" + + def __init__(self) -> None: + self.tests: Dict[str, TestResult] = {} + + def pytest_runtest_logreport(self, report: pytest.TestReport): + # Only collect the results if it did not fail + if report.when == "teardown" and report.nodeid not in self.tests: + self.tests[report.nodeid] = TestResult( + report.capstdout, report.capstderr, report.nodeid, not report.failed + ) + + def pytest_exception_interact( + self, node: pytest.Item, call: pytest.CallInfo, report: pytest.TestReport + ): + # We need to collect the results and the stderr if the test failed + if report.failed: + self.tests[node.nodeid] = TestResult( + report.capstdout, + str(call.excinfo.getrepr() if call.excinfo else ""), + report.nodeid, + False, + ) + + +class AstParser: + """ + Helper class for extraction of function definitions and imports. + To find all reference solutions: + Parse the module file using the AST module and retrieve all function definitions and imports. + For each reference solution store the names of all other functions used inside of it. + """ + + def __init__(self, module_file: pathlib.Path) -> None: + self.module_file = module_file + self.function_defs = {} + self.function_imports = {} + self.called_function_names = {} + + tree = ast.parse(self.module_file.read_text(encoding="utf-8")) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.function_defs[node.name] = node + elif isinstance(node, (ast.Import, ast.ImportFrom)) and hasattr( + node, "module" + ): + for n in node.names: + self.function_imports[n.name] = node.module + + for node in tree.body: + if ( + node in self.function_defs.values() + and hasattr(node, "name") + and node.name.startswith("reference_") + ): + self.called_function_names[node.name] = self.retrieve_functions( + {**self.function_defs, **self.function_imports}, node, {node.name} + ) + + def retrieve_functions( + self, all_functions: Dict, node: object, called_functions: Set[object] + ) -> Set[object]: + """ + Recursively walk the AST tree to retrieve all function definitions in a file + """ + + if isinstance(node, ast.AST): + for n in ast.walk(node): + match n: + case ast.Call(ast.Name(id=name)): + called_functions.add(name) + if name in all_functions: + called_functions = self.retrieve_functions( + all_functions, all_functions[name], called_functions + ) + for child in ast.iter_child_nodes(n): + called_functions = self.retrieve_functions( + all_functions, child, called_functions + ) + + return called_functions + + def get_solution_code(self, name): + """ + Find the respective reference solution for the executed function. + Create a str containing its code and the code of all other functions used, + whether coming from the same file or an imported one. + """ + + solution_functions = self.called_function_names[f"reference_{name}"] + solution_code = "" + + for f in solution_functions: + if f in self.function_defs: + solution_code += ast.unparse(self.function_defs[f]) + "\n\n" + elif f in self.function_imports: + function_file = pathlib.Path( + f"{self.function_imports[f].replace('.', '/')}.py" + ) + if function_file.exists(): + function_file_tree = ast.parse( + function_file.read_text(encoding="utf-8") + ) + for node in function_file_tree.body: + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == f + ): + solution_code += ast.unparse(node) + "\n\n" + + return solution_code + + +class FunctionNotFoundError(Exception): + """Custom exception raised when the solution code cannot be parsed""" + + def __init__(self) -> None: + super().__init__("No functions to test defined in the cell") + + +class InstanceNotFoundError(Exception): + """Custom exception raised when an instance cannot be found""" + + def __init__(self, name: str) -> None: + super().__init__(f"Could not get {name} instance")