Initial commit
This commit is contained in:
commit
76c2ed7cba
14 changed files with 384 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
**/.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
**/*.egg-info/
|
||||||
|
*.coverage
|
||||||
|
**/.mypy_cache/
|
42
.gitlab-ci.yml
Normal file
42
.gitlab-ci.yml
Normal 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
13
examples/config.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"address": "::1",
|
||||||
|
"port": 8000,
|
||||||
|
"template": "examples/template.json",
|
||||||
|
"plugins_dir": "examples/plugins",
|
||||||
|
|
||||||
|
"plugins": {
|
||||||
|
"example": {
|
||||||
|
"test_value": "the Spanish Inquisition"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
40
examples/plugins/example.py
Normal file
40
examples/plugins/example.py
Normal 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
20
examples/template.json
Normal 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
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.
|
21
setup.cfg
Normal file
21
setup.cfg
Normal 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
36
setup.py
Executable 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'
|
||||||
|
]
|
||||||
|
)
|
2
spaceapi_server/__init__.py
Normal file
2
spaceapi_server/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
__version__ = '0.1'
|
8
spaceapi_server/__main__.py
Normal file
8
spaceapi_server/__main__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
# Load and run the server module
|
||||||
|
|
||||||
|
from spaceapi_server.server import start
|
||||||
|
|
||||||
|
|
||||||
|
start()
|
25
spaceapi_server/config/__init__.py
Normal file
25
spaceapi_server/config/__init__.py
Normal 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
|
38
spaceapi_server/plugins/__init__.py
Normal file
38
spaceapi_server/plugins/__init__.py
Normal 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
|
98
spaceapi_server/server/__init__.py
Normal file
98
spaceapi_server/server/__init__.py
Normal 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))
|
||||||
|
)
|
32
spaceapi_server/template/__init__.py
Normal file
32
spaceapi_server/template/__init__.py
Normal 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())
|
Loading…
Reference in a new issue