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