Skip to content

Commit

Permalink
Enable pip installation (#14)
Browse files Browse the repository at this point in the history
- Change directory structure for Python packaging
- Split `algoesup.py` into modules to separate IPython code
- Add README for PyPI
- Use `poetry` for building and development
    - replace `requirements.txt` with `pyproject.toml`
    - make linters dev dependencies
- Update doc config for Google-style docstrings

---------

Co-authored-by: Michel Wermelinger <[email protected]>
  • Loading branch information
densnow and mwermelinger authored Feb 27, 2024
1 parent 26da24e commit c06c3dd
Show file tree
Hide file tree
Showing 11 changed files with 3,241 additions and 32 deletions.
22 changes: 9 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,18 @@ If you want to adapt this material to your course, this repository has
the guides in the `docs/` folder and the rest in the `Deepnote/` folder.

## Development
If you want to contribute to this repository, create a virtual environment,
preferably with Python 3.10 for compatibility with Deepnote, and install the software:
```bash
python3.10 -m venv venv
. venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
To preview the documentation that will be in GitHub Pages, enter `mkdocs serve`.
This repository is developed with [poetry](https://python-poetry.org).
After installing `poetry`, enter `poetry install` to create the development environment.

To preview the documentation that will be in GitHub Pages, enter `poetry run mkdocs serve`.

The documentation must be written in strict Markdown:
blank line before a new list; break lines with two spaces; indent with 4 spaces.
blank line before a new list; line breaks are two spaces; indentation is 4 spaces.

To build the documentation in the `docs/` folder, enter `mkdocs build`.
(Don't use `mkdocs gh-deploy` because it also pushes untracked files. For example,
it was going to push all 37k+ venv files, even though `venv` is on `.gitignore`!)
To build the documentation in the `docs/` folder from the `src/docs` files,
enter `poetry run mkdocs build`.
(Don't use `mkdocs gh-deploy` because it pushes untracked and other files,
without giving you a chance to check what will be pushed.)

After accepting a commit to folder `Deepnote/`, the owners will upload the
updated files to the Deepnote project linked above.
Expand Down
4 changes: 2 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ plugins:
- mkdocstrings:
handlers:
python:
paths: [Deepnote]
paths: [src]
options:
docstring_style: null
docstring_style: "google"
separate_signature: true
show_signature_annotations: true
show_source: false
Expand Down
2,653 changes: 2,653 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[tool.poetry]
name = "algoesup"
version = "0.1.0"
description = "Algorithmic essay support library"
authors = [
"Michel Wermelinger <[email protected]>",
"Michael Snowden"
]
packages = [
{ include = "algoesup", from = "src" },
]
readme = "src/README.md"
license = "BSD-3-Clause"
repository = "https://github.com/dsa-ou/algoesup"
homepage = "https://dsa-ou.github.io/algoesup/"
keywords = ["education"]
classifiers = [
"Intended Audience :: Education",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3.10",
"Topic :: Education",
"Operating System :: OS Independent",
]

[tool.poetry.dependencies]
python = "^3.10"
ipython = "^8.13.1"
matplotlib = "^3.4.2"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.11"
allowed = "^1.3"
pytype = {version = ">=2023.04.27", markers = "platform_system != 'Windows'"}
mkdocs = "^1.5.3"
mkdocs-material = "^9.5.11"
mkdocstrings = { version = "^0.24.0", extras = ["python"] }
mkdocs-jupyter = "^0.24.6"
mkdocs-open-in-new-tab = "^1.0.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
10 changes: 0 additions & 10 deletions requirements.txt

This file was deleted.

34 changes: 34 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Algoesup

This small library was written to support the writing of
[algorithmic essays](https://dsa-ou.github.io/algoesup),
but can be used independently of that purpose.
The library provides auxiliary functions that make it easier to:
- write unit tests for functions
- measure and plot run-times for best, average and worst case inputs
- use linters within Jupyter Notebook environments.

Guidance on how to use the library is [here](https://dsa-ou.github.io/algoesup/writing/#code).

To install in the global Python environment, open a terminal or PowerShell, and enter
```bash
pip install algoesup
```
To install in a virtual environment, activate it before entering the command above.

The library supports the [ruff](https://docs.astral.sh/ruff) and
[allowed](https://dsa-ou.github.io/allowed) linters, and the
[pytype](https://google.github.io/pytype) type checker.
You have to install them explicitly if you want to use them from within a notebook:
```bash
pip install ruff allowed pytype
```
Note that `pytype` is not available for Windows.

## Licence

`algoesup` is Copyright © 2023–2024 by The Open University, UK.
The code is licensed under a
[BSD 3-clause licence](https://github.com/dsa-ou/algoesup/blob/main/LICENSE).
The documentation is licensed under a
[Creative Commons Attribution 4.0 International Licence](https://creativecommons.org/licenses/by/4.0/)
2 changes: 2 additions & 0 deletions src/algoesup/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .test import test
from .time import time_cases, time_functions, time_functions_int
247 changes: 247 additions & 0 deletions src/algoesup/magics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""Linting tools for Jupyter Notebook environments"""

import json
import os
import re
import subprocess
import tempfile
from typing import Callable

from IPython.core.inputtransformer2 import TransformerManager
from IPython.core.magic import register_line_magic
from IPython.core.magic_arguments import argument, magic_arguments, parse_argstring
from IPython.display import display_markdown


def show_errors(checker: str, output: str, filename: str) -> None:
"""Print the errors for the given file in the checker's output."""
md = [f"**{checker}** found issues:"]
for line in output.split("\n"):
if "syntax" in line.lower() and "error" in line.lower():
continue # syntax errors already reported when running the cell
if m := re.match(rf".*{filename}[^\d]*(\d+[^:]*:.*)", line):
md.append(f"- {m.group(1)}")
if len(md) > 1:
display_markdown("\n".join(md), raw=True)


def show_ruff_json(checker: str, output: str, filename: str) -> None:
"""Print the errors in ruff's JSON output for the given file."""
if errors := json.loads(output):
md = [f"**{checker}** found issues:"]
# the following assumes errors come in line order
for error in errors:
line = error["location"]["row"]
code = error["code"]
url = error["url"]
msg = error["message"]
if error["fix"]:
msg += f". Suggested fix: {error['fix']['message']}"
md.append(f"- {line}: \[[{code}]({url})\] {msg}")
display_markdown("\n".join(md), raw=True)


def show_pytype_errors(checker: str, output: str, filename: str) -> None:
"""Print the errors in pytype's output for the given file."""
md = [f"**{checker}** found issues:"]
for error in output.split("\n"):
if "syntax" in error.lower() and "error" in error.lower():
continue # syntax errors already reported when running the cell
if m := re.match(rf".*{filename}[^\d]*(\d+)[^:]*:(.*)\[(.*)\]", error):
line = m.group(1)
msg = m.group(2)
code = m.group(3)
md.append(
f"- {line}:{msg}\[[{code}](https://google.github.io/pytype/errors.html#{code})\]"
)
if len(md) > 1:
display_markdown("\n".join(md), raw=True)


# register the supported checkers, their commands and the output processor
checkers: dict[str, tuple[str, Callable]] = {
"pytype": ("pytype", show_pytype_errors),
"allowed": ("allowed", show_errors),
"ruff": ("ruff check --output-format json", show_ruff_json),
}
# initially no checker is active
active: set[str] = set()


def process_status(name: str, status: str) -> None:
"""Process the status of a checker."""
if status is None:
print(name, "is", "active" if name in active else "inactive")
elif status == "on":
active.add(name)
print(name, "was activated")
elif status == "off":
active.discard(name)
print(name, "was deactivated")


@magic_arguments()
@argument(
"status",
choices=["on", "off"],
type=str.lower,
help="Activate or deactivate the linter. If omitted, show the current status.",
nargs="?",
default=None,
)
@register_line_magic
def pytype(line: str) -> None:
"""Activate/deactivate the `pytype` linter.
This ipython magic command controls the activation state of the `pytype` linter within
the ipython environment. It can be toggled on or off, or queried for its
current state.
Examples:
```
%pytype on
pytype was activated
%pytype off
pytype was deactivated
%pytype
pytype is inactive
```
"""
args = parse_argstring(pytype, line)
process_status("pytype", args.status)


@magic_arguments()
@argument(
"-c",
"--config",
default=None,
help="Use configuration file CONFIG (default: m269.json).",
)
@argument(
"status",
choices=["on", "off"],
type=str.lower,
help="Activate or deactivate the linter. If omitted, show the current status.",
nargs="?",
default=None,
)
@register_line_magic
def allowed(line: str) -> None:
"""Activate/deactivate the `pytype` linter.
This magic command controls the activation state of the `pytype` linter within
the ipython environment. It can be toggled on or off, or queried for its
current state.
Examples:
```
%pytype on
pytype was activated
%pytype off
pytype was deactivated
%pytype
pytype is inactive
```
"""
args = parse_argstring(allowed, line)
config = f"-c {args.config}" if args.config else ""
checkers["allowed"] = (f"allowed {config}", show_errors)
process_status("allowed", args.status)


@magic_arguments()
@argument(
"status",
choices=["on", "off"],
type=str.lower,
help="Activate or deactivate the linter. If omitted, show the current status.",
nargs="?",
default=None,
)
@register_line_magic
def ruff(line: str) -> None:
"""Activate/deactivate the `ruff` linter.
This ipython magic command controls the activation state of the `ruff` linter within
the interactive environment. It can be toggled on or off, or queried for its
current state.
Examples:
`%ruff on`
`ruff was activated`
`%ruff off`
`ruff was deactivated`
`%ruff`
`ruff is inactive`
"""
args = parse_argstring(ruff, line)
process_status("ruff", args.status)


# TODO: add an option to set the output processor function
@register_line_magic
def checker(line: str) -> None:
"""Define or turn on/off a given checker."""
global checkers, active

args = line.split()
if len(args) == 0:
print("Active checkers:", *active)
print("Inactive checkers:", *(set(checkers) - active))
return

name = args[0]
if len(args) == 1:
if name not in checkers:
print(f"Checker {name} isn't defined.")
else:
status = "active" if name in active else "inactive"
print(f"Checker {name} is {status} and defined as '{checkers[name]}'.")
elif args[1].lower() == "on":
if name not in checkers:
print(f"Error: checker {name} isn't defined.")
else:
active.add(name)
print(f"Checker {name} has been activated.")
elif args[1].lower() == "off":
if name not in checkers:
print(f"Error: checker {name} isn't defined.")
else:
active.discard(name)
print(f"Checker {name} has been deactivated.")
else:
command = " ".join(args[1:])
status = "redefined" if name in checkers else "defined"
checkers[name] = command
active.add(name)
print(f"Checker {name} has been {status} and activated.")


def run_checkers(result) -> None:
"""Run all active checkers after a cell is executed."""
if not active:
return
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as temp:
# transform IPython to pure Python to avoid linters reporting syntax errors
temp.write(TransformerManager().transform_cell(result.info.raw_cell))
temp_name = temp.name
for checker in active:
command, display = checkers[checker]
command += " " + temp_name
try:
output = subprocess.run(
command, capture_output=True, text=True, check=False, shell=True
).stdout
display(checker, output, temp_name)
except Exception as e:
print(f"Error on executing {command}:\n{e}")
except Exception as e:
print(f"Error on writing cell to a temporary file:\n{e}")
finally:
os.remove(temp_name)


def load_ipython_extension(ipython):
ipython.events.register("post_run_cell", run_checkers) # type: ignore[name-defined]
Loading

0 comments on commit c06c3dd

Please sign in to comment.