Add Cloudron packaging for Maubot
This commit is contained in:
2
maubot-src/maubot/cli/__init__.py
Normal file
2
maubot-src/maubot/cli/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import commands
|
||||
from .base import app
|
||||
3
maubot-src/maubot/cli/__main__.py
Normal file
3
maubot-src/maubot/cli/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import app
|
||||
|
||||
app()
|
||||
23
maubot-src/maubot/cli/base.py
Normal file
23
maubot-src/maubot/cli/base.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import click
|
||||
|
||||
from .config import load_config
|
||||
|
||||
|
||||
@click.group()
|
||||
def app() -> None:
|
||||
load_config()
|
||||
2
maubot-src/maubot/cli/cliq/__init__.py
Normal file
2
maubot-src/maubot/cli/cliq/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cliq import command, option
|
||||
from .validators import PathValidator, SPDXValidator, VersionValidator
|
||||
169
maubot-src/maubot/cli/cliq/cliq.py
Normal file
169
maubot-src/maubot/cli/cliq/cliq.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import traceback
|
||||
|
||||
from colorama import Fore
|
||||
from prompt_toolkit.validation import Validator
|
||||
from questionary import prompt
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..base import app
|
||||
from ..config import get_token
|
||||
from .validators import ClickValidator, Required
|
||||
|
||||
|
||||
def with_http(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
try:
|
||||
return await func(*args, sess=sess, **kwargs)
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"{Fore.RED}Connection error: {e}{Fore.RESET}")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_authenticated_http(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, server: str, **kwargs):
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
async with aiohttp.ClientSession(headers={"Authorization": f"Bearer {token}"}) as sess:
|
||||
try:
|
||||
return await func(*args, sess=sess, server=server, **kwargs)
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"{Fore.RED}Connection error: {e}{Fore.RESET}")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def command(help: str) -> Callable[[Callable], Callable]:
|
||||
def decorator(func) -> Callable:
|
||||
questions = getattr(func, "__inquirer_questions__", {}).copy()
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if key not in questions:
|
||||
continue
|
||||
if value is not None and (questions[key]["type"] != "confirm" or value != "null"):
|
||||
questions.pop(key, None)
|
||||
try:
|
||||
required_unless = questions[key].pop("required_unless")
|
||||
if isinstance(required_unless, str) and kwargs[required_unless]:
|
||||
questions.pop(key)
|
||||
elif isinstance(required_unless, list):
|
||||
for v in required_unless:
|
||||
if kwargs[v]:
|
||||
questions.pop(key)
|
||||
break
|
||||
elif isinstance(required_unless, dict):
|
||||
for k, v in required_unless.items():
|
||||
if kwargs.get(v, object()) == v:
|
||||
questions.pop(key)
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
question_list = list(questions.values())
|
||||
question_list.reverse()
|
||||
resp = prompt(question_list, kbi_msg="Aborted!")
|
||||
if not resp and question_list:
|
||||
return
|
||||
kwargs = {**kwargs, **resp}
|
||||
|
||||
try:
|
||||
res = func(*args, **kwargs)
|
||||
if inspect.isawaitable(res):
|
||||
asyncio.run(res)
|
||||
except Exception:
|
||||
print(Fore.RED + "Fatal error running command" + Fore.RESET)
|
||||
traceback.print_exc()
|
||||
|
||||
return app.command(help=help)(wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def yesno(val: str) -> bool | None:
|
||||
if not val:
|
||||
return None
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
elif val.lower() in ("true", "t", "yes", "y"):
|
||||
return True
|
||||
elif val.lower() in ("false", "f", "no", "n"):
|
||||
return False
|
||||
|
||||
|
||||
yesno.__name__ = "yes/no"
|
||||
|
||||
|
||||
def option(
|
||||
short: str,
|
||||
long: str,
|
||||
message: str = None,
|
||||
help: str = None,
|
||||
click_type: str | Callable[[str], Any] = None,
|
||||
inq_type: str = None,
|
||||
validator: type[Validator] = None,
|
||||
required: bool = False,
|
||||
default: str | bool | None = None,
|
||||
is_flag: bool = False,
|
||||
prompt: bool = True,
|
||||
required_unless: str | list | dict = None,
|
||||
) -> Callable[[Callable], Callable]:
|
||||
if not message:
|
||||
message = long[2].upper() + long[3:]
|
||||
|
||||
if isinstance(validator, type) and issubclass(validator, ClickValidator):
|
||||
click_type = validator.click_type
|
||||
if is_flag:
|
||||
click_type = yesno
|
||||
|
||||
def decorator(func) -> Callable:
|
||||
click.option(short, long, help=help, type=click_type)(func)
|
||||
if not prompt:
|
||||
return func
|
||||
if not hasattr(func, "__inquirer_questions__"):
|
||||
func.__inquirer_questions__ = {}
|
||||
q = {
|
||||
"type": (
|
||||
inq_type if isinstance(inq_type, str) else ("input" if not is_flag else "confirm")
|
||||
),
|
||||
"name": long[2:],
|
||||
"message": message,
|
||||
}
|
||||
if required_unless is not None:
|
||||
q["required_unless"] = required_unless
|
||||
if default is not None:
|
||||
q["default"] = default
|
||||
if required or required_unless is not None:
|
||||
q["validate"] = Required(validator)
|
||||
elif validator:
|
||||
q["validate"] = validator
|
||||
func.__inquirer_questions__[long[2:]] = q
|
||||
return func
|
||||
|
||||
return decorator
|
||||
85
maubot-src/maubot/cli/cliq/validators.py
Normal file
85
maubot-src/maubot/cli/cliq/validators.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Callable
|
||||
import os
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
import click
|
||||
|
||||
from ..util import spdx as spdxlib
|
||||
|
||||
|
||||
class Required(Validator):
|
||||
proxy: Validator
|
||||
|
||||
def __init__(self, proxy: Validator = None) -> None:
|
||||
self.proxy = proxy
|
||||
|
||||
def validate(self, document: Document) -> None:
|
||||
if len(document.text) == 0:
|
||||
raise ValidationError(message="This field is required")
|
||||
if self.proxy:
|
||||
return self.proxy.validate(document)
|
||||
|
||||
|
||||
class ClickValidator(Validator):
|
||||
click_type: Callable[[str], str] = None
|
||||
|
||||
@classmethod
|
||||
def validate(cls, document: Document) -> None:
|
||||
try:
|
||||
cls.click_type(document.text)
|
||||
except click.BadParameter as e:
|
||||
raise ValidationError(message=e.message, cursor_position=len(document.text))
|
||||
|
||||
|
||||
def path(val: str) -> str:
|
||||
val = os.path.abspath(val)
|
||||
if os.path.exists(val):
|
||||
return val
|
||||
directory = os.path.dirname(val)
|
||||
if not os.path.isdir(directory):
|
||||
if os.path.exists(directory):
|
||||
raise click.BadParameter(f"{directory} is not a directory")
|
||||
raise click.BadParameter(f"{directory} does not exist")
|
||||
return val
|
||||
|
||||
|
||||
class PathValidator(ClickValidator):
|
||||
click_type = path
|
||||
|
||||
|
||||
def version(val: str) -> Version:
|
||||
try:
|
||||
return Version(val)
|
||||
except InvalidVersion as e:
|
||||
raise click.BadParameter(f"{val} is not a valid PEP-440 version") from e
|
||||
|
||||
|
||||
class VersionValidator(ClickValidator):
|
||||
click_type = version
|
||||
|
||||
|
||||
def spdx(val: str) -> str:
|
||||
if not spdxlib.valid(val):
|
||||
raise click.BadParameter(f"{val} is not a valid SPDX license identifier")
|
||||
return val
|
||||
|
||||
|
||||
class SPDXValidator(ClickValidator):
|
||||
click_type = spdx
|
||||
1
maubot-src/maubot/cli/commands/__init__.py
Normal file
1
maubot-src/maubot/cli/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import auth, build, init, login, logs, upload
|
||||
166
maubot-src/maubot/cli/commands/auth.py
Normal file
166
maubot-src/maubot/cli/commands/auth.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import json
|
||||
import webbrowser
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..cliq import cliq
|
||||
|
||||
history_count: int = 10
|
||||
|
||||
friendly_errors = {
|
||||
"server_not_found": (
|
||||
"Registration target server not found.\n\n"
|
||||
"To log in or register through maubot, you must add the server to the\n"
|
||||
"homeservers section in the config. If you only want to log in,\n"
|
||||
"leave the `secret` field empty."
|
||||
),
|
||||
"registration_no_sso": (
|
||||
"The register operation is only for registering with a password.\n\n"
|
||||
"To register with SSO, simply leave out the --register flag."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def list_servers(server: str, sess: aiohttp.ClientSession) -> None:
|
||||
url = URL(server) / "_matrix/maubot/v1/client/auth/servers"
|
||||
async with sess.get(url) as resp:
|
||||
data = await resp.json()
|
||||
print(f"{Fore.GREEN}Available Matrix servers for registration and login:{Fore.RESET}")
|
||||
for server in data.keys():
|
||||
print(f"* {Fore.CYAN}{server}{Fore.RESET}")
|
||||
|
||||
|
||||
@cliq.command(help="Log into a Matrix account via the Maubot server")
|
||||
@cliq.option("-h", "--homeserver", help="The homeserver to log into", required_unless="list")
|
||||
@cliq.option(
|
||||
"-u", "--username", help="The username to log in with", required_unless=["list", "sso"]
|
||||
)
|
||||
@cliq.option(
|
||||
"-p",
|
||||
"--password",
|
||||
help="The password to log in with",
|
||||
inq_type="password",
|
||||
required_unless=["list", "sso"],
|
||||
)
|
||||
@cliq.option(
|
||||
"-s",
|
||||
"--server",
|
||||
help="The maubot instance to log in through",
|
||||
default="",
|
||||
required=False,
|
||||
prompt=False,
|
||||
)
|
||||
@click.option(
|
||||
"-r", "--register", help="Register instead of logging in", is_flag=True, default=False
|
||||
)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--update-client",
|
||||
help="Instead of returning the access token, create or update a client in maubot using it",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option("-l", "--list", help="List available homeservers", is_flag=True, default=False)
|
||||
@click.option(
|
||||
"-o", "--sso", help="Use single sign-on instead of password login", is_flag=True, default=False
|
||||
)
|
||||
@click.option(
|
||||
"-n",
|
||||
"--device-name",
|
||||
help="The initial e2ee device displayname (only for login)",
|
||||
default="Maubot",
|
||||
required=False,
|
||||
)
|
||||
@cliq.with_authenticated_http
|
||||
async def auth(
|
||||
homeserver: str,
|
||||
username: str,
|
||||
password: str,
|
||||
server: str,
|
||||
register: bool,
|
||||
list: bool,
|
||||
update_client: bool,
|
||||
device_name: str,
|
||||
sso: bool,
|
||||
sess: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
if list:
|
||||
await list_servers(server, sess)
|
||||
return
|
||||
endpoint = "register" if register else "login"
|
||||
url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / endpoint
|
||||
if update_client:
|
||||
url = url.update_query({"update_client": "true"})
|
||||
if sso:
|
||||
url = url.update_query({"sso": "true"})
|
||||
req_data = {"device_name": device_name}
|
||||
else:
|
||||
req_data = {"username": username, "password": password, "device_name": device_name}
|
||||
|
||||
async with sess.post(url, json=req_data) as resp:
|
||||
if not 200 <= resp.status < 300:
|
||||
await print_error(resp, is_register=register)
|
||||
elif sso:
|
||||
await wait_sso(resp, sess, server, homeserver)
|
||||
else:
|
||||
await print_response(resp, is_register=register)
|
||||
|
||||
|
||||
async def wait_sso(
|
||||
resp: aiohttp.ClientResponse, sess: aiohttp.ClientSession, server: str, homeserver: str
|
||||
) -> None:
|
||||
data = await resp.json()
|
||||
sso_url, reg_id = data["sso_url"], data["id"]
|
||||
print(f"{Fore.GREEN}Opening {Fore.CYAN}{sso_url}{Fore.RESET}")
|
||||
webbrowser.open(sso_url, autoraise=True)
|
||||
print(f"{Fore.GREEN}Waiting for login token...{Fore.RESET}")
|
||||
wait_url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / "sso" / reg_id / "wait"
|
||||
async with sess.post(wait_url, json={}) as resp:
|
||||
await print_response(resp, is_register=False)
|
||||
|
||||
|
||||
async def print_response(resp: aiohttp.ClientResponse, is_register: bool) -> None:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
action = "registered" if is_register else "logged in as"
|
||||
print(f"{Fore.GREEN}Successfully {action} {Fore.CYAN}{data['user_id']}{Fore.GREEN}.")
|
||||
print(f"{Fore.GREEN}Access token: {Fore.CYAN}{data['access_token']}{Fore.RESET}")
|
||||
print(f"{Fore.GREEN}Device ID: {Fore.CYAN}{data['device_id']}{Fore.RESET}")
|
||||
elif resp.status in (201, 202):
|
||||
data = await resp.json()
|
||||
action = "created" if resp.status == 201 else "updated"
|
||||
print(
|
||||
f"{Fore.GREEN}Successfully {action} client for "
|
||||
f"{Fore.CYAN}{data['id']}{Fore.GREEN} / "
|
||||
f"{Fore.CYAN}{data['device_id']}{Fore.GREEN}.{Fore.RESET}"
|
||||
)
|
||||
else:
|
||||
await print_error(resp, is_register)
|
||||
|
||||
|
||||
async def print_error(resp: aiohttp.ClientResponse, is_register: bool) -> None:
|
||||
try:
|
||||
err_data = await resp.json()
|
||||
error = friendly_errors.get(err_data["errcode"], err_data["error"])
|
||||
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
|
||||
error = await resp.text()
|
||||
action = "register" if is_register else "log in"
|
||||
print(f"{Fore.RED}Failed to {action}: {error}{Fore.RESET}")
|
||||
155
maubot-src/maubot/cli/commands/build.py
Normal file
155
maubot-src/maubot/cli/commands/build.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
import glob
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from colorama import Fore
|
||||
from questionary import prompt
|
||||
from ruamel.yaml import YAML, YAMLError
|
||||
import click
|
||||
|
||||
from mautrix.types import SerializerError
|
||||
|
||||
from ...loader import PluginMeta
|
||||
from ..base import app
|
||||
from ..cliq import cliq
|
||||
from ..cliq.validators import PathValidator
|
||||
from ..config import get_token
|
||||
from .upload import upload_file
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
|
||||
def zipdir(zip, dir):
|
||||
for root, dirs, files in os.walk(dir):
|
||||
for file in files:
|
||||
zip.write(os.path.join(root, file))
|
||||
|
||||
|
||||
def read_meta(path: str) -> PluginMeta | None:
|
||||
try:
|
||||
with open(os.path.join(path, "maubot.yaml")) as meta_file:
|
||||
try:
|
||||
meta_dict = yaml.load(meta_file)
|
||||
except YAMLError as e:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file is not YAML")
|
||||
print(Fore.RED + str(e) + Fore.RESET)
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file not found" + Fore.RESET)
|
||||
return None
|
||||
try:
|
||||
meta = PluginMeta.deserialize(meta_dict)
|
||||
except SerializerError as e:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file is not valid")
|
||||
print(Fore.RED + str(e) + Fore.RESET)
|
||||
return None
|
||||
return meta
|
||||
|
||||
|
||||
def read_output_path(output: str, meta: PluginMeta) -> str | None:
|
||||
directory = os.getcwd()
|
||||
filename = f"{meta.id}-v{meta.version}.mbp"
|
||||
if not output:
|
||||
output = os.path.join(directory, filename)
|
||||
elif os.path.isdir(output):
|
||||
output = os.path.join(output, filename)
|
||||
elif os.path.exists(output):
|
||||
q = [{"type": "confirm", "name": "override", "message": f"{output} exists, override?"}]
|
||||
override = prompt(q)["override"]
|
||||
if not override:
|
||||
return None
|
||||
os.remove(output)
|
||||
return os.path.abspath(output)
|
||||
|
||||
|
||||
def write_plugin(meta: PluginMeta, output: str | IO) -> None:
|
||||
with zipfile.ZipFile(output, "w") as zip:
|
||||
meta_dump = BytesIO()
|
||||
yaml.dump(meta.serialize(), meta_dump)
|
||||
zip.writestr("maubot.yaml", meta_dump.getvalue())
|
||||
|
||||
for module in meta.modules:
|
||||
if os.path.isfile(f"{module}.py"):
|
||||
zip.write(f"{module}.py")
|
||||
elif module is not None and os.path.isdir(module):
|
||||
if os.path.isfile(f"{module}/__init__.py"):
|
||||
zipdir(zip, module)
|
||||
else:
|
||||
print(
|
||||
Fore.YELLOW
|
||||
+ f"Module {module} is missing __init__.py, skipping"
|
||||
+ Fore.RESET
|
||||
)
|
||||
else:
|
||||
print(Fore.YELLOW + f"Module {module} not found, skipping" + Fore.RESET)
|
||||
for pattern in meta.extra_files:
|
||||
for file in glob.iglob(pattern):
|
||||
zip.write(file)
|
||||
|
||||
|
||||
@cliq.with_authenticated_http
|
||||
async def upload_plugin(output: str | IO, *, server: str, sess: ClientSession) -> None:
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
if isinstance(output, str):
|
||||
with open(output, "rb") as file:
|
||||
await upload_file(sess, file, server)
|
||||
else:
|
||||
await upload_file(sess, output, server)
|
||||
|
||||
|
||||
@app.command(
|
||||
short_help="Build a maubot plugin",
|
||||
help=(
|
||||
"Build a maubot plugin. First parameter is the path to root of the plugin "
|
||||
"to build. You can also use --output to specify output file."
|
||||
),
|
||||
)
|
||||
@click.argument("path", default=os.getcwd())
|
||||
@click.option(
|
||||
"-o", "--output", help="Path to output built plugin to", type=PathValidator.click_type
|
||||
)
|
||||
@click.option(
|
||||
"-u", "--upload", help="Upload plugin to server after building", is_flag=True, default=False
|
||||
)
|
||||
@click.option("-s", "--server", help="Server to upload built plugin to")
|
||||
def build(path: str, output: str, upload: bool, server: str) -> None:
|
||||
meta = read_meta(path)
|
||||
if not meta:
|
||||
return
|
||||
if output or not upload:
|
||||
output = read_output_path(output, meta)
|
||||
if not output:
|
||||
return
|
||||
else:
|
||||
output = BytesIO()
|
||||
os.chdir(path)
|
||||
write_plugin(meta, output)
|
||||
if isinstance(output, str):
|
||||
print(f"{Fore.GREEN}Plugin built to {Fore.CYAN}{output}{Fore.GREEN}.{Fore.RESET}")
|
||||
else:
|
||||
output.seek(0)
|
||||
if upload:
|
||||
asyncio.run(upload_plugin(output, server=server))
|
||||
99
maubot-src/maubot/cli/commands/init.py
Normal file
99
maubot-src/maubot/cli/commands/init.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
|
||||
from jinja2 import Template
|
||||
from packaging.version import Version
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .. import cliq
|
||||
from ..cliq import SPDXValidator, VersionValidator
|
||||
from ..util import spdx
|
||||
|
||||
loaded: bool = False
|
||||
meta_template: Template
|
||||
mod_template: Template
|
||||
base_config: str
|
||||
|
||||
|
||||
def load_templates():
|
||||
global mod_template, meta_template, base_config, loaded
|
||||
if loaded:
|
||||
return
|
||||
meta_template = Template(resource_string("maubot.cli", "res/maubot.yaml.j2").decode("utf-8"))
|
||||
mod_template = Template(resource_string("maubot.cli", "res/plugin.py.j2").decode("utf-8"))
|
||||
base_config = resource_string("maubot.cli", "res/config.yaml").decode("utf-8")
|
||||
loaded = True
|
||||
|
||||
|
||||
@cliq.command(help="Initialize a new maubot plugin")
|
||||
@cliq.option(
|
||||
"-n",
|
||||
"--name",
|
||||
help="The name of the project",
|
||||
required=True,
|
||||
default=os.path.basename(os.getcwd()),
|
||||
)
|
||||
@cliq.option(
|
||||
"-i",
|
||||
"--id",
|
||||
message="ID",
|
||||
required=True,
|
||||
help="The maubot plugin ID (Java package name format)",
|
||||
)
|
||||
@cliq.option(
|
||||
"-v",
|
||||
"--version",
|
||||
help="Initial version for project (PEP-440 format)",
|
||||
default="0.1.0",
|
||||
validator=VersionValidator,
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-l",
|
||||
"--license",
|
||||
validator=SPDXValidator,
|
||||
default="AGPL-3.0-or-later",
|
||||
help="The license for the project (SPDX identifier)",
|
||||
required=False,
|
||||
)
|
||||
@cliq.option(
|
||||
"-c",
|
||||
"--config",
|
||||
message="Should the plugin include a config?",
|
||||
help="Include a config in the plugin stub",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
)
|
||||
def init(name: str, id: str, version: Version, license: str, config: bool) -> None:
|
||||
load_templates()
|
||||
main_class = name[0].upper() + name[1:]
|
||||
meta = meta_template.render(
|
||||
id=id, version=str(version), license=license, config=config, main_class=main_class
|
||||
)
|
||||
with open("maubot.yaml", "w") as file:
|
||||
file.write(meta)
|
||||
if license:
|
||||
with open("LICENSE", "w") as file:
|
||||
file.write(spdx.get(license)["licenseText"])
|
||||
if not os.path.isdir(name):
|
||||
os.mkdir(name)
|
||||
mod = mod_template.render(config=config, name=main_class)
|
||||
with open(f"{name}/__init__.py", "w") as file:
|
||||
file.write(mod)
|
||||
if config:
|
||||
with open("base-config.yaml", "w") as file:
|
||||
file.write(base_config)
|
||||
77
maubot-src/maubot/cli/commands/login.py
Normal file
77
maubot-src/maubot/cli/commands/login.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import json
|
||||
import os
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
|
||||
from ..cliq import cliq
|
||||
from ..config import config, save_config
|
||||
|
||||
|
||||
@cliq.command(help="Log in to a Maubot instance")
|
||||
@cliq.option(
|
||||
"-u",
|
||||
"--username",
|
||||
help="The username of your account",
|
||||
default=os.environ.get("USER", None),
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-p", "--password", help="The password to your account", inq_type="password", required=True
|
||||
)
|
||||
@cliq.option(
|
||||
"-s",
|
||||
"--server",
|
||||
help="The server to log in to",
|
||||
default="http://localhost:29316",
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-a",
|
||||
"--alias",
|
||||
help="Alias to reference the server without typing the full URL",
|
||||
default="",
|
||||
required=False,
|
||||
)
|
||||
@cliq.with_http
|
||||
async def login(
|
||||
server: str, username: str, password: str, alias: str, sess: aiohttp.ClientSession
|
||||
) -> None:
|
||||
data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
url = URL(server) / "_matrix/maubot/v1/auth/login"
|
||||
async with sess.post(url, json=data) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
config["servers"][server] = data["token"]
|
||||
if not config["default_server"]:
|
||||
print(Fore.CYAN, "Setting", server, "as the default server")
|
||||
config["default_server"] = server
|
||||
if alias:
|
||||
config["aliases"][alias] = server
|
||||
save_config()
|
||||
print(Fore.GREEN + "Logged in successfully")
|
||||
else:
|
||||
try:
|
||||
err = (await resp.json())["error"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
err = await resp.text()
|
||||
print(Fore.RED + err + Fore.RESET)
|
||||
107
maubot-src/maubot/cli/commands/logs.py
Normal file
107
maubot-src/maubot/cli/commands/logs.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
|
||||
from aiohttp import ClientSession, WSMessage, WSMsgType
|
||||
from colorama import Fore
|
||||
import click
|
||||
|
||||
from mautrix.types import Obj
|
||||
|
||||
from ..base import app
|
||||
from ..config import get_token
|
||||
|
||||
history_count: int = 10
|
||||
|
||||
|
||||
@app.command(help="View the logs of a server")
|
||||
@click.argument("server", required=False)
|
||||
@click.option("-t", "--tail", default=10, help="Maximum number of old log lines to display")
|
||||
def logs(server: str, tail: int) -> None:
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
global history_count
|
||||
history_count = tail
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(view_logs(server, token))
|
||||
|
||||
|
||||
def parsedate(entry: Obj) -> None:
|
||||
i = entry.time.index("+")
|
||||
i = entry.time.index(":", i)
|
||||
entry.time = entry.time[:i] + entry.time[i + 1 :]
|
||||
entry.time = datetime.strptime(entry.time, "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||
|
||||
|
||||
levelcolors = {
|
||||
"DEBUG": "",
|
||||
"INFO": Fore.CYAN,
|
||||
"WARNING": Fore.YELLOW,
|
||||
"ERROR": Fore.RED,
|
||||
"FATAL": Fore.MAGENTA,
|
||||
}
|
||||
|
||||
|
||||
def print_entry(entry: dict) -> None:
|
||||
entry = Obj(**entry)
|
||||
parsedate(entry)
|
||||
print(
|
||||
"{levelcolor}[{date}] [{level}@{logger}] {message}{resetcolor}".format(
|
||||
date=entry.time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
level=entry.levelname,
|
||||
levelcolor=levelcolors.get(entry.levelname, ""),
|
||||
resetcolor=Fore.RESET,
|
||||
logger=entry.name,
|
||||
message=entry.msg,
|
||||
)
|
||||
)
|
||||
if entry.exc_info:
|
||||
print(entry.exc_info)
|
||||
|
||||
|
||||
def handle_msg(data: dict) -> bool:
|
||||
if "auth_success" in data:
|
||||
if data["auth_success"]:
|
||||
print(Fore.GREEN + "Connected to log websocket" + Fore.RESET)
|
||||
else:
|
||||
print(Fore.RED + "Failed to authenticate to log websocket" + Fore.RESET)
|
||||
return False
|
||||
elif "history" in data:
|
||||
for entry in data["history"][-history_count:]:
|
||||
print_entry(entry)
|
||||
else:
|
||||
print_entry(data)
|
||||
return True
|
||||
|
||||
|
||||
async def view_logs(server: str, token: str) -> None:
|
||||
async with ClientSession() as session:
|
||||
async with session.ws_connect(f"{server}/_matrix/maubot/v1/logs") as ws:
|
||||
await ws.send_str(token)
|
||||
try:
|
||||
msg: WSMessage
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
if not handle_msg(msg.json()):
|
||||
break
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
print(Fore.YELLOW + "Connection error: " + msg.data + Fore.RESET)
|
||||
elif msg.type == WSMsgType.CLOSE:
|
||||
print(Fore.YELLOW + "Server closed connection" + Fore.RESET)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
58
maubot-src/maubot/cli/commands/upload.py
Normal file
58
maubot-src/maubot/cli/commands/upload.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import IO
|
||||
import json
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..cliq import cliq
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@cliq.command(help="Upload a maubot plugin")
|
||||
@click.argument("path")
|
||||
@click.option("-s", "--server", help="The maubot instance to upload the plugin to")
|
||||
@cliq.with_authenticated_http
|
||||
async def upload(path: str, server: str, sess: aiohttp.ClientSession) -> None:
|
||||
with open(path, "rb") as file:
|
||||
await upload_file(sess, file, server)
|
||||
|
||||
|
||||
async def upload_file(sess: aiohttp.ClientSession, file: IO, server: str) -> None:
|
||||
url = (URL(server) / "_matrix/maubot/v1/plugins/upload").with_query({"allow_override": "true"})
|
||||
headers = {"Content-Type": "application/zip"}
|
||||
async with sess.post(url, data=file, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
data = await resp.json()
|
||||
print(
|
||||
f"{Fore.GREEN}Plugin {Fore.CYAN}{data['id']} v{data['version']}{Fore.GREEN} "
|
||||
f"uploaded to {Fore.CYAN}{server}{Fore.GREEN} successfully.{Fore.RESET}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
err = await resp.json()
|
||||
if "stacktrace" in err:
|
||||
print(err["stacktrace"])
|
||||
err = err["error"]
|
||||
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
|
||||
err = await resp.text()
|
||||
print(f"{Fore.RED}Failed to upload plugin: {err}{Fore.RESET}")
|
||||
80
maubot-src/maubot/cli/config.py
Normal file
80
maubot-src/maubot/cli/config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import json
|
||||
import os
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"servers": {},
|
||||
"aliases": {},
|
||||
"default_server": None,
|
||||
}
|
||||
configdir = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.environ.get("HOME"), ".config"))
|
||||
|
||||
|
||||
def get_default_server() -> tuple[str | None, str | None]:
|
||||
try:
|
||||
server: str < None = config["default_server"]
|
||||
except KeyError:
|
||||
server = None
|
||||
if server is None:
|
||||
print(f"{Fore.RED}Default server not configured.{Fore.RESET}")
|
||||
print(f"Perhaps you forgot to {Fore.CYAN}mbc login{Fore.RESET}?")
|
||||
return None, None
|
||||
return server, _get_token(server)
|
||||
|
||||
|
||||
def get_token(server: str) -> tuple[str | None, str | None]:
|
||||
if not server:
|
||||
return get_default_server()
|
||||
if server in config["aliases"]:
|
||||
server = config["aliases"][server]
|
||||
return server, _get_token(server)
|
||||
|
||||
|
||||
def _resolve_alias(alias: str) -> str | None:
|
||||
try:
|
||||
return config["aliases"][alias]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_token(server: str) -> str | None:
|
||||
try:
|
||||
return config["servers"][server]
|
||||
except KeyError:
|
||||
print(f"{Fore.RED}No access token saved for {server}.{Fore.RESET}")
|
||||
return None
|
||||
|
||||
|
||||
def save_config() -> None:
|
||||
with open(f"{configdir}/maubot-cli.json", "w") as file:
|
||||
json.dump(config, file)
|
||||
|
||||
|
||||
def load_config() -> None:
|
||||
try:
|
||||
with open(f"{configdir}/maubot-cli.json") as file:
|
||||
loaded = json.load(file)
|
||||
config["servers"] = loaded.get("servers", {})
|
||||
config["aliases"] = loaded.get("aliases", {})
|
||||
config["default_server"] = loaded.get("default_server", None)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
6
maubot-src/maubot/cli/res/config.yaml
Normal file
6
maubot-src/maubot/cli/res/config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
example_1: Example value 1
|
||||
example_2:
|
||||
list:
|
||||
- foo
|
||||
- bar
|
||||
value: asd
|
||||
42
maubot-src/maubot/cli/res/maubot.yaml.j2
Normal file
42
maubot-src/maubot/cli/res/maubot.yaml.j2
Normal file
@@ -0,0 +1,42 @@
|
||||
# The unique ID for the plugin. Java package naming style. (i.e. use your own domain, not xyz.maubot)
|
||||
id: {{ id }}
|
||||
|
||||
# A PEP 440 compliant version string.
|
||||
version: {{ version }}
|
||||
|
||||
# The SPDX license identifier for the plugin. https://spdx.org/licenses/
|
||||
# Optional, assumes all rights reserved if omitted.
|
||||
{% if license %}
|
||||
license: {{ license }}
|
||||
{% else %}
|
||||
#license: null
|
||||
{% endif %}
|
||||
|
||||
# The list of modules to load from the plugin archive.
|
||||
# Modules can be directories with an __init__.py file or simply python files.
|
||||
# Submodules that are imported by modules listed here don't need to be listed separately.
|
||||
# However, top-level modules must always be listed even if they're imported by other modules.
|
||||
modules:
|
||||
- {{ name }}
|
||||
|
||||
# The main class of the plugin. Format: module/Class
|
||||
# If `module` is omitted, will default to last module specified in the module list.
|
||||
# Even if `module` is not omitted here, it must be included in the modules list.
|
||||
# The main class must extend maubot.Plugin
|
||||
main_class: {{ main_class }}
|
||||
|
||||
# Extra files that the upcoming build tool should include in the mbp file.
|
||||
{% if config %}
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
{% else %}
|
||||
#extra_files:
|
||||
#- base-config.yaml
|
||||
{% endif %}
|
||||
|
||||
# List of dependencies
|
||||
#dependencies:
|
||||
#- foo
|
||||
|
||||
#soft_dependencies:
|
||||
#- bar>=0.1
|
||||
27
maubot-src/maubot/cli/res/plugin.py.j2
Normal file
27
maubot-src/maubot/cli/res/plugin.py.j2
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import Type
|
||||
{% if config %}
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
{% endif %}
|
||||
from maubot import Plugin
|
||||
{% if config %}
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("example_1")
|
||||
helper.copy("example_2.list")
|
||||
helper.copy("example_2.value")
|
||||
{% endif %}
|
||||
|
||||
class {{ name }}(Plugin):
|
||||
async def start(self) -> None:{% if config %}
|
||||
self.config.load_and_update()
|
||||
self.log.debug("Loaded %s from config example 2", self.config["example_2.value"]){% else %}
|
||||
pass{% endif %}
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
{% if config %}
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
||||
return Config
|
||||
{% endif %}
|
||||
BIN
maubot-src/maubot/cli/res/spdx.json.zip
Normal file
BIN
maubot-src/maubot/cli/res/spdx.json.zip
Normal file
Binary file not shown.
0
maubot-src/maubot/cli/util/__init__.py
Normal file
0
maubot-src/maubot/cli/util/__init__.py
Normal file
45
maubot-src/maubot/cli/util/spdx.py
Normal file
45
maubot-src/maubot/cli/util/spdx.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pkg_resources
|
||||
|
||||
spdx_list: dict[str, dict[str, str]] | None = None
|
||||
|
||||
|
||||
def load() -> None:
|
||||
global spdx_list
|
||||
if spdx_list is not None:
|
||||
return
|
||||
with pkg_resources.resource_stream("maubot.cli", "res/spdx.json.zip") as disk_file:
|
||||
with zipfile.ZipFile(disk_file) as zip_file:
|
||||
with zip_file.open("spdx.json") as file:
|
||||
spdx_list = json.load(file)
|
||||
|
||||
|
||||
def get(id: str) -> dict[str, str]:
|
||||
if not spdx_list:
|
||||
load()
|
||||
return spdx_list[id]
|
||||
|
||||
|
||||
def valid(id: str) -> bool:
|
||||
if not spdx_list:
|
||||
load()
|
||||
return id in spdx_list
|
||||
Reference in New Issue
Block a user