Compare commits

...

36 commits
v0.2 ... main

Author SHA1 Message Date
9cd927cc8e
feat: release v0.5.5
All checks were successful
/ test (push) Successful in 1m12s
/ codestyle (push) Successful in 1m0s
/ build_wheel (push) Successful in 1m20s
/ build_debian (push) Successful in 2m31s
2023-12-19 04:45:19 +01:00
2c29c725e5
feat: release v0.5.4
All checks were successful
/ test (push) Successful in 1m2s
/ codestyle (push) Successful in 1m0s
/ build_wheel (push) Successful in 1m19s
/ build_debian (push) Successful in 2m32s
2023-12-19 04:31:22 +01:00
4435ec6dcf
feat: migrate from woodpecker to forgejo actions
All checks were successful
/ test (push) Successful in 43s
/ codestyle (push) Successful in 1m10s
2023-12-19 04:25:25 +01:00
c7e3a79244
fix: ci badge, debian control file
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2023-10-25 04:24:18 +02:00
16f223d423
fix: ci, version 0.5.3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2023-10-25 04:20:42 +02:00
8f21eb95e6
chore: version 0.5.2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2023-10-25 04:13:17 +02:00
f0b6bc7d66
chore: migrate from gitlab-ci to woodpecker
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-10-25 04:10:48 +02:00
s3lph
03b4beef92 Release v0.5.1 2023-10-05 02:01:40 +02:00
s3lph
f851170231 fix: ci 2023-10-05 01:55:08 +02:00
s3lph
a6eb99f084 fix: ci 2023-10-05 01:49:51 +02:00
s3lph
00cd0ef70b fix: ci 2023-10-05 01:47:01 +02:00
s3lph
ec8cd80063 fix: ci 2023-10-05 01:37:16 +02:00
s3lph
882ba34e47 fix: ci 2023-10-05 01:33:40 +02:00
s3lph
1521aa96ce fix: ci 2023-10-05 00:29:40 +02:00
s3lph
429a48bff4 Changelog v0.5 2023-10-05 00:11:27 +02:00
s3lph
0018e11392 fix: various smaller issues in README 2023-10-05 00:10:10 +02:00
s3lph
5ad9d309b8 fix: depend on multi-user.target rather than network-online.target 2023-10-05 00:00:00 +02:00
s3lph
826a108308 Update examples to v0.4 2021-12-07 03:42:04 +01:00
s3lph
7557183a3a master -> main 2021-12-07 03:34:15 +01:00
s3lph
aa05cfa296 Fix Archlinux build 2021-05-30 18:09:03 +02:00
s3lph
093a40fefa Replace Jinja2 with PyYAML 2021-05-30 17:52:25 +02:00
s3lph
a3eb452786 Merge branch 'hotfix-release-script' into 'master'
release.py: Replace default user agent by that of curl

See merge request s3lph/spaceapi-server!2
2020-06-21 03:15:33 +00:00
s3lph
9959d5ca49 release.py: Replace default user agent by that of curl 2020-06-21 05:13:15 +02:00
s3lph
5906c201ca Merge branch 'feature/container-image' into 'master'
Add Container Image

Closes #1

See merge request s3lph/spaceapi-server!1
2020-06-20 02:41:38 +00:00
s3lph
c925281039 Update Readme 2020-06-20 04:39:50 +02:00
s3lph
6b957ac864 Revert "Fix CI script"
This reverts commit b5f5c8f32b.
2020-06-20 04:29:06 +02:00
s3lph
b5f5c8f32b Fix CI script 2020-06-20 04:09:27 +02:00
s3lph
e8c35f697d Update CI image to include Docker 2020-06-20 03:52:47 +02:00
s3lph
79fae47918 Add a CI step to create a container image 2020-06-20 03:43:08 +02:00
s3lph
d210e05d7e Release 0.3 2019-12-07 22:00:18 +01:00
s3lph
e33d0d03d6 Codestyle: Missing newline 2019-12-02 22:32:17 +01:00
s3lph
45384b2a62 Add input/output example to readme 2019-12-02 22:27:30 +01:00
s3lph
138234c4a3 Remove template_filter decorator, as there really isn't any use case for it. 2019-11-30 04:49:01 +01:00
s3lph
f6ad53555c Add plugin API reference 2019-11-30 04:39:33 +01:00
s3lph
6a84976d84 Minor readme fixes 2019-11-30 03:43:31 +01:00
s3lph
5c6065996e More readme 2019-11-30 03:24:29 +01:00
40 changed files with 627 additions and 621 deletions

View file

