Initial commit

This commit is contained in:
s3lph 2019-11-25 02:48:12 +01:00
commit 76c2ed7cba
14 changed files with 384 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
**/.idea/
*.iml
**/__pycache__/
*.pyc
**/*.egg-info/
*.coverage
**/.mypy_cache/

42
.gitlab-ci.yml Normal file
View file

@ -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

13
examples/config.json Normal file
View file

@ -0,0 +1,13 @@
{
"address": "::1",
"port": 8000,
"template": "examples/template.json",
"plugins_dir": "examples/plugins",
"plugins": {
"example": {
"test_value": "the Spanish Inquisition"
}
}
}

View file

@ -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

20
examples/template.json Normal file
View file

@ -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') }}"
}
}

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
.

21
setup.cfg Normal file
View file

@ -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

36
setup.py Executable file
View file

@ -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'
]
)

View file

@ -0,0 +1,2 @@
__version__ = '0.1'

View file

@ -0,0 +1,8 @@
#!/usr/bin/python3
# Load and run the server module
from spaceapi_server.server import start
start()

View file

@ -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

View file

@ -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

View file

@ -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))
)

View file

@ -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())