From 0898341511bb8a04ab392c8b70e56efe932de1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Fra=C3=9F?= Date: Fri, 21 Jun 2024 14:04:58 +0200 Subject: [PATCH] [ini] --- .gitignore | 1 + misc/conf.example.json | 16 ++++ readme.md | 38 ++++++++ source/backend.py | 118 +++++++++++++++++++++++++ source/conf.py | 191 +++++++++++++++++++++++++++++++++++++++++ source/helpers.py | 63 ++++++++++++++ source/log.py | 139 ++++++++++++++++++++++++++++++ source/main.py | 134 +++++++++++++++++++++++++++++ tools/build | 17 ++++ tools/install | 16 ++++ 10 files changed, 733 insertions(+) create mode 100644 .gitignore create mode 100644 misc/conf.example.json create mode 100644 readme.md create mode 100644 source/backend.py create mode 100644 source/conf.py create mode 100644 source/helpers.py create mode 100644 source/log.py create mode 100644 source/main.py create mode 100755 tools/build create mode 100755 tools/install diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d94939 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.geany diff --git a/misc/conf.example.json b/misc/conf.example.json new file mode 100644 index 0000000..f1d3132 --- /dev/null +++ b/misc/conf.example.json @@ -0,0 +1,16 @@ +{ + "log": { + "format": "human_readable", + "min_level": "info" + }, + "backend": { + "scheme": "http", + "host": "localhost", + "port": 4916, + "path": "" + }, + "credentials": { + "name": "admin", + "password": "admin" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..fa0835d --- /dev/null +++ b/readme.md @@ -0,0 +1,38 @@ +# Mondvogel + +## Beschreibung + +- Kommandozeilen-Client für das [Espe](https://gitlab.die-linke.cloud/espe) + + +## Erstellung + +### Voraussetzungen + +- (keine) + + +### Anweisungen + +- `tools/build ` ausführen + + +## Benutzung + +### Voraussetzungen + +- Python 3 (Debian-Paket-Name: `python3`) + + +### Anweisungen + +- Ausführung + - `python3 /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 diff --git a/source/backend.py b/source/backend.py new file mode 100644 index 0000000..b32be78 --- /dev/null +++ b/source/backend.py @@ -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, + } + ) + diff --git a/source/conf.py b/source/conf.py new file mode 100644 index 0000000..298c6cc --- /dev/null +++ b/source/conf.py @@ -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 + diff --git a/source/helpers.py b/source/helpers.py new file mode 100644 index 0000000..76af232 --- /dev/null +++ b/source/helpers.py @@ -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 + diff --git a/source/log.py b/source/log.py new file mode 100644 index 0000000..1d9f20d --- /dev/null +++ b/source/log.py @@ -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 {}) + } + ) + diff --git a/source/main.py b/source/main.py new file mode 100644 index 0000000..f719ac5 --- /dev/null +++ b/source/main.py @@ -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 = "", + 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 = "", + help = "Pfad zur Konfigurations-Datei", + ) + argument_parser.add_argument( + "-m", + "--membership-number", + type = str, + dest = "membership_number", + default = None, + metavar = "", + help = "Mitglieds-Nummer des Mitglieds", + ) + argument_parser.add_argument( + "-n", + "--name", + type = str, + dest = "name", + default = None, + metavar = "", + help = "Name des Mitglieds", + ) + argument_parser.add_argument( + "-e", + "--email-address", + type = str, + dest = "email_address", + default = None, + metavar = "", + 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) diff --git a/tools/build b/tools/build new file mode 100755 index 0000000..0ff3652 --- /dev/null +++ b/tools/build @@ -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}" diff --git a/tools/install b/tools/install new file mode 100755 index 0000000..ec2d274 --- /dev/null +++ b/tools/install @@ -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}"