Compare commits

..

No commits in common. "main" and "v0.3" have entirely different histories.
main ... v0.3

40 changed files with 655 additions and 442 deletions

View file

@ -1,43 +0,0 @@
---
on:
push:
tags:
- "v*"
jobs:
build_wheel:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build Python wheel
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 setup.py egg_info bdist_wheel
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
build_debian:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Prepare package
run: |
find package/debian -name .gitkeep -delete
# Copy example plugin
install -m0644 examples/plugins/filestate.py package/debian/spaceapi-server/etc/spaceapi-server/plugins/filestate.py
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v4
with:
python_module: spaceapi_server
package_name: spaceapi-server
package_root: package/debian/spaceapi-server
package_output_path: package/debian
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
deb: "package/debian/*.deb"

View file

@ -1,27 +0,0 @@
---
on: push
jobs:
test:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: test
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
python3 -m coverage run --rcfile=setup.cfg -m unittest discover spaceapi_server
python3 -m coverage combine
python3 -m coverage report --rcfile=setup.cfg
codestyle:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: codestyle
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
pycodestyle spaceapi_server

151
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,151 @@
---
image: s3lph/spaceapi-server-ci:20191126-02
stages:
- test
- build
- release
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
build_debian:
stage: build
script:
- find package/debian -name .gitkeep -delete
# Copy example plugin
- install -m0644 examples/plugins/example.py package/debian/spaceapi-server/etc/spaceapi-server/plugins/example.py
# Create control
- |
cat > package/debian/spaceapi-server/DEBIAN/control <<EOF
Package: spaceapi-server
Version: ${SPACEAPI_SERVER_VERSION}
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web
Priority: optional
Architecture: all
Depends: python3 (>= 3.6), python3-jinja2, python3-bottle
Description: Lightweight SpaceAPI endpoint server
Lightweight server for SpaceAPI endpoints. Includes support for pluggable
templating, so dynamic content, like sensor values, can be added.
EOF
- chmod 0644 package/debian/spaceapi-server/DEBIAN/control
# Create changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "spaceapi-server (${version}-1); urgency=medium\n" >> package/debian/spaceapi-server/usr/share/doc/spaceapi-server/changelog
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/spaceapi-server/usr/share/doc/spaceapi-server/changelog
echo "\n -- s3lph <account-gitlab-ideynizv@kernelpanic.lol> $(date -R)\n" >> package/debian/spaceapi-server/usr/share/doc/spaceapi-server/changelog
done
- gzip -9n package/debian/spaceapi-server/usr/share/doc/spaceapi-server/changelog
# Copy license
- install -m0644 LICENSE package/debian/spaceapi-server/usr/share/doc/spaceapi-server/copyright
# Install spaceapi-server into package root
- python3 setup.py egg_info install --root=package/debian/spaceapi-server/ --prefix=/usr --optimize=1
- cd package/debian/spaceapi-server
- mkdir -p usr/lib/python3/dist-packages/
- cp -r usr/lib/python3.8/site-packages/spaceapi_server* usr/lib/python3/dist-packages/
- rm -rf usr/lib/python3.8/
# Remove compiled Python files
- find usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
- find usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
# Remove spaceapi-server script
- rm usr/bin/spaceapi-server
# Fix file permissions
- find . -type f -exec chmod 0644 {} \;
- find . -type d -exec chmod 755 {} \;
- chmod 0755 DEBIAN/postinst DEBIAN/prerm DEBIAN/postrm
# Build the package
- cd ..
- dpkg-deb --build spaceapi-server
- mv spaceapi-server.deb "spaceapi-server_${SPACEAPI_SERVER_VERSION}-1_all.deb"
# Run lintian
- sudo -u nobody lintian --fail-on-warnings "spaceapi-server_${SPACEAPI_SERVER_VERSION}-1_all.deb"
# Generate checksum
- sha256sum *.deb > SHA256SUMS
artifacts:
paths:
- "package/debian/*.deb"
- package/debian/SHA256SUMS
only:
- tags
build_archlinux:
stage: build
image: archlinux/base:latest # Use an archlinux image instead of the customized debian image.
script:
- find package/archlinux -name .gitkeep -delete
# Install dependencies
- pacman -Sy --noconfirm namcap python python-setuptools python-pip python-wheel python-jinja python-bottle base-devel
- export SPACEAPI_SERVER_VERSION=$(python -c 'import spaceapi_server; print(spaceapi_server.__version__)')
# Copy example plugin
- install -m0644 examples/plugins/example.py package/archlinux/spaceapi-server/etc/spaceapi-server/plugins/example.py
# Create changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "$(date +%Y-%m-%d) s3lph <account-gitlab-ideynizv@kernelpanic.lol>" >> package/archlinux/spaceapi-server.changelog
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/\t*/g' >> package/archlinux/spaceapi-server.changelog
echo >> package/archlinux/spaceapi-server.changelog
done
# Copy license
- install -m0644 LICENSE package/archlinux/spaceapi-server/usr/share/licenses/spaceapi-server/LICENSE
# Install spaceapi-server into pkgdir
- python setup.py egg_info -d -b +master install --root=package/archlinux/spaceapi-server/ --prefix=/usr --optimize=1
- cd package/archlinux
# Remove spaceapi-server script
- rm -rf spaceapi-server/usr/bin
# Build the package
- sed -re "s/__VERSION__/${SPACEAPI_SERVER_VERSION}/g" -i PKGBUILD
- sudo -u nobody makepkg -c
# Run namcap
- sudo -u nobody namcap *.pkg.tar.xz
# Generate checksum
- sha256sum *.pkg.tar.xz > SHA256SUMS
artifacts:
paths:
- "package/archlinux/*.pkg.tar.xz"
- package/archlinux/SHA256SUMS
only:
- tags
release:
stage: release
script:
- python3 package/release.py
only:
- tags