@ -0,0 +1,43 @@
---
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

@ -0,0 +1,27 @@
---
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

View file

@ -1,151 +0,0 @@
---
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,5 +1,112 @@
# 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 -->
## Version 0.3
Feature Removal Release
### Changes
<!-- BEGIN CHANGES 0.3 -->
- Remove template_test decorator
<!-- END CHANGES 0.3 -->
<!-- END RELEASE v0.3 -->
<!-- BEGIN RELEASE v0.2 -->
## Version 0.2

295
README.md
View file

@ -1,64 +1,173 @@
# 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 support for pluggable templating, so dynamic content,
like sensor values, can be added.
A lightweight server for [SpaceAPI][spaceapi] endpoints. Includes
support for pluggable templating, so dynamic content, like sensor
values, can be added.
## Dependencies
- Python 3 (>=3.6)
- [Bottle][pypi-bottle]
- [Jinja2][pypi-jinja2]
- [PyYAML][pypi-yaml]
## License
[MIT License][mit]
## Usage
## Introduction
```bash
python -m spaceapi_server config.json
This project is an attempt to implement a lightweight, yet versatile
SpaceAPI endpoint server. In its simplest configuration, it just
serves a plain, static, boring JSON document.
In order to provide dynamic content (e.g. whether your space is
currently open), you can replace parts of the YAML document (anything
except object keys) with custom plugin invocations. These plugins
look up and return your dynamic content.
<table>
<thead>
<tr>
<td>Input</td>
<td>Output</td>
</tr>
</thead>
<tbody>
<tr>
<td>
```yaml
---
api: "0.13"
api_compatibility: ["14"]
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" ]
```
## Configuration
</td>
<td>
```json
{
"address": "::1", # The address to listen on.
"port": 8000, # The TCP port to listen on.
"server": "wsgiref", # The Bottle backend server to use.
"template": "template.json", # Path to the SpaceAPI response template file.
"plugins_dir": "plugins", # Path to the directory containing your plugins.
"plugins": {
# Plugin-specific configuration should go in here, e.g. `.plugins.sqlite.database`
"api": "0.13",
"api_compatibility": ["14"],
"space": "My Hackerspace",
"state": {
"open": true,
"lastchange": 1575160777,
"message": "Visitors Welcome!"
},
"sensors": {
"network_connections": [
{
"value": 4,
"type": "wifi",
"name": "2.4 GHz"
},
{
"value": 7,
"type": "wifi",
"name": "5 GHz"
}
]
}
}
```
</td>
</tr>
</tbody>
</table>
## Serve a Static SpaceAPI Endpoint
## Usage
Have a look at [SpaceAPI: Getting Started][spaceapi-getting-started]. If you only want to serve static content, your
`template.json` may consist of regular JSON data, which is served almost-as-is (once parsed and re-serialized).
### 0. Download
## Add Dynamic Content
Head over to the [Packages][packages] tab, download and install the
package that suits your needs or set up the Debian repository.
Alternatively, clone the repo and get started.
This example guides you through adding a dynamic `.state` property to your SpaceAPI endpoint. We'll use the following
(rather simple, and probably not too useful) data source: Check a certain file, and mark the space as open depending on
its existence.
The remainder of this document assumes that you installed the
server as a Debian package.
1. Create a plugin to fetch the data. Let's name it `mybackend.py` and put in in our plugins directory:
### 1. Overview
The configuration of this server consists of three parts:
- The **main configuration** file, usually located at
`/etc/spaceapi-server/config.yaml`. This file controls all the
internal settings of the server.
- The **response template** file, located at
`/etc/spaceapi-server/template.yaml`. This file defines the content
served by your sever.
- The **plugins** directory, located at
`/etc/spaceapi-server/plugins/`. Here you can put your plugins for
rendering dynamic content.
### 2. Configure the Server
Open the file `/etc/spaceapi-server/config.yaml`.
The following options are currently available:
```yaml
---
# 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:
# Plugin-specific configuration should go in here, separated by plugin
my_plugin:
my_option: "Hello, World!"
my_other_option: [ 42, 1337 ]
```
### 3. Configure a Static SpaceAPI Endpoint
Open the file `/etc/spaceapi-server/template.yaml`. By default it
contains a minimal example response. If you only want to serve static
content, your `template.json` should simply contain the SpaceAPI JSON
response you want to serve.
The content is served "almost-as-is" (apart from the conversion from
YAML to JSON).
To learn about how a SpaceAPI response should look like, have a look
at the [SpaceAPI Website][spaceapi].
### 4. Add Dynamic Content
This example guides you through adding a dynamic `state` property to
your SpaceAPI endpoint. We'll use the following (rather simple, and
probably not too useful) data source: Check a certain file, and mark
the space as open depending on its existence.
1. Create a plugin to fetch the data. Let's name it `filestate.py`
and put in in our plugins directory:
```python
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('mybackend')
conf = config.get_plugin_config('filestate')
# Get the filename
filename = conf.get('filename', '/var/space_state')
try:
@ -76,44 +185,128 @@ its existence.
}
```
The `@template_function` decorator registers the function as a callable in Jinja's globals. There's also
`@template_filter`, which registers a Jinja2 filter, and `@template_test`, which registers a test. For more
information on the Jinja2 templating engine, see [Jinja2][jinja].
The `@template_function` decorator registers a constructor in
PyYAML's parser with the function's name as tag
(e.g. `!space_state).
2. Call the template function in your template:
```json
{
```yaml
# ...
"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:
```json
```yaml
# ...
"template": "template.json",
"plugins_dir": "plugins",
"plugins": {
"mybackend": {
"filename", "/var/space_state"
}
}
template: template.yaml
plugins_dir: plugins
plugins:
filestate:
filename: /var/space_state
# ...
```
4. Start the server and query it.
### 5. Start the Server
[master]: https://gitlab.com/s3lph/spaceapi-server/commits/master
Start the server with e.g.:
```bash
systemctl start spaceapi-server.service
```
To reload the configuration, template and plugins, send a SIGHUP,
e.g. through `systemctl reload`.
If you need to run the server ad-hoc, you can start it with:
```bash
python3 -m spaceapi_server /path/to/config.yaml
```
### 6. Test the Server
```bash
curl http://localhost:8000/
```
You should be greeted with the SpaceAPI endpoint response.
## Plugin API Reference
### Configuration
The following functions provide access to values defined in the
configuration file.
#### `spaceapi_server.config.get_plugin_config(name: str)`
This function returns a plugin's configuration.
The function takes one argument, the name of the plugin. This name is
used to look up the plugin configuration.
The function returns the content present at the key `.plugins.<name>`
of the global configuration file, or an empty object if absent.
Usage:
```python
from spaceapi_server import plugins
print(plugins.get_plugin_config('my_plugin'))
```
### Templating
The following decorators register a function for use as a PyYAML
constructor, so they become usable from within templates. They are
invoked when the template is rendered, which happens for each HTTP
request.
If performance is an issue, consider applying caching, either in your
plugins, or by using a caching HTTP reverse proxy.
#### `spaceapi_server.plugins.template_function`
This decorator registers the function's name as a YAML tag in the parser.
The decorated function may take arguments (always passed as
`**kwargs`, so `*args`, or arguments before `*` won't work) that can
be represented in a YAML file.
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_function
def lookup_sensor(query, default=None):
# Do something with the query
result = ...
# If the lookup failed, return a default value
if not result:
return default or []
return result
```
```yaml
# ...
state: !lookup_sensor
query: SELECT timestamp, value FROM people_now_present LIMIT 1
# ...
```
[main]: https://gitlab.com/s3lph/spaceapi-server/commits/main
[packages]: https://git.kabelsalat.ch/s3lph/spaceapi-server/packages
[spaceapi]: https://spaceapi.io/
[pypi-bottle]: https://pypi.org/project/bottle/
[pypi-jinja2]: https://pypi.org/project/Jinja2/
[mit]: https://gitlab.com/s3lph/spaceapi-server/blob/master/LICENSE
[spaceapi-getting-started]: https://spaceapi.io/getting-started/
[pypi-yaml]: https://pypi.org/project/PyYAML/
[mit]: https://gitlab.com/s3lph/spaceapi-server/blob/main/LICENSE
[jinja]: https://jinja.palletsprojects.com/
[registry]: https://gitlab.com/s3lph/spaceapi-server/container_registry

