├── .gitmodules ├── doc ├── img │ ├── minimal.gif │ ├── nppwad.gif │ ├── nppwad2.gif │ └── serverless.gif ├── example_wsgi.py └── AutoReload.md ├── common.py ├── .gitattributes ├── License.md ├── .gitignore ├── serverless.py ├── client.py ├── server.py ├── README.md ├── minimal_allinone.py └── allinone.py /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/img/minimal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo/HEAD/doc/img/minimal.gif -------------------------------------------------------------------------------- /doc/img/nppwad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo/HEAD/doc/img/nppwad.gif -------------------------------------------------------------------------------- /doc/img/nppwad2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo/HEAD/doc/img/nppwad2.gif -------------------------------------------------------------------------------- /doc/img/serverless.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo/HEAD/doc/img/serverless.gif -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Description: Information needed on both client and server side. 4 | 5 | This file is part of NearlyPurePythonWebAppDemo 6 | https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo 7 | 8 | Author: Mike Ellis 9 | Copyright 2017 Ellis & Grant, Inc. 10 | License: MIT License 11 | """ 12 | ## The number of state items to display in the web page. 13 | nitems = 10 14 | ## Enumerated names for each item. 15 | statekeys = ["item{}".format(n) for n in range(nitems)] 16 | ## Initial step size for random walk 17 | stepsize = 0.5 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.c text 7 | *.css text 8 | *.example text 9 | *.h text text 10 | *.html text 11 | *.ics text 12 | *.ini text 13 | *.js text 14 | *.json text 15 | *.jsonp text 16 | *.load text 17 | *.md text 18 | *.map text 19 | *.markdown text 20 | *.py text 21 | *.local text 22 | *.sh text 23 | *.xml text 24 | rc.local text 25 | 26 | # Declare files that will always have CRLF line endings on checkout. 27 | # *.sln text eol=crlf 28 | 29 | # Denote all files that are truly binary and should not be modified. 30 | *.png binary 31 | *.jpg binary 32 | *.ico 33 | *.pdf binary 34 | *.woff binary 35 | *.woff2 binary 36 | *.svg binary 37 | *.sqlite binary 38 | *.ttf binary 39 | *.eot binary 40 | *.table binary 41 | 42 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 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 | -------------------------------------------------------------------------------- /doc/example_wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example wsgi python file for pythonAnywhere hosting. 3 | 4 | # This file contains the WSGI configuration required to serve up your 5 | # web application at http://YourSubdomain.pythonanywhere.com/ 6 | # It works by setting the variable 'application' to a WSGI handler of some 7 | # description. 8 | 9 | # +++++++++++ GENERAL DEBUGGING TIPS +++++++++++ 10 | # getting imports and sys.path right can be fiddly! 11 | # We've tried to collect some general tips here: 12 | # https://www.pythonanywhere.com/wiki/DebuggingImportError 13 | 14 | # +++++++++++ VIRTUALENV +++++++++++ 15 | # If you want to use a virtualenv, set its path on the web app setup tab. 16 | # Then come back here and import your application object as per the 17 | # instructions below. 18 | 19 | # +++++++++++ WORKING DIRECTORY 20 | # Also, if the application needs to access files for, 21 | # say, rebuilding sources then your need to set the working directory 22 | # accordingly on the web app setup tab. 23 | """ 24 | 25 | # +++++++++++ CUSTOM WSGI +++++++++++ 26 | # If you have a WSGI file that you want to serve using PythonAnywhere, perhaps 27 | # in your home directory under version control, then use something like this: 28 | # 29 | import sys 30 | path = '/home/YourSubdomain/path/to/NearlyPurePythonWebAppDemo' 31 | if path not in sys.path: 32 | sys.path.append(path) 33 | 34 | # Import the wrapped Bottle app object. 35 | from server import app_for_wsgi_env as application 36 | 37 | 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## File types that should not be version controlled. 2 | 3 | # Compiled source # 4 | ################### 5 | *.com 6 | *.class 7 | *.dll 8 | *.exe 9 | *.o 10 | *.so 11 | 12 | # Packages # 13 | ############ 14 | # it's better to unpack these files and commit the raw source 15 | # git has its own built in compression methods 16 | *.7z 17 | *.dmg 18 | *.gz 19 | *.iso 20 | *.jar 21 | *.rar 22 | *.tar 23 | *.zip 24 | 25 | # Logs and databases # 26 | ###################### 27 | *.db 28 | *.log 29 | *.log.* 30 | *.sql 31 | *.sqlite 32 | *.out 33 | 34 | 35 | # OS generated files # 36 | ###################### 37 | .DS_Store 38 | .DS_Store? 39 | ._* 40 | .Spotlight-V100 41 | .Trashes 42 | ehthumbs.db 43 | Thumbs.db 44 | 45 | # Editor temporary files # 46 | ########################## 47 | *.swo 48 | *.swp 49 | *.bak 50 | 51 | 52 | # UltiSnips snippet files # 53 | ########################### 54 | *.snippets 55 | 56 | ## Python specific files 57 | 58 | # Byte-compiled / optimized / DLL files 59 | __pycache__/ 60 | *.py[cod] 61 | *$py.class 62 | 63 | # C extensions 64 | *.so 65 | 66 | # Distribution / packaging 67 | .Python 68 | env/ 69 | build/ 70 | develop-eggs/ 71 | dist/ 72 | downloads/ 73 | eggs/ 74 | .eggs/ 75 | lib/ 76 | lib64/ 77 | parts/ 78 | sdist/ 79 | var/ 80 | *.egg-info/ 81 | .installed.cfg 82 | *.egg 83 | 84 | # PyInstaller 85 | # Usually these files are written by a python script from a template 86 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 87 | *.manifest 88 | *.spec 89 | 90 | # Installer logs 91 | pip-log.txt 92 | pip-delete-this-directory.txt 93 | 94 | # Unit test / coverage reports 95 | htmlcov/ 96 | .tox/ 97 | .coverage 98 | .coverage.* 99 | .cache 100 | nosetests.xml 101 | coverage.xml 102 | *,cover 103 | .hypothesis/ 104 | 105 | # Translations 106 | *.mo 107 | *.pot 108 | 109 | # Django stuff: 110 | *.log 111 | local_settings.py 112 | 113 | # Flask stuff: 114 | instance/ 115 | .webassets-cache 116 | 117 | # Scrapy stuff: 118 | .scrapy 119 | 120 | # Sphinx documentation 121 | docs/_build/ 122 | 123 | # PyBuilder 124 | target/ 125 | 126 | # IPython Notebook 127 | .ipynb_checkpoints 128 | 129 | # pyenv 130 | .python-version 131 | 132 | # celery beat schedule file 133 | celerybeat-schedule 134 | 135 | # dotenv 136 | .env 137 | 138 | # virtualenv 139 | venv/ 140 | ENV/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # NearlyPurePythonWebAppDemo directories for generated files 149 | __javascript__/ 150 | __pycache__/ 151 | __html__/ 152 | 153 | 154 | -------------------------------------------------------------------------------- /doc/AutoReload.md: -------------------------------------------------------------------------------- 1 | # Auto Reload 2 | 3 | Other things being equal, the rate of progress in application development is directly related to the time it takes to make and test one small change. By default, the NearlyPurePythonWebAppDemo (NPPWAD hereafter) skeleton will detect changes in any of the Python source files and reload both the server and the client side -- including a rebuild of `client.js` from `client.py` if needed. 4 | 5 | *Note: The mechanism described here is also incorporated in the new single-file skeleton, `allinone.py`.* 6 | ## How it works 7 | 8 | Bottle already provides an auto-reloader and a debug mode. The Bottle Tutorial explains both of those [here](https://bottlepy.org/docs/dev/tutorial.html#debug-mode). NPPWAD takes advantage of them and extends them by 9 | 10 | 1. Incorporating a simple rebuild facility in `server.py,` and 11 | 2. Running a client-side check on server start time in `client.py`. 12 | 13 | The result is that changing any of the .py source files triggers the following chain of events: 14 | 15 | * Bottle notices that a file has changed and reloads `server.py.` 16 | * As `server.py` re-initializes it checks the modification times on the sources and regenerates `__html__/index.html` and `client.js` as needed. 17 | * If a prior version of `client.js` is running in a browser, the next state update will detect a change in `_state['server_start_time']` and reload the page using `location.reload()`. 18 | 19 | ## Usage 20 | Reload and debug are enabled by default when you launch from the command line. 21 | 22 | To disable these features in a production version, launch `server.py` with `--no-reloader --no-debug` at the command line. If launching by direct call to `server.serve()`, note that reload and debug are *disabled* by default in the keyword args to `serve()`. 23 | 24 | 25 | 26 | ## Limitations and caveats 27 | 28 | The reloader in Bottle inspects `sys.modules` to determine what files to monitor -- meaning that it only tracks modules that are in the tree of modules imported into `server.py.` To monitor changes in client-side Python sources, e.g. `client.py`, we must import them into `server.py` -- which means that such modules must not cause errors or have undesired side effects when imported into Python code. Notice, for example, at the bottom of `client.py` the lines 29 | 30 | ``` 31 | try: 32 | document.addEventListener('DOMContentLoaded', start) 33 | except NameError: 34 | pass 35 | ``` 36 | This prevents a failed import when `document`, a global object in JS, is not defined in Python. Fortunately, the event-driven nature of client-side code makes it easy to confine module-level execution to a single statement, as above, to avoid littering the code with many similar guard constructs. 37 | 38 | Another obvious limitation is that the reloader only monitors Python sources. For a large project with sources in other languages, you would probably want to set up a build system triggered from a monitor program such as `entr`. To connect it with the `server.py` reloader, you could arrange for the build system to update a dummy python module, e.g. `reloadme.py` imported by `server.py` for the sole purpose of detecting the need for a reload. 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /serverless.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | A once-through script that creates and loads HTML and Javascript. 4 | Uses Transcrypt and CPython's webbrowser module. There are 3 sections: 5 | 6 | Common Code -- used by both client and server 7 | Server Code -- Specific to the web service 8 | Client Code -- Compiled to JS by Transcrypt 9 | 10 | You can search for the above names in your editor for convenient navigation. 11 | 12 | External dependencies: 13 | Python >= 3.5 (Available from www.python.org) 14 | pip install transcrypt 15 | pip install htmltree 16 | 17 | USAGE: 18 | Typically: 19 | $ python serverless.py 20 | 21 | Author: Mike Ellis 22 | Copyright 2017 Ellis & Grant, Inc 23 | License: MIT License 24 | 25 | This file is part of NearlyPurePythonWebAppDemo 26 | https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo 27 | """ 28 | ####################################################################### 29 | ## Common Code 30 | ## Visible to CPython and Transcrypt 31 | ####################################################################### 32 | from htmltree.htmltree import * 33 | 34 | ## If working from a renamed copy of this module, change the next 35 | ## line accordingly. 36 | _module_basename = 'serverless' 37 | _module_pyname = _module_basename + '.py' 38 | _module_jsname = _module_basename + '.js' 39 | 40 | ## A little bit of trickiness here. We need to cause CPython to see only 41 | ## what's intended to be server-side code and Transcrypt to see only what's 42 | ## intended be client-side code. The solution is to try to invoke the Transcrypt 43 | ## pragma function. It won't be defined in CPython, so the server code can 44 | ## go in the 'except' branch (wrapped in skip/noskip pragmas in comments) 45 | ## and the client code follows in an 'else' branch. 46 | try: 47 | __pragma__('js', "") 48 | except NameError: 49 | ####################################################################### 50 | ## Server Code 51 | ####################################################################### 52 | #__pragma__('skip') 53 | 54 | import subprocess 55 | import webbrowser 56 | 57 | def buildIndexHtml(): 58 | """ 59 | Create the content index.html file. For the purposes of the demo, we 60 | create it with an empty body element to be filled in on the client 61 | side. Returns file URL. 62 | """ 63 | viewport = Meta(name='viewport', content='width=device-width, initial-scale=1') 64 | 65 | head = Head(viewport, 66 | Script(src='../__javascript__/' + _module_jsname, 67 | charset='UTF-8')) 68 | 69 | body = Body() 70 | 71 | doc = Html(head, body) 72 | print(doc.render(0)) 73 | return doc.renderToFile('__html__/index.html', 0) 74 | 75 | 76 | ## Create the HTML 77 | indexurl = buildIndexHtml() 78 | print(indexurl) 79 | 80 | ## Transpile to client code 81 | proc = subprocess.Popen('transcrypt -b -n -m {}'.format(_module_pyname), shell=True) 82 | if proc.wait() != 0: 83 | raise Exception("Failed trying to build {}".format(_module_jsname)) 84 | 85 | ## Open in default web browser. 86 | webbrowser.open(indexurl) 87 | 88 | #__pragma__('noskip') 89 | ## ------------- End of Server Code ----------------------------------- 90 | 91 | pass ## needed so the except branch looks complete to Transcrypt. 92 | else: 93 | 94 | ####################################################################### 95 | ## Client Code 96 | ## Compiled by Transcrypt 97 | ####################################################################### 98 | 99 | 100 | def makeBody(): 101 | """ 102 | Create HTML for the body element content. This is done as a demo to show 103 | that the code in htmltree.py works in Transcrypted JS as well as in Python. 104 | It could have been accomplished just as easily on the server side. 105 | 106 | Uses JS: .innerHTML, .style 107 | """ 108 | document.body.style.backgroundColor = "black" 109 | banner = H1("Hello, {}.".format(_module_basename), style=dict(color='yellow')) 110 | counter = H2(id="counter", style=dict(color='green')) 111 | header = Div(banner, counter, style=dict(text_align='center')) 112 | bodycontent = Div(header) 113 | 114 | ## Use the DOM API to insert rendered content 115 | document.body.innerHTML = bodycontent.render() 116 | 117 | 118 | 119 | _updater = None ## Initialized below as a generator instance. 120 | 121 | def start (): 122 | """ 123 | Client-side app execution starts here. 124 | 125 | Uses JS: .getElementById, .addEventListener, 126 | .textContent, .hasOwnProperty, .setInterval 127 | """ 128 | ## Create the body content 129 | makeBody() 130 | 131 | ## Counter update generator ## 132 | def update_counter(e): 133 | counter = document.getElementById('counter') 134 | count = 0 135 | while True: 136 | count += 1 137 | counter.textContent = "{}".format(count) 138 | yield 139 | 140 | global _updater 141 | _updater = update_counter() 142 | 143 | ## define polling function 144 | def update (): 145 | next(_updater) 146 | 147 | ## First update 148 | update () 149 | ## Repeat every second. 150 | window.setInterval (update, 1000) 151 | 152 | ## Wait until the DOM is loaded before calling start() 153 | document.addEventListener('DOMContentLoaded', start) 154 | 155 | ## ------------- End of Client Code ----------------------------------- 156 | -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Description: Client-side code that gets transpiled to JS by Transcrypt(TM) 5 | Execution begins in the start() method -- which is invoked from a script element 6 | in the html head element of index.html 7 | 8 | This file is part of NearlyPurePythonWebAppDemo 9 | https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo 10 | 11 | Author: Mike Ellis 12 | Copyright 2017 Ellis & Grant, Inc. 13 | License: MIT License 14 | """ 15 | import common 16 | from htmltree.htmltree import * 17 | 18 | _state = {} 19 | _prior_state = {} 20 | _readouts = None 21 | 22 | def makeBody(): 23 | """ 24 | Create HTML for the body element content. This is done as a demo to show 25 | that the code in htmltree.py works in Transcrypted JS as well as in Python. 26 | It could have been accomplished just as easily on the server side. 27 | """ 28 | banner = H1("Nearly Pure Python Web App Demo", style=dict(color='yellow')) 29 | projectlink = A('Source Code on GitHub', 30 | href='https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo') 31 | subbanner = H2(projectlink) 32 | 33 | header = Div(banner, subbanner, style=dict(text_align='center')) 34 | 35 | readouts = [] 36 | for datakey in common.statekeys: 37 | readouts.append(Div('waiting ...', _class='readout', data_key=datakey)) 38 | 39 | stepinput = Label("Step Size", 40 | Input(id='stepinput', type='text', style=dict(margin='1em')), 41 | style=dict(color='white')) 42 | 43 | stepsubmit = Input(type="submit", value="Submit") 44 | 45 | stepform = Form( 46 | Div(stepinput, stepsubmit, style=dict(margin='20px')), 47 | id='setstep') 48 | 49 | bodycontent = Div(header) 50 | bodycontent.C.extend(readouts) 51 | bodycontent.C.append(stepform) 52 | 53 | ## Use the DOM API to insert rendered content 54 | console.log(bodycontent.render()) 55 | document.body.innerHTML = bodycontent.render() 56 | 57 | ######################################################### 58 | # jQuery replacement functions 59 | # The next 3 function could logically be placed in a 60 | # separate module. 61 | ######################################################### 62 | def triggerCustomEvent(name, data): 63 | """ 64 | JS version of jQuery.trigger. 65 | see http://youmightnotneedjquery.com/#trigger_custom 66 | """ 67 | if window.CustomEvent: 68 | event = __new__ (CustomEvent(name, {'detail' : data})) 69 | else: 70 | event = document.createEvent('CustomEvent') 71 | event.initCustomEvent(name, True, True, data) 72 | document.dispatchEvent(event) 73 | 74 | def getJSON(url, f): 75 | """ 76 | JS version of jQuery.getJSON 77 | see http://youmightnotneedjquery.com/#get_json 78 | url must return a JSON string 79 | f(data) handles an object parsed from the return JSON string 80 | """ 81 | request = __new__ (XMLHttpRequest()) 82 | request.open('GET', url, True) 83 | def onload(): 84 | if 200 <= request.status < 400: 85 | data = JSON.parse(request.responseText) 86 | f(data) ## call handler with object created from JSON string 87 | else: 88 | _ = "Server returned {} for getJSON request on {}".format(request.status, url) 89 | console.log(_) 90 | def onerror(): 91 | _ = "Connection error for getJSON request on {}".format(url) 92 | console.log(_) 93 | request.onload = onload 94 | request.onerror = onerror 95 | request.send() 96 | 97 | def post(url, data): 98 | """ 99 | JS version of jQuery.post 100 | see http://youmightnotneedjquery.com/#post 101 | data is expected to be a dict. 102 | """ 103 | request = __new__(XMLHttpRequest()) 104 | request.open('POST', url, True) 105 | request.setRequestHeader('Content-Type', 106 | 'application/x-www-form-urlencoded; ' 107 | 'charset=UTF-8') 108 | ## serialize the data, see http://stackoverflow.com/a/1714899/426853 109 | ldata = [] 110 | for k,v in data.items(): 111 | if data.hasOwnProperty(k): 112 | lh = encodeURIComponent(k) 113 | rh = encodeURIComponent(v) 114 | ldata.append("{}={}".format(lh, rh)) 115 | 116 | request.send("&".join(ldata)) 117 | 118 | # End of j!uery replacement functions 119 | ######################################################## 120 | 121 | def getState(): 122 | """ Fetch JSON obj containing monitored variables. """ 123 | def f(data): 124 | global _state, _prior_state 125 | _prior_state.update(_state) 126 | _state = data 127 | triggerCustomEvent('state:update', {}) 128 | #console.log(_state) 129 | getJSON('/getstate', f) 130 | return 131 | 132 | def update_readouts(): 133 | """ 134 | Triggered on each readout by 'state:update' custom event. We check each 135 | state value and alter it's text color accordingly. 136 | """ 137 | ## queue the new values and colora 138 | queue = [] 139 | for el in _readouts: 140 | key = el.getAttribute('data-key') 141 | value = _state[key] 142 | valuef = float(value) 143 | if valuef <= 2.0: 144 | color = 'deepskyblue' 145 | elif valuef >= 8.0: 146 | color = 'red' 147 | else: 148 | color = 'green' 149 | queue.append((el, value, color)) 150 | 151 | ## write them to the DOM 152 | for el, value, color in queue: 153 | el.textContent = value 154 | el.setAttribute('style', "color:{}; font-size:32;".format(color)) 155 | 156 | ## Also update the stepsize input with the current value, but 157 | ## check that the element does not have focus before doing so 158 | ## tp prevent update while user is typing. 159 | inp = document.getElementById('stepinput') 160 | if inp != document.activeElement: 161 | inp.value = _state['stepsize'] 162 | 163 | 164 | def handle_stepchange(event): 165 | """ 166 | Check that the request for a new step size is a number between 0 and 10 167 | before allowing the submit action to proceed. 168 | """ 169 | fail_msg = "Step size must be a number between 0 and 10" 170 | v = document.getElementById('stepinput').value 171 | # Transcrypt float() is buggy, so use some inline JS. 172 | # See https://github.com/QQuick/Transcrypt/issues/314 173 | #__pragma__('js','{}','var vj = parseFloat(v); var isfloat = !isNaN(vj);') 174 | if isfloat and (0.0 <= vj <= 10.0): 175 | ## It's valid. Send it. 176 | post('/setstepsize', { 'stepsize': v }) 177 | return False 178 | else: 179 | alert(fail_msg) 180 | return False 181 | 182 | def start (): 183 | """ 184 | Client-side app execution starts here. 185 | """ 186 | ## Create the body content 187 | makeBody() 188 | 189 | ## Initialize the readouts 190 | global _readouts 191 | _readouts = document.querySelectorAll('.readout') 192 | for el in _readouts: 193 | el.style.fontSize = '12' 194 | 195 | 196 | ## Bind event handler to step change form 197 | ssform = document.getElementById('setstep') 198 | ssform.addEventListener('submit', handle_stepchange) 199 | 200 | ## Bind custom event handler to document 201 | document.addEventListener('state:update', update_readouts) 202 | 203 | ## define polling function 204 | global _state, _prior_state 205 | def update (): 206 | getState() 207 | ## Reload if server has restarted 208 | if (_prior_state is not None and 209 | _prior_state.hasOwnProperty('server_start_time')): 210 | if _state['server_start_time'] > _prior_state['server_start_time']: 211 | location.reload(True) 212 | 213 | ## First update 214 | update () 215 | ## Repeat every 0.5 secondss 216 | window.setInterval (update, 500) 217 | 218 | try: 219 | document.addEventListener('DOMContentLoaded', start) 220 | except NameError: 221 | pass 222 | 223 | 224 | 225 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Description: Server for NearlyPurePythonWebAppDemo. 5 | 6 | Sources and more info at: 7 | https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo 8 | 9 | Author: Mike Ellis 10 | Copyright 2017 Ellis & Grant, Inc 11 | License: MIT License 12 | """ 13 | 14 | 15 | ############################################################ 16 | # Initialization 17 | ############################################################ 18 | import os 19 | import sys 20 | import time 21 | import doctest 22 | import random 23 | import subprocess 24 | import bottle 25 | import common 26 | from traceback import format_exc 27 | from htmltree.htmltree import * 28 | 29 | ## We import client.py so the Bottle reloader will track it for changes 30 | ## It has no use in this module and no side effects other than a small 31 | ## overhead to load it. 32 | import client 33 | 34 | # Create an app instance. 35 | app = bottle.Bottle() 36 | request = bottle.request ## the request object accessor 37 | 38 | 39 | ############################################################ 40 | # Build index.html 41 | ############################################################ 42 | def buildIndexHtml(): 43 | """ 44 | Create the content index.html file. For the purposes of the demo, we 45 | create it with an empty body element to be filled in on the client side. 46 | Returns: None 47 | Raises: Nothing 48 | """ 49 | 50 | style = Style(**{'a:link':dict(color='red'), 51 | 'a:visited':dict(color='green'), 52 | 'a:hover':dict(color='hotpink'), 53 | 'a:active':dict(color='blue'), 54 | }) 55 | 56 | head = Head(style, Script(src='/client.js', charset='UTF-8')) 57 | 58 | body = Body("Replace me on the client side", 59 | style=dict(background_color='black')) 60 | 61 | doc = Html(head, body) 62 | return doc.render() 63 | 64 | 65 | ############################################################ 66 | # Routes and callback functions 67 | # The following routes are defined below: 68 | # /client.js 69 | # /home (= /index.html = /) 70 | # /getstate 71 | # /setstepsize 72 | ############################################################ 73 | 74 | @app.route('/client.js') 75 | def client(): 76 | """ 77 | Route for serving client.js 78 | """ 79 | root = os.path.abspath("./__javascript__") 80 | return bottle.static_file('client.js', root=root) 81 | 82 | @app.route("/") 83 | @app.route("/index.html") 84 | @app.route("/home") 85 | def index(): 86 | """ Serve the home page """ 87 | root = os.path.abspath("./__html__") 88 | return bottle.static_file('index.html', root=root) 89 | 90 | ## Module level variable used to exchange data beteen handlers 91 | _state = {} 92 | 93 | def stategen(): 94 | """ 95 | Initialize each state item with a random float between 0 and 10, then 96 | on each next() call, 'walk' the value by a randomly chosen increment. The 97 | purpose is to simulate a set of drifting measurements to be displayed 98 | and color coded on the client side. 99 | """ 100 | last = time.time() 101 | counter = 0 102 | nitems = common.nitems 103 | statekeys = common.statekeys 104 | _state['step'] = (-common.stepsize, 0.0, common.stepsize) 105 | _state['stepsize'] = common.stepsize 106 | statevalues = [round(random.random()*10, 2) for n in range(nitems)] 107 | _state.update(dict(zip(statekeys, statevalues))) 108 | while True: 109 | ## Update no more frequently than twice per second 110 | now = time.time() 111 | if now - last >= 0.5: 112 | last = now 113 | counter += 1 114 | step = _state['step'] 115 | statevalues = [round(v + random.choice(step), 2) for v in statevalues] 116 | statevalues = [min(10.0, max(0.0, v)) for v in statevalues] 117 | _state.update(dict(zip(statekeys, statevalues))) 118 | _state['count'] = counter 119 | yield 120 | 121 | ## The generator needs to persist outside of handlers. 122 | _stateg = stategen() 123 | 124 | @app.route("/getstate") 125 | def getstate(): 126 | """ 127 | Serve a JSON object representing state values. 128 | Returns: dict(count=n, item0=v0, item1=v1, ...) 129 | Raises: Nothing 130 | """ 131 | next(_stateg) 132 | return _state 133 | 134 | @app.post("/setstepsize") 135 | def setStepSize(): 136 | """ 137 | Called when user submits step size input. Validation 138 | happens client side so we don't check it here. In a real 139 | app you'd want some server-side validation would help protect 140 | against exploits. 141 | """ 142 | stepsize = float(request.forms.get('stepsize')) 143 | _state['stepsize'] = stepsize 144 | _state['step'] = (-stepsize, 0, stepsize) 145 | return {} 146 | 147 | ######################################################## 148 | # Build functions 149 | ######################################################## 150 | 151 | def needsBuild(target, sources): 152 | """ 153 | Returns True if target doesn't exist or is older than any of the sources. 154 | Sources must be an iterable, e.g. list or tuple. 155 | """ 156 | return not os.path.exists(target) or any([(os.stat(target).st_mtime 157 | < os.stat(source).st_mtime) for source in sources]) 158 | def doBuild(): 159 | """ 160 | Build the html and js files, if needed. 161 | 162 | Note: In larger projects with more complex dependencies, you'll probably 163 | want to use make or scons to build the targets instead of the simple 164 | approach taken here. 165 | """ 166 | ## build the index.html file 167 | index_sources = ('server.py', 'htmltree/htmltree.py', 'common.py') 168 | target = '__html__/index.html' 169 | if needsBuild(target, index_sources): 170 | os.makedirs(os.path.dirname(target), exist_ok=True) 171 | with open(target, 'w') as f: 172 | print(buildIndexHtml(),file=f) 173 | 174 | ## build the client.js file 175 | client_sources = ('client.py', 'htmltree/htmltree.py', 'common.py') 176 | if needsBuild('__javascript__/client.js', client_sources): 177 | proc = subprocess.Popen('transcrypt -b -n -m client.py', shell=True) 178 | if proc.wait() != 0: 179 | raise Exception("Failed trying to build client.js") 180 | 181 | class AppWrapperMiddleware: 182 | """ 183 | Some hosted environments, e.g. pythonanywhere.com, require you 184 | to export the Bottle app object. Exporting an instance of this 185 | wrapper class makes sure the build procedure runs on startup. 186 | """ 187 | def __init__(self, app): 188 | self.app = app 189 | def __call__(self, e, h): 190 | doBuild() 191 | return self.app(e,h) 192 | 193 | ################################################## 194 | ## Import this from external wsgi file 195 | app_for_wsgi_env = AppWrapperMiddleware(app) 196 | ################################################## 197 | 198 | ######################################################## 199 | ## Default wrapper so we can spawn this app commandline or 200 | ## from multiprocessing. 201 | ######################################################## 202 | def serve(server='wsgiref', port=8800, reloader=False, debugmode=False): 203 | """ 204 | Build the html and js files, if needed, then launch the app. 205 | 206 | The default server is the single-threaded 'wsgiref' server that comes with 207 | Python. It's fine for a demo, but for production you'll want to use 208 | something better, e.g. server='cherrypy'. For an extensive list of server 209 | options, see http://bottlepy.org/docs/dev/deployment.html 210 | """ 211 | bottle.debug(debugmode) 212 | 213 | ## Client side tracks _state['server_start_time'] 214 | ## to decide if it should reload. 215 | _state['server_start_time'] = time.time() 216 | 217 | ## rebuild as needed 218 | doBuild() 219 | 220 | ## Launch the web service loop. 221 | bottle.run(app, 222 | host='0.0.0.0', 223 | server=server, 224 | port=port, 225 | reloader=reloader, 226 | debug=debugmode) 227 | 228 | ################################################### 229 | ## The following runs only when we start from 230 | ## the command line. 231 | ################################################### 232 | if __name__ == '__main__': 233 | doctest.testmod() 234 | import argparse 235 | parser = argparse.ArgumentParser( 236 | description = "Nearly Pure Python Web App Demo") 237 | parser.add_argument('-s', '--server', type=str, default='wsgiref', 238 | help="server program to use.") 239 | parser.add_argument('-p', '--port', type=int, default=8800, 240 | help="port number to serve on.") 241 | parser.add_argument('--no-reloader', dest='reloader', action='store_false', 242 | help="disable reloader (defult: enabled)") 243 | parser.add_argument('--no-debug', dest='debug', action='store_false', 244 | help="disable debug mode (defult: enabled)") 245 | parser.set_defaults(reloader=True) 246 | parser.set_defaults(debug=True) 247 | args = parser.parse_args() 248 | serve(server=args.server, port=args.port, 249 | reloader=args.reloader, debugmode=args.debug) 250 | 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nearly Pure Python Web App Demo 2 | 3 | ## A simple but complete web application skeleton that runs 'out of the box'. 4 | 5 | * No static html, css, or js files 6 | * Demonstrates how to generate and manipulate html in pure python on both the client and server sides. 7 | * A useful skeleton that includes Ajax JSON state updates from server to client and vice versa. 8 | * Automatically reloads server and client pages when any source file changes. 9 | * Calls a few JS methods directly from Python, hence the 'nearly pure' in the title 10 | * Powered by Jacques De Hooge's [*Transcrypt™*](https://transcrypt.org/) Python to JS transpiler and Marcel Hellkamp's [*Bottle Python Web Framework.*](http://bottlepy.org/docs/dev/) 11 | 12 | ### Who's this for? 13 | * Developers with a taste for minimalism who want to experiment with a (mostly) pure python approach to web app development. 14 | * Course instructors looking a complete example students can use as a starting point. 15 | 16 | ### What can I do with it? 17 | * Fork or clone it as a starting point for your own projects. 18 | * Read the code. There are only 4 short files with less than 400 sloc total. 19 | * Stare at the colorful numbers until you grok the message hidden there *just for you*[1](#hint). 20 | 21 | ### Dependencies (install these first) 22 | * [Python]( https://www.python.org/downloads/) >= 3.5 23 | * [Transcrypt™](http://transcrypt.org/) >= 3.6.24 24 | * `pip install transcrypt` 25 | * [Bottle](http://bottlepy.org/docs/dev/) >= 0.12.13 26 | * `pip install bottle` 27 | * [htmltree](https://github.com/Michael-F-Ellis/htmltree) >= 0.7.5 28 | * pip install htmltree 29 | 30 | ### NEW Single Source Files 31 | Use the recently added `allinone.py` which combines the content of 3 files into a single one that automatically builds the Javascript and launches the server. Just do `python allinone.py` instead of `python server.py`. There are also two other new files, `minimal_allinone.py` and `serverless.py`. These files will likely be the focus of future development and, hence, will continue to diverge from the behavior of `server.py + client.py + common.py` which should now be considered deprecated, or at least discouraged. 32 | 33 | ### Installation and usage 34 | ``` 35 | git clone https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo 36 | cd NearlyPurePythonWebAppDemo 37 | python allinone.py 38 | ``` 39 | * Note: You can choose a different server and port. Do `python allinone.py -h` for details 40 | 41 | * browse to http://localhost:8800 . Your should see a screen like the one below with readout values updating every half-second. Values are color coded as follows: 42 | * blue: V <= 2.0 : blue 43 | * green: 2.0 < V < 8.0 : green 44 | * red: V >= 8.0 red 45 | 46 | * Use the slider to change the Step Size to any number between 0 and 10. 47 | * Larger values cause faster drifts through color ranges. 48 | 49 |  50 | 51 | ### Rapid development 52 | * While the app is running, saving a change to any source file triggers a rebuild and reload of the server and the client page. See [Auto Reload](doc/AutoReload.md) for details. 53 | * Clean pythonic syntax for generating html. See [htmltree](https://github.com/Michael-F-Ellis/htmltree) docs for details. Here's the function that creates all the body html in the demo above. 54 | ``` 55 | def makeBody(): 56 | """ 57 | Create HTML for the body element content. This is done as a demo to show 58 | that the code in htmltree.py works in Transcrypted JS as well as in Python. 59 | It could have been accomplished just as easily on the server side. 60 | 61 | Uses JS: .innerHTML 62 | """ 63 | banner = H1("Nearly Pure Python Web App Demo", style=dict(color='yellow')) 64 | projectlink = A('Source Code on GitHub', 65 | href='https://github.com/Michael-F-Ellis/NearlyPurePythonWebAppDemo') 66 | subbanner = H2(projectlink) 67 | 68 | header = Div(banner, subbanner, style=dict(text_align='center')) 69 | 70 | ## Each readout is a div containing a meter element and a span to hold 71 | ## a text representaton of the current value. 72 | readouts = [] 73 | for datakey in common.statekeys: 74 | meter = Meter(min="0.1", low="2.0", high="8.0", max="10.0", 75 | style=dict(width="25%", margin_top="5px", margin_bottom="5px")) 76 | value = Span() 77 | readouts.append(Div(meter, value, _class='readout', data_key=datakey)) 78 | 79 | 80 | ## The step input is a range slider input with a label on the left and 81 | ## a span for the current value on the right. 82 | slider = Input(id='stepinput', _type='range', 83 | min="0.1", max="10.0", step="0.1", 84 | style=dict(margin='1em')) 85 | 86 | stepinput = Label("Step Size", slider, 87 | style=dict(color='white')) 88 | 89 | ## Make a div container for the step input. 90 | stepdiv = Div(stepinput, 91 | Span(id='stepvalue', style=dict(color="white")), 92 | style=dict(margin='20px')) 93 | 94 | ## Assemble header, readouts, and stepdiv within a div 95 | bodycontent = Div(header) 96 | bodycontent.C.extend(readouts) 97 | bodycontent.C.append(stepdiv) 98 | 99 | ## Use the DOM API to insert rendered content 100 | print(bodycontent.render(0)) 101 | document.body.innerHTML = bodycontent.render() 102 | ``` 103 | and here is the output `makeBody()` produces: 104 | ``` 105 |