View file

@ -1,99 +1,5 @@
# SpaceAPI Server Changelog # SpaceAPI Server Changelog
<!-- BEGIN RELEASE v0.5.5 -->
## Version 0.5.5
Bugfix & Maintenance release
### Changes
<!-- BEGIN CHANGES 0.5.5 -->
- Add SpaceAPI Schema v14 in example
- Fix template filename in example config.yaml
<!-- END CHANGES 0.5.5 -->
<!-- END RELEASE v0.5.5 -->
<!-- BEGIN RELEASE v0.5.4 -->
## Version 0.5.4
Maintenance build
### Changes
<!-- BEGIN CHANGES 0.5.4 -->
- Migration from Woodpecker to Forgejo Actions
<!-- END CHANGES 0.5.4 -->
<!-- END RELEASE v0.5.4 -->
<!-- BEGIN RELEASE v0.5.2 -->
## Version 0.5.2
Maintenance build
### Changes
<!-- BEGIN CHANGES 0.5.2 -->
- Migration from Gitlab to Gitea+Woodpecker
<!-- END CHANGES 0.5.2 -->
<!-- END RELEASE v0.5.2 -->
<!-- BEGIN RELEASE v0.5.1 -->
## Version 0.5.1
Maintenance build
### Changes
<!-- BEGIN CHANGES 0.5.1 -->
- Fix: CI & Release process
<!-- END CHANGES 0.5.1 -->
<!-- END RELEASE v0.5.1 -->
<!-- BEGIN RELEASE v0.5 -->
## Version 0.5
Update Readme, fix systemd unit
### Changes
<!-- BEGIN CHANGES 0.5 -->
- Fix: Systemd unit now depends on multi-user.target
- Fix smaller issues in Readme and update for SpaceAPI v14
<!-- END CHANGES 0.5 -->
<!-- END RELEASE v0.5 -->
<!-- BEGIN RELEASE v0.4 -->
## Version 0.4
Replace Jinja2+JSON with PyYAML
### Changes
<!-- BEGIN CHANGES 0.4 -->
- Switch config file format from JSON to YAML
- Remove Jinja2, replaced with YAML custom tags for plugin calls
<!-- END CHANGES 0.4 -->
<!-- END RELEASE v0.4 -->
<!-- BEGIN RELEASE v0.3.1 -->
## Version 0.3
Container image release
### Changes
<!-- BEGIN CHANGES 0.3.1 -->
- Release as container image
<!-- END CHANGES 0.3.1 -->
<!-- END RELEASE v0.3.1 -->
<!-- BEGIN RELEASE v0.3 --> <!-- BEGIN RELEASE v0.3 -->
## Version 0.3 ## Version 0.3

194
README.md
View file

