├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── assets ├── .grablib.lock ├── grablib.yml ├── js │ └── reload.js └── scss │ ├── _bootstrap.scss │ ├── _code.scss │ ├── _variables.scss │ └── main.scss ├── demo-script.py ├── notbook ├── __init__.py ├── __main__.py ├── cli.py ├── context.py ├── exec.py ├── main.py ├── models.py ├── render.py ├── render_tools.py ├── templates │ ├── base.jinja │ ├── error.jinja │ └── main.jinja ├── version.py └── watch.py ├── requirements ├── all.txt ├── demo.txt └── linting.txt ├── screen.gif ├── scripts ├── error.py └── simple.py ├── setup.cfg └── setup.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | test: 13 | name: test on ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu, windows, macos] 18 | 19 | runs-on: ${{ format('{0}-latest', matrix.os) }} 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: set up python 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: '3.8' 28 | 29 | - name: install dependencies 30 | run: | 31 | make install-all 32 | pip freeze 33 | 34 | - name: lint 35 | run: make lint 36 | 37 | - name: build 38 | run: notbook build demo-script.py 39 | 40 | pages: 41 | name: 'Deploy to github pages' 42 | needs: test 43 | if: "success() && github.ref == 'refs/heads/master'" 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | 49 | - name: set up python 50 | uses: actions/setup-python@v1 51 | with: 52 | python-version: '3.8' 53 | 54 | - name: install 55 | run: | 56 | make install 57 | pip install -r requirements/demo.txt 58 | 59 | - name: build 60 | run: notbook build demo-script.py 61 | 62 | - name: deploy to pages 63 | uses: JamesIves/github-pages-deploy-action@releases/v3 64 | with: 65 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 66 | BRANCH: gh-pages 67 | FOLDER: site 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /env*/ 2 | /.idea 3 | __pycache__/ 4 | *.py[cod] 5 | *.cache 6 | .pytest_cache/ 7 | .coverage.* 8 | /.DS_Store 9 | /.coverage 10 | /htmlcov/ 11 | /build/ 12 | /dist/ 13 | /sandbox/ 14 | /demo.py 15 | *.egg-info 16 | /docs/_build/ 17 | /.mypy_cache/ 18 | .vscode/ 19 | .venv/ 20 | /.auto-format 21 | /site/ 22 | /.live/ 23 | /assets/.libs/ 24 | /assets/build/ 25 | /TODO.md 26 | /assets-gist/ 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Samuel Colvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | isort = isort -rc notbook 3 | black = black -S -l 120 --target-version py37 notbook 4 | 5 | .PHONY: install 6 | install: 7 | pip install -U setuptools pip 8 | pip install -e . 9 | 10 | .PHONY: install-all 11 | install-all: install 12 | pip install -r requirements/all.txt 13 | 14 | .PHONY: format 15 | format: 16 | $(isort) 17 | $(black) 18 | 19 | .PHONY: lint 20 | lint: 21 | flake8 notbook/ 22 | $(isort) --check-only -df 23 | $(black) --check 24 | 25 | 26 | .PHONY: all 27 | all: lint 28 | 29 | .PHONY: clean 30 | clean: 31 | rm -rf `find . -name __pycache__` 32 | rm -f `find . -type f -name '*.py[co]' ` 33 | rm -f `find . -type f -name '*~' ` 34 | rm -f `find . -type f -name '.*~' ` 35 | rm -rf .cache 36 | rm -rf .pytest_cache 37 | rm -rf htmlcov 38 | rm -rf *.egg-info 39 | rm -f .coverage 40 | rm -f .coverage.* 41 | rm -rf build 42 | rm -rf dist 43 | python setup.py clean 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notbook 2 | 3 | An argument that [Jupyter Notebooks](https://jupyter.org/) are conceptually flawed just as spreadsheets are - 4 | **the (python) world needs a successor.** 5 | 6 | See [this discussion on reddit](https://www.reddit.com/r/Python/comments/givwa6/jupyter_notebooks_are_flawed_ive_built_a_demo_of/) 7 | and notbook and alternatives. 8 | 9 | To try and convince you of that I've built a quick demo of on alternative. 10 | TL;DR [here's](https://samuelcolvin.github.io/notbook/) an example of the kind of document that's generated. 11 | 12 | But before you look at that, I should convince you notebooks have fundamental problems... 13 | 14 | --- 15 | 16 | # Quiz: 17 | 18 | ## Question: What's the common flaw between Jupyter Notebooks and Excel? 19 | 20 | ### Answer 1: They both don't work with version control 21 | 22 | True, that's very annoying. Both `.xlsx` and `.ipynb` show as blobs, it's impossible to clearly see what's changed 23 | in either between versions. 24 | 25 | **But it's not the real problem, try again.** 26 | 27 | ### Answer 2: The logic they contain can't be directly used in production 28 | 29 | Another good try. It's virtually impossible to have clearly visible logic in either a notebook or an excel sheet 30 | which can also be used in production. You end up copying the logic out, rewriting it and adding it to your production 31 | stack. 32 | 33 | Problems come when you want to modify the logic and share it with someone again, now you have two (somewhat different) 34 | implementations to keep track of and keep identical. 35 | 36 | **But still not the fundamental common problem, try again...** 37 | 38 | ### Answer 3: Bugs are common since they're both easy to partially update 39 | 40 | Remember `ctrl + alt + shift + F9` in Excel? Go for coffee and wait for it to update, and hope nothing crashes, 41 | search through all you sheets to see if anything has gone wrong. 42 | Notebooks are no better - sections don't automatically 43 | update when an earlier section is modified, so you end up running "Run All Cells" the whole time. 44 | But even that's not the whole problem, because notebooks reuse a single python process, you can have more subtle 45 | bugs: declare a function in one cell, then use it in the next - all works well, now delete the function from the 46 | first cell, but the function object still exists in globals, so the notebook continues to work. 47 | Now you send that sheet to someone else and of course everything fails! 48 | 49 | But that's not all: both excel and notebooks don't make it obvious when an error has happened, you could have an 50 | exception in a cell that's offscreen (or a sheet you're not looking at) and you wouldn't be aware of it. 51 | 52 | **That's a big problem, but really it's just an implementation mistake, not the root problem, keep trying...** 53 | 54 | ### Answer 4: Bugs are common since they don't provide an easy way of writing unit tests 55 | 56 | True, just as you can't reuse logic in a production environment, you can't easily access logic outside the main 57 | document to write unit tests. 58 | 59 | **Mad, but still not THE problem, keep trying...** 60 | 61 | ### Answer 5: Neither let me write logic in my IDE/editor of choice 62 | 63 | So annoying, anyone who writes code for more than a few hours a month becomes very at-home in their editor of choice. 64 | Having to leave it to use either excel or notebooks is really painful, plus both lack many of the advanced features 65 | of a modern IDE. 66 | 67 | **Still not the answer I'm looking for, have another try...** 68 | 69 | ### Answer 6: Neither give me a viewer-friendly way of displaying their results 70 | 71 | Excel is awful at displaying complex results, particularly with a narrative. Notebooks are better at describing 72 | a narrative, but they're still ugly - you have to show lots of code that the casual reader doesn't care about 73 | (like imports and utility functions), there's also no really pretty way of displaying a notebook that I know of. 74 | 75 | **Still not the right answer, have one more guess...** 76 | 77 | ### The real answer: Logic, results and even input data are combined in one horrid blob 78 | 79 | YES! That the fundamental problem, and has led to many very serious (heisen)bugs. I personally have fallen into 80 | this bear pit more than once. 81 | 82 | Three conceptually very different things: 83 | * input data (sometimes, with both it's theoretically possible to read input data from external sources) 84 | * logic to calculate results (and parameters for those calculations) 85 | * the results 86 | 87 | Are stored in the same file. 88 | 89 | Imagine if python automatically appended the output of a script to that script every time you ran it! 90 | 91 | Of course this didn't seem like a big problems when spreadsheets were conceived in the 60s (the word "spreadsheet" or 92 | "spread-sheet" [comes from](https://en.wikipedia.org/wiki/Spreadsheet#Paper_spreadsheets) 93 | two facing pieces of paper used as a leger). 94 | They were designed as just a clever table, just as you had the inputs and output on the same piece of paper when 95 | you did manual accounts on lined paper; it seemed sensible to keep everything in one file when building the 96 | computerised equivalent. 97 | But why on earth did anyone think this was still a good idea when inventing notebooks? 98 | 99 | You would never think of storing your customer data in the same file as the logic to generate their invoices 100 | (unless you're still using excel) - so why would you store the results of your 101 | machine learning model along side its definition? 102 | 103 | **This is the fundamental problem with both excel and notebooks, it's the root cause of many of the issues described 104 | above and the reason most experience develops eschew both.** 105 | 106 | --- 107 | 108 | # My proposed alternative: 109 | 110 | (Here my MVP is called "notbook", but if anyone actually wants to use it, it should be renamed to avoid confusion 111 | with Jupyter Notebooks.) 112 | 113 | A program that executes a python script, and renders an HTML document with: 114 | * sections of the code, smartly rendered 115 | * the output of print commands shown beneath the associated print statement 116 | * comments rendered as markdown for the body of the document, again inserted in the right place in the document 117 | * rich objects e.g. plots and table rendered properly in the document 118 | * (not yet built) allow HTML inputs (text inputs, number inputs, range slides etc.) in the HTML to change the 119 | inputs to calculations 120 | * (not yet built) link to imported python modules which can be rendered recursively as more hybrid documents or just 121 | as highlighted python 122 | 123 | That document can be built either using: 124 | 125 | ### notbook build ... 126 | 127 | `notbook build my-logic.py` - where the HTML document is built once and the process exists, if execution raises 128 | an exception, no document is built and the processes exits with code `1`. 129 | 130 | To view the document generated with the `notbook build demo-script.py` see 131 | **[samuelcolvin.github.io/notbook/](https://samuelcolvin.github.io/notbook/)**. 132 | 133 | ### notbook watch ... 134 | 135 | `notbook watch my-logic.py` - where the file is watched and a web-server is started showing the document, 136 | when the file changes the HTML document is updated and the page automatically updates giving almost instant feedback. 137 | 138 | Watch mode in action: 139 | 140 | ![Notbook watch mode screencast](https://github.com/samuelcolvin/notbook/blob/master/screen.gif "Notbook watch mode screencast") 141 | 142 | ### The script is just python 143 | 144 | The python script(s) containing all logic: 145 | * only contain valid python syntax 146 | * be executable via the standard `python` CLI 147 | * be importable into other code and be able to import other code 148 | 149 | This might not sound like much (it's basically just another static site generator which works on python files, not 150 | markdown etc.), but I think it could dramatically improve the workflow for data scientists and anyone python-literate 151 | currently using notebooks or excel. 152 | 153 | ### Advantages 154 | 155 | * It fixes all the issues described in the "quiz" above 156 | * **it's simpler** - because the process here is dramatically simpler than notebooks, the source code is much smaller. 157 | Therefore extending it, fixing bugs and customising usage should be much easier. 158 | 159 | ### Disadvantages 160 | 161 | * perhaps in some scenarios slightly slower than running code in an existing python process 162 | (this is currently exacerbated by [this issue with bokeh](https://github.com/bokeh/bokeh/issues/10007)) 163 | * probably some other stuff I can't think of right now 164 | * seriously I can't see the downsides of this relative to notebooks, please create and issue if you think differently 165 | 166 | ### Further enhancements 167 | 168 | There's much more this could do: 169 | * the two things marked as "not yet built" above 170 | * rendering tables from pandas and similar 171 | * currently there's basic support for [bokeh](https://docs.bokeh.org/en/latest/index.html) plots but other 172 | plotting libraries should be supported 173 | * stage caching so slow steps in calculations could be cached between executions 174 | * richer printing: currently [`devtools.debug`](https://github.com/samuelcolvin/python-devtools) is used to 175 | print complex objects (e.g. not `str`, `int`, `float`), this should be replaced with an interactive tree-view 176 | like chrome 177 | * server based mode - instead of running the python script locally and showing it on localhost, the python source 178 | is posted to a server which runs the script and renders the results at some URL for the develop (or anyone else 179 | with permissions) to view 180 | * ability to "import" or otherwise reference content in markdown files, rather than always using comments 181 | * a "zip mode" and renderer of "zip mode": zips all the assets need to render a document, you can then send that binary 182 | file to someone to view, they can display the zipped document without having to mess about with the command line to 183 | get the document extracted and ready to view 184 | -------------------------------------------------------------------------------- /assets/.grablib.lock: -------------------------------------------------------------------------------- 1 | 69c3b5164e386873fdd4d91092c1ca4c https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i|Titillium+Web|Ubuntu+Mono&display=swap google-fonts.scss 2 | cae64ccea22220810c27884ea6bb4cea https://github.com/twbs/bootstrap/archive/v4.4.1.zip :zip-lookup 3 | 39ced015aa448fb7092b987b5dfbcc9e https://github.com/twbs/bootstrap/archive/v4.4.1.zip :zip-raw 4 | 18f72f816db1fd418350d0115f2b2c84 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_alert.scss 5 | ce84cd07128b49e5edfcdab05123e960 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_badge.scss 6 | eabed2899a02dfecab794b309eae7369 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_breadcrumb.scss 7 | aa313e235a305e61e181f81a853df629 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_button-group.scss 8 | 8830d9dd8e4202cfe94dc6e2cb0995f6 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_buttons.scss 9 | 77efc4136fa86ffabed26862a073238b https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_card.scss 10 | 4eefd87810986cc77dfa528be7bab554 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_carousel.scss 11 | 35e1d4a72da6f5c486185fcf86831f0d https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_close.scss 12 | 498c7818162f2b57176558feaac02c31 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_code.scss 13 | 91fa3a4de68a03d4248c8dcd6157e628 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_custom-forms.scss 14 | 9d2edb87fb8daecb40b8d8fe9da75c1d https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_dropdown.scss 15 | 2b13c084576cbe8f815236a71f173585 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_forms.scss 16 | 938e4b6c26e7d6f48e3721e4a9a33194 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_functions.scss 17 | 020071fa44dc66998911a5804285b6eb https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_grid.scss 18 | d50c4811e230bbe09536f6b5eca808a9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_images.scss 19 | 1325ae3b0c7d430e49d9fea8fc0c9228 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_input-group.scss 20 | d969f3ff6b93ef5d1b8a2d30149e336b https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_jumbotron.scss 21 | c8c1f74e9498fd7c159f0eece949f3d5 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_list-group.scss 22 | d2ea169e5ccb567ff12e945885a90fa6 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_media.scss 23 | 3123d0b4c5feba595c28c84f0436cb4e https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_mixins.scss 24 | 03021826ee61f62c9df03daa7b9c6be9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_modal.scss 25 | 5d521d43b5ef0384a4fb0af4e99cd3e0 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_nav.scss 26 | 8ad41716fbcfada6bbcaaa224f9f3d16 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_navbar.scss 27 | f7a9e1868f6e7d3a93d4f42c64f1ad64 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_pagination.scss 28 | 6758af26108de59e73d60ec68768845e https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_popover.scss 29 | 02c5f9cef09bb47142a78dd057a5974d https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_print.scss 30 | abb2d89ea765e311844828c83a4b140c https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_progress.scss 31 | 3ac9c7ca8795f1b9bb5fa01e78eb87a9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_reboot.scss 32 | 10c061bb2595b3de4b6930d879f6d81b https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_root.scss 33 | 82e71fea62a61193ceba682115a96fd8 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_spinners.scss 34 | ec7f2a3120db952b694dd0e9f871419c https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_tables.scss 35 | 0cce5ec73009c0f087fe07c485336af6 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_toasts.scss 36 | 61cfa44c3bbc57aca5079673875abb8d https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_tooltip.scss 37 | 3718dbf96921102bc26ac3224f2de72a https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_transitions.scss 38 | 606bf8337b2fbda90bb5c01d85e5edd7 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_type.scss 39 | 7ad37cfec1056da9fb94304769ef02aa https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_utilities.scss 40 | e0327f55227fd3585bdc1e0265b14eb3 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/_variables.scss 41 | 022dc9b159ff06a93bf4ad2fcfb3d3e1 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/bootstrap-grid.scss 42 | e99a106ea4451851681bfc346db16690 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/bootstrap-reboot.scss 43 | 3f4595ba8aac319ac993a63ddddef482 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/bootstrap.scss 44 | 2bed73c51e646a7d2e30f05d02864101 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_alert.scss 45 | dde06e2581478dfddbb6ef918e889875 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_background-variant.scss 46 | 9245d772f26fa773f3f1cfcc57c3b46c https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_badge.scss 47 | 9ffa6232218cd95aa16086fd00f8ccfa https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_border-radius.scss 48 | e50901e86d7f225f22a5f088e8726276 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_box-shadow.scss 49 | 9a14819fe9ca5e92c4b264126c626947 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_breakpoints.scss 50 | 29199db24ea700d652aaeae43da09942 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_buttons.scss 51 | fc2f3d41523c58e8c50d02d58c182751 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_caret.scss 52 | f8d39651a1054cf73e1d56ad398c0af0 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_clearfix.scss 53 | 19856441739526899f902c884f3d2b58 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_deprecate.scss 54 | 87899ae33449eea6ce1d4bc0aabace07 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_float.scss 55 | d8a1fbb1cc66648b6e8b061a6bd22f98 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_forms.scss 56 | 0f44c939a3f29492ccc0cbd62499f940 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_gradients.scss 57 | c2b45cee9bc8462ba45eb18c6d55f7c6 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_grid-framework.scss 58 | ea151fd2c8340583e5f21632be6cea3e https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_grid.scss 59 | 60a4287f9d84cfb0ffbd73beb5dee528 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_hover.scss 60 | f450601fbd9a38badd2c35fc71500bb1 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_image.scss 61 | d3000f3208a4f7f91a2336bfc729c131 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_list-group.scss 62 | deb8df605dc4faaf23c52f20948be296 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_lists.scss 63 | 24720d5c00af52ebe44bcc3b2b66a9b9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_nav-divider.scss 64 | 89f0d99dff6d6c54feab5056360f4186 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_pagination.scss 65 | 1ddcdf93d8d2f170349cce70e12df44f https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_reset-text.scss 66 | af032cea5fd5e37d9a5a8b971e290ff4 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_resize.scss 67 | f4feadefad85a3aff1d7f0ccacffaa3a https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_screen-reader.scss 68 | 07e14cdbaee0d59ce17c0b0b35542db3 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_size.scss 69 | 2fc9394e48aa92ee1059c219fa5407f1 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_table-row.scss 70 | 47485aaa1da5e72c134628854be72aa1 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_text-emphasis.scss 71 | 31dc39c6f1caeeb8a58a2b61f0b85ef2 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_text-hide.scss 72 | c51a1018bf42368c45eb12d6ac16f938 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_text-truncate.scss 73 | 4f9766c6933508019a1157be5f6ab14c https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_transition.scss 74 | 3d3bc176127e434b66ffc633db0a4cb9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/mixins/_visibility.scss 75 | 2d85a42f5904cead7a9371485c63dce5 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_align.scss 76 | 2b8011f6e8ca1ed10175f3a764310e9a https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_background.scss 77 | 89e155df0b515db48d06c70e86bd2766 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_borders.scss 78 | 01ed6cc705196c6f0fe33300de134ee7 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_clearfix.scss 79 | 8af96c91de4e92e373a40d5f9b87cd91 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_display.scss 80 | d3f25ca3432be66d146f108e4b855595 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_embed.scss 81 | 6a75ca706305a0a90e6c2d8d9f0ea162 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_flex.scss 82 | caa8e0a1ce2bab5af0c96dfbefe3dd9b https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_float.scss 83 | db617c241dbced8683a23c0428717633 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_overflow.scss 84 | 0ca5a3796af56ce5a9eb8997463e41a9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_position.scss 85 | 84c388e27d908d2489d1724f464cdc71 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_screenreaders.scss 86 | 8d38293481d07336b8811782205e50c8 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_shadows.scss 87 | 3e7cdb7eadea66c9cd46d6b268da6576 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_sizing.scss 88 | c401a7ad414bf95c2e45f51176383072 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_spacing.scss 89 | 26d1a1fb32d45482e8703e17dce77065 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_stretched-link.scss 90 | a19147cc0714ec7b92322a757165f605 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_text.scss 91 | 545510f15dee6de8164d514fcfe1ab52 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/utilities/_visibility.scss 92 | 23ec02c88f8d4d1a8958ea9a456193b9 https://github.com/twbs/bootstrap/archive/v4.4.1.zip bootstrap-scss/vendor/_rfs.scss 93 | -------------------------------------------------------------------------------- /assets/grablib.yml: -------------------------------------------------------------------------------- 1 | download_root: .libs 2 | download: 3 | 'https://github.com/twbs/bootstrap/archive/v4.4.1.zip': 4 | 'bootstrap-4.4.1/scss/(.+)$': 'bootstrap-scss/' 5 | 6 | 'https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i|Titillium+Web|Ubuntu+Mono&display=swap': 'google-fonts.scss' 7 | 8 | build_root: '../site' 9 | debug: true 10 | build: 11 | wipe: '^assets/.*' 12 | sass: 13 | assets: 14 | src: 'scss' 15 | cat: 16 | 'assets/reload.js': 17 | - 'js/reload.js' 18 | -------------------------------------------------------------------------------- /assets/js/reload.js: -------------------------------------------------------------------------------- 1 | // reload the current page based on file changes 2 | 3 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) 4 | 5 | function reload_page() { 6 | location.reload(true) 7 | } 8 | 9 | async function wait_for_server (to_run) { 10 | let back_off = 50 11 | while (true) { 12 | if (back_off < 2000) { 13 | back_off = back_off * 2 14 | } 15 | if (back_off > 2000) { 16 | back_off = 2000 17 | } 18 | try { 19 | const r = await fetch('/.reload/up/') 20 | if (r.status !== 200) { 21 | console.debug(`unexpected response from '/.reload/up/': ${r.status}, waiting ${back_off} and trying again...`) 22 | await sleep(back_off) 23 | continue 24 | } 25 | await r.text() 26 | } catch (error) { 27 | // generally TypeError: failed to fetch 28 | console.debug(`failed to connect to '/.reload/up/', waiting ${back_off} and trying again...`) 29 | await sleep(back_off) 30 | continue 31 | } 32 | to_run() 33 | return 34 | } 35 | } 36 | 37 | class ReloadWebsocket { 38 | constructor() { 39 | this._socket = null 40 | this.connect() 41 | this._connected = false 42 | this._clear_connected = null 43 | } 44 | 45 | connect = () => { 46 | this._connected = false 47 | const proto = location.protocol.replace('http', 'ws') 48 | const url = `${proto}//${window.location.host}/.reload/ws/` 49 | console.debug(`websocket connecting to "${url}"...`) 50 | try { 51 | this._socket = new WebSocket(url) 52 | } catch (error) { 53 | console.warn('ws connection error', error) 54 | this._socket = null 55 | return 56 | } 57 | 58 | state_badge(true) 59 | this._socket.onclose = this._on_close 60 | this._socket.onerror = this._on_error 61 | this._socket.onmessage = this._on_message 62 | this._socket.onopen = this._on_open 63 | } 64 | 65 | _on_open = () => { 66 | console.debug('websocket open') 67 | setTimeout(() => { 68 | this._connected = true 69 | }, 1000) 70 | } 71 | 72 | _on_message = event => { 73 | if (event.data !== 'reload') { 74 | console.warn('unexpected websocket message:', event) 75 | return 76 | } 77 | if (this._connected) { 78 | console.debug('files changed, reloading') 79 | state_badge(false) 80 | wait_for_server(reload_page) 81 | } 82 | } 83 | 84 | _on_error = event => { 85 | console.debug('websocket error', event) 86 | clearInterval(this._clear_connected) 87 | } 88 | 89 | _on_close = event => { 90 | clearInterval(this._clear_connected) 91 | if (this._connected) { 92 | // slight delay so this doesn't run as the page is manually reloaded 93 | setTimeout(() => { 94 | console.debug('websocket closed, prompting reload') 95 | state_badge(false) 96 | wait_for_server(reload_page) 97 | }, 100) 98 | } else { 99 | console.debug('websocket closed, reconnecting in 2s...', event) 100 | setTimeout(this.connect, 2000) 101 | } 102 | } 103 | } 104 | 105 | function state_badge(connected) { 106 | const el = document.getElementById('connection-state') 107 | if (connected) { 108 | el.classList.add('badge-success') 109 | el.classList.remove('badge-warning') 110 | el.innerText = 'connected' 111 | el.title = 'connected to watch server' 112 | } else { 113 | el.classList.add('badge-warning') 114 | el.classList.remove('badge-success') 115 | el.innerText = 'not connected' 116 | el.title = 'not connected to watch server, reconnecting...' 117 | 118 | } 119 | } 120 | 121 | wait_for_server(() => { 122 | window.dev_reloader = new ReloadWebsocket() 123 | }) 124 | 125 | -------------------------------------------------------------------------------- /assets/scss/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | 4 | //@import "DL/bootstrap-scss/functions"; 5 | //@import "DL/bootstrap-scss/variables"; 6 | @import "DL/bootstrap-scss/mixins"; 7 | @import "DL/bootstrap-scss/root"; 8 | @import "DL/bootstrap-scss/reboot"; 9 | @import "DL/bootstrap-scss/type"; 10 | //@import "DL/bootstrap-scss/images"; 11 | @import "DL/bootstrap-scss/code"; 12 | @import "DL/bootstrap-scss/grid"; 13 | @import "DL/bootstrap-scss/tables"; 14 | //@import "DL/bootstrap-scss/forms"; 15 | //@import "DL/bootstrap-scss/buttons"; 16 | //@import "DL/bootstrap-scss/transitions"; 17 | //@import "DL/bootstrap-scss/dropdown"; 18 | //@import "DL/bootstrap-scss/button-group"; 19 | //@import "DL/bootstrap-scss/input-group"; 20 | //@import "DL/bootstrap-scss/custom-forms"; 21 | //@import "DL/bootstrap-scss/nav"; 22 | //@import "DL/bootstrap-scss/navbar"; 23 | //@import "DL/bootstrap-scss/card"; 24 | //@import "DL/bootstrap-scss/breadcrumb"; 25 | //@import "DL/bootstrap-scss/pagination"; 26 | @import "DL/bootstrap-scss/badge"; 27 | //@import "DL/bootstrap-scss/jumbotron"; 28 | //@import "DL/bootstrap-scss/alert"; 29 | //@import "DL/bootstrap-scss/progress"; 30 | //@import "DL/bootstrap-scss/media"; 31 | //@import "DL/bootstrap-scss/list-group"; 32 | //@import "DL/bootstrap-scss/close"; 33 | //@import "DL/bootstrap-scss/toasts"; 34 | //@import "DL/bootstrap-scss/modal"; 35 | //@import "DL/bootstrap-scss/tooltip"; 36 | //@import "DL/bootstrap-scss/popover"; 37 | //@import "DL/bootstrap-scss/carousel"; 38 | //@import "DL/bootstrap-scss/spinners"; 39 | @import "DL/bootstrap-scss/utilities"; 40 | @import "DL/bootstrap-scss/print"; 41 | -------------------------------------------------------------------------------- /assets/scss/_code.scss: -------------------------------------------------------------------------------- 1 | // taken from https://github.com/zenorocha/dracula-theme and partially converted to scss 2 | 3 | @import "variables"; 4 | 5 | //.code-block { 6 | // background: $code-background; 7 | // border-radius: $border-radius; 8 | // padding: 0.75rem 1.25rem; 9 | // margin: 0.5rem 0 1rem; 10 | //} 11 | 12 | code { 13 | background: #f3f4f4; 14 | padding: 0.2rem 0.4rem; 15 | border-radius: 3px; 16 | } 17 | 18 | .code-block, .print-statement { 19 | margin: 0; 20 | font-family: $font-family-monospace; 21 | color: $code-block-color; 22 | padding: 0.5rem 1.25rem; 23 | border-bottom: 1px solid $gray-600; 24 | &:first-child { 25 | border-radius: $border-radius $border-radius 0 0; 26 | } 27 | &:last-child { 28 | border-bottom-left-radius: $border-radius; 29 | border-bottom-right-radius: $border-radius; 30 | border-bottom: 0; 31 | } 32 | } 33 | 34 | .code-block { 35 | background: $code-background; 36 | } 37 | 38 | .print-statement { 39 | background: #1b1b1f; 40 | } 41 | 42 | .highlight, .raw, .print-statement { 43 | font-family: $font-family-monospace; 44 | color: $code-block-color; 45 | } 46 | 47 | $comment: $gray-500; 48 | 49 | .highlight { 50 | .hll { 51 | background-color: #f1fa8c; 52 | } 53 | // Comment 54 | .c { 55 | color: $comment; 56 | } 57 | // Error 58 | .err { 59 | color: #f8f8f2; 60 | } 61 | // Generic 62 | .g { 63 | color: #f8f8f2; 64 | } 65 | // Keyword 66 | .k { 67 | color: #ff79c6; 68 | } 69 | // Literal 70 | .l { 71 | color: #f8f8f2; 72 | } 73 | // Name 74 | .n { 75 | color: #f8f8f2; 76 | } 77 | // Operator 78 | .o { 79 | color: #ff79c6; 80 | } 81 | // Other 82 | .x { 83 | color: #f8f8f2; 84 | } 85 | // Punctuation 86 | .p { 87 | color: #f8f8f2; 88 | } 89 | // Comment.Hashbang 90 | .ch { 91 | color: $comment; 92 | } 93 | // Comment.Multiline 94 | .cm { 95 | color: $comment; 96 | } 97 | // Comment.Preproc 98 | .cp { 99 | color: #ff79c6; 100 | } 101 | // Comment.PreprocFile 102 | .cpf { 103 | color: $comment; 104 | } 105 | // Comment.Single 106 | .c1 { 107 | color: $comment; 108 | } 109 | // Comment.Special 110 | .cs { 111 | color: $comment; 112 | } 113 | // Generic.Deleted 114 | .gd { 115 | color: #8b080b; 116 | } 117 | // Generic.Emph 118 | .ge { 119 | color: #f8f8f2; 120 | text-decoration: underline; 121 | } 122 | // Generic.Error 123 | .gr { 124 | color: #f8f8f2; 125 | } 126 | // Generic.Heading 127 | .gh { 128 | color: #f8f8f2; 129 | font-weight: bold; 130 | } 131 | // Generic.Inserted 132 | .gi { 133 | color: #f8f8f2; 134 | font-weight: bold; 135 | } 136 | // Generic.Output 137 | .go { 138 | color: #44475a; 139 | } 140 | // Generic.Prompt 141 | .gp { 142 | color: #f8f8f2; 143 | } 144 | // Generic.Strong 145 | .gs { 146 | color: #f8f8f2; 147 | } 148 | // Generic.Subheading 149 | .gu { 150 | color: #f8f8f2; 151 | font-weight: bold 152 | } 153 | // Generic.Traceback 154 | .gt { 155 | color: #f8f8f2; 156 | } 157 | // Keyword.Constant 158 | .kc { 159 | color: #ff79c6; 160 | } 161 | // Keyword.Declaration 162 | .kd { 163 | color: #8be9fd; 164 | font-style: italic 165 | } 166 | // Keyword.Namespace 167 | .kn { 168 | color: #ff79c6; 169 | } 170 | // Keyword.Pseudo 171 | .kp { 172 | color: #ff79c6; 173 | } 174 | // Keyword.Reserved 175 | .kr { 176 | color: #ff79c6; 177 | } 178 | // Keyword.Type 179 | .kt { 180 | color: #8be9fd; 181 | } 182 | // Literal.Date 183 | .ld { 184 | color: #f8f8f2; 185 | } 186 | // Literal.Number 187 | .m { 188 | color: #bd93f9; 189 | } 190 | // Literal.String 191 | .s { 192 | color: #f1fa8c; 193 | } 194 | // Name.Attribute 195 | .na { 196 | color: #50fa7b; 197 | } 198 | // Name.Builtin 199 | .nb { 200 | color: #8be9fd; 201 | //font-style: italic 202 | } 203 | // Name.Class 204 | .nc { 205 | color: #50fa7b; 206 | } 207 | // Name.Constant 208 | .no { 209 | color: #f8f8f2; 210 | } 211 | // Name.Decorator 212 | .nd { 213 | color: #f8f8f2; 214 | } 215 | // Name.Entity 216 | .ni { 217 | color: #f8f8f2; 218 | } 219 | // Name.Exception 220 | .ne { 221 | color: #f8f8f2; 222 | } 223 | // Name.Function 224 | .nf { 225 | color: #50fa7b; 226 | } 227 | // Name.Label 228 | .nl { 229 | color: #8be9fd; 230 | //font-style: italic; 231 | } 232 | // Name.Namespace 233 | .nn { 234 | color: #f8f8f2; 235 | } 236 | // Name.Other 237 | .nx { 238 | color: #f8f8f2; 239 | } 240 | // Name.Property 241 | .py { 242 | color: #f8f8f2; 243 | } 244 | // Name.Tag 245 | .nt { 246 | color: #ff79c6; 247 | } 248 | // Name.Variable 249 | .nv { 250 | color: #8be9fd; 251 | font-style: italic; 252 | } 253 | // Operator.Word 254 | .ow { 255 | color: #ff79c6; 256 | } 257 | // Text.Whitespace 258 | .w { 259 | color: #f8f8f2; 260 | } 261 | // Literal.Number.Bin 262 | .mb { 263 | color: #bd93f9; 264 | } 265 | // Literal.Number.Float 266 | .mf { 267 | color: #bd93f9; 268 | } 269 | // Literal.Number.Hex 270 | .mh { 271 | color: #bd93f9; 272 | } 273 | // Literal.Number.Integer 274 | .mi { 275 | color: #bd93f9; 276 | } 277 | // Literal.Number.Oct 278 | .mo { 279 | color: #bd93f9; 280 | } 281 | // Literal.String.Affix 282 | .sa { 283 | color: #f1fa8c; 284 | } 285 | // Literal.String.Backtick 286 | .sb { 287 | color: #f1fa8c; 288 | } 289 | // Literal.String.Char 290 | .sc { 291 | color: #f1fa8c; 292 | } 293 | // Literal.String.Delimiter 294 | .dl { 295 | color: #f1fa8c; 296 | } 297 | // Literal.String.Doc 298 | .sd { 299 | color: #f1fa8c; 300 | } 301 | // Literal.String.Double 302 | .s2 { 303 | color: #f1fa8c; 304 | } 305 | // Literal.String.Escape 306 | .se { 307 | color: #f1fa8c; 308 | } 309 | // Literal.String.Heredoc 310 | .sh { 311 | color: #f1fa8c; 312 | } 313 | // Literal.String.Interpol 314 | .si { 315 | color: #f1fa8c; 316 | } 317 | // Literal.String.Other 318 | .sx { 319 | color: #f1fa8c; 320 | } 321 | // Literal.String.Regex 322 | .sr { 323 | color: #f1fa8c; 324 | } 325 | // Literal.String.Single 326 | .s1 { 327 | color: #f1fa8c; 328 | } 329 | // Literal.String.Symbol 330 | .ss { 331 | color: #f1fa8c; 332 | } 333 | // Name.Builtin.Pseudo 334 | .bp { 335 | color: #f8f8f2; 336 | font-style: italic 337 | } 338 | // Name.Function.Magic 339 | .fm { 340 | color: #50fa7b; 341 | } 342 | // Name.Variable.Class 343 | .vc { 344 | color: #8be9fd; 345 | font-style: italic; 346 | } 347 | // Name.Variable.Global 348 | .vg { 349 | color: #8be9fd; 350 | font-style: italic; 351 | } 352 | // Name.Variable.Instance 353 | .vi { 354 | color: #8be9fd; 355 | font-style: italic; 356 | } 357 | // Name.Variable.Magic 358 | .vm { 359 | color: #8be9fd; 360 | font-style: italic; 361 | } 362 | // Literal.Number.Integer.Long 363 | .il { 364 | color: #bd93f9; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | @import "DL/bootstrap-scss/functions"; 2 | @import "DL/bootstrap-scss/variables"; 3 | 4 | $font-family-sans-serif: 'Merriweather', serif; 5 | $font-family-monospace: 'Ubuntu Mono', monospace; 6 | $headings-font-family: 'Titillium Web', sans-serif; 7 | 8 | $code-font-size: 100%; 9 | $code-background: #282a36; 10 | $code-color: $gray-800; 11 | $code-block-color: #f8f8f2; 12 | 13 | $h1-font-size: $font-size-base * 2; 14 | $h2-font-size: $font-size-base * 1.75; 15 | $h3-font-size: $font-size-base * 1.5; 16 | $h4-font-size: $font-size-base * 1.25; 17 | $h5-font-size: $font-size-base * 1.1; 18 | $h6-font-size: $font-size-base; 19 | -------------------------------------------------------------------------------- /assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "DL/google-fonts"; 2 | @import "bootstrap"; 3 | @import "code"; 4 | 5 | html, body { 6 | font-family: $font-family-sans-serif; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | nav { 12 | background: $gray-100; 13 | padding-bottom: 0.5rem; 14 | border-bottom: 4px solid $gray-400; 15 | } 16 | 17 | .repo-link { 18 | img { 19 | width: 24px; 20 | } 21 | color: $body-color !important; 22 | text-decoration: none !important; 23 | } 24 | 25 | .page-width, article { 26 | max-width: 940px; 27 | margin: 0 auto; 28 | } 29 | 30 | article { 31 | padding: 1rem 0.5rem 3rem; 32 | } 33 | 34 | section { 35 | margin-bottom: 1rem; 36 | } 37 | 38 | .cursor-help { 39 | cursor: help; 40 | } 41 | 42 | main { 43 | h1, .h1 { 44 | font-size: $h2-font-size; 45 | } 46 | h2, .h2 { 47 | font-size: $h3-font-size; 48 | } 49 | h3, .h3 { 50 | font-size: $h4-font-size; 51 | } 52 | } 53 | 54 | .equation { 55 | color: white; 56 | mjx-container { 57 | color: $body-color; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo-script.py: -------------------------------------------------------------------------------- 1 | from notbook import show_plot 2 | from bokeh.plotting import figure 3 | import numpy as np 4 | import scipy.optimize as opt 5 | 6 | """md 7 | _This is an example of a better way to work on and display numerical python code._ 8 | 9 | [Here's](https://github.com/samuelcolvin/notbook/blob/master/demo-script.py) the input script used to generate 10 | this document. 11 | 12 | --- 13 | 14 | # Logistic curve fitting example 15 | 16 | Example is taken mostly from 17 | [here](https://ipython-books.github.io/93-fitting-a-function-to-data-with-nonlinear-least-squares/). 18 | 19 | We define a logistic function with four parameters: 20 | 21 | ```maths 22 | \[f_{a,b,c,d}(x) = \frac{a}{1 + \exp\left(-c (x-d)\right)} + b\] 23 | ``` 24 | """ 25 | 26 | # { 27 | def f(x, a, b, c, d): 28 | return a / (1. + np.exp(-c * (x - d))) + b 29 | # } The equation we'll try and fit 30 | 31 | """md 32 | Define some random parameters: 33 | """ 34 | # { 35 | a, c = np.random.exponential(size=2) 36 | b, d = np.random.randn(2) 37 | # } 38 | 39 | # we actually cheat here and keep a "good" set of paramters 40 | a = 0.8744 41 | b = -2.0836 42 | c = 1.3389 43 | d = 0.4991 44 | 45 | """md 46 | which are: 47 | """ 48 | print(f'a={a:0.4f} b={b:0.4f} c={c:0.4f} d={d:0.4f}') 49 | 50 | """md 51 | Now, we generate random data points by using the sigmoid function and adding a bit of noise: 52 | """ 53 | # { 54 | n = 100 55 | x = np.linspace(-10, 10, n) 56 | y_model = f(x, a, b, c, d) 57 | y = y_model + a * 0.2 * np.random.randn(n) 58 | # } 59 | 60 | """md 61 | Plot that to see how it looks: 62 | """ 63 | 64 | p = figure(title='Model and random data') 65 | p.line(x, y_model, color='black', line_dash='dashed', line_width=5, legend_label='model') 66 | p.circle(x, y, size=10, legend_label='random data') 67 | p.legend.location = 'top_left' 68 | p.legend.click_policy = 'hide' 69 | show_plot(p) 70 | 71 | """md 72 | We now assume that we only have access to the data points and not the underlying generative function. 73 | These points could have been obtained during an experiment. 74 | 75 | By looking at the data, the points appear to approximately follow a sigmoid, so we may want to try to fit such a 76 | curve to the points. 77 | That's what curve fitting is about. 78 | 79 | SciPy's [`curve_fit()`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html) 80 | function allows us to fit a curve defined by an 81 | arbitrary Python function to the data: 82 | """ 83 | 84 | # { 85 | (a_, b_, c_, d_), _ = opt.curve_fit(f, x, y) 86 | 87 | """md 88 | And use those parameters to estimate our underlying curve 89 | """ 90 | y_fit = f(x, a_, b_, c_, d_) 91 | # } 92 | 93 | p = figure(title='Model, random data and curve fit') 94 | p.line(x, y_model, color='black', line_dash='dashed', line_width=5, legend_label='model') 95 | p.circle(x, y, size=10, legend_label='random data') 96 | p.line(x, y_fit, color='red', line_width=5, legend_label='curve fit') 97 | p.legend.location = 'top_left' 98 | p.legend.click_policy = 'hide' 99 | show_plot(p) 100 | 101 | """md 102 | we can also manually compare our initial parameters with with those from `curve_fit()`: 103 | """ 104 | 105 | print(f'initial parameters: a={a:0.4f} b={b:0.4f} c={c:0.4f} d={d:0.4f}') 106 | print(f'curve_fit parameters: a={a_:0.4f} b={b_:0.4f} c={c_:0.4f} d={d_:0.4f}') 107 | -------------------------------------------------------------------------------- /notbook/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from types import FrameType 3 | 4 | from . import context 5 | from .models import PlotBlock 6 | 7 | try: 8 | from bokeh import plotting as bokeh_plotting 9 | from bokeh.embed import file_html as bokeh_file_html 10 | from bokeh.plotting import Figure as BokehFigure 11 | except ImportError: 12 | bokeh_plotting = None 13 | 14 | __all__ = ('show_plot',) 15 | 16 | plot_id = 0 17 | 18 | 19 | def show_plot(plot, *, title: str = None, filename: str = None): 20 | global plot_id 21 | if repr(plot.__class__) == "": 22 | assert bokeh_plotting is not None, 'could not find bokeh install' 23 | assert isinstance(plot, BokehFigure), plot 24 | if context.is_active(): 25 | frame = inspect.currentframe().f_back 26 | if plot.sizing_mode is None: 27 | plot.sizing_mode = 'stretch_both' 28 | if plot.aspect_ratio is None: 29 | plot.aspect_ratio = 1.78 30 | plot.title.text_font = 'Titillium Web, sans-serif' 31 | plot.title.text_font_size = '1.5rem' 32 | plot.title.align = 'center' 33 | plot.legend.label_text_font = 'Merriweather, serif' 34 | plot.legend.label_text_font_size = '1rem' 35 | 36 | plot.xaxis.axis_label_text_font = 'Ubuntu Mono, monospace' 37 | plot.xaxis.axis_label_text_font_size = '1.2rem' 38 | plot.xaxis.major_label_text_font_size = '1rem' 39 | plot.yaxis.axis_label_text_font = 'Ubuntu Mono, monospace' 40 | plot.yaxis.axis_label_text_font_size = '1.2rem' 41 | plot.yaxis.major_label_text_font_size = '1rem' 42 | bokeh_figure_to_html(plot, frame, title) 43 | else: 44 | if not filename: 45 | plot_id += 1 46 | filename = f'plot_{plot_id}.html' 47 | bokeh_plotting.output_file(filename, title=title) 48 | bokeh_plotting.show(plot) 49 | else: 50 | raise NotImplementedError(f'cannot render {plot} ({type(plot)})') 51 | 52 | 53 | class FakeTemplate: 54 | def __init__(self): 55 | self.context = None 56 | 57 | def render(self, context): 58 | self.context = context 59 | 60 | 61 | def bokeh_figure_to_html(fig, frame: FrameType, title: str = None): 62 | t = FakeTemplate() 63 | bokeh_file_html(fig, (None, None), template=t, title=title) 64 | plot_script = t.context['plot_script'].strip('\n') 65 | plot_div = t.context['plot_div'].strip('\n') 66 | block = PlotBlock(f'{plot_div}\n{plot_script}', frame.f_lineno) 67 | context.append(block) 68 | -------------------------------------------------------------------------------- /notbook/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | if __name__ == '__main__': 4 | cli() 5 | -------------------------------------------------------------------------------- /notbook/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from time import time 4 | 5 | import typer 6 | 7 | from . import main 8 | from .render_tools import ExecException 9 | from .version import VERSION 10 | from .watch import watch as _watch 11 | 12 | cli = typer.Typer() 13 | file_default = typer.Argument(..., exists=True, file_okay=True, dir_okay=True, readable=True) 14 | dev_mode = 'NOTBOOK_DEV' in os.environ 15 | 16 | 17 | @cli.command() 18 | def build( 19 | file: Path = file_default, 20 | output_dir: Path = typer.Argument(Path('site'), file_okay=False, dir_okay=True, readable=True), 21 | ): 22 | print(f'executing {file} and saving output to {output_dir}...') 23 | start = time() 24 | try: 25 | main.build(file, output_dir, dev=dev_mode) 26 | except ExecException as exc: 27 | print(exc.format('shell')) 28 | print(f'build failed after {time() - start:0.3f}s') 29 | raise typer.Exit(1) 30 | else: 31 | print(f'build completed in {time() - start:0.3f}s') 32 | 33 | 34 | @cli.command() 35 | def watch( 36 | file: Path = file_default, 37 | output_dir: Path = typer.Argument(Path('.live'), file_okay=False, dir_okay=True, readable=True), 38 | ): 39 | _watch(file, output_dir, dev=dev_mode) 40 | 41 | 42 | def version_callback(value: bool): 43 | if value: 44 | print(f'notbook: v{VERSION}') 45 | raise typer.Exit() 46 | 47 | 48 | @cli.callback(help=f'notbook command line interface v{VERSION}') 49 | def callback( 50 | version: bool = typer.Option( 51 | None, '--version', callback=version_callback, is_eager=True, help='Show the version and exit.' 52 | ), 53 | ) -> None: 54 | pass 55 | 56 | 57 | if __name__ == '__main__': 58 | cli() 59 | -------------------------------------------------------------------------------- /notbook/context.py: -------------------------------------------------------------------------------- 1 | _exec_context = None 2 | 3 | 4 | def activate(): 5 | global _exec_context 6 | _exec_context = [] 7 | 8 | 9 | def is_active() -> bool: 10 | return _exec_context is not None 11 | 12 | 13 | def get(): 14 | return _exec_context 15 | 16 | 17 | def append(obj): 18 | assert isinstance(_exec_context, list), 'context not prepared' 19 | _exec_context.append(obj) 20 | -------------------------------------------------------------------------------- /notbook/exec.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import os 4 | import re 5 | import sys 6 | from io import BufferedWriter 7 | from operator import attrgetter 8 | from pathlib import Path 9 | from typing import Any, List, Optional, Union 10 | 11 | from devtools import PrettyFormat 12 | 13 | from . import context 14 | from .models import CodeBlock, PlotBlock, PrintArg, PrintBlock, PrintStatement, Section, TextBlock 15 | from .render_tools import ExecException 16 | 17 | __all__ = ('exec_file',) 18 | 19 | MAX_LINE_LENGTH = 120 20 | LONG_LINE = 50 21 | pformat = PrettyFormat(simple_cutoff=LONG_LINE) 22 | 23 | 24 | def exec_file(file: Path) -> List[Section]: 25 | file_text = file.read_text('utf-8') 26 | 27 | context.activate() 28 | os.environ['NOTBOOK'] = '1' 29 | mp = MockPrint(file) 30 | exec_globals = dict(print=mp) 31 | code = compile(file_text, str(file), 'exec') 32 | try: 33 | exec(code, exec_globals) 34 | except Exception: 35 | raise ExecException(sys.exc_info()) 36 | 37 | lines: List[Union[str, PrintStatement, PlotBlock]] = file_text.split('\n') 38 | 39 | extra = sorted(mp.statements + context.get(), key=attrgetter('line_no'), reverse=True) 40 | 41 | for p in extra: 42 | if isinstance(p, PrintStatement): 43 | for back in range(1, 100): 44 | m = re.search(r'^( *)print\(', lines[p.line_no - back]) 45 | if m: 46 | p.indent = len(m.group(1)) 47 | break 48 | lines.insert(p.line_no, p) 49 | else: 50 | assert isinstance(p, PlotBlock), p 51 | lines.insert(p.line_no, p) 52 | 53 | return MakeSections(lines).sections 54 | 55 | 56 | def simplify(obj): 57 | from dataclasses import is_dataclass, fields 58 | 59 | if is_dataclass(obj): 60 | return {'*type': obj.__class__.__name__, **{f.name: simplify(getattr(obj, f.name)) for f in fields(obj)}} 61 | elif isinstance(obj, list): 62 | return [simplify(v) for v in obj] 63 | else: 64 | return obj 65 | 66 | 67 | class MakeSections: 68 | def __init__(self, lines: List[Union[str, PrintStatement, PlotBlock]]): 69 | self.iter = iter(lines) 70 | self.sections: List[Section] = [] 71 | self.current_name: Optional[str] = None 72 | self.current_code: Optional[CodeBlock] = None 73 | try: 74 | while True: 75 | line = next(self.iter) 76 | if isinstance(line, str): 77 | if self.section_divide(line): 78 | continue 79 | if self.triple_quote(line): 80 | continue 81 | 82 | if self.current_code: 83 | self.current_code.lines.append(line) 84 | elif isinstance(line, PrintStatement): 85 | self.print_statement(line) 86 | else: 87 | self.plot_block(line) 88 | except StopIteration: 89 | pass 90 | self.maybe_add_current_code() 91 | 92 | def section_divide(self, line: str) -> bool: 93 | start = re.match(r' *# *{ *(.*)', line) 94 | if start: 95 | self.maybe_add_current_code() 96 | self.current_code = CodeBlock([]) 97 | self.current_name = start.group(1) or None 98 | return True 99 | end = re.match(r' *# *} *(.*)', line) 100 | if end: 101 | self.maybe_add_current_code(end.group(1) or None) 102 | return True 103 | 104 | return False 105 | 106 | def triple_quote(self, first_line: str) -> bool: 107 | start = re.match(' *("""|\'\'\')(.*)', first_line) 108 | if not start: 109 | return False 110 | quotes, start_line = start.groups() 111 | renderer = None 112 | m_renderer = re.match(r'(md|html)\w*(.*)', start_line) 113 | if m_renderer: 114 | renderer, start_line = m_renderer.groups() 115 | if start_line: 116 | lines = [start_line] 117 | else: 118 | lines = [] 119 | else: 120 | lines = [first_line] 121 | while True: 122 | line = next(self.iter) 123 | end = re.match(f'(.*){quotes}', line) 124 | if end: 125 | if renderer: 126 | lines.append(end.group(1)) 127 | else: 128 | lines.append(line) 129 | break 130 | lines.append(line) 131 | 132 | if renderer: 133 | was_in_section = bool(self.current_code) 134 | self.maybe_add_current_code() 135 | self.sections.append(Section(TextBlock('\n'.join(lines), renderer))) 136 | if was_in_section: 137 | self.current_code = CodeBlock([]) 138 | elif self.current_code: 139 | self.current_code.lines.extend(lines) 140 | else: 141 | # no render and not in a code block, do nothing 142 | pass 143 | return True 144 | 145 | def print_statement(self, print_statement: PrintStatement): 146 | if self.current_code: 147 | if self.current_code.lines and isinstance(self.current_code.lines[-1], PrintBlock): 148 | self.current_code.lines[-1].statements.append(print_statement) 149 | else: 150 | self.current_code.lines.append(PrintBlock([print_statement])) 151 | else: 152 | if self.sections and isinstance(self.sections[-1].block, PrintBlock): 153 | self.sections[-1].block.statements.append(print_statement) 154 | else: 155 | self.sections.append(Section(PrintBlock([print_statement]))) 156 | 157 | def plot_block(self, plot: PlotBlock): 158 | assert isinstance(plot, PlotBlock), plot 159 | if self.current_code: 160 | raise NotImplementedError('TODO') 161 | else: 162 | self.sections.append(Section(plot)) 163 | 164 | def maybe_add_current_code(self, caption: str = None): 165 | if self.current_code: 166 | self.sections.append(Section(self.current_code, self.current_name, caption)) 167 | self.current_code = None 168 | self.current_name = None 169 | 170 | 171 | default = object() 172 | 173 | 174 | class MockPrint: 175 | def __init__(self, file: Path): 176 | self.file = file 177 | self.statements: List[PrintStatement] = [] 178 | 179 | def __call__(self, *args, file: Optional[BufferedWriter] = default, flush=None): 180 | if file is not default: 181 | print(*args, file=file, flush=flush) 182 | return 183 | frame = inspect.currentframe() 184 | if sys.version_info >= (3, 8): 185 | frame = frame.f_back 186 | 187 | if not self.file.samefile(frame.f_code.co_filename): 188 | raise RuntimeError('in another file, todo') 189 | 190 | args = [parse_print_value(arg) for arg in args] 191 | self.statements.append(PrintStatement(args, frame.f_lineno)) 192 | 193 | 194 | def parse_print_value(value: Any) -> PrintArg: 195 | """ 196 | process objects passed to print and try to make them pretty 197 | """ 198 | # attempt to build a pretty equivalent of the print output 199 | if not isinstance(value, (str, int, float)): 200 | return PrintArg(pformat(value), 'py') 201 | elif ( 202 | isinstance(value, str) 203 | and len(value) > 10 204 | and any(re.fullmatch(r, value, flags=re.DOTALL) for r in [r'{".+}', r'\[.+\]']) 205 | ): 206 | try: 207 | obj = json.loads(value) 208 | except ValueError: 209 | # not JSON, not a problem 210 | pass 211 | else: 212 | return PrintArg(json.dumps(obj, indent=2), 'json') 213 | 214 | return PrintArg(str(value), 'str') 215 | -------------------------------------------------------------------------------- /notbook/main.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | from .exec import exec_file 5 | from .render import render, render_exception 6 | from .render_tools import ExecException 7 | 8 | __all__ = 'build', 'prepare' 9 | 10 | 11 | def build(exec_file_path: Path, output_dir: Path, *, reload: bool = False, dev: bool = False) -> None: 12 | if not reload and not dev: 13 | prepare(output_dir) 14 | try: 15 | sections = exec_file(exec_file_path) 16 | except ExecException as exc: 17 | if reload: 18 | content = render_exception(exc, reload=reload, dev=dev) 19 | else: 20 | raise 21 | else: 22 | content = render(sections, reload=reload, dev=dev) 23 | (output_dir / 'index.html').write_text(content) 24 | 25 | 26 | def prepare(output_dir: Path) -> None: 27 | if output_dir.exists(): 28 | assert output_dir.is_dir(), output_dir 29 | shutil.rmtree(output_dir) 30 | output_dir.mkdir(parents=True) 31 | -------------------------------------------------------------------------------- /notbook/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Literal, Optional, Union 3 | 4 | 5 | @dataclass 6 | class PrintArg: 7 | content: str 8 | format: Literal['py', 'json', 'str'] 9 | 10 | 11 | @dataclass 12 | class PrintStatement: 13 | args: List[PrintArg] 14 | line_no: int 15 | indent: int = 0 16 | 17 | 18 | @dataclass 19 | class PrintBlock: 20 | statements: List[PrintStatement] 21 | 22 | 23 | @dataclass 24 | class TextBlock: 25 | content: str 26 | format: Literal['md', 'html'] 27 | 28 | 29 | @dataclass 30 | class CodeBlock: 31 | lines: List[Union[str, PrintBlock]] 32 | format: Literal['py'] = 'py' 33 | 34 | 35 | @dataclass 36 | class PlotBlock: 37 | html: str 38 | line_no: int 39 | format: Literal['bokeh'] = 'bokeh' 40 | 41 | 42 | @dataclass 43 | class Section: 44 | block: Union[TextBlock, CodeBlock, PrintBlock, PlotBlock] 45 | title: Optional[str] = None 46 | caption: Optional[str] = None 47 | -------------------------------------------------------------------------------- /notbook/render.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Dict, Generator, List 4 | 5 | from jinja2 import Environment, PackageLoader 6 | 7 | from .models import CodeBlock, PlotBlock, PrintBlock, PrintStatement, Section, TextBlock 8 | from .render_tools import ExecException, highlight_code, render_markdown 9 | 10 | THIS_DIR = Path(__file__).parent.resolve() 11 | __all__ = 'render', 'render_exception' 12 | 13 | assets_gist = ( 14 | 'https://gistcdn.githack.com/samuelcolvin/647671890d647695930ff74f1ca5bfc2/raw/' 15 | '9bdb8ecebe25293cad8332cbb2a79d844cdfd9a3' 16 | ) 17 | github_icon_url = f'{assets_gist}/github.png' 18 | css_url = f'{assets_gist}/notbook.css' 19 | reload_js_url = f'{assets_gist}/reload.js' 20 | 21 | 22 | def render(sections: List[Section], *, reload: bool = False, dev: bool = False) -> str: 23 | template = get_env(reload, dev).get_template('main.jinja') 24 | return template.render( 25 | sections=render_sections(sections), 26 | bokeh_plot=any(isinstance(s.block, PlotBlock) and s.block.format == 'bokeh' for s in sections), 27 | ) 28 | 29 | 30 | def render_exception(exc: ExecException, *, reload: bool = False, dev: bool = False) -> str: 31 | template = get_env(reload, dev).get_template('error.jinja') 32 | return template.render(exception=exc.format('html')) 33 | 34 | 35 | def get_env(reload: bool, dev: bool) -> Environment: 36 | env = Environment(loader=PackageLoader('notbook'), autoescape=True) 37 | env.globals.update( 38 | highlight=highlight_code, 39 | title='Notbook', 40 | description='An experiment in displaying python scripts.', 41 | css_url='/assets/main.css' if dev else css_url, 42 | github_icon_url=github_icon_url, 43 | repo='samuelcolvin/notbook', 44 | ) 45 | if reload: 46 | env.globals['reload_js_url'] = '/assets/reload.js' if dev else reload_js_url 47 | env.filters.update(is_simple=is_simple) 48 | return env 49 | 50 | 51 | def render_sections(sections: List[Section]) -> Generator[Dict[str, str], None, None]: 52 | for section in sections: 53 | b = section.block 54 | d = dict( 55 | name=re.sub(r'(? bool: 87 | return all(a.format == 'str' for a in p.args) 88 | -------------------------------------------------------------------------------- /notbook/render_tools.py: -------------------------------------------------------------------------------- 1 | import re 2 | import traceback 3 | from typing import Literal 4 | 5 | from markupsafe import Markup 6 | from misaka import HtmlRenderer, Markdown, escape_html 7 | from pygments import highlight as pyg_highlight 8 | from pygments.formatters import HtmlFormatter, Terminal256Formatter 9 | from pygments.lexers import Python3TracebackLexer, get_lexer_by_name 10 | from pygments.util import ClassNotFound 11 | 12 | __all__ = 'render_markdown', 'code_block', 'highlight_code', 'slugify', 'ExecException' 13 | 14 | MD_EXTENSIONS = 'fenced-code', 'strikethrough', 'no-intra-emphasis', 'tables' 15 | DL_REGEX = re.compile('
  • (.*?)::(.*?)
  • ', re.S) 16 | LI_REGEX = re.compile('
  • (.*?)
  • ', re.S) 17 | tb_lexer = Python3TracebackLexer() 18 | shell_formatter = Terminal256Formatter(style='vim') 19 | html_formatter = HtmlFormatter(nowrap=True) 20 | 21 | 22 | class CustomHtmlRenderer(HtmlRenderer): 23 | @staticmethod 24 | def blockcode(text, lang) -> str: 25 | if lang.lower() in {'math', 'maths', 'eq', 'equation'}: 26 | return f'

    {escape_html(text)}

    ' 27 | else: 28 | return code_block(lang, text) 29 | 30 | def link(self, content, url, title=''): 31 | maybe_title = ' title="%s"' % escape_html(title) if title else '' 32 | url = escape_html(url) 33 | return f'{content}' 34 | 35 | @staticmethod 36 | def header(content, level): 37 | return f'{content}\n' 38 | 39 | @staticmethod 40 | def triple_emphasis(content): 41 | return f'{content}' 42 | 43 | 44 | md_to_html = Markdown(CustomHtmlRenderer(), extensions=MD_EXTENSIONS) 45 | 46 | 47 | def render_markdown(md: str) -> str: 48 | return md_to_html(md) 49 | 50 | 51 | def code_block(lang: str, code: str) -> str: 52 | return f'
    {highlight_code(lang, code)}
    ' 53 | 54 | 55 | def highlight_code(format: str, code: str) -> Markup: 56 | try: 57 | lexer = get_lexer_by_name(format, stripall=True) 58 | except ClassNotFound: 59 | lexer = None 60 | 61 | if lexer: 62 | h = pyg_highlight(code, lexer=lexer, formatter=html_formatter).strip('\n') 63 | return Markup(f'{h}') 64 | else: 65 | return Markup(f'{escape_html(code)}') 66 | 67 | 68 | RE_URI_NOT_ALLOWED = re.compile(r'[^a-zA-Z0-9_\-/.]') 69 | RE_HTML_SYMBOL = re.compile(r'&(?:#\d{2,}|[a-z0-9]{2,});') 70 | RE_TITLE_NOT_ALLOWED = re.compile(r'[^a-z0-9_\-]') 71 | RE_REPEAT_DASH = re.compile(r'-{2,}') 72 | 73 | 74 | def slugify(v, *, path_like=True): 75 | v = v.replace(' ', '-').lower() 76 | if path_like: 77 | v = RE_URI_NOT_ALLOWED.sub('', v) 78 | else: 79 | v = RE_HTML_SYMBOL.sub('', v) 80 | v = RE_TITLE_NOT_ALLOWED.sub('', v) 81 | return RE_REPEAT_DASH.sub('-', v).strip('_-') 82 | 83 | 84 | class ExecException(Exception): 85 | def __init__(self, exc_info): 86 | self.exc_info = exc_info 87 | 88 | def format(self, format: Literal['html', 'shell']) -> str: 89 | stack = traceback.format_exception(*self.exc_info) 90 | # remove the fist element in the trace which refers to this file 91 | # (element 0 is the standard "Traceback (most recent call last):" message, hence removing element 1) 92 | stack.pop(1) 93 | tb = ''.join(stack) 94 | if format == 'html': 95 | h = pyg_highlight(tb, lexer=tb_lexer, formatter=html_formatter).rstrip('\n') 96 | return Markup(f'{h}') 97 | else: 98 | return pyg_highlight(tb, lexer=tb_lexer, formatter=shell_formatter).rstrip('\n') 99 | -------------------------------------------------------------------------------- /notbook/templates/base.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 | 11 | 27 |
    28 | {#

    {{ title }}

    #} 29 | {#

    An experiment in displaying python scripts.

    #} 30 | {% if reload_js_url -%} 31 |
    32 | 33 | waiting... 34 | 35 |
    36 | {%- endif -%} 37 |
    38 | {% block main %} 39 | {% endblock %} 40 |
    41 |
    42 | 43 | {% block js %} 44 | {% endblock %} 45 | {% if reload_js_url -%} 46 | 47 | {%- endif %} 48 | 49 | -------------------------------------------------------------------------------- /notbook/templates/error.jinja: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja' %} 2 | 3 | {% block main %} 4 |

    Execution Failed

    5 |
    6 |
     7 |       {{- exception -}}
     8 |     
    9 |
    10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /notbook/templates/main.jinja: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja' %} 2 | 3 | {%- macro show_print(statements) -%} 4 | {%- for statement in statements -%} 5 | 16 | {%- endfor -%} 17 | {%- endmacro -%} 18 | 19 | {% block main %} 20 | {%- for section in sections %} 21 |
    22 | {% if section.title -%} 23 |

    {{ section.title }}

    24 | {% endif -%} 25 | 26 |
    27 | {% if section.html -%} 28 | {{ section.html|safe }} 29 | {% elif section.print_statements -%} 30 | {{ show_print(section.print_statements) }} 31 | {% elif section.code -%} 32 | {%- for chunk in section.code -%} 33 | {%- if chunk.format -%} 34 |
    35 |                 {{- highlight(chunk.format, chunk.content) -}}
    36 |               
    37 | {%- else -%} 38 | {{ show_print(chunk) }} 39 | {%- endif -%} 40 | {%- endfor -%} 41 | {% elif section.plot %} 42 | {{ section.plot|safe }} 43 | {% endif -%} 44 |
    45 | {% if section.caption %} 46 |
    {{ section.caption }}
    47 | {% endif %} 48 |
    49 | {%- endfor %} 50 | {% endblock %} 51 | 52 | {% block js %} 53 | 60 | 62 | 63 | {%- if bokeh_plot %} 64 | 67 | {% endif %} 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /notbook/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.0.1' 2 | -------------------------------------------------------------------------------- /notbook/watch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from multiprocessing.context import Process 3 | from pathlib import Path 4 | from time import time 5 | 6 | from aiohttp import web 7 | from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPNotFound 8 | from aiohttp.web_fileresponse import FileResponse 9 | from aiohttp.web_response import Response 10 | from watchgod import PythonWatcher, awatch 11 | 12 | from .main import build, prepare 13 | 14 | __all__ = ('watch',) 15 | WS = 'websockets' 16 | 17 | 18 | async def static(request): 19 | request_path = request.match_info['path'].lstrip('/') 20 | directory: Path = request.app['output_dir'].resolve() 21 | if request_path == '': 22 | filepath = directory / 'index.html' 23 | else: 24 | try: 25 | filepath = (directory / request_path).resolve() 26 | filepath.relative_to(directory) 27 | except Exception as exc: 28 | # perm error or other kind! 29 | raise HTTPNotFound() from exc 30 | for _ in range(20): 31 | if filepath.exists(): 32 | break 33 | await asyncio.sleep(0.1) 34 | 35 | if filepath.is_file(): 36 | return FileResponse(filepath) 37 | else: 38 | raise HTTPNotFound() 39 | 40 | 41 | async def server_up(request): 42 | return Response(body=b'server up\n', content_type='text/plain') 43 | 44 | 45 | async def reload_websocket(request): 46 | ws = web.WebSocketResponse() 47 | await ws.prepare(request) 48 | 49 | request.app[WS].add(ws) 50 | async for _ in ws: 51 | pass 52 | 53 | request.app[WS].remove(ws) 54 | return ws 55 | 56 | 57 | async def moved(request): 58 | raise HTTPMovedPermanently('/') 59 | 60 | 61 | def build_in_subprocess(exec_file_path: Path, output_dir: Path, dev: bool): 62 | process = Process(target=build, args=(exec_file_path, output_dir), kwargs=dict(reload=True, dev=dev)) 63 | process.start() 64 | process.join() 65 | 66 | 67 | async def rebuild(app: web.Application): 68 | exec_file_path: Path = app['exec_file_path'] 69 | output_dir: Path = app['output_dir'] 70 | dev: bool = app['dev'] 71 | watcher = awatch(exec_file_path, watcher_cls=PythonWatcher) 72 | async for _ in watcher: 73 | print(f're-running {exec_file_path}...') 74 | start = time() 75 | await watcher.run_in_executor(build_in_subprocess, exec_file_path, output_dir, dev) 76 | for ws in app[WS]: 77 | await ws.send_str('reload') 78 | c = len(app[WS]) 79 | print(f'run completed in {time() - start:0.3f}s, {c} browser{"" if c == 1 else "s"} updated') 80 | 81 | 82 | async def startup(app): 83 | asyncio.get_event_loop().create_task(rebuild(app)) 84 | 85 | 86 | def watch(exec_file_path: Path, output_dir: Path, dev: bool = False): 87 | if not dev: 88 | prepare(output_dir) 89 | print(f'running {exec_file_path}...') 90 | build_in_subprocess(exec_file_path, output_dir, dev) 91 | 92 | app = web.Application() 93 | app.on_startup.append(startup) 94 | app.update( 95 | exec_file_path=exec_file_path, output_dir=output_dir, build=build, dev=dev, websockets=set(), 96 | ) 97 | app.add_routes( 98 | [ 99 | web.get('/.reload/up/', server_up), 100 | web.get('/.reload/ws/', reload_websocket), 101 | web.get('/index.html', moved), 102 | web.get('/{path:.*}', static), 103 | ] 104 | ) 105 | 106 | port = 8000 107 | print(f'watching {exec_file_path}, serving output at http://localhost:{port}') 108 | 109 | web.run_app(app, port=port, print=lambda s: None) 110 | -------------------------------------------------------------------------------- /requirements/all.txt: -------------------------------------------------------------------------------- 1 | -r demo.txt 2 | -r linting.txt 3 | -------------------------------------------------------------------------------- /requirements/demo.txt: -------------------------------------------------------------------------------- 1 | bokeh==2.0.2 2 | numpy==1.18.4 3 | scipy==1.4.1 4 | -------------------------------------------------------------------------------- /requirements/linting.txt: -------------------------------------------------------------------------------- 1 | black==19.10b0 2 | coverage==5.0.3 3 | flake8==3.7.9 4 | flake8-quotes==2.1.1 5 | isort==4.3.21 6 | git+https://github.com/PyCQA/pycodestyle@5c60447 7 | git+https://github.com/PyCQA/pyflakes@c688d2b 8 | -------------------------------------------------------------------------------- /screen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/notbook/a26b5519029cde6999de5e78eb89ae1c833c1a42/screen.gif -------------------------------------------------------------------------------- /scripts/error.py: -------------------------------------------------------------------------------- 1 | print('this is okay') 2 | 3 | raise ValueError('this is an error') 4 | -------------------------------------------------------------------------------- /scripts/simple.py: -------------------------------------------------------------------------------- 1 | """md 2 | This is a very simple script. 3 | """ 4 | # { 5 | 6 | def fib(n: int) -> int: 7 | if n < 2: 8 | return n 9 | return fib(n-2) + fib(n-1) 10 | 11 | print(fib(10)) 12 | 13 | print({i: fib(i) for i in range(9)}) 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | timeout = 10 4 | filterwarnings = error 5 | 6 | [flake8] 7 | max-line-length = 120 8 | max-complexity = 14 9 | inline-quotes = ' 10 | multiline-quotes = """ 11 | ignore = E203, W503 12 | 13 | [bdist_wheel] 14 | python-tag = py38 15 | 16 | [coverage:run] 17 | source = notbook 18 | branch = True 19 | 20 | [coverage:report] 21 | precision = 2 22 | exclude_lines = 23 | pragma: no cover 24 | raise NotImplementedError 25 | raise NotImplemented 26 | if TYPE_CHECKING: 27 | @overload 28 | 29 | [isort] 30 | line_length=120 31 | known_first_party=notbook 32 | multi_line_output=3 33 | include_trailing_comma=True 34 | force_grid_wrap=0 35 | combine_as_imports=True 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from importlib.machinery import SourceFileLoader 2 | from pathlib import Path 3 | 4 | from setuptools import setup 5 | 6 | description = 'Experiment in an alternative to jupyter notebooks' 7 | THIS_DIR = Path(__file__).resolve().parent 8 | try: 9 | long_description = THIS_DIR.joinpath('README.md').read_text() 10 | except FileNotFoundError: 11 | long_description = description 12 | 13 | # avoid loading the package before requirements are installed: 14 | version = SourceFileLoader('version', 'notbook/version.py').load_module() 15 | 16 | setup( 17 | name='notbook', 18 | version=version.VERSION, 19 | description=description, 20 | long_description=long_description, 21 | long_description_content_type='text/markdown', 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3 :: Only', 27 | 'Programming Language :: Python :: 3.7', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Intended Audience :: Developers', 30 | 'Intended Audience :: Information Technology', 31 | 'Intended Audience :: System Administrators', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Operating System :: Unix', 34 | 'Operating System :: POSIX :: Linux', 35 | 'Environment :: MacOS X', 36 | ], 37 | author='Samuel Colvin', 38 | author_email='s@muelcolvin.com', 39 | url='https://github.com/samuelcolvin/notbook', 40 | license='MIT', 41 | packages=['notbook'], 42 | package_data={'notbook': ['templates/*.jinja']}, 43 | entry_points=""" 44 | [console_scripts] 45 | notbook=notbook.__main__:cli 46 | """, 47 | python_requires='>=3.8', 48 | zip_safe=True, 49 | install_requires=[ 50 | 'aiohttp>=3.6.2', 51 | 'devtools>=0.5.1', 52 | 'jinja2>=2.11.2', 53 | 'misaka>=2.1.1', 54 | 'pygments>=2.6.1', 55 | 'typer>=0.2.1', 56 | 'watchgod>=0.6', 57 | ], 58 | ) 59 | --------------------------------------------------------------------------------