This commit is contained in:
roydfalk 2024-06-21 14:04:58 +02:00
commit 0898341511
10 changed files with 733 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/.geany

16
misc/conf.example.json Normal file
View file

@ -0,0 +1,16 @@
{
"log": {
"format": "human_readable",
"min_level": "info"
},
"backend": {
"scheme": "http",
"host": "localhost",
"port": 4916,
"path": ""
},
"credentials": {
"name": "admin",
"password": "admin"
}
}

38
readme.md Normal file
View file

@ -0,0 +1,38 @@
# Mondvogel
## Beschreibung
- Kommandozeilen-Client für das [Espe](https://gitlab.die-linke.cloud/espe)
## Erstellung
### Voraussetzungen
- (keine)
### Anweisungen
- `tools/build <ziel-verzeichnis>` ausführen
## Benutzung
### Voraussetzungen
- Python 3 (Debian-Paket-Name: `python3`)
### Anweisungen
- Ausführung
- `python3 <ziel-verzeichnis>/main.py` ausführen
- durch Anfügen des Schalters `-h`/`--help` wird die Erläuterung zur Syntax angezeigt
- Konfiguration
- `misc/conf.example.json` kopieren nach (bspw.) `~/.mondvogel/conf.json` und die Werte anpassen um eine initiale Konfiguration zu erstellen
- das JSON-Schema der Konfiguration mit Hinweisen zu den verfügbaren Optionen lässt sich durch die Aktion `conf-schema` ausgeben
- die vervollständigte Konfiguration lässt sich durch die Aktion `conf-expose` anzeigen
- Installation
- als `root` `tools/install` ausführen
- dadurch sollte `mondvogel` als systemweiter Befehl verwendbar sein

118
source/backend.py Normal file
View file

@ -0,0 +1,118 @@
import typing as _typing
import json as _json
import requests as _requests
from helpers import *
from conf import *
from log import *
def backend_api_call_generic(
session_key,
http_method,
action_path,
data
):
log_info(
"backend_api_call",
{
"with_session_key": (not (session_key is None)),
"http_method": http_method,
"path": action_path,
}
)
target = string_coin(
"{{scheme}}://{{host}}:{{port}}{{path_base}}{{path_action}}",
{
"scheme": conf_get()["api"]["scheme"],
"host": conf_get()["api"]["host"],
"port": ("%u" % conf_get()["api"]["port"]),
"path_base": conf_get()["api"]["path"],
"path_action": action_path,
}
)
if (http_method == "GET"):
response_raw = _requests.get(
target,
headers = {
"X-Session-Key": session_key,
}
)
return _json.loads(response_raw.text)
elif (http_method == "POST"):
response_raw = _requests.post(
target,
headers = {
"Content-Type": "application/json",
"X-Session-Key": session_key,
},
json = data
)
return _json.loads(response_raw.text)
elif (http_method == "DELETE"):
response_raw = _requests.delete(
target,
headers = {
"X-Session-Key": session_key,
},
json = data
)
return _json.loads(response_raw.text)
else:
raise NotImplementedError("unhandled HTTP method: %s" % http_method)
def backend_api_call_session_begin(
) -> str:
return backend_api_call_generic(
None,
"POST",
"/session/begin",
{
"name": conf_get()["account"]["name"],
"password": conf_get()["account"]["password"],
}
)
def backend_api_call_session_end(
session_key
):
return backend_api_call_generic(
session_key,
"DELETE",
"/session/end",
None
)
def backend_api_call_member_list(
session_key : str
):
return backend_api_call_generic(
session_key,
"GET",
"/member/list",
None
)
def backend_api_call_member_project(
session_key : str,
membership_number : _typing.Optional[str],
name_real_value : str,
email_address_private : _typing.Optional[str],
notification_target_url_template : _typing.Optional[str]
):
return backend_api_call_generic(
session_key,
"POST",
"/member/project",
{
"membership_number": membership_number,
"name_real_value": name_real_value,
"email_address_private": email_address_private,
"notification_target_url_template": notification_target_url_template,
}
)

191
source/conf.py Normal file
View file

@ -0,0 +1,191 @@
import os as _os
import json as _json
from helpers import *
_conf_data = None
def conf_schema(
):
return {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [
1
]
},
"log": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": [
"jsonl",
"human_readable",
],
"default": "human_readable",
"description": "Format der Log-Einträge"
},
"min_level": {
"type": "string",
"enum": [
"debug",
"info",
"notice",
"warning",
"error",
],
"default": "notice",
"description": "Mindest-Level für Log-Einträge um aufgeführt zu werden"
},
},
"additionalProperties": False,
"required": [
]
},
"api": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"default": "https"
},
"host": {
"type": "string",
},
"port": {
"type": "number",
"default": 4916
},
"path": {
"type": "string",
"default": ""
}
},
"additionalProperties": False,
"required": [
"host"
]
},
"account": {
"type": "object",
"properties": {
"name": {
"type": "string",
},
"password": {
"type": "string",
},
},
"additionalProperties": False,
"required": [
"name",
"password",
]
},
}
}
def conf_refine(
data_raw
):
flaws = []
if (not ("version" in data_raw)):
flaws.append(
{
"incident": "mandatory_value_missing",
"details": {
"path": "version",
}
}
)
data = None
else:
version = data_raw["version"]
if (version == 1):
# log
if True:
data_raw_log = data_raw.get("log", {})
data_log = {
"format": data_raw_log.get("format", "human_readable"),
"min_level": data_raw_log.get("min_level", "notice"),
}
# api
if True:
data_raw_api = data_raw.get("api", {})
data_api = {
"scheme": data_raw_api.get("scheme", "https"),
"host": data_raw_api["host"],
"port": data_raw_api.get("port", 4916),
"path": data_raw_api.get("path", ""),
}
# account
if True:
data_raw_account = data_raw.get("account", {})
data_account = {
"name": data_raw_account["name"],
"password": data_raw_account["password"],
}
data = {
"log": data_log,
"api": data_api,
"account": data_account,
}
else:
flaws.append(
{
"incident": "invalid_version",
"details": {
"value": version,
}
}
)
data = None
return {
"flaws": flaws,
"data": data,
}
def conf_load(
path : str
):
global _conf_data
if (not _os.path.exists(path)):
raise ValueError("configuration missing: %s" % path)
else:
data_raw = _json.loads(file_text_read(path))
refinement = conf_refine(data_raw)
if (len(refinement["flaws"]) > 0):
raise ValueError(
"configuration invalid:\n%s"
% convey(
refinement["flaws"],
[
lambda flaws: map(
lambda flaw: string_coin(
"- {{incident}} | {{details}}",
{
"incident": flaw["incident"],
"details": _json.dumps(flaw["details"]),
}
),
flaws
),
"\n".join,
]
)
)
else:
_conf_data = refinement["data"]
def conf_get(
):
global _conf_data
return _conf_data