@ -1,5 +1,8 @@
# SpaceAPI Server # SpaceAPI Server
[![pipeline status](https://gitlab.com/s3lph/spaceapi-server/badges/master/pipeline.svg)][master]
[![coverage report](https://gitlab.com/s3lph/spaceapi-server/badges/master/coverage.svg)][master]
A lightweight server for [SpaceAPI][spaceapi] endpoints. Includes A lightweight server for [SpaceAPI][spaceapi] endpoints. Includes
support for pluggable templating, so dynamic content, like sensor support for pluggable templating, so dynamic content, like sensor
values, can be added. values, can be added.
@ -8,7 +11,7 @@ values, can be added.
- Python 3 (>=3.6) - Python 3 (>=3.6)
- [Bottle][pypi-bottle] - [Bottle][pypi-bottle]
- [PyYAML][pypi-yaml] - [Jinja2][pypi-jinja2]
## License ## License
@ -21,9 +24,9 @@ SpaceAPI endpoint server. In its simplest configuration, it just
serves a plain, static, boring JSON document. serves a plain, static, boring JSON document.
In order to provide dynamic content (e.g. whether your space is In order to provide dynamic content (e.g. whether your space is
currently open), you can replace parts of the YAML document (anything currently open), you can replace parts of the JSON document (anything
except object keys) with custom plugin invocations. These plugins except object keys) with [Jinja2 templates][jinja], from which you can
look up and return your dynamic content. invoke custom plugins, which look up and return your dynamic content.
<table> <table>
<thead> <thead>
@ -36,20 +39,15 @@ look up and return your dynamic content.
<tr> <tr>
<td> <td>
```yaml ```json
--- {
api: "0.13" "api": "0.13",
api_compatibility: ["14"] "space": "My Hackerspace",
space: My Hackerspace "state": "{{ space_state() }}",
# This is a plugin invocation "sensors": {
# with no arguments "network_connections": "{{ network_connections() }}"
state: !space_state {} }
sensors: }
# This is a plugin invocation with
# arguments. They are passed to the
# plugin function as kwargs.
network_connections: !network_connections
networks: [ "2.4 GHz", "5 GHz" ]
``` ```
</td> </td>
@ -58,8 +56,6 @@ sensors:
```json ```json
{ {
"api": "0.13", "api": "0.13",
"api_compatibility": ["14"],
"space": "My Hackerspace",
"state": { "state": {
"open": true, "open": true,
"lastchange": 1575160777, "lastchange": 1575160777,
@ -90,22 +86,22 @@ sensors:
### 0. Download ### 0. Download
Head over to the [Packages][packages] tab, download and install the Head over to the [Releases][releases], download and install the
package that suits your needs or set up the Debian repository. package that suits your needs. Alternatively, clone the repo and get
Alternatively, clone the repo and get started. started.
The remainder of this document assumes that you installed the The remainder of this document assumes that you installed the
server as a Debian package. server as an OS distribution package.
### 1. Overview ### 1. Overview
The configuration of this server consists of three parts: The configuration of this server consists of three parts:
- The **main configuration** file, usually located at - The **main configuration** file, usually located at
`/etc/spaceapi-server/config.yaml`. This file controls all the `/etc/spaceapi-server/config.json`. This file controls all the
internal settings of the server. internal settings of the server.
- The **response template** file, located at - The **response template** file, located at
`/etc/spaceapi-server/template.yaml`. This file defines the content `/etc/spaceapi-server/template.json`. This file defines the content
served by your sever. served by your sever.
- The **plugins** directory, located at - The **plugins** directory, located at
`/etc/spaceapi-server/plugins/`. Here you can put your plugins for `/etc/spaceapi-server/plugins/`. Here you can put your plugins for
@ -113,41 +109,39 @@ The configuration of this server consists of three parts:
### 2. Configure the Server ### 2. Configure the Server
Open the file `/etc/spaceapi-server/config.yaml`. Open the file `/etc/spaceapi-server/config.json`.
The following options are currently available: The following options are currently available:
```yaml ```json
--- {
# The address to listen on. "address": "::1", # The address to listen on.
address: "::1" "port": 8000, # The TCP port to listen on.
# The TCP port to listen on. "server": "wsgiref", # The Bottle backend server to use.
port: 8000 "template": "template.json", # Path to the SpaceAPI response template file.
# The Bottle backend server to use. "plugins_dir": "plugins", # Path to the directory containing your plugins.
server: wsgiref
# Path to the SpaceAPI response template file. "plugins": {
template: template.yaml
# Path to the directory containing your plugins.
plugins_dir: plugins
plugins:
# Plugin-specific configuration should go in here, separated by plugin # Plugin-specific configuration should go in here, separated by plugin
my_plugin: "my_plugin": {
my_option: "Hello, World!" "my_option": "Hello, World!",
my_other_option: [ 42, 1337 ] "my_other_option": [ 42, 1337 ]
}
}
}
``` ```
### 3. Configure a Static SpaceAPI Endpoint ### 3. Configure a Static SpaceAPI Endpoint
Open the file `/etc/spaceapi-server/template.yaml`. By default it Open the file `/etc/spaceapi-server/template.json`. By default it
contains a minimal example response. If you only want to serve static contains a minimal example response. If you only want to serve static
content, your `template.json` should simply contain the SpaceAPI JSON content, your `template.json` should simply contain the SpaceAPI JSON
response you want to serve. response you want to serve.
The content is served "almost-as-is" (apart from the conversion from The content is served "almost-as-is" (once parsed and re-serialized).
YAML to JSON).
To learn about how a SpaceAPI response should look like, have a look To learn about how a SpaceAPI response should look like, have a look
at the [SpaceAPI Website][spaceapi]. at [SpaceAPI: Getting Started][spaceapi-getting-started].
### 4. Add Dynamic Content ### 4. Add Dynamic Content
@ -164,7 +158,6 @@ the space as open depending on its existence.
from spaceapi_server import config, plugins from spaceapi_server import config, plugins
@plugins.template_function @plugins.template_function
# The plugin can be invoked by using the !space_state YAML tag
def space_state(): def space_state():
# Get the plugin config dict # Get the plugin config dict
conf = config.get_plugin_config('filestate') conf = config.get_plugin_config('filestate')
@ -185,27 +178,39 @@ the space as open depending on its existence.
} }
``` ```
The `@template_function` decorator registers a constructor in The `@template_function` decorator registers the function as a
PyYAML's parser with the function's name as tag callable in Jinja's globals. There's also `@template_filter`,
(e.g. `!space_state). which registers a Jinja2 filter. For more information on the
Jinja2 templating engine, see [Jinja2][jinja].
2. Call the template function in your template: 2. Call the template function in your template:
```yaml ```json
{
# ... # ...
state: !space_state "state": "{{ space_state() }}"
# ... # ...
}
``` ```
Although the value for the `state` key is a string containing the
Jinja2 template, the return value of your template function is a
complex data type, which will be inserted into the result as such.
Please be aware that only the first token of a templated string is
used in the result. This limitation is caused by the way Jinja2
is (ab-) used.
3. Configure the server: 3. Configure the server:
```yaml ```json
# ... # ...
template: template.yaml "template": "template.json",
plugins_dir: plugins "plugins_dir": "plugins",
plugins: "plugins": {
filestate: "filestate": {
filename: /var/space_state "filename": "/var/space_state"
}
}
# ... # ...
``` ```
@ -223,7 +228,7 @@ e.g. through `systemctl reload`.
If you need to run the server ad-hoc, you can start it with: If you need to run the server ad-hoc, you can start it with:
```bash ```bash
python3 -m spaceapi_server /path/to/config.yaml python3 -m spaceapi_server /path/to/config.json
``` ```
### 6. Test the Server ### 6. Test the Server
@ -260,8 +265,8 @@ print(plugins.get_plugin_config('my_plugin'))
### Templating ### Templating
The following decorators register a function for use as a PyYAML The following decorators register a function in the Jinja2
constructor, so they become usable from within templates. They are environment, so they become usable from within templates. They are
invoked when the template is rendered, which happens for each HTTP invoked when the template is rendered, which happens for each HTTP
request. request.
@ -270,11 +275,11 @@ plugins, or by using a caching HTTP reverse proxy.
#### `spaceapi_server.plugins.template_function` #### `spaceapi_server.plugins.template_function`
This decorator registers the function's name as a YAML tag in the parser. This decorator registers a function as a **global function** in the
Jinja2 environment.
The decorated function may take arguments (always passed as The decorated function may take any arguments that can be represented
`**kwargs`, so `*args`, or arguments before `*` won't work) that can in a Jinja2 template.
be represented in a YAML file.
The decorated function may return any value that can be serialized The decorated function may return any value that can be serialized
into JSON. This includes objects and arrays. into JSON. This includes objects and arrays.
@ -288,25 +293,58 @@ from spaceapi_server import plugins
def lookup_sensor(query, default=None): def lookup_sensor(query, default=None):
# Do something with the query # Do something with the query
result = ... result = ...
# If the lookup failed, return a default value # If the loo
if not result: if not result:
return default or [] return default or []
return result
``` ```
```yaml ```json
# ... {
state: !lookup_sensor # ...
query: SELECT timestamp, value FROM people_now_present LIMIT 1 "state": "{{ lookup_sensor('SELECT timestamp, value FROM people_now_present LIMIT 1') }}"
# ... # ...
}
```
#### `spaceapi_server.plugins.template_filter`
This decorator registers a function as a **filter** in the Jinja2
environment. The decorated function must take at least one argument.
The decorated function may take any arguments that can be represented
in a Jinja2 template.
The decorated function may return any value that can be serialized
into JSON. This includes objects and arrays.
Usage:
```python
from spaceapi_server import plugins
@plugins.template_filter
def lookup_sensor(query, default=None):
# Do something with the query
result = ...
# If the loo
if not result:
return default or []
```
```json
{
# ...
"state": "{{ 'SELECT timestamp, value FROM people_now_present LIMIT 1' | lookup_sensor }}"
# ...
}
``` ```
[main]: https://gitlab.com/s3lph/spaceapi-server/commits/main [master]: https://gitlab.com/s3lph/spaceapi-server/commits/master
[packages]: https://git.kabelsalat.ch/s3lph/spaceapi-server/packages [releases]: https://gitlab.com/s3lph/spaceapi-server/-/releases
[spaceapi]: https://spaceapi.io/ [spaceapi]: https://spaceapi.io/
[pypi-bottle]: https://pypi.org/project/bottle/ [pypi-bottle]: https://pypi.org/project/bottle/
[pypi-yaml]: https://pypi.org/project/PyYAML/ [pypi-jinja2]: https://pypi.org/project/Jinja2/
[mit]: https://gitlab.com/s3lph/spaceapi-server/blob/main/LICENSE [mit]: https://gitlab.com/s3lph/spaceapi-server/blob/master/LICENSE
[spaceapi-getting-started]: https://spaceapi.io/getting-started/
[jinja]: https://jinja.palletsprojects.com/ [jinja]: https://jinja.palletsprojects.com/
[registry]: https://gitlab.com/s3lph/spaceapi-server/container_registry

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

@ -1,16 +0,0 @@
---
# The address to listen on.
address: "::1"
# The TCP port to listen on.
port: 8000
# The Bottle backend server to use.
server: wsgiref
# Path to the SpaceAPI response template file.
template: template.yaml
# Path to the directory containing your plugins.
plugins_dir: plugins
plugins:
# Config for the "filestate" plugin
filestate:
# Use this statefile instead of the default
filename: /var/www/html/space_state

View file

@ -0,0 +1,36 @@
from spaceapi_server import 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_function
def example_config_function():
"""
This function demonstrates the use of configuration.
{( example_config_function() }}
"""
# Config lookup example. A plugin's config should be below
# `.plugins[plugin_name]` (JSONPath)
# Get the .plugins.example dict
conf = plugins.get_plugin_config('example')
# Get the .test_value property from the plugin config, falling
# back to a default value
return conf.get('test_value', 'the Spanish Inquisition')

View file

@ -1,23 +0,0 @@
import os
from spaceapi_server import config, plugins
@plugins.template_function
# The plugin can be invoked by using the !space_state YAML tag
def space_state():
# Get the plugin config dict
conf = config.get_plugin_config('filestate')
# Get the filename, default to /var/space_state
filename = conf.get('filename', '/var/space_state')
try:
# Get the file's properties
stat = os.stat(filename)
except FileNotFoundError:
# File doesn't exist, aka. space is closed
return {
'open': False
}
# File exists, aka. space is open. Also report the mtime as "last changed" timestamp
return {
'open': True,
'lastchange': int(stat.st_mtime)
}

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

View file

@ -1,12 +0,0 @@
---
api: "0.13"
space: My Hackerspace
# This is a plugin invocation
# with no arguments
state: !space_state {}
sensors:
# This is a plugin invocation with
# arguments. They are passed to the
# plugin function as kwargs.
network_connections: !network_connections
networks: [ "2.4 GHz", "5 GHz" ]

6
package/Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM python:3.8-buster as python
RUN apt update \
&& apt install -y --no-install-recommends lintian rsync sudo \
&& pip3 install pycodestyle coverage \
&& rm -rf /var/cache/apt

View file

@ -0,0 +1,27 @@
# Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
pkgname=spaceapi-server
pkgver=__VERSION__
pkgrel=1
pkgdesc="Lightweight SpaceAPI endpoint server"
arch=('any')
url="https://gitlab.com/s3lph/spaceapi-server"
license=('MIT')
groups=()
depends=('python'
'python-jinja'
'python-bottle')
makedepends=('python-setuptools')
checkdepends=()
optdepends=()
provides=()
conflicts=()
replaces=()
backup=('etc/spaceapi-server/config.json'
'etc/spaceapi-server/template.json'
'etc/spaceapi-server/plugins')
install=$pkgname.install
changelog=$pkgname.changelog
package() {
cp -r ../spaceapi-server/* ../pkg/spaceapi-server/
}

View file

@ -0,0 +1,31 @@
post_install() {
if ! getent group spaceapi-server >/dev/null; then
groupadd --system spaceapi-server
fi
if ! getent passwd spaceapi-server >/dev/null; then
useradd --system --create-home --gid spaceapi-server --home-dir /var/lib/spaceapi-server \
--shell /usr/sbin/nologin spaceapi-server
fi
chown root:spaceapi-server /etc/spaceapi-server
chmod 0750 /etc/spaceapi-server
systemctl daemon-reload || true
}
pre_remove() {
systemctl stop spaceapi-server.service
userdel spaceapi-server
}
post_remove() {
systemctl daemon-reload
}

View file

@ -0,0 +1,7 @@
{
"address": "::",
"port": 8080,
"template": "/etc/spaceapi-server/template.json",
"plugins_dir": "/etc/spaceapi-server/plugins",
"plugins": {}
}

View file

@ -0,0 +1,19 @@
{
"api": "0.13",
"space": "Example Space",
"logo": "https://example.org/logo.png",
"url": "https://example.org",
"location": {
"lat": 0.0,
"lon": 0.0
},
"state": {
"open": null
},
"contact": {
"email": "example@example.org"
},
"issue_report_channels": [
"email"
]
}

View file

@ -0,0 +1,10 @@
[Unit]
Description=Lightweight SpaceAPI Endpoint Server
[Service]
ExecStart=/usr/bin/python -m spaceapi_server /etc/spaceapi-server/config.json
ExecReload=/usr/bin/kill -HUP $MAINPID
User=spaceapi-server
[Install]
WantedBy=network-online.target

View file

@ -1,3 +1,3 @@
/etc/spaceapi-server/config.yaml /etc/spaceapi-server/config.json
/etc/spaceapi-server/template.yaml /etc/spaceapi-server/template.json
/etc/spaceapi-server/plugins/filestate.py /etc/spaceapi-server/plugins/example.py

View file

@ -1,10 +0,0 @@
Package: spaceapi-server
Version: __VERSION__
Maintainer: s3lph <s3lph@kabelsalat.ch>
Section: web
Priority: optional
Architecture: all
Depends: python3 (>= 3.9), python3-yaml, python3-bottle
Description: Lightweight SpaceAPI endpoint server
Lightweight server for SpaceAPI endpoints. Includes support for pluggable
templating, so dynamic content, like sensor values, can be added.

View file

@ -0,0 +1,7 @@
{
"address": "::",
"port": 8080,
"template": "/etc/spaceapi-server/template.json",
"plugins_dir": "/etc/spaceapi-server/plugins",
"plugins": {}
}

View file

@ -1,6 +0,0 @@
---
address: "::"
port: 8080
template: /etc/spaceapi-server/template.yaml
plugins_dir: /etc/spaceapi-server/plugins
plugins: {}

View file

@ -0,0 +1,19 @@
{
"api": "0.13",
"space": "Example Space",
"logo": "https://example.org/logo.png",
"url": "https://example.org",
"location": {
"lat": 0.0,
"lon": 0.0
},
"state": {
"open": null
},
"contact": {
"email": "example@example.org"
},
"issue_report_channels": [
"email"
]
}

View file

@ -1,16 +0,0 @@
---
api: "0.13"
api_compatibility:
- "14"
space: Example
logo: https://example.org/logo.png
url: https://example.org
location:
lat: 0.0
lon: 0.0
state:
open: null
contact:
email: example@example.org
issue_report_channels:
- email

View file

@ -2,9 +2,9 @@
Description=Lightweight SpaceAPI Endpoint Server Description=Lightweight SpaceAPI Endpoint Server
[Service] [Service]
ExecStart=/usr/bin/python3 -m spaceapi_server /etc/spaceapi-server/config.yaml ExecStart=/usr/bin/python3 -m spaceapi_server /etc/spaceapi-server/config.json
ExecReload=/usr/bin/kill -HUP $MAINPID ExecReload=/usr/bin/kill -HUP $MAINPID
User=spaceapi-server User=spaceapi-server
[Install] [Install]
WantedBy=multi-user.target WantedBy=network-online.target

View file

@ -1,13 +0,0 @@
FROM python:3.9-alpine
ADD spaceapi_server/ /spaceapi_server/
ADD setup.py /setup.py
RUN python setup.py install && mkdir -p /config/plugins && chmod 0755 -R /config
ADD package/docker/config.yaml /config/config.yaml
ADD package/docker/template.yaml /config/template.yaml
VOLUME /config
EXPOSE 8000/tcp
USER 1000
ENTRYPOINT [ "/usr/local/bin/python3.9", "-m", "spaceapi_server", "/config/config.yaml" ]

View file

@ -1,6 +0,0 @@
---
address: "::"
port: 8080
template: /etc/spaceapi-server/template.json
plugins_dir: /etc/spaceapi-server/plugins
plugins: {}

View file

@ -1,14 +0,0 @@
---
api: "0.13"
space: Example
logo: https://example.org/logo.png
url: https://example.org
location:
lat: 0.0
lon: 0.0
state:
open: null
contact:
email: example@example.org
issue_report_channels:
- email

View file

@ -10,9 +10,6 @@ import http.client
from urllib.error import HTTPError from urllib.error import HTTPError
USER_AGENT = 'curl/7.70.0'
def parse_changelog(tag: str) -> Optional[str]: def parse_changelog(tag: str) -> Optional[str]:
release_changelog: str = '' release_changelog: str = ''
with open('CHANGELOG.md', 'r') as f: with open('CHANGELOG.md', 'r') as f:
@ -35,8 +32,7 @@ def parse_changelog(tag: str) -> Optional[str]:
def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]: def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]:
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs' url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
headers: Dict[str, str] = { headers: Dict[str, str] = {
'Private-Token': api_token, 'Private-Token': api_token
'User-Agent': USER_AGENT
} }
req = urllib.request.Request(url, headers=headers) req = urllib.request.Request(url, headers=headers)
try: try:
@ -56,10 +52,7 @@ def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str
def fetch_single_shafile(url: str) -> str: def fetch_single_shafile(url: str) -> str:
headers: Dict[str, str] = { req = urllib.request.Request(url)
'User-Agent': USER_AGENT
}
req = urllib.request.Request(url, headers=headers)
try: try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req) resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e: except HTTPError as e:
@ -87,6 +80,14 @@ def fetch_debian_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[s
return debian_url, debian_sha_url return debian_url, debian_sha_url
def fetch_arch_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_archlinux"]}/artifacts/raw'
arch_sha_url: str = f'{mybase}/package/archlinux/SHA256SUMS'
arch_filename: str = fetch_single_shafile(arch_sha_url)
arch_url: str = f'{mybase}/package/archlinux/{arch_filename}'
return arch_url, arch_sha_url
def main(): def main():
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN') api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG') release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
@ -120,39 +121,23 @@ def main():
wheel_url, wheel_sha_url = fetch_wheel_url(base_url, job_ids) wheel_url, wheel_sha_url = fetch_wheel_url(base_url, job_ids)
debian_url, debian_sha_url = fetch_debian_url(base_url, job_ids) debian_url, debian_sha_url = fetch_debian_url(base_url, job_ids)
arch_url, arch_sha_url = fetch_arch_url(base_url, job_ids)
augmented_changelog = f'''{changelog.strip()} augmented_changelog = f'''{changelog.strip()}
### Download ### Download
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url})) - [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))''' - [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))
- [Arch Linux Package]({arch_url}) ([sha256]({arch_sha_url}))'''
post_body: str = json.dumps({ post_body: str = json.dumps({'description': augmented_changelog})
'tag_name': release_tag,
'description': augmented_changelog,
'assets': {
'links': [
{
'name': 'Python Wheel',
'url': wheel_url,
'link_type': 'package'
},
{
'name': 'Debian Package',
'url': debian_url,
'link_type': 'package'
}
]
}
})
gitlab_release_api_url: str = \ gitlab_release_api_url: str = \
f'https://gitlab.com/api/v4/projects/{project_id}/releases' f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
headers: Dict[str, str] = { headers: Dict[str, str] = {
'Private-Token': api_token, 'Private-Token': api_token,
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8'
'User-Agent': 'curl/7.70.0'
} }
request = urllib.request.Request( request = urllib.request.Request(

View file

@ -19,15 +19,8 @@ setup(
python_requires='>=3.6', python_requires='>=3.6',
install_requires=[ install_requires=[
'bottle', 'bottle',
'PyYAML', 'jinja2'
], ],
extras_require={
'test': [
'coverage',
'pycodestyle',
'twine'
]
},
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'spaceapi-server = spaceapi_server:start' 'spaceapi-server = spaceapi_server:start'
@ -38,7 +31,7 @@ setup(
'Environment :: Web Environment', 'Environment :: Web Environment',
'Intended Audience :: System Administrators', 'Intended Audience :: System Administrators',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application' 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application'
] ]
) )

View file

@ -1,2 +1,2 @@
__version__ = '0.5.5' __version__ = '0.3'

View file

@ -1,5 +1,5 @@
import yaml import json
# The parsed config object # The parsed config object
@ -8,13 +8,13 @@ __CONFIG = {}
def load(filename: str) -> None: def load(filename: str) -> None:
""" """
Load a YAML-formatted configuration file. Load a JSON-formatted configuration file.
:param filename: The config file to load. :param filename: The config file to load.
""" """
global __CONFIG global __CONFIG
# Open and parse the YAML config file # Open and parse the JSON config file
with open(filename, 'r') as conf: with open(filename, 'r') as conf:
__CONFIG = yaml.safe_load(conf) __CONFIG = json.load(conf)
def get() -> dict: def get() -> dict:

View file

View file

@ -0,0 +1,38 @@
import unittest
import tempfile
from spaceapi_server.config import load, get
from spaceapi_server.plugins import get_plugin_config
class ConfigTest(unittest.TestCase):
CONFIG = '''
{
"foo": "bar",
"baz": 42,
"plugins": {
"myplugin": {
"foo": "baz"
}
}
}
'''
def setUp(self) -> None:
_, self.temp = tempfile.mkstemp('.json', 'spaceapi_server_config_', text=True)
with open(self.temp, 'w') as f:
f.write(self.CONFIG)
def test_load(self):
pre = get()
pre_plugin = get_plugin_config('myplugin')
self.assertEqual({}, pre)
self.assertEqual({}, pre_plugin)
load(self.temp)
post = get()
post_plugin = get_plugin_config('myplugin')
self.assertEqual('bar', post['foo'])
self.assertEqual(42, post['baz'])
self.assertEqual('baz', post_plugin['foo'])

View file

@ -1,6 +1,4 @@
import yaml
from spaceapi_server import config, template from spaceapi_server import config, template
@ -9,8 +7,22 @@ def template_function(fn):
Register the decorated function as a callable template function. Register the decorated function as a callable template function.
:param fn: The function to register. :param fn: The function to register.
""" """
yaml.SafeLoader.add_constructor(f'!{fn.__name__}', template.plugin_constructor) # Make sure the Jinja2 environment is initialized
template.PluginInvocation.register_plugin(fn.__name__, fn) env = template._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 = template._env_init()
# Add the function to the environment's filters
env.filters[fn.__name__] = fn
return fn return fn

View file

@ -1,7 +1,6 @@
import os import os
import sys import sys
import json import json
import yaml
import signal import signal
import importlib import importlib
@ -52,10 +51,10 @@ def load(*args, **kwargs):
m_spec.loader.exec_module(module) m_spec.loader.exec_module(module)
# Get the template path # Get the template path
template_path = conf.get('template', 'template.yaml') template_path = conf.get('template', 'template.json')
# Load and parse the JSON template # Load and parse the JSON template
with open(template_path, 'r') as f: with open(template_path, 'r') as f:
__TEMPLATE = yaml.safe_load(f) __TEMPLATE = json.load(f)
def init(): def init():

View file

@ -1,31 +1,44 @@
import json
class PluginInvocation(): import jinja2
_plugins = {}
def __init__(self, name, data):
super().__init__()
if name not in self._plugins:
raise KeyError(f'No such plugin function: {name}')
self._name = name
self._data = data
def __call__(self):
return self._plugins[self._name](**self._data)
@classmethod
def register_plugin(cls, name, fn):
cls._plugins[name] = fn
def plugin_constructor(loader, node): # The Jinja2 environment
data = loader.construct_mapping(node) __ENV = None
return PluginInvocation(node.tag[1:], data)
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 Jinja2 environment is initialized
env = _env_init()
# Create a Jinja2 template from the input string
t = env.from_string(template)
decoder = json.JSONDecoder()
# Render the template and turn the JSON dump back into complex data structures
# Only parse the first JSON object in the string, ignore the rest
obj, i = decoder.raw_decode(t.render())
return obj
def render_traverse(obj): def render_traverse(obj):
""" """
Walk through a complex, JSON-serializable data structure, and Walk through a complex, JSON-serializable data structure, and pass
invoke plugins if for custom tags. string objects through the Jinja2 templating engine.
:param obj: The object to traverse. :param obj: The object to traverse.
""" """
if isinstance(obj, list): if isinstance(obj, list):
@ -34,9 +47,9 @@ def render_traverse(obj):
elif isinstance(obj, dict): elif isinstance(obj, dict):
# dict -> recurse into the value of each (key, value) # dict -> recurse into the value of each (key, value)
return {k: render_traverse(v) for k, v in obj.items()} return {k: render_traverse(v) for k, v in obj.items()}
elif isinstance(obj, PluginInvocation): elif isinstance(obj, str):
# PluginTag -> invoke the plugin with the stored arguments # str -> template
return obj() return render(obj)
else: else:
# anything else -> return as-is # anything else -> return as-is
return obj return obj

View file

@ -0,0 +1,69 @@
import unittest
from spaceapi_server.template import _env_init, render, render_traverse
from spaceapi_server.plugins import template_function, template_filter
class TemplateTest(unittest.TestCase):
@staticmethod
@template_function
def template_test_function(value):
return f'test_{value}'
@staticmethod
@template_filter
def template_test_filter(value, other):
return f'test_{other}_{value}'
@staticmethod
@template_function
def template_test_function_nocache():
TemplateTest.counter1 += 1
return TemplateTest.counter1
def setUp(self) -> None:
TemplateTest.counter1 = 0
def test_template(self):
env = _env_init()
self.assertEqual('"not a template"', env.from_string('not a template').render())
self.assertEqual('"a template"', env.from_string('{{ "a template" }}').render())
self.assertEqual('42', env.from_string('{{ 42 }}').render())
self.assertEqual('["foo"]', env.from_string('{{ [ "foo" ] }}').render())
self.assertEqual('{"foo": ["bar"]}', env.from_string('{{ { "foo": [ "bar" ] } }}').render())
def test_render(self):
self.assertEqual('not a template', render('not a template'))
self.assertEqual('a template', render('{{ "a template" }}'))
self.assertEqual(42, render('{{ 42 }}'))
self.assertEqual(['foo'], render('{{ [ "foo" ] }}'))
self.assertEqual({'foo': ['bar']}, render('{{ { "foo": [ "bar" ] } }}'))
self.assertEqual('foo', render('foo{{ "bar" }}'))
def test_render_traverse(self):
template = {
'foo': 42,
'bar': [1, 2, 3],
'notemplate': 'foo',
'builtin': '{{ [ 1337, 42 ] | first }}',
'test_functions': {
'test_function': '{{ template_test_function("foo") }}',
'test_filter': '{{ "bar" | template_test_filter("other") }}'
}
}
rendered = render_traverse(template)
self.assertEqual(42, rendered['foo'])
self.assertEqual([1, 2, 3], rendered['bar'])
self.assertEqual('foo', rendered['notemplate'])
self.assertEqual(1337, rendered['builtin'])
self.assertEqual('test_foo', rendered['test_functions']['test_function'])
self.assertEqual('test_other_bar', rendered['test_functions']['test_filter'])
def test_no_cache(self):
template = ['{{ template_test_function_nocache() }}']
r1 = render_traverse(template)
r2 = render_traverse(template)
self.assertEqual(1, r1[0])
self.assertEqual(2, r2[0])