From 54112070dd59320154c2cb5b876fc6efb81d548a Mon Sep 17 00:00:00 2001 From: Victor Rachieru Date: Fri, 26 Jul 2019 14:55:01 +0300 Subject: [PATCH] Initial commit --- .gitignore | 130 +++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 12 ++++ LICENSE | 21 +++++++ README.md | 103 ++++++++++++++++++++++++++++++++++ app.py | 68 ++++++++++++++++++++++ requirements.txt | 1 + templates/index.html | 38 +++++++++++++ 7 files changed, 373 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..743b5a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +### Python Patch ### +.venv/ + +### Python.VirtualEnv Stack ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + + +# End of https://www.gitignore.io/api/python \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dfa7252 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3-alpine + +MAINTAINER Victor Rachieru + +WORKDIR /app +COPY . . + +RUN pip install -r requirements.txt + +EXPOSE 8080 + +CMD [ "python", "app.py" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..78edaf0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Victor Rachieru + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fabe17 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +

+ rasa-model-server +
+ + Version + + + + + + + +
+ Simple webserver for externalizing RASA models. +

+ + +### Quick start + +I recommend pulling the [latest image](https://hub.docker.com/r/vrachieru/rasa-model-server/) from Docker hub as this is the easiest way: +```bash +$ docker pull vrachieru/rasa-model-server +``` + +If you'd like, you can build the Docker image yourself: +```bash +docker build -t /rasa-model-server . +``` + +Specify your desired configuration and run the container: +```bash +$ docker run - --rm \ + -v /host/path/to/models:/models \ + -p :8080 \ + vrachieru/rasa-model-server +``` + +You can stop the container using: +```bash +$ docker stop rasa-model-server +``` + + +### Configuration + +You can configure the service via the following environment variables. + +| Environment Variable | Default Value | Description | +| --------------------- | ------------- | ------------------------------------------------------- | +| PORT | 8080 | Port on which to run the webserver. | +| MODELS_DIR | models | The absolute or relative location of the models folder. | + + +### Example + +Fetch a model without specifying a `If-None-Match` header. +``` +$ curl -s -I 'http://localhost:8080/bot/model.tar.gz' +HTTP/1.0 200 OK +Content-Disposition: attachment; filename=model.tar.gz +Content-Length: 6478848 +Content-Type: application/x-tar +Last-Modified: Tue, 23 Apr 2019 12:28:43 GMT +Cache-Control: public, max-age=43200 +Expires: Fri, 26 Jul 2019 23:42:05 GMT +ETag: "1556022523.364716-6478848-1948524791" +Date: Fri, 26 Jul 2019 11:42:05 GMT +Accept-Ranges: bytes +Server: Werkzeug/0.14.1 Python/3.6.3 +``` + +Once the model is loaded by RASA, subsequent requests will use the received ETAG to check if the model has been updated. +``` +$ curl -s -I 'http://localhost:8080/bot/model.tar.gz' -H 'If-None-Match: 1556022523.364716-6478848-1948524791' +HTTP/1.0 304 NOT MODIFIED +Content-Disposition: attachment; filename=model.tar.gz +Cache-Control: public, max-age=43200 +Expires: Fri, 26 Jul 2019 23:42:48 GMT +ETag: "1556022523.364716-6478848-1948524791" +Date: Fri, 26 Jul 2019 11:42:48 GMT +Accept-Ranges: bytes +Server: Werkzeug/0.14.1 Python/3.6.3 +``` + +Update the model on the server an the next request will pull the new model upon ETag mismatch. +``` +$ curl -s -I 'http://localhost:8080/bot/model.tar.gz' -H 'If-None-Match: 1556022523.364716-6478848-1948524791' +HTTP/1.0 200 OK +Content-Disposition: attachment; filename=model.tar.gz +Content-Length: 900 +Content-Type: application/x-tar +Last-Modified: Sat, 29 Dec 2018 23:17:54 GMT +Cache-Control: public, max-age=43200 +Expires: Fri, 26 Jul 2019 23:43:32 GMT +ETag: "1546125474.453404-900-1948524791" +Date: Fri, 26 Jul 2019 11:43:32 GMT +Accept-Ranges: bytes +Server: Werkzeug/0.14.1 Python/3.6.3 +``` + +### License + +MIT \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..f7b0cdd --- /dev/null +++ b/app.py @@ -0,0 +1,68 @@ +from os import scandir, listdir, environ +from os.path import isfile, isdir, dirname, basename, relpath, join, exists, getsize +from datetime import datetime +from flask import Flask, render_template, send_from_directory + + +class Scaner: + def __init__(self, path): + self.entries = [Entry(entry) for entry in scandir(path.encode())] + +class Entry: + def __init__(self, entry): + self.name = entry.name.decode() + self.path = entry.path.decode() + self.rel_path = relpath(self.path, models_dir) + self.is_dir = entry.is_dir() + self.created_time = datetime.fromtimestamp(entry.stat().st_ctime).ctime() + self.modified_time = datetime.fromtimestamp(entry.stat().st_mtime).ctime() + self.size = self._human_readable_size(self._get_size(entry.path)) + + def _get_size(self, path): + total_size = getsize(path) + if isdir(path): + for item in listdir(path): + item_path = join(path, item) + if isfile(item_path): + total_size += getsize(item_path) + elif isdir(item_path): + total_size += self._get_size(item_path) + return total_size + + def _human_readable_size(self, size): + units = ['B', 'KB', 'MB', 'GB', 'TB'] + human_fmt = '{0:.2f} {1}' + human_radix = 1024. + + for unit in units[:-1]: + if size < human_radix: + return human_fmt.format(size, unit) + size /= humanradix + + return human_fmt.format(size, units[-1]) + + +models_dir = environ.get('MODELS_DIR', 'models') + +app = Flask(__name__) + +@app.route('/', methods=['GET']) +def index(): + return render_template('index.html', path='/', entries=Scaner(models_dir).entries) + +@app.route('/', methods=['GET']) +def serve(path): + real_path = join(models_dir, path) + parent_path = relpath(dirname(real_path), models_dir) + + if not exists(real_path): + return 'Not Found', 404 + + if isdir(real_path): + return render_template('index.html', parent_path=parent_path, path=path, entries=Scaner(real_path).entries) + else: + return send_from_directory(dirname(real_path), basename(real_path), as_attachment=True, attachment_filename=basename(real_path)) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=environ.get('PORT', 8080)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae09db0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==1.0.2 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a1072b9 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,38 @@ + + + + + {{ path }} + + + + +

{{ path }}

+
+ + {% if parent_path %} + + + + {% endif %} + {% for entry in entries %} + + + + + + + {% endfor %} +
+ ../ +
+ + {{ entry.name|truncate(50, True) }}{% if entry.is_dir %}/{% endif %} + + {{ entry.created_time }}{{ entry.modified_time }}{{ entry.size }}
+
+ + \ No newline at end of file