63
source/helpers.py Normal file
View file

@ -0,0 +1,63 @@
import os as _os
import json as _json
import subprocess as _subprocess
import functools as _functools
def convey(
x,
fs
):
y = x
for f in fs:
y = f(y)
return y
def file_text_read(
path : str
) -> str:
handle = open(path, "r")
content = handle.read()
handle.close()
return content
def file_binary_read(
path : str
) -> str:
handle = open(path, "rb")
content = handle.read()
handle.close()
return content
def file_text_write(
path : str,
content : str
):
_os.makedirs(_os.path.dirname(path), exist_ok = True)
handle = open(path, "w" if _os.path.exists(path) else "a")
handle.write(content)
handle.close()
def file_binary_write(
path : str,
content
):
_os.makedirs(_os.path.dirname(path), exist_ok = True)
handle = open(path, ("w" if _os.path.exists(path) else "a") + "b")
handle.write(content)
handle.close()
def string_coin(
template : str,
arguments : dict[str,str]
) -> str:
result = template
for (key, value, ) in arguments.items():
result = result.replace("{{%s}}" % key, value)
return result

139
source/log.py Normal file
View file

@ -0,0 +1,139 @@
import typing as _typing
import enum as _enum
import sys as _sys
import json as _json
import datetime as _datetime
from helpers import *
class enum_log_level(_enum.Enum):
DEBUG = 0
NOTICE = 1
INFO = 2
WARNING = 3
ERROR = 4
def __lt__(self, other):
return (self.value < other.value)
_log_outputs = []
def log_add_output(
output
):
_log_outputs.append(output)
def log_generic(
level,
report
):
def encode_level(level):
return {
enum_log_level.ERROR: "err",
enum_log_level.WARNING: "wrn",
enum_log_level.NOTICE: "ntc",
enum_log_level.INFO: "inf",
enum_log_level.DEBUG: "dbg",
}[level]
for output in _log_outputs:
if (level < output["min_level"]):
pass
else:
if (output["format"] == "jsonl"):
_sys.stderr.write(
_json.dumps(
{
"level": encode_level(level),
"timestamp": int(_datetime.datetime.now().timestamp()),
"incident": report["incident"],
"details": report["details"],
}
)
+
"\n"
)
elif (output["format"] == "human_readable"):
_sys.stderr.write(
string_coin(
"<{{time}}> [{{level}}] {{incident}} | {{details}}",
{
"time": _datetime.datetime.now().isoformat()[:19],
"level": encode_level(level),
"incident": report["incident"],
"details": _json.dumps(report["details"])[:127],
}
)
+
"\n"
)
else:
raise ValueError("invalid log format: %s" % output["format"])
def log_error(
incident,
details = None
):
log_generic(
enum_log_level.ERROR,
{
"incident": incident,
"details": (details or {})
}
)
def log_warning(
incident,
details = None
):
log_generic(
enum_log_level.WARNING,
{
"incident": incident,
"details": (details or {})
}
)
def log_notice(
incident,
details = None
):
log_generic(
enum_log_level.NOTICE,
{
"incident": incident,
"details": (details or {})
}
)
def log_info(
incident,
details = None
):
log_generic(
enum_log_level.INFO,
{
"incident": incident,
"details": (details or {})
}
)
def log_debug(
incident,
details = None
):
log_generic(
enum_log_level.DEBUG,
{
"incident": incident,
"details": (details or {})
}
)

