diff --git a/src/py/templates/.gitkeep b/.githooks/.gitkeep similarity index 100% rename from src/py/templates/.gitkeep rename to .githooks/.gitkeep diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..00d6070 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,16 @@ +#!/bin/sh +# this shell script is stored as .githooks/pre-commit + +UID=$(id -u) + +echo 'Check entangled files are up to date' + +# Entangle Markdown to source code and store the output +LOG=$(docker run --rm --user ${UID} -v ${PWD}:/data nlesc/pandoc-tangle:0.5.0 --preserve-tabs *.md 2>&1 > /dev/null) +# Parse which filenames have been written from output +FILES=$(echo $LOG | perl -ne 'print $1,"\n" if /^Writing \`(.*)\`./') +[ -z "$FILES" ] && exit 0 +echo $FILES + +echo 'Adding written files to commit' +echo $FILES | xargs git add \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f4d2cbb..370927a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,26 +8,31 @@ on: [push, pull_request] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: + entangle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Check all entangled files are in sync with Markdown + run: make check cpp: # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - # Should not be needed anymore when https://github.com/NLESC-JCER/cpp2wasm/issues/1 is fixed - - name: Generate source code - uses: docker://nlesc/pandoc-tangle:0.5.0 - with: - args: --preserve-tabs README.md INSTALL.md + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 - - name: Run C++ examples - run: make test-cli test-cgi + - name: Run C++ examples + run: make test-cli test-cgi python: - # The type of runner that the job will run on + # The type of runner that the job will run on + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.6, 3.7, 3.8] + fail-fast: true # Redis is needed for Celery services: redis: @@ -35,65 +40,64 @@ jobs: ports: - 6379:6379 steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - # Should not be needed anymore when https://github.com/NLESC-JCER/cpp2wasm/issues/1 is fixed - - name: Generate source code - uses: docker://nlesc/pandoc-tangle - with: - args: --preserve-tabs README.md INSTALL.md - - - uses: actions/setup-python@v1 - with: - python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax - architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - - - name: Install Python dependencies - run: make py-deps && pip install httpie - - - name: Run Python example - run: make test-py - - - name: Start web application in background - run: make run-webapp & - - - name: Test web application - run: http --ignore-stdin -f localhost:5001 epsilon=0.001 guess=-20 - - - name: Start web service in background - run: make run-webservice & - - - name: Test web service - run: make test-webservice - - - name: Start Celery web app in background - run: make run-celery-webapp & - - - name: Start Celery worker in background - run: | - cd src/py - PYTHONPATH=$PWD/../.. celery -A tasks worker & - cd ../.. - - - name: Test Celery web app - run: | - http --ignore-stdin -hf localhost:5000 epsilon=0.001 guess=-20 | tee response.txt - # Parse result url from response - RESULT_URL=$(cat response.txt |grep Location |awk '{print $2}') - sleep 2 - http --ignore-stdin $RESULT_URL + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + architecture: "x64" + + - name: Which Python + run: which python + + - name: Install Python dependencies + run: make py-deps && pip install httpie + + - name: Run Python example + run: make test-py + + - name: Start web application in background + run: make run-webapp 2>&1 | tee ./run-webapp.log & + + - name: Test web application + run: http --ignore-stdin -f localhost:5001 epsilon=0.001 guess=-20 + + - name: Start web service in background + run: make run-webservice 2>&1 | tee ./run-webservice.log & + + - name: Test web service + run: make test-webservice + + - name: Start Celery web app in background + run: make run-celery-webapp 2>&1 | tee ./run-celery-webapp.log & + + - name: Start Celery worker in background + run: | + cd src/py + PYTHONPATH=$PWD/../.. celery -A tasks worker 2>&1 | tee ./run-celery-worker.log & + cd ../.. + + - name: Test Celery web app + run: | + http --ignore-stdin -hf localhost:5000 epsilon=0.001 guess=-20 | tee response.txt + # Parse result url from response + RESULT_URL=$(cat response.txt |grep Location |awk '{print $2}') + sleep 2 + http --ignore-stdin $RESULT_URL + + - name: Upload log of services + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: service-logs + path: ./run-*.log wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - # Should not be needed anymore when https://github.com/NLESC-JCER/cpp2wasm/issues/1 is fixed - - name: Generate source code - uses: docker://nlesc/pandoc-tangle - with: - args: --preserve-tabs README.md INSTALL.md - - name: Install emscripten uses: mymindstorm/setup-emsdk@v4 @@ -101,7 +105,14 @@ jobs: run: make build-wasm - name: Start web server for hosting files in background - run: make host-files & + run: make host-files 2>&1 | tee ./web-server.log & - name: Run tests run: make test-wasm + + - name: Upload log of web server + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: web-server-log + path: ./web-server.log diff --git a/.gitignore b/.gitignore index 5824f8b..304aa76 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,13 @@ __pycache__/ cypress/plugins cypress/support cypress/videos + +# Ignore compiled files +bin/newtonraphson.exe +src/py/newtonraphsonpy.*.so +apache2/cgi-bin/newtonraphson +src/js/newtonraphsonwasm.js +src/js/newtonraphsonwasm.wasm + +# Ignore entangled db +.entangled/db.sqlite diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..89c5b5b --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,6 @@ +{ + "license": { + "id": "Apache-2.0" + }, + "title": "Guide to make C++ available as a web application" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4aa11fd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,180 @@ +# Contributing + +- [Contributing](#contributing) + - [Types of Contributions](#types-of-contributions) + - [Report Bugs](#report-bugs) + - [Fix Bugs](#fix-bugs) + - [Implement Features](#implement-features) + - [Submit Feedback](#submit-feedback) + - [Get Started!](#get-started) + - [Pull Request Guidelines](#pull-request-guidelines) + - [Tips](#tips) + - [Generating code from Markdown](#generating-code-from-markdown) + - [Generate code from Markdown and vice versa](generate-code-from-markdown-and-vice-versa) + - [Generate code from Markdown on commit](#generate-code-from-markdown-on-commit) + - [New release](#new-release) + +Contributions are welcome, and they are greatly appreciated! Every little bit +helps, and credit will always be given. + +You can contribute in many ways: + +## Types of Contributions + +### Report Bugs + +Report bugs at [https://github.com/NLESC-JCER/cpp2wasm/issues](https://github.com/NLESC-JCER/cpp2wasm/issues). + +If you are reporting a bug, please include: + +- Your operating system name and version. +- Any details about your local setup that might be helpful in troubleshooting. +- Detailed steps to reproduce the bug. + +### Fix Bugs + +Look through the GitHub issues for bugs. Anything tagged with "bug" and "help +wanted" is open to whoever wants to implement it. + +### Implement Features + +Look through the GitHub issues for features. Anything tagged with "enhancement" +and "help wanted" is open to whoever wants to implement it. + +### Submit Feedback + +The best way to send feedback is to file an issue at [https://github.com/NLESC-JCER/cpp2wasm/issues](https://github.com/NLESC-JCER/cpp2wasm/issues). + +If you are proposing a feature: + +- Explain in detail how it would work. +- Keep the scope as narrow as possible, to make it easier to implement. +- Remember that this is a volunteer-driven project, and that contributions + are welcome :) + +## Get Started! + +Ready to contribute? Here's how to set up `cpp2wasm` for local development. + +1. Fork the `cpp2wasm` repo on GitHub. +2. Clone your fork locally:: + + ```shell + git clone git@github.com:your_name_here/cpp2wasm.git + ``` + +3. Install the dependencies as listed in [INSTALL.md#dependencies](INSTALL.md#dependencies). + +4. Create a branch for local development:: + + ```shell + git checkout -b name-of-your-bugfix-or-feature + ``` + + Now you can make your changes locally. + +5. Write tests where possible. Writing tests should be done in a literate way in [TESTING.md](TESTING.md) + +6. When you're done making changes, make sure the Markdown and source code files are entangled with. + + ```shell + make entangle + ``` + +7. Commit your changes and push your branch to GitHub:: + + ```shell + git add . + git commit -m "Your detailed description of your changes." + git push origin name-of-your-bugfix-or-feature + ``` + +8. Submit a pull request through the GitHub website. + +## Pull Request Guidelines + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests. +2. If the pull request adds functionality, the docs should be updated. Put + your new functionality into a function with a docstring, and add the + feature to the list in README.rst. +3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check + https://travis-ci.com/{{ cookiecutter.github_username }}/cpp2wasm/pull_requests + and make sure that the tests pass for all supported Python versions. + +## Tips + +## Generating code from Markdown + +The [Entangled - Pandoc filters](https://github.com/entangled/filters) Docker image can be used to generate source code files from the Markdown files. + +```{.awk #pandoc-tangle} +docker run --rm --user ${UID} -v ${PWD}:/data nlesc/pandoc-tangle:0.5.0 --preserve-tabs *.md +``` + +## Generate code from Markdown and vice versa + +Use Entangled deamon to convert code blocks in Markdown to and from source code files. +Each time a Markdown code block is changed the source code files will be updated. +Each time a source code file is changed the code blocks in the Markdown files will be updated. + +1. Install [entangled](https://github.com/entangled/entangled) +2. Run entangled daemon with + +```shell +entangled daemon +``` + +### Generate code from Markdown on commit + +To automatically generate code from Markdown on each commit, initialize the git hook with. + +```shell +make init-git-hook +``` + +The rest of this section describes how the git hook works. + +The pre-commit hook script runs entangle using Docker and adds newly written files to the current git commit. + +```{.awk file=.githooks/pre-commit} +#!/bin/sh +# this shell script is stored as .githooks/pre-commit + +UID=$(id -u) + +echo 'Check entangled files are up to date' + +# Entangle Markdown to source code and store the output +LOG=$(docker run --rm --user ${UID} -v ${PWD}:/data nlesc/pandoc-tangle:0.5.0 --preserve-tabs *.md 2>&1 > /dev/null) +# Parse which filenames have been written from output +FILES=$(echo $LOG | perl -ne 'print $1,"\n" if /^Writing \`(.*)\`./') +[ -z "$FILES" ] && exit 0 +echo $FILES + +echo 'Adding written files to commit' +echo $FILES | xargs git add +``` + +The hook must be made executable with + +```{.awk #hook-permission} +chmod +x .githooks/pre-commit +``` + +The git hook can be enabled with + +```{.awk #init-git-hook} +git config --local core.hooksPath .githooks +``` + +(`core.hooksPath` config is available in git version >= 2.9) + +## New release + +A reminder for the maintainers on how to create a new release. + +1. Make sure all your changes are committed. +1. Create a GitHub release +1. Check and fix author list on Zenodo diff --git a/INSTALL.md b/INSTALL.md index d6dd941..8daf4a4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -9,30 +9,28 @@ To run the commands in the README.md the following items are required 1. Python development, install with `sudo apt install -y python3-dev` 1. [Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html) 1. [Docker Engine](https://docs.docker.com/install/), setup so `docker` command can be run [without sudo](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user). +1. [Perl](https://www.perl.org/), already installed on Linux -## Generating code from Markdown - -Entangled is used to convert code blocks in Markdown to source code files. +## Command collection -1. Install [entangled](https://github.com/entangled/entangled) -2. Run entangled daemon with +All the commands in the [README.md](README.md) and [CONTRIBUTING.md](CONTRIBUTING.md) can be captured in a [Makefile](https://en.wikipedia.org/wiki/Makefile) like so: -```shell -entangled README.md INSTALL.md -``` +```{.makefile file=Makefile} +# this Makefile snippet is stored as Makefile +.PHONY: clean clean-compiled clean-entangled test all check entangle entangle-list py-deps start-redis stop-redis run-webservice run-celery-webapp run-webapp build-wasm host-files test-wasm -Or the [Entangled - Pandoc filters](https://github.com/entangled/filters) Docker image can be used +UID := $(shell id -u) +# Prevent suicide by excluding Makefile +ENTANGLED := $(shell perl -ne 'print $$1,"\n" if /^```\{.*file=(.*)\}/' *.md | grep -v Makefile | sort -u) +COMPILED := bin/newtonraphson.exe src/py/newtonraphsonpy.*.so apache2/cgi-bin/newtonraphson src/js/newtonraphsonwasm.js src/js/newtonraphsonwasm.wasm -```shell -docker run --rm -ti --user $(id -u) -v ${PWD}:/data nlesc/pandoc-tangle:0.5.0 --preserve-tabs README.md INSTALL.md -``` +entangle: *.md + <> -## Command collection +$(ENTANGLED): entangle -All the commands in the README.md can be captured in a Makefile like so: - -```{.makefile file=Makefile} -.PHONY: clean test entangle py-deps start-redis stop-redis run-webservice run-celery-webapp run-webapp build-wasm host-files test-wasm +entangled-list: + @echo $(ENTANGLED) py-deps: pip-pybind11 pip-flask pip-celery pip-connexion @@ -68,9 +66,17 @@ test-py: src/py/example.py src/py/newtonraphsonpy.*.so test: test-cli test-cgi test-py test-webservice +all: $(ENTANGLED) $(COMPILED) + +clean: clean-compiled clean-entangled + # Removes the compiled files -clean: - $(RM) bin/newtonraphson.exe src/py/newtonraphsonpy.*.so apache2/cgi-bin/newtonraphson src/js/newtonraphsonwasm.js src/js/newtonraphsonwasm.wasm +clean-compiled: + $(RM) $(COMPILED) + +# Removed the entangled files +clean-entangled: + $(RM) $(ENTANGLED) start-redis: <> @@ -104,6 +110,12 @@ host-files: build-wasm test-wasm: <> +init-git-hook: + <> + <> + +check: entangle + git diff-index --quiet HEAD -- ``` For example the Python dependencies can be installed with @@ -112,59 +124,4 @@ For example the Python dependencies can be installed with make py-deps ``` -See [GitHub Actions workflow](.github/workflows/main.yml) for other usages of the Makefile. - -## Tests - -To make sure WebAssembly module code snippets work we want have a tests for it. -To test the WebAssembly module we will use the [cypress](https://www.cypress.io/) test framework. -Cypress can simulate what a user would do and expect in a web browser. - -We want to test if visiting the example web page renders the answer `-1.00`. - -First a test for the direct WebAssembly example. - -```{.js file=cypress/integration/example_spec.js} -// this JavaScript snippet is run by cypress and is stored as cypress/integration/example_spec.js -describe('src/js/example.html', () => { - it('should render -1.00', () => { - cy.visit('http://localhost:8000/src/js/example.html'); - cy.get('#answer').contains('-1.00'); - }); -}); -``` - -Second a test for the WebAssembly called through a web worker. - -```{.js file=cypress/integration/example-web-worker_spec.js} -// this JavaScript snippet is run by cypress and is stored as cypress/integration/example-web-worker_spec.js -describe('src/js/example-web-worker.html', () => { - it('should render -1.00', () => { - cy.visit('http://localhost:8000/src/js/example-web-worker.html'); - cy.get('#answer').contains('-1.00'); - }); -}); -``` - -And lastly a test for the full React/form/Web worker/WebAssembly combination. - -```{.js file=cypress/integration/example-app_spec.js} -describe('src/js/example-app.html', () => { - it('should render -1.00', () => { - cy.visit('http://localhost:8000/src/js/example-app.html'); - cy.get('input[name=guess]').type('-30'); - cy.contains('Submit').click(); - cy.get('#answer').contains('-1.00'); - }); -}); -``` - -The test can be run with - -```{.awk #test-wasm} -npx cypress run --config-file false -``` - -The `npx` command ships with NodeJS which is included in the Emscripten SDK and can be used to run commands available on [npm repository](https://npmjs.com/). - -The tests will also be run in the [GH Action continous integration build](.github/workflows/main.yml). +See [GitHub Actions workflow](.github/workflows/main.yml) and [CONTRIBUTING.md](CONTRIBUTING.md) for other usages of the Makefile. diff --git a/Makefile b/Makefile index 1a1fc32..a69c1d5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,18 @@ -.PHONY: clean test entangle py-deps start-redis stop-redis run-webservice run-celery-webapp run-webapp build-wasm host-files test-wasm +# this Makefile snippet is stored as Makefile +.PHONY: clean clean-compiled clean-entangled test all check entangle entangle-list py-deps start-redis stop-redis run-webservice run-celery-webapp run-webapp build-wasm host-files test-wasm + +UID := $(shell id -u) +# Prevent suicide by excluding Makefile +ENTANGLED := $(shell perl -ne 'print $$1,"\n" if /^```\{.*file=(.*)\}/' *.md | grep -v Makefile | sort -u) +COMPILED := bin/newtonraphson.exe src/py/newtonraphsonpy.*.so apache2/cgi-bin/newtonraphson src/js/newtonraphsonwasm.js src/js/newtonraphsonwasm.wasm + +entangle: *.md + docker run --rm --user ${UID} -v ${PWD}:/data nlesc/pandoc-tangle:0.5.0 --preserve-tabs *.md + +$(ENTANGLED): entangle + +entangled-list: + @echo $(ENTANGLED) py-deps: pip-pybind11 pip-flask pip-celery pip-connexion @@ -9,7 +23,7 @@ pip-flask: pip install flask pip-celery: - pip install celery[redis] + pip install celery[redis]==4.4.3 pip-connexion: pip install connexion[swagger-ui] @@ -35,9 +49,17 @@ test-py: src/py/example.py src/py/newtonraphsonpy.*.so test: test-cli test-cgi test-py test-webservice +all: $(ENTANGLED) $(COMPILED) + +clean: clean-compiled clean-entangled + # Removes the compiled files -clean: - $(RM) bin/newtonraphson.exe src/py/newtonraphsonpy.*.so apache2/cgi-bin/newtonraphson src/js/newtonraphsonwasm.js src/js/newtonraphsonwasm.wasm +clean-compiled: + $(RM) $(COMPILED) + +# Removed the entangled files +clean-entangled: + $(RM) $(ENTANGLED) start-redis: docker run --rm -d -p 6379:6379 --name some-redis redis @@ -69,4 +91,11 @@ host-files: build-wasm python3 -m http.server 8000 test-wasm: - npx cypress run --config-file false \ No newline at end of file + npx cypress run --config-file false + +init-git-hook: + chmod +x .githooks/pre-commit + git config --local core.hooksPath .githooks + +check: entangle + git diff-index --quiet HEAD -- \ No newline at end of file diff --git a/README.md b/README.md index 3c3da4a..c271398 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Visualization](#visualization) [![CI](https://github.com/NLESC-JCER/cpp2wasm/workflows/CI/badge.svg)](https://github.com/NLESC-JCER/cpp2wasm/actions?query=workflow%3ACI) +[![Entangled](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/) Document describing a way that a researcher with a C++ algorithm can make it available as a web application. We will host the C++ algorithm as an web application in several different ways: @@ -25,7 +26,7 @@ Document describing a way that a researcher with a C++ algorithm can make it ava - as a Python application via pybind11, Flask and Celery - in the web browser using web assembly and JavaScript -We assume the operating system is Linux (We used Linux while writing this guide). The required dependencies to run this guide and method to convert the code snippets to files are described in the [INSTALL.md](INSTALL.md) document. +This guide was written and tested on Linux operating system. The required dependencies to run this guide are described in the [INSTALL.md](INSTALL.md) document. If you want to contribute to the guide see [CONTRIBUTING.md](CONTRIBUTING.md). The [repo](https://github.com/NLESC-JCER/cpp2wasm) contains the files that can be made from the code snippets in this guide. The code snippets can be [entangled](https://entangled.github.io/) to files using any of [these](CONTRIBUTING.md#tips) methods. The [Newton-Raphson root finding algorithm](https://en.wikipedia.org/wiki/Newton%27s_method) will be the use case. The algorithm is explained in [this video series](https://www.youtube.com/watch?v=cOmAk82cr9M). @@ -373,98 +374,25 @@ The web application has 3 kinds of pages: 2. a page to show the progress of the calculation 3. and a page which shows the result of the calculation. Each calculation will have it's own submit and result page. -Each page is available on a different url. In flask the way urls are mapped to Python function is done by adding a route decorator to the function for example: +Each page is available on a different url. In Flask the way urls are mapped to Python function is done by adding a [route decorator](https://flask.palletsprojects.com/en/1.1.x/quickstart/#routing) (`@app.route`) to the function. -```{.python file=src/py/hello.py} -# this Python snippet is stored as src/py/hello.py -from flask import Flask -app = Flask(__name__) - -@app.route("/") -def hello(): - return "Hello World!" - -app.run() -``` - -Run with - -```{.awk #py-hello} -python src/py/hello.py -``` - -The above route will just return the string "Hello World!" in the web browser when visiting [http://localhost:5000/](http://localhost:5000/). It is possible to return a html page as well, but to make it dynamic it soon becomes a mess of string concatenations. Template engines help to avoid the concatination mess. Flask is configured with the [Jinja2](https://jinja.palletsprojects.com/) template engine. A template for the above route could look like: - -```{.html file=src/py/templates/hello.html} -{# this Jinja2 template snippet is stored as src/py/templates/hello.html #} - -Hello from Flask -{% if name %} -

