commit 76c2ed7cbaf72323b51f8add1203108a76a16071 Author: s3lph Date: Mon Nov 25 02:48:12 2019 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a01865 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +**/.idea/ +*.iml + +**/__pycache__/ +*.pyc +**/*.egg-info/ +*.coverage +**/.mypy_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a24af43 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,42 @@ +--- +image: python:3.8 + +stages: +- test +- build + + + +before_script: +- export SPACEAPI_SERVER_VERSION=$(python -c 'import spaceapi_server; print(spaceapi_server.__version__)') + + + +test: + stage: test + script: + - pip3 install -e . + - python3 -m coverage run --rcfile=setup.cfg -m unittest discover spaceapi_server + - python3 -m coverage combine + - python3 -m coverage report --rcfile=setup.cfg + +codestyle: + stage: test + script: + - pip3 install -e . + - pycodestyle spaceapi_server + + + +build_wheel: + stage: build + script: + - python3 setup.py egg_info bdist_wheel + - cd dist + - sha256sum *.whl > SHA256SUMS + artifacts: + paths: + - "dist/*.whl" + - dist/SHA256SUMS + only: + - tags diff --git a/examples/config.json b/examples/config.json new file mode 100644 index 0000000..e891d7d --- /dev/null +++ b/examples/config.json @@ -0,0 +1,13 @@ +{ + "address": "::1", + "port": 8000, + "template": "examples/template.json", + "plugins_dir": "examples/plugins", + + "plugins": { + "example": { + "test_value": "the Spanish Inquisition" + } + } +} + diff --git a/examples/plugins/example.py b/examples/plugins/example.py new file mode 100644 index 0000000..67989b6 --- /dev/null +++ b/examples/plugins/example.py @@ -0,0 +1,40 @@ + +from spaceapi_server import config, plugins + + +@plugins.template_function +def example_function(name: str): + ''' + This function is registered as a Jinja2 function. It can be used like this: + {{ example_function('the Spanish Inquisition') }} + ''' + return f'Nobody expects {name}' + + +@plugins.template_filter +def example_filter(name: str): + ''' + This function is registered as a Jinja2 filter. It can be used like this: + {{ 'the Spanish Inquisition' | example_filter }} + ''' + return f'Nobody expects {name}' + + +@plugins.template_test +def example_test(name: str): + ''' + This function is registered as a Jinja2 test. It can be used like this: + {% if 'the Spanish Inquisition' is example_test %} + ''' + # Config lookup example. A plugin's config should be below + # `.plugins[plugin_name]` (JSONPath) + + # Get the .plugins.example dict + conf = config.get().get('plugins', {}).get('example', {}) + # Get the .test_value property from the plugin config, falling + # back to a default value + test_value = conf.get('test_value', 'the Spanish Inquisition') + + # Tests must always return a boolean or boolean-castable + # expression + return name == test_value diff --git a/examples/template.json b/examples/template.json new file mode 100644 index 0000000..b066559 --- /dev/null +++ b/examples/template.json @@ -0,0 +1,20 @@ +{ + "api": "0.13 {#- Go look at https://spaceapi.io/docs -#}", + "space": "Our New Hackerspace", + "logo": "https://example.org/logo.png", + "url": "https://example.org/", + "location": { + "lat": 0.0, + "lon": 0.0 + }, + "contact": { + "email": "example@example.org" + }, + "issue_report_channels": [ + "email" + ] + "state": "{#- You can write your own plugins for retrieving dynamic information -#} {{ our_space_state() }}", + "sensors": { + "people_now_present": "{{ our_sensor_backend('people_count') }}" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +. diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..07a61d3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +# +# PyCodestyle +# + +[pycodestyle] +max-line-length = 120 +statistics = True + +# +# Coverage +# + +[run] +branch = True +parallel = True +source = spaceapi_server/ + +[report] +show_missing = True +include = spaceapi_server/* +omit = */test/*.py diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..61e3a7d --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + +from spaceapi_server import __version__ + +setup( + name='spaceapi_server', + version=__version__, + author='s3lph', + author_email='', + description='A lightweight SpaceAPI (spaceapi.io) endpoint server', + license='MIT', + keywords='spaceapi', + url='https://gitlab.com/s3lph/spaceapi-server', + packages=find_packages(exclude=['*.test']), + long_description='A lightweight server for SpaceAPI endpoints. Includes support for pluggable templating, so dynamic content, like sensor values, can be added.', + python_requires='>=3.6', + install_requires=[ + 'bottle', + 'jinja2' + ], + entry_points={ + 'console_scripts': [ + 'spaceapi-server = spaceapi_server:start' + ] + }, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Web Environment', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application' + ] +) diff --git a/spaceapi_server/__init__.py b/spaceapi_server/__init__.py new file mode 100644 index 0000000..373d726 --- /dev/null +++ b/spaceapi_server/__init__.py @@ -0,0 +1,2 @@ + +__version__ = '0.1' diff --git a/spaceapi_server/__main__.py b/spaceapi_server/__main__.py new file mode 100644 index 0000000..634a508 --- /dev/null +++ b/spaceapi_server/__main__.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 + +# Load and run the server module + +from spaceapi_server.server import start + + +start() diff --git a/spaceapi_server/config/__init__.py b/spaceapi_server/config/__init__.py new file mode 100644 index 0000000..000d239 --- /dev/null +++ b/spaceapi_server/config/__init__.py @@ -0,0 +1,25 @@ + +import json + + +# The parsed config object +__CONFIG = {} + + +def load(filename: str) -> None: + ''' + Load a JSON-formatted configuration file. + @param filename The config file to load. + ''' + global __CONFIG + # Open and parse the JSON config file + with open(filename, 'r') as conf: + __CONFIG = json.load(conf) + + +def get() -> dict: + ''' + Return the current configuration. + ''' + global __CONFIG + return __CONFIG diff --git a/spaceapi_server/plugins/__init__.py b/spaceapi_server/plugins/__init__.py new file mode 100644 index 0000000..49d9775 --- /dev/null +++ b/spaceapi_server/plugins/__init__.py @@ -0,0 +1,38 @@ + +from spaceapi_server.template import env_init + + +def template_function(fn): + ''' + Register the decorated function as a callable template function. + @param fn The function to register. + ''' + # Make sure the Jinja2 environment is initialized + env = env_init() + # Add the function to the environment's globals + env.globals[fn.__name__] = fn + return fn + + +def template_filter(fn): + ''' + Register the decorated function as a template filter. + @param fn The function to register. + ''' + # Make sure the Jinja2 environment is initialized + env = env_init() + # Add the function to the environment's filters + env.filters[fn.__name__] = fn + return fn + + +def template_test(fn): + ''' + Register the decorated function as a template test. + @param fn The function to register. + ''' + # Make sure the Jinja2 environment is initialized + env = env_init() + # Add the function to the environment's tests + env.tests[fn.__name__] = fn + return fn diff --git a/spaceapi_server/server/__init__.py b/spaceapi_server/server/__init__.py new file mode 100644 index 0000000..831e934 --- /dev/null +++ b/spaceapi_server/server/__init__.py @@ -0,0 +1,98 @@ +import os +import sys +import json +import signal +import importlib + +import bottle + +from spaceapi_server import template, config + + +# The SpaceAPI response template instance +__TEMPLATE = None + + +def render_traverse(obj): + ''' + Walk through a complex, JSON-serializable data structure, and pass + string objects through the Jinja2 templating engine. + ''' + if isinstance(obj, list): + # list -> recurse into each item + for i in range(len(obj)): + obj[i] = render_traverse(obj[i]) + return obj + elif isinstance(obj, dict): + # dict -> recurse into the value of each (key, value) + for k, v in obj.items(): + obj[k] = render_traverse(obj[k]) + return obj + elif isinstance(obj, str): + # str -> template + return template.render(obj) + else: + # anything else -> return as-is + return obj + + +@bottle.route('/') +def serve(): + global __TEMPLATE + # Render the response template + rendered = render_traverse(__TEMPLATE) + # Set the response Content-Type + bottle.response.content_type = 'application/json; charset=utf-8' + # CORS "whitelist" + # https://spaceapi.io/getting-started/#common-issues + bottle.response.headers['Access-Control-Allow-Origin'] = '*' + # Return the JSON-serialized rendered data as response body, + # indented with two spaces + return json.dumps(rendered, indent=2) + + +def load(*args, **kwargs): + global __TEMPLATE + + # If a config file path was passed, load it + if len(sys.argv) > 1: + config.load(sys.argv[1]) + # Get the current config + conf = config.get() + + # Get the absoulute plugins dir path + plugin_dir = os.path.abspath(conf.get('plugins_dir', 'plugins')) + # Iterate the plugins dir and take all python files + for f in os.listdir(plugin_dir): + if f.endswith('.py'): + # Get the full name to the plugin file + plugin_fname = os.path.join(plugin_dir, f) + # Load the file as module and import it + # https://stackoverflow.com/a/67692 + m_spec = importlib.util.spec_from_file_location(f[:-3], plugin_fname) + module = importlib.util.module_from_spec(m_spec) + m_spec.loader.exec_module(module) + + # Get the template path + template_path = conf.get('template', 'template.json') + # Load and parse the JSON template + with open(template_path, 'r') as f: + __TEMPLATE = json.load(f) + + +def init(): + # Register SIGHUP config reload handler + signal.signal(signal.SIGHUP, load) + # Prepare everything + load() + + +def start(): + init() + # Start the HTTP server + conf = config.get() + bottle.run( + server=conf.get('server', 'wsgiref'), + host=conf.get('address', '::'), + port=int(conf.get('port', 8080)) + ) diff --git a/spaceapi_server/template/__init__.py b/spaceapi_server/template/__init__.py new file mode 100644 index 0000000..811d926 --- /dev/null +++ b/spaceapi_server/template/__init__.py @@ -0,0 +1,32 @@ +import json + +import jinja2 + + +# The Jinja2 environment +_ENV = None + + +def env_init(force: bool = False): + ''' + Initialize the Jinja2 environment. + @param force If true, force reload the environment. + ''' + global _ENV + if _ENV is None or force: + # Use json.dumps as finalizer in order to preserve complex data structures + _ENV = jinja2.Environment(finalize=json.dumps) + return _ENV + + +def render(template: str): + ''' + Render the given string as a Jinja2 template. + @param template The template string to render + ''' + # Make sure the Jinaj2 environment is initialized + env = env_init() + # Create a Jinja2 template from the input string + t = env.from_string(template) + # Render the template and turn the JSON dump back into complex data structures + return json.loads(t.render())