View file

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

16
examples/config.yaml Normal file
View file

@ -0,0 +1,16 @@
---
# 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

@ -1,40 +0,0 @@
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_plugin_config('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

View file

@ -0,0 +1,23 @@
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)
}

View file

@ -1,20 +0,0 @@
{
"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') }}"
}
}

12
examples/template.yaml Normal file
View file

@ -0,0 +1,12 @@
---
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" ]

View file

@ -1,6 +0,0 @@
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

@ -1,27 +0,0 @@
# 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

@ -1,31 +0,0 @@
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

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

View file

@ -1,19 +0,0 @@
{
"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,10 +0,0 @@
[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.json
/etc/spaceapi-server/template.json
/etc/spaceapi-server/plugins/example.py
/etc/spaceapi-server/config.yaml
/etc/spaceapi-server/template.yaml
/etc/spaceapi-server/plugins/filestate.py

View file

@ -0,0 +1,10 @@
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

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

View file

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

View file

@ -1,19 +0,0 @@
{
"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,16 @@
---
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
[Service]
ExecStart=/usr/bin/python3 -m spaceapi_server /etc/spaceapi-server/config.json
ExecStart=/usr/bin/python3 -m spaceapi_server /etc/spaceapi-server/config.yaml
ExecReload=/usr/bin/kill -HUP $MAINPID
User=spaceapi-server
[Install]
WantedBy=network-online.target
WantedBy=multi-user.target

13
package/docker/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
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

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

View file

@ -0,0 +1,14 @@
---
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,6 +10,9 @@ import http.client
from urllib.error import HTTPError
USER_AGENT = 'curl/7.70.0'
def parse_changelog(tag: str) -> Optional[str]:
release_changelog: str = ''
with open('CHANGELOG.md', 'r') as f:
@ -32,7 +35,8 @@ def parse_changelog(tag: str) -> Optional[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'
headers: Dict[str, str] = {
'Private-Token': api_token
'Private-Token': api_token,
'User-Agent': USER_AGENT
}
req = urllib.request.Request(url, headers=headers)
try:
@ -52,7 +56,10 @@ def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str
def fetch_single_shafile(url: str) -> str:
req = urllib.request.Request(url)
headers: Dict[str, str] = {
'User-Agent': USER_AGENT
}
req = urllib.request.Request(url, headers=headers)
try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e:
@ -80,14 +87,6 @@ def fetch_debian_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[s
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():
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
@ -121,23 +120,39 @@ def main():
wheel_url, wheel_sha_url = fetch_wheel_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()}
### Download
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))
- [Arch Linux Package]({arch_url}) ([sha256]({arch_sha_url}))'''
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))'''
post_body: str = json.dumps({'description': augmented_changelog})
post_body: str = json.dumps({
'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 = \
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
f'https://gitlab.com/api/v4/projects/{project_id}/releases'
headers: Dict[str, str] = {
'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(

View file

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

View file

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

View file

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

View file

@ -1,37 +0,0 @@
import unittest
import tempfile
from spaceapi_server.config import load, get, 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,5 +1,7 @@
from spaceapi_server.template import _env_init
import yaml
from spaceapi_server import config, template
def template_function(fn):
@ -7,32 +9,14 @@ 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
yaml.SafeLoader.add_constructor(f'!{fn.__name__}', template.plugin_constructor)
template.PluginInvocation.register_plugin(fn.__name__, fn)
return fn
def template_filter(fn):
def get_plugin_config(name: str):
"""
Register the decorated function as a template filter.
:param fn: The function to register.
Return a plugin's configuration under .plugins[name]
:param name: The plugin name.
"""
# 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
return config.get().get('plugins', {}).get(name, {})

View file

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

View file

@ -1,44 +1,31 @@
import json
import jinja2
class PluginInvocation():
_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
# 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 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 plugin_constructor(loader, node):
data = loader.construct_mapping(node)
return PluginInvocation(node.tag[1:], data)
def render_traverse(obj):
"""
Walk through a complex, JSON-serializable data structure, and pass
string objects through the Jinja2 templating engine.
Walk through a complex, JSON-serializable data structure, and
invoke plugins if for custom tags.
:param obj: The object to traverse.
"""
if isinstance(obj, list):
@ -47,9 +34,9 @@ def render_traverse(obj):
elif isinstance(obj, dict):
# dict -> recurse into the value of each (key, value)
return {k: render_traverse(v) for k, v in obj.items()}
elif isinstance(obj, str):
# str -> template
return render(obj)
elif isinstance(obj, PluginInvocation):
# PluginTag -> invoke the plugin with the stored arguments
return obj()
else:
# anything else -> return as-is
return obj

View file

@ -1,78 +0,0 @@
import unittest
from spaceapi_server.template import _env_init, render, render_traverse
from spaceapi_server.plugins import template_function, template_filter, template_test
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_test
def template_test_test(value):
return value == 'baz'
@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") }}',
'test_test_true': '{{ "baz" is template_test_test }}',
'test_test_false': '{{ "foo" is template_test_test }}'
}
}
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'])
self.assertTrue(rendered['test_functions']['test_test_true'])
self.assertFalse(rendered['test_functions']['test_test_false'])
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])