Hello {{ name }}!

-{% else %} -

Hello, World!

-{% endif %} -``` - -and to render the template the function would look like: - -```{.python file=src/py/hello-templated.py} -# this Python snippet is stored as src/py/hello-templated.py -from flask import Flask, render_template - -app = Flask(__name__) - -@app.route('/hello/') -def hello_name(name=None): - return render_template('hello.html', name=name) - -app.run() -``` - -Where `name` is a variable which gets combined with template to render into a html page. - -The web application can be started with - -```{.awk #py-hello-templated} -python src/py/hello-templated.py -``` - -In a web browser you can visit [http://localhost:5000/hello/yourname](http://localhost:5000/hello/yourname) to the web application. - -Let's make the web application for our Newton raphson algorithm. - -The first thing we want is the web page with the form, the template that renders the form looks like - -```{.html file=src/py/templates/form.html} -{# this Jinja2 template snippet is stored as src/py/templates/form.html #} - -
- - - - - -
-``` - -The home page will render the form like so +The first page with the form and submit button is defined as a function returning a HTML form. ```{.python #py-form} # this Python code snippet is later referred to as <> @app.route('/', methods=['GET']) def form(): - return render_template('form.html') -``` - -The result will be displayed on a html page with the following template - -```{.html file=src/py/templates/result.html} -{# this Jinja2 template snippet is stored as src/py/templates/result.html #} - -

With epsilon of {{ epsilon }} and a guess of {{ guess }} the found root is {{ root }}.

+ return ''' +
+ + + + + +
''' ``` -The form will be submitted to the '/' path with the POST method. In the handler of this route we want to perform the calculation and return the result html page. +The form will be submitted to the '/' path with the POST method. In the handler of this route we want to perform the calculation and return the result html page. To get the submitted values we use the Flask global [request](https://flask.palletsprojects.com/en/1.1.x/quickstart/#accessing-request-data) object. To construct the returned html we use [f-strings](https://docs.python.org/3/reference/lexical_analysis.html#formatted-string-literals) to replace the variable names with the variable values. ```{.python #py-calculate} # this Python code snippet is later referred to as <> @@ -477,14 +405,19 @@ def calculate(): finder = NewtonRaphson(epsilon) root = finder.find(guess) - return render_template('result.html', epsilon=epsilon, guess=guess, root=root) + return f''' +

With epsilon of {epsilon} and a guess of {guess} the found root is {root}.

''' +``` + +```{.python #py-calculate} + # this Python code snippet is appended to <> ``` Putting it all together in ```{.python file=src/py/webapp.py} # this Python snippet is stored as src/py/webapp.py -from flask import Flask, render_template, request +from flask import Flask, request app = Flask(__name__) <> @@ -516,7 +449,7 @@ docker run --rm -d -p 6379:6379 --name some-redis redis To use Celery we must install the redis flavored version with ```{.awk #pip-celery} -pip install celery[redis] +pip install celery[redis]==4.4.3 ``` Let's set up a method that can be submitted to the Celery task queue. @@ -576,9 +509,14 @@ def result(jobid): job.maybe_throw() if job.successful(): result = job.get() - return render_template('result.html', epsilon=result['epsilon'], guess=result['guess'], root=result['root']) + epsilon = result['epsilon'] + guess = result['guess'] + root = result['root'] + return f''' +

With epsilon of {epsilon} and a guess of {guess} the found root is {root}.

''' else: - return job.status + return f''' +

{job.status}

''' ``` Putting it all together @@ -952,7 +890,6 @@ To make writing a SPA easier, a number of frameworks have been developed. The mo They have their strengths and weaknesses which are summarized in the [here](https://en.wikipedia.org/wiki/Comparison_of_JavaScript_frameworks#Features). - For Newton-Raphson web application I picked React as it is light and functional, because I like the small API footprint and the functional programming paradigm. The C++ algorithm is compiled into a wasm file using bindings. When a calculation form is submitted in the React application a web worker loads the wasm file, starts the calculation, renders the result. With this architecture the application only needs cheap static file hosting to host the html, js and wasm files. **The calculation will be done in the web browser on the end users machine instead of a server**. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a8fae55 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,55 @@ +# Tests + +To make sure [JavaScript and WebAssembly code snippets](README.md#JavaScript) and [Single page application](README.md#single-page-application) work we want have a tests for them. + +To test, we will use the [cypress](https://www.cypress.io/) JavaScript end to end testing framework. +Cypress can simulate user behavior such as clicking buttons etc. and checks expected result in a web browser. + +In the following example, we test if the example web page renders the answer `-1.00` when it is visited. + +Let's, first write a test for the direct WebAssembly example. + +```{.js file=cypress/integration/example_spec.js} +// this JavaScript snippet is run by cypress and is stored as cypress/integration/example_spec.js +describe('src/js/example.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example.html'); + cy.get('#answer').contains('-1.00'); + }); +}); +``` + +Second, a test for the WebAssembly called through a web worker. + +```{.js file=cypress/integration/example-web-worker_spec.js} +// this JavaScript snippet is run by cypress and is stored as cypress/integration/example-web-worker_spec.js +describe('src/js/example-web-worker.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-web-worker.html'); + cy.get('#answer').contains('-1.00'); + }); +}); +``` + +And lastly, a test for the React/form/Web worker/WebAssembly combination. + +```{.js file=cypress/integration/example-app_spec.js} +describe('src/js/example-app.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-app.html'); + cy.get('input[name=guess]').type('-30'); + cy.contains('Submit').click(); + cy.get('#answer').contains('-1.00'); + }); +}); +``` + +The test can be run with the following command: + +```{.awk #test-wasm} +npx cypress run --config-file false +``` + +The [`npx`](https://www.npmjs.com/package/npx) command ships with NodeJS which is included in the Emscripten SDK and can be used to run commands available on [npm repository](https://npmjs.com/). + +The tests will also be run in the [GH Action continous integration build](.github/workflows/main.yml). diff --git a/apache2/apache2.conf b/apache2/apache2.conf new file mode 100644 index 0000000..6961d67 --- /dev/null +++ b/apache2/apache2.conf @@ -0,0 +1,11 @@ +# this Apache2 configuration snippet is stored as apache2/apache2.conf +ServerName 127.0.0.1 +Listen 8080 +LoadModule mpm_event_module /usr/lib/apache2/modules/mod_mpm_event.so +LoadModule authz_core_module /usr/lib/apache2/modules/mod_authz_core.so +LoadModule alias_module /usr/lib/apache2/modules/mod_alias.so +LoadModule cgi_module /usr/lib/apache2/modules/mod_cgi.so +ErrorLog httpd_error_log +PidFile httpd.pid + +ScriptAlias "/cgi-bin/" "cgi-bin/" \ No newline at end of file diff --git a/cypress/integration/example-app_spec.js b/cypress/integration/example-app_spec.js new file mode 100644 index 0000000..f831bac --- /dev/null +++ b/cypress/integration/example-app_spec.js @@ -0,0 +1,8 @@ +describe('src/js/example-app.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-app.html'); + cy.get('input[name=guess]').type('-30'); + cy.contains('Submit').click(); + cy.get('#answer').contains('-1.00'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/example-web-worker_spec.js b/cypress/integration/example-web-worker_spec.js new file mode 100644 index 0000000..fcc8b24 --- /dev/null +++ b/cypress/integration/example-web-worker_spec.js @@ -0,0 +1,7 @@ +// this JavaScript snippet is run by cypress and is stored as cypress/integration/example-web-worker_spec.js +describe('src/js/example-web-worker.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-web-worker.html'); + cy.get('#answer').contains('-1.00'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/example_spec.js b/cypress/integration/example_spec.js new file mode 100644 index 0000000..023a8fd --- /dev/null +++ b/cypress/integration/example_spec.js @@ -0,0 +1,7 @@ +// this JavaScript snippet is run by cypress and is stored as cypress/integration/example_spec.js +describe('src/js/example.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example.html'); + cy.get('#answer').contains('-1.00'); + }); +}); \ No newline at end of file diff --git a/entangled.dhall b/entangled.dhall new file mode 100644 index 0000000..ea5bcba --- /dev/null +++ b/entangled.dhall @@ -0,0 +1,7 @@ +let entangled = https://raw.githubusercontent.com/entangled/entangled/v1.0.1/data/config-schema.dhall + sha256:9fd18824499379eee53b974ca7570b3bc064fda546348d9b31841afab3b053a7 + +in { entangled = entangled.Config :: { database = Some ".entangled/db.sqlite" + , watchList = ["README.md", "INSTALL.md", "CONTRIBUTING.md", "TESTING.md"] : List Text + } + } diff --git a/src/cgi-newtonraphson.cpp b/src/cgi-newtonraphson.cpp new file mode 100644 index 0000000..fa2ebd8 --- /dev/null +++ b/src/cgi-newtonraphson.cpp @@ -0,0 +1,63 @@ +// this C++ snippet is stored as src/cgi-newtonraphson.hpp +#include +#include +#include + +// this C++ code snippet is later referred to as <> +#include "newtonraphson.hpp" + +namespace rootfinding +{ + +// An example function is x^3 - x^2 + 2 +double func(double x) +{ + return x * x * x - x * x + 2; +} + +// Derivative of the above function which is 3*x^x - 2*x +double derivFunc(double x) +{ + return 3 * x * x - 2 * x; +} + +NewtonRaphson::NewtonRaphson(double tolerancein) : tolerance(tolerancein) {} + +// Function to find the root +double NewtonRaphson::find(double xin) +{ + double x = xin; + double delta_x = func(x) / derivFunc(x); + while (abs(delta_x) >= tolerance) + { + delta_x = func(x) / derivFunc(x); + + // x_new = x_old - f(x) / f'(x) + x = x - delta_x; + } + return x; +}; + + +} // namespace rootfinding + +int main(int argc, char *argv[]) +{ + std::cout << "Content-type: application/json" << std::endl << std::endl; + + // Retrieve epsilon and guess from request body + nlohmann::json request(nlohmann::json::parse(std::cin)); + double epsilon = request["epsilon"]; + double guess = request["guess"]; + + // Find root + rootfinding::NewtonRaphson finder(epsilon); + double root = finder.find(guess); + + // Assemble response + nlohmann::json response; + response["guess"] = guess; + response["root"] = root; + std::cout << response.dump(2) << std::endl; + return 0; +} \ No newline at end of file diff --git a/src/cli-newtonraphson.cpp b/src/cli-newtonraphson.cpp new file mode 100644 index 0000000..b72e168 --- /dev/null +++ b/src/cli-newtonraphson.cpp @@ -0,0 +1,52 @@ +// this C++ snippet is stored as src/newtonraphson.cpp +#include + +// this C++ code snippet is later referred to as <> +#include "newtonraphson.hpp" + +namespace rootfinding +{ + +// An example function is x^3 - x^2 + 2 +double func(double x) +{ + return x * x * x - x * x + 2; +} + +// Derivative of the above function which is 3*x^x - 2*x +double derivFunc(double x) +{ + return 3 * x * x - 2 * x; +} + +NewtonRaphson::NewtonRaphson(double tolerancein) : tolerance(tolerancein) {} + +// Function to find the root +double NewtonRaphson::find(double xin) +{ + double x = xin; + double delta_x = func(x) / derivFunc(x); + while (abs(delta_x) >= tolerance) + { + delta_x = func(x) / derivFunc(x); + + // x_new = x_old - f(x) / f'(x) + x = x - delta_x; + } + return x; +}; + + +} // namespace rootfinding + +// Driver program to test above +int main() +{ + double x0 = -20; // Initial values assumed + double epsilon = 0.001; + rootfinding::NewtonRaphson finder(epsilon); + double x1 = finder.find(x0); + + std::cout << "The value of the root is : " << x1 << std::endl; + return 0; +} \ No newline at end of file diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 0000000..a320aff --- /dev/null +++ b/src/js/app.js @@ -0,0 +1,75 @@ +// this JavaScript snippet is stored as src/js/app.js +function Heading() { + const title = 'Root finding web application'; + return

{title}

+} +// this JavaScript snippet stored as src/js/app.js +function Result(props) { + const root = props.root; + let message = 'Not submitted'; + if (root !== undefined) { + message = 'Root = ' + root; + } + return
{message}
; +} +// this JavaScript snippet appenended to src/js/app.js +function App() { + // this JavaScript snippet is later referred to as <> + const [epsilon, setEpsilon] = React.useState(0.001); + // this JavaScript snippet is appended to <> + function onEpsilonChange(event) { + setEpsilon(event.target.value); + } + // this JavaScript snippet is appended to <> + const [guess, setGuess] = React.useState(-20); + + function onGuessChange(event) { + setGuess(event.target.value); + } + // this JavaScript snippet is appended to <> + const [root, setRoot] = React.useState(undefined); + + function handleSubmit(event) { + // this JavaScript snippet is later referred to as <> + event.preventDefault(); + // this JavaScript snippet is appended to <> + const worker = new Worker('worker.js'); + // this JavaScript snippet is appended to <> + worker.postMessage({ + type: 'CALCULATE', + payload: { epsilon: epsilon, guess: guess } + }); + // this JavaScript snippet is appended to <> + worker.onmessage = function(message) { + if (message.data.type === 'RESULT') { + const result = message.data.payload.root; + setRoot(result); + worker.terminate(); + } + }; + } + + return ( +
+ + { /* this JavaScript snippet is later referred to as <> */ } +
+ + + +
+ +
+ ); +} +// this JavaScript snippet appenended to src/js/app.js +ReactDOM.render( + , + document.getElementById('container') +); \ No newline at end of file diff --git a/src/js/example-app.html b/src/js/example-app.html new file mode 100644 index 0000000..e24de67 --- /dev/null +++ b/src/js/example-app.html @@ -0,0 +1,12 @@ + + + + + + + + +
+ + + \ No newline at end of file diff --git a/src/js/example-web-worker.html b/src/js/example-web-worker.html new file mode 100644 index 0000000..170077a --- /dev/null +++ b/src/js/example-web-worker.html @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/src/js/example.html b/src/js/example.html new file mode 100644 index 0000000..3ec769c --- /dev/null +++ b/src/js/example.html @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/src/js/worker.js b/src/js/worker.js new file mode 100644 index 0000000..53f7855 --- /dev/null +++ b/src/js/worker.js @@ -0,0 +1,23 @@ +// this JavaScript snippet is stored as src/js/worker.js +importScripts('newtonraphsonwasm.js'); + +// this JavaScript snippet is later referred to as <> +onmessage = function(message) { + // this JavaScript snippet is before referred to as <> + if (message.data.type === 'CALCULATE') { + createModule().then((module) => { + // this JavaScript snippet is before referred to as <> + const epsilon = message.data.payload.epsilon; + const finder = new module.NewtonRaphson(epsilon); + const guess = message.data.payload.guess; + const root = finder.find(guess); + // this JavaScript snippet is before referred to as <> + postMessage({ + type: 'RESULT', + payload: { + root: root + } + }); + }); + } +}; \ No newline at end of file diff --git a/src/newtonraphson.hpp b/src/newtonraphson.hpp new file mode 100644 index 0000000..7cc6647 --- /dev/null +++ b/src/newtonraphson.hpp @@ -0,0 +1,17 @@ +// this C++ snippet is stored as src/newtonraphson.hpp +#ifndef H_NEWTONRAPHSON_H +#define H_NEWTONRAPHSON_H + +#include + +namespace rootfinding { + class NewtonRaphson { + public: + NewtonRaphson(double tolerancein); + double find(double xin); + private: + double tolerance; + }; +} + +#endif \ No newline at end of file diff --git a/src/py-newtonraphson.cpp b/src/py-newtonraphson.cpp new file mode 100644 index 0000000..2555b13 --- /dev/null +++ b/src/py-newtonraphson.cpp @@ -0,0 +1,54 @@ +// this C++ snippet is stored as src/py-newtonraphson.cpp +#include +#include + +// this C++ code snippet is later referred to as <> +#include "newtonraphson.hpp" + +namespace rootfinding +{ + +// An example function is x^3 - x^2 + 2 +double func(double x) +{ + return x * x * x - x * x + 2; +} + +// Derivative of the above function which is 3*x^x - 2*x +double derivFunc(double x) +{ + return 3 * x * x - 2 * x; +} + +NewtonRaphson::NewtonRaphson(double tolerancein) : tolerance(tolerancein) {} + +// Function to find the root +double NewtonRaphson::find(double xin) +{ + double x = xin; + double delta_x = func(x) / derivFunc(x); + while (abs(delta_x) >= tolerance) + { + delta_x = func(x) / derivFunc(x); + + // x_new = x_old - f(x) / f'(x) + x = x - delta_x; + } + return x; +}; + + +} // namespace rootfinding + +namespace py = pybind11; + +PYBIND11_MODULE(newtonraphsonpy, m) { + py::class_(m, "NewtonRaphson") + .def(py::init(), py::arg("epsilon")) + .def("find", + &rootfinding::NewtonRaphson::find, + py::arg("guess"), + "Find root starting from initial guess" + ) + ; +} \ No newline at end of file diff --git a/src/py/api.py b/src/py/api.py new file mode 100644 index 0000000..1760858 --- /dev/null +++ b/src/py/api.py @@ -0,0 +1,8 @@ +# this Python snippet is stored as src/py/api.py +def calculate(body): + epsilon = body['epsilon'] + guess = body['guess'] + from newtonraphsonpy import NewtonRaphson + finder = NewtonRaphson(epsilon) + root = finder.find(guess) + return {'root': root} \ No newline at end of file diff --git a/src/py/example.py b/src/py/example.py new file mode 100644 index 0000000..3894153 --- /dev/null +++ b/src/py/example.py @@ -0,0 +1,6 @@ +# this Python snippet is stored as src/py/example.py +from newtonraphsonpy import NewtonRaphson + +finder = NewtonRaphson(epsilon=0.001) +root = finder.find(guess=-20) +print(root) \ No newline at end of file diff --git a/src/py/hello-templated.py b/src/py/hello-templated.py new file mode 100644 index 0000000..2c3144d --- /dev/null +++ b/src/py/hello-templated.py @@ -0,0 +1,10 @@ +# this Python snippet is stored as src/py/hello-templated.py +from flask import Flask, render_template + +app = Flask(__name__) + +@app.route('/hello/') +def hello_name(name=None): + return render_template('hello.html', name=name) + +app.run() \ No newline at end of file diff --git a/src/py/hello.py b/src/py/hello.py new file mode 100644 index 0000000..ebb2637 --- /dev/null +++ b/src/py/hello.py @@ -0,0 +1,9 @@ +# this Python snippet is stored as src/py/hello.py +from flask import Flask +app = Flask(__name__) + +@app.route("/") +def hello(): + return "Hello World!" + +app.run() \ No newline at end of file diff --git a/src/py/openapi.yaml b/src/py/openapi.yaml new file mode 100644 index 0000000..3ff1f97 --- /dev/null +++ b/src/py/openapi.yaml @@ -0,0 +1,50 @@ +# this yaml snippet is stored as src/py/openapi.yaml +openapi: 3.0.0 +info: + title: Root finder + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 0.1.0 +paths: + /api/newtonraphson: + post: + description: Perform root finding with the Newton Raphson algorithm + operationId: api.calculate + requestBody: + content: + 'application/json': + schema: + $ref: '#/components/schemas/NRRequest' + example: + epsilon: 0.001 + guess: -20 + responses: + '200': + description: The found root + content: + application/json: + schema: + $ref: '#/components/schemas/NRResponse' +components: + schemas: + NRRequest: + type: object + properties: + epsilon: + type: number + minimum: 0 + guess: + type: number + required: + - epsilon + - guess + additionalProperties: false + NRResponse: + type: object + properties: + root: + type: number + required: + - root + additionalProperties: false \ No newline at end of file diff --git a/src/py/tasks.py b/src/py/tasks.py new file mode 100644 index 0000000..9554c0a --- /dev/null +++ b/src/py/tasks.py @@ -0,0 +1,19 @@ +# this Python snippet is stored as src/py/tasks.py +import time + +# this Python code snippet is later referred to as <> +from celery import Celery +capp = Celery('tasks', broker='redis://localhost:6379', backend='redis://localhost:6379') + +@capp.task(bind=True) +def calculate(self, epsilon, guess): + if not self.request.called_directly: + self.update_state(state='INITIALIZING') + time.sleep(5) + from newtonraphsonpy import NewtonRaphson + finder = NewtonRaphson(epsilon) + if not self.request.called_directly: + self.update_state(state='FINDING') + time.sleep(5) + root = finder.find(guess) + return {'root': root, 'guess': guess, 'epsilon':epsilon} \ No newline at end of file diff --git a/src/py/webapp-celery.py b/src/py/webapp-celery.py new file mode 100644 index 0000000..874002d --- /dev/null +++ b/src/py/webapp-celery.py @@ -0,0 +1,45 @@ +# this Python snippet is stored as src/py/webapp-celery.py +from flask import Flask, render_template, request, redirect, url_for + +app = Flask(__name__) + +# this Python code snippet is later referred to as <> +@app.route('/', methods=['GET']) +def form(): + return ''' +
+ + + + + +
''' + +# this Python code snippet is later referred to as <> +@app.route('/', methods=['POST']) +def submit(): + epsilon = float(request.form['epsilon']) + guess = float(request.form['guess']) + from tasks import calculate + job = calculate.delay(epsilon, guess) + return redirect(url_for('result', jobid=job.id)) + +# this Python code snippet is later referred to as <> +@app.route('/result/') +def result(jobid): + from tasks import capp + job = capp.AsyncResult(jobid) + job.maybe_throw() + if job.successful(): + result = job.get() + epsilon = result['epsilon'] + guess = result['guess'] + root = result['root'] + return f''' +

With epsilon of {epsilon} and a guess of {guess} the found root is {root}.

''' + else: + return f''' +

{job.status}

''' + +if __name__ == '__main__': + app.run(port=5000) \ No newline at end of file diff --git a/src/py/webapp.py b/src/py/webapp.py new file mode 100644 index 0000000..a6eff3b --- /dev/null +++ b/src/py/webapp.py @@ -0,0 +1,31 @@ +# this Python snippet is stored as src/py/webapp.py +from flask import Flask, request +app = Flask(__name__) + +# this Python code snippet is later referred to as <> +@app.route('/', methods=['GET']) +def form(): + return ''' +

+ + + + + +
''' + +# this Python code snippet is later referred to as <> +@app.route('/', methods=['POST']) +def calculate(): + epsilon = float(request.form['epsilon']) + guess = float(request.form['guess']) + + from newtonraphsonpy import NewtonRaphson + finder = NewtonRaphson(epsilon) + root = finder.find(guess) + + return f''' +

With epsilon of {epsilon} and a guess of {guess} the found root is {root}.

''' + # this Python code snippet is appended to <> + +app.run(port=5001) \ No newline at end of file diff --git a/src/py/webservice.py b/src/py/webservice.py new file mode 100644 index 0000000..bfaabcf --- /dev/null +++ b/src/py/webservice.py @@ -0,0 +1,6 @@ +# this Python snippet is stored as src/py/webservice.py +import connexion + +app = connexion.App(__name__) +app.add_api('openapi.yaml', validate_responses=True) +app.run(port=8080) \ No newline at end of file diff --git a/src/wasm-newtonraphson.cpp b/src/wasm-newtonraphson.cpp new file mode 100644 index 0000000..a632904 --- /dev/null +++ b/src/wasm-newtonraphson.cpp @@ -0,0 +1,49 @@ +// this C++ snippet is stored as src/wasm-newtonraphson.cpp +#include + +// this C++ code snippet is later referred to as <> +#include "newtonraphson.hpp" + +namespace rootfinding +{ + +// An example function is x^3 - x^2 + 2 +double func(double x) +{ + return x * x * x - x * x + 2; +} + +// Derivative of the above function which is 3*x^x - 2*x +double derivFunc(double x) +{ + return 3 * x * x - 2 * x; +} + +NewtonRaphson::NewtonRaphson(double tolerancein) : tolerance(tolerancein) {} + +// Function to find the root +double NewtonRaphson::find(double xin) +{ + double x = xin; + double delta_x = func(x) / derivFunc(x); + while (abs(delta_x) >= tolerance) + { + delta_x = func(x) / derivFunc(x); + + // x_new = x_old - f(x) / f'(x) + x = x - delta_x; + } + return x; +}; + + +} // namespace rootfinding + +using namespace emscripten; + +EMSCRIPTEN_BINDINGS(newtonraphsonwasm) { + class_("NewtonRaphson") + .constructor() + .function("find", &rootfinding::NewtonRaphson::find) + ; +} \ No newline at end of file