134
source/main.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
import typing as _typing
import sys as _sys
import os as _os
import json as _json
import argparse as _argparse
from log import *
from conf import *
from backend import *
def main():
## args
argument_parser = _argparse.ArgumentParser(
prog = "mondvogel",
description = "CLI-Client-Programm für Espe",
formatter_class = _argparse.ArgumentDefaultsHelpFormatter
)
argument_parser.add_argument(
type = str,
dest = "action",
choices = [
"member-list",
"member-project",
],
metavar = "<action>",
help = "auszuführende Aktion; Optionen: 'conf-schema' : JSON-Schema der Konfiguration ausgeben | 'conf-expose' : vervollständigte Konfiguration ausgegeben | 'member-list' : Liste der Mitglieder ausgeben | 'member-project' : ein Mitglied anlegen und die ID des erzeugten Datensatzes ausgeben",
)
argument_parser.add_argument(
"-c",
"--conf-path",
type = str,
dest = "conf_path",
default = _os.path.join(_os.path.expanduser("~"), ".mondvogel", "conf.json"),
metavar = "<conf-path>",
help = "Pfad zur Konfigurations-Datei",
)
argument_parser.add_argument(
"-m",
"--membership-number",
type = str,
dest = "membership_number",
default = None,
metavar = "<membership-number>",
help = "Mitglieds-Nummer des Mitglieds",
)
argument_parser.add_argument(
"-n",
"--name",
type = str,
dest = "name",
default = None,
metavar = "<name>",
help = "Name des Mitglieds",
)
argument_parser.add_argument(
"-e",
"--email-address",
type = str,
dest = "email_address",
default = None,
metavar = "<email-address>",
help = "E-Mail-Adresse des Mitglieds",
)
args = argument_parser.parse_args()
## conf
conf_load(args.conf_path)
log_add_output(
{
"format": conf_get()["log"]["format"],
"min_level": (
{
"error": enum_log_level.ERROR,
"warning": enum_log_level.WARNING,
"notice": enum_log_level.NOTICE,
"info": enum_log_level.INFO,
"debug": enum_log_level.DEBUG,
}[conf_get()["log"]["min_level"]]
),
}
)
## exec
if (args.action == "conf-schema"):
_sys.stdout.write(_json.dumps(conf_schema(), indent = "\t") + "\n")
elif (args.action == "conf-expose"):
_sys.stdout.write(_json.dumps(conf_get(), indent = "\t") + "\n")
elif (args.action == "member-list"):
session_key = backend_api_call_session_begin()
data = backend_api_call_member_list(
session_key
)
backend_api_call_session_end(session_key)
_sys.stdout.write(_json.dumps(data, indent = "\t") + "\n")
elif (args.action == "member-project"):
if (
(args.membership_number is None)
or
(args.name is None)
or
(args.email_address is None)
):
log_error(
"mandatory_parameters_missing",
{
"parameters": [
"membership_number",
"name",
"email_address",
]
}
)
else:
session_key = backend_api_call_session_begin()
member_id = backend_api_call_member_project(
session_key,
args.membership_number,
args.name,
args.email_address,
None
)
backend_api_call_session_end(session_key)
_sys.stdout.write(_json.dumps(member_id, indent = "\t") + "\n")
else:
raise NotImplementedError()
try:
main()
except ValueError as error:
_sys.stderr.write(str(error) + "\n")
_sys.exit(1)

17
tools/build Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env sh
## args
if [ $# -ge 1 ] ; then dir_build=$1 && shift ; else dir_build="/tmp/mondvogel" ; fi
## vars
dir_source="source"
## exec
mkdir --parents ${dir_build}
cp --recursive --update ${dir_source}/*.py ${dir_build}/
echo "-- ${dir_build}"

16
tools/install Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env sh
## args
if [ $# -ge 1 ] ; then dir_build=$1 && shift ; else dir_build="/tmp/mondvogel" ; fi
if [ $# -ge 1 ] ; then dir_target=$1 && shift ; else dir_target="/opt/mondvogel" ; fi
if [ $# -ge 1 ] ; then path_bin=$1 && shift ; else path_bin="/usr/local/bin/mondvogel" ; fi
## exec
mkdir --parents ${dir_target}
cp --recursive ${dir_build}/* ${dir_target}/
echo "cd ${dir_target} && python3 main.py \$@" > ${path_bin}
chmod +x ${path_bin}
echo "-- ${path_bin}"