🍽 Fork YunoHost — snapshot mangé par la machine à tsoins
Upstream: https://github.com/YunoHost/yunohost @ 3a5f8bac8301c450897b96cbd43a4c7d3ba750fb But (José) : transformer tout le code en bions + ploxions du xerboxion. La carte de digestion vit au labo : /yunohost-digest.json
This commit is contained in:
42
doc/api.html
Normal file
42
doc/api.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- HTML for static distribution bundle build -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="swagger/swagger-ui.css" />
|
||||
<link rel="stylesheet" type="text/css" href="swagger/index.css" />
|
||||
<link rel="icon" type="image/png" href="swagger/favicon-32x32.png" sizes="32x32" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="swagger/swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="swagger/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script src="openapi.js" type="text/javascript" language="javascript"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
//<editor-fold desc="Changeable Configuration Block">
|
||||
// the following lines will be replaced by docker/configurator, when it runs in a docker-container
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: openapiJSON,
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
displayOperationId: true,
|
||||
validatorUrl: null,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
|
||||
//</editor-fold>
|
||||
};
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
70
doc/bash_completion.sh.j2
Normal file
70
doc/bash_completion.sh.j2
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# completion for yunohost
|
||||
# automatically generated from the actionsmap
|
||||
#
|
||||
|
||||
_yunohost()
|
||||
{
|
||||
local cur prev opts narg
|
||||
COMPREPLY=()
|
||||
|
||||
# the number of words already typed
|
||||
narg=${#COMP_WORDS[@]}
|
||||
|
||||
# the current word being typed
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
# If one is currently typing a category,
|
||||
# match with categorys
|
||||
if [[ $narg == 2 ]]; then
|
||||
opts="{{ categories | join(" ") }}"
|
||||
fi
|
||||
|
||||
# If one already typed a category,
|
||||
# match the actions or the subcategories of that category
|
||||
if [[ $narg == 3 ]]; then
|
||||
# the category typed
|
||||
category="${COMP_WORDS[1]}"
|
||||
{%- for category, catinfo in categories.items() %}
|
||||
if [[ $category == "{{ category }}" ]]; then
|
||||
opts="{{ (catinfo.actions + catinfo.subs.keys()|list) | join(" ") }}"
|
||||
fi
|
||||
{%- endfor %}
|
||||
fi
|
||||
|
||||
# If one already typed an action or a subcategory,
|
||||
# match the actions of that subcategory
|
||||
if [[ $narg == 4 ]]; then
|
||||
# the category typed
|
||||
category="${COMP_WORDS[1]}"
|
||||
|
||||
# the action or the subcategory typed
|
||||
action_or_subcategory="${COMP_WORDS[2]}"
|
||||
|
||||
{%- for category, catinfo in categories.items() %}
|
||||
{%- if catinfo.subs %}
|
||||
if [[ $category == "{{ category }}" ]]; then
|
||||
{%- for sub, subinfo in catinfo.subs.items() %}
|
||||
if [[ $action_or_subcategory == "{{ sub }}" ]]; then
|
||||
opts="{{ subinfo | join(" ") }}"
|
||||
fi
|
||||
{%- endfor %}
|
||||
fi
|
||||
{%- endif -%}
|
||||
{%- endfor %}
|
||||
fi
|
||||
|
||||
# If no options were found propose --help
|
||||
if [ -z "$opts" ]; then
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ $prev != "--help" ]]; then
|
||||
opts=( --help )
|
||||
fi
|
||||
fi
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F _yunohost yunohost
|
||||
288
doc/generate_api_doc.py
Executable file
288
doc/generate_api_doc.py
Executable file
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 YunoHost Contributors
|
||||
#
|
||||
# This file is part of YunoHost (see https://yunohost.org)
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""
|
||||
Generate JSON specification files API
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def main():
|
||||
with open("../share/actionsmap.yml") as f:
|
||||
action_map = yaml.safe_load(f)
|
||||
|
||||
# try:
|
||||
# with open("/etc/yunohost/current_host", "r") as f:
|
||||
# domain = f.readline().rstrip()
|
||||
# except IOError:
|
||||
# domain = requests.get("http://ip.yunohost.org").text
|
||||
|
||||
with open("../debian/changelog") as f:
|
||||
top_changelog = f.readline()
|
||||
api_version = top_changelog[top_changelog.find("(") + 1 : top_changelog.find(")")]
|
||||
|
||||
csrf = {
|
||||
"name": "X-Requested-With",
|
||||
"in": "header",
|
||||
"required": True,
|
||||
"schema": {"type": "string", "default": "Swagger API"},
|
||||
}
|
||||
|
||||
resource_list = {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "YunoHost API",
|
||||
"description": "This is the YunoHost API used on all YunoHost instances. This API is essentially used by YunoHost Webadmin.",
|
||||
"version": api_version,
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://{domain}/yunohost/api",
|
||||
"variables": {
|
||||
"domain": {
|
||||
"default": "demo.yunohost.org",
|
||||
"description": "Your yunohost domain",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
"tags": [{"name": "public", "description": "Public route"}],
|
||||
"paths": {
|
||||
"/login": {
|
||||
"post": {
|
||||
"tags": ["public"],
|
||||
"summary": "Logs in and returns the authentication cookie",
|
||||
"parameters": [csrf],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credentials": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
}
|
||||
},
|
||||
"required": ["credentials"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully login",
|
||||
"headers": {"Set-Cookie": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/installed": {
|
||||
"get": {
|
||||
"tags": ["public"],
|
||||
"summary": "Test if the API is working",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully working",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def convert_categories(categories, parent_category=""):
|
||||
for category, category_params in categories.items():
|
||||
if parent_category:
|
||||
category = f"{parent_category} {category}"
|
||||
if "subcategory_help" in category_params:
|
||||
category_params["category_help"] = category_params["subcategory_help"]
|
||||
|
||||
if "category_help" not in category_params:
|
||||
category_params["category_help"] = ""
|
||||
resource_list["tags"].append(
|
||||
{"name": category, "description": category_params["category_help"]}
|
||||
)
|
||||
|
||||
for action, action_params in category_params["actions"].items():
|
||||
if "action_help" not in action_params:
|
||||
action_params["action_help"] = ""
|
||||
if "api" not in action_params:
|
||||
continue
|
||||
if not isinstance(action_params["api"], list):
|
||||
action_params["api"] = [action_params["api"]]
|
||||
|
||||
for i, api in enumerate(action_params["api"]):
|
||||
print(api)
|
||||
method, path = api.split(" ")
|
||||
method = method.lower()
|
||||
key_param = ""
|
||||
if "{" in path:
|
||||
key_param = path[path.find("{") + 1 : path.find("}")]
|
||||
resource_list["paths"].setdefault(path, {})
|
||||
|
||||
notes = ""
|
||||
|
||||
operationId = f"{category}_{action}"
|
||||
if i > 0:
|
||||
operationId += f"_{i}"
|
||||
operation = {
|
||||
"tags": [category],
|
||||
"operationId": operationId,
|
||||
"summary": action_params["action_help"],
|
||||
"description": notes,
|
||||
"responses": {"200": {"description": "successful operation"}},
|
||||
}
|
||||
if action_params.get("deprecated"):
|
||||
operation["deprecated"] = True
|
||||
|
||||
operation["parameters"] = []
|
||||
if method == "post":
|
||||
operation["parameters"] = [csrf]
|
||||
|
||||
if "arguments" in action_params:
|
||||
if method in ["put", "post", "patch"]:
|
||||
operation["requestBody"] = {
|
||||
"required": True,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
for arg_name, arg_params in action_params["arguments"].items():
|
||||
if "help" not in arg_params:
|
||||
arg_params["help"] = ""
|
||||
param_type = "query"
|
||||
allow_multiple = False
|
||||
required = True
|
||||
allowable_values = None
|
||||
name = str(arg_name).replace("-", "_")
|
||||
if name[0] == "_":
|
||||
required = False
|
||||
if "full" in arg_params:
|
||||
name = arg_params["full"][2:]
|
||||
else:
|
||||
name = name[2:]
|
||||
name = name.replace("-", "_")
|
||||
|
||||
if "choices" in arg_params:
|
||||
allowable_values = arg_params["choices"]
|
||||
_type = "string"
|
||||
if "type" in arg_params:
|
||||
types = {"open": "file", "int": "int"}
|
||||
_type = types[arg_params["type"]]
|
||||
if (
|
||||
"action" in arg_params
|
||||
and arg_params["action"] == "store_true"
|
||||
):
|
||||
_type = "boolean"
|
||||
|
||||
if "nargs" in arg_params:
|
||||
if arg_params["nargs"] == "*":
|
||||
allow_multiple = True
|
||||
required = False
|
||||
_type = "array"
|
||||
if arg_params["nargs"] == "+":
|
||||
allow_multiple = True
|
||||
required = True
|
||||
_type = "array"
|
||||
if arg_params["nargs"] == "?":
|
||||
allow_multiple = False
|
||||
required = False
|
||||
else:
|
||||
allow_multiple = False
|
||||
|
||||
if name == key_param:
|
||||
param_type = "path"
|
||||
required = True
|
||||
allow_multiple = False
|
||||
|
||||
if method in ["put", "post", "patch"]:
|
||||
schema = operation["requestBody"]["content"][
|
||||
"multipart/form-data"
|
||||
]["schema"]
|
||||
schema["properties"][name] = {
|
||||
"type": _type,
|
||||
"description": arg_params["help"],
|
||||
}
|
||||
if required:
|
||||
schema["required"].append(name)
|
||||
prop_schema = schema["properties"][name]
|
||||
else:
|
||||
parameters = {
|
||||
"name": name,
|
||||
"in": param_type,
|
||||
"description": arg_params["help"],
|
||||
"required": required,
|
||||
"schema": {
|
||||
"type": _type,
|
||||
},
|
||||
"explode": allow_multiple,
|
||||
}
|
||||
prop_schema = parameters["schema"]
|
||||
operation["parameters"].append(parameters)
|
||||
|
||||
if allowable_values is not None:
|
||||
prop_schema["enum"] = allowable_values
|
||||
if "default" in arg_params:
|
||||
prop_schema["default"] = arg_params["default"]
|
||||
if arg_params.get("metavar") == "PASSWORD":
|
||||
prop_schema["format"] = "password"
|
||||
if arg_params.get("metavar") == "MAIL":
|
||||
prop_schema["format"] = "mail"
|
||||
# Those lines seems to slow swagger ui too much
|
||||
# if 'pattern' in arg_params.get('extra', {}):
|
||||
# prop_schema['pattern'] = arg_params['extra']['pattern'][0]
|
||||
|
||||
resource_list["paths"][path][method.lower()] = operation
|
||||
|
||||
# Includes subcategories
|
||||
if "subcategories" in category_params:
|
||||
convert_categories(category_params["subcategories"], category)
|
||||
|
||||
del action_map["_global"]
|
||||
convert_categories(action_map)
|
||||
|
||||
openapi_json = json.dumps(resource_list)
|
||||
# Save the OpenAPI json
|
||||
with open(os.getcwd() + "/openapi.json", "w") as f:
|
||||
f.write(openapi_json)
|
||||
|
||||
openapi_js = f"var openapiJSON = {openapi_json}"
|
||||
with open(os.getcwd() + "/openapi.js", "w") as f:
|
||||
f.write(openapi_js)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
89
doc/generate_bash_completion.py
Executable file
89
doc/generate_bash_completion.py
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 YunoHost Contributors
|
||||
#
|
||||
# This file is part of YunoHost (see https://yunohost.org)
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""
|
||||
Simple automated generation of a bash_completion file
|
||||
for yunohost command from the actionsmap.
|
||||
|
||||
Generates a bash completion file assuming the structure
|
||||
`yunohost category action`
|
||||
adds `--help` at the end if one presses [tab] again.
|
||||
|
||||
author: Christophe Vuillot
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
YUNOHOST_SRCDIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def render(actions: dict[str, Any]) -> str:
|
||||
template_file = YUNOHOST_SRCDIR / "doc" / "bash_completion.sh.j2"
|
||||
template = Template(
|
||||
template_file.read_text(),
|
||||
comment_start_string="disabled because bash contains {#",
|
||||
)
|
||||
|
||||
result = template.render(
|
||||
categories=actions,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_actions() -> dict[str, Any]:
|
||||
actionsmap = YUNOHOST_SRCDIR / "share" / "actionsmap.yml"
|
||||
categories = yaml.safe_load(actionsmap.open())
|
||||
|
||||
fullmap: dict[str, Any] = {}
|
||||
|
||||
for category, cat_info in categories.items():
|
||||
if category.startswith("_"):
|
||||
continue
|
||||
fullmap[category] = {}
|
||||
fullmap[category]["actions"] = []
|
||||
fullmap[category]["subs"] = {}
|
||||
|
||||
for action, _ in cat_info.get("actions", {}).items():
|
||||
fullmap[category]["actions"].append(action)
|
||||
|
||||
for subcat, sub_info in cat_info.get("subcategories", {}).items():
|
||||
fullmap[category]["subs"][subcat] = list(sub_info["actions"].keys())
|
||||
|
||||
return fullmap
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", "-o", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
actions = get_actions()
|
||||
result = render(actions)
|
||||
|
||||
args.output.write_text(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
doc/generate_json_schema.py
Executable file
23
doc/generate_json_schema.py
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 YunoHost Contributors
|
||||
#
|
||||
# This file is part of YunoHost (see https://yunohost.org)
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from yunohost.utils.configpanel import ConfigPanelModel
|
||||
|
||||
print(ConfigPanelModel.schema_json(indent=2))
|
||||
107
doc/generate_manpages.py
Executable file
107
doc/generate_manpages.py
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 YunoHost Contributors
|
||||
#
|
||||
# This file is part of YunoHost (see https://yunohost.org)
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""
|
||||
Inspired by yunohost_completion.py (author: Christophe Vuillot)
|
||||
=======
|
||||
|
||||
This script generates man pages for yunohost.
|
||||
Pages are stored in OUTPUT_DIR
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
base_path = os.path.split(os.path.realpath(__file__))[0]
|
||||
|
||||
template = Template(open(os.path.join(base_path, "manpage.template")).read())
|
||||
|
||||
|
||||
THIS_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ACTIONSMAP_FILE = os.path.join(THIS_SCRIPT_DIR, "../share/actionsmap.yml")
|
||||
|
||||
|
||||
def ordered_yaml_load(stream):
|
||||
class OrderedLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
OrderedLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
lambda loader, node: OrderedDict(loader.construct_pairs(node)),
|
||||
)
|
||||
return yaml.load(stream, OrderedLoader)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="generate yunohost manpage based on actionsmap.yml"
|
||||
)
|
||||
parser.add_argument("-o", "--output", default="output/yunohost")
|
||||
parser.add_argument("-z", "--gzip", action="store_true", default=False)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.isdir(args.output):
|
||||
if not os.path.exists(args.output):
|
||||
os.makedirs(args.output)
|
||||
|
||||
output_path = os.path.join(args.output, "yunohost")
|
||||
else:
|
||||
output_dir = os.path.split(args.output)[0]
|
||||
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = args.output
|
||||
|
||||
# man pages of "yunohost *"
|
||||
with open(ACTIONSMAP_FILE, "r") as actionsmap:
|
||||
# Getting the dictionary containning what actions are possible per domain
|
||||
actionsmap = ordered_yaml_load(actionsmap)
|
||||
|
||||
for i in list(actionsmap.keys()):
|
||||
if i.startswith("_"):
|
||||
del actionsmap[i]
|
||||
|
||||
today = date.today()
|
||||
|
||||
result = template.render(
|
||||
month=today.strftime("%B"),
|
||||
year=today.year,
|
||||
categories=actionsmap,
|
||||
str=str,
|
||||
)
|
||||
|
||||
if not args.gzip:
|
||||
with open(output_path, "w") as output:
|
||||
output.write(result)
|
||||
else:
|
||||
with gzip.open(output_path, mode="w", compresslevel=9) as output:
|
||||
output.write(result.encode())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
738
doc/generate_zsh_completion.py
Executable file
738
doc/generate_zsh_completion.py
Executable file
@@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 YunoHost Contributors
|
||||
#
|
||||
# This file is part of YunoHost (see https://yunohost.org)
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Automated generation of a zsh_completion file for yunohost.
|
||||
|
||||
Using the actionsmap yaml file and a jinja template.
|
||||
|
||||
INSTALL:
|
||||
This script creates a zsh completion file for yunohost.
|
||||
To install, copy (and rename) the created file to:
|
||||
- (Debian) `/usr/share/zsh/vendor-completions/_yunohost`
|
||||
- (Fedora) `/usr/share/zsh/site-functions/_yunohost`
|
||||
- (other distribution) `/usr/local/share/zsh/site-functions/_yunohost`
|
||||
|
||||
DOCS:
|
||||
- https://github.com/zsh-users/zsh/blob/master/Etc/completion-style-guide
|
||||
- http://zsh.sourceforge.net/Doc/Release/Completion-System.html#Completion-System
|
||||
or `man zshcompsys`
|
||||
- http://zsh.sourceforge.net/Guide/zshguide06.html
|
||||
|
||||
MISC:
|
||||
- http://zsh.sourceforge.net/Doc/Release/Parameters.html#Array-Parameters
|
||||
|
||||
MISSING:
|
||||
- use the extra:required:True pattern (similar to `nargs`?)
|
||||
- In `yunohost.yml`, consider merging:
|
||||
- metavar
|
||||
- pattern
|
||||
- autocomplete
|
||||
- Make use of `type`, maybe using `_guard`
|
||||
- Use `pattern`, maybe with `_guard`. This seems hard though, as ZSH has
|
||||
its own globbing language...
|
||||
Link about this globbing system:
|
||||
http://zsh.sourceforge.net/Doc/Release/Expansion.html#Filename-Generation
|
||||
|
||||
Notes:
|
||||
- Command for debugging zsh: `unfunction _yunohost; autoload -U _yunohost`
|
||||
|
||||
- Optimization:
|
||||
- caching mecanism: invalidate the cache afer some commands? Hard, the
|
||||
cache is local to user
|
||||
- implement a zstyle switch, to change the cache validity period?
|
||||
|
||||
AUTHORS:
|
||||
- buzuck (Fol)
|
||||
- kayou
|
||||
- getzze
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
class Function(TypedDict):
|
||||
"""Details of a helper function."""
|
||||
|
||||
name: str
|
||||
shell_call: NotRequired[str]
|
||||
aggregated: NotRequired[str]
|
||||
|
||||
class Case(TypedDict):
|
||||
"""Details of dynamic argument completion function."""
|
||||
|
||||
name: str
|
||||
shell_call: str
|
||||
|
||||
class Action(TypedDict):
|
||||
"""Command line action."""
|
||||
|
||||
name: str
|
||||
help: str
|
||||
arguments: list[str]
|
||||
cases: list[Case]
|
||||
|
||||
class Category(TypedDict):
|
||||
"""Command line category.
|
||||
|
||||
Categories have different level:
|
||||
- level 1: main category, e.g. `yunohost user`
|
||||
- level 2: sub-category, e.g. `yunohost user group`
|
||||
|
||||
Only categories of level 1 have a `subs` key.
|
||||
|
||||
Reminder:
|
||||
yunohost user group list --full --short
|
||||
^ ^ ^ ^
|
||||
(script) | category | subcategory | action | parameters
|
||||
|
||||
"""
|
||||
|
||||
name: str
|
||||
help: str
|
||||
level: int
|
||||
actions: list[Action]
|
||||
subs: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
YUNOHOST_SRCDIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def get_actions_zsh(
|
||||
ynh_map: dict[str, dict[str, Any]],
|
||||
) -> tuple[list[Category], list[Function]]:
|
||||
"""Parse categories, subcategories and actions from an actionsmap yml file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ynh_map: dict[str, dict[str, Any]]
|
||||
A dict loaded from an actionsmap yml file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[Category], list[Function]]
|
||||
A tuple of categories dict and helper functions dict.
|
||||
|
||||
"""
|
||||
categories: list[Category] = []
|
||||
functions: list[Function] = []
|
||||
|
||||
for category, cat_info in ynh_map.items():
|
||||
if category.startswith("_") or cat_info.get("hide_in_help", False):
|
||||
continue
|
||||
|
||||
cats, funcs = parse_category(category, cat_info)
|
||||
|
||||
categories.extend(cats)
|
||||
functions.extend(funcs)
|
||||
|
||||
# Remove duplicates in functions
|
||||
functions = [
|
||||
cast("Function", dict(t)) for t in {tuple(d.items()) for d in functions}
|
||||
]
|
||||
|
||||
return categories, functions
|
||||
|
||||
|
||||
def parse_category(
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
) -> tuple[list[Category], list[Function]]:
|
||||
"""Parse a Category (level 1) for its actions and subcategories.
|
||||
|
||||
Reminder:
|
||||
yunohost monitor info --cpu --ram
|
||||
^ ^ ^ ^
|
||||
(script) | category | action | parameters
|
||||
|
||||
A Category may contain subcategories (of level 2), so a list of categories
|
||||
(of level 1 and 2) is returned.
|
||||
A category may need to define help functions that are needed to build
|
||||
the list of options. The list of help functions is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: str
|
||||
the category name
|
||||
info: dict[str, Any]
|
||||
the information dict about the category
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[Category], list[Function]]
|
||||
A tuple of the list of category and subcategories dicts
|
||||
and the list of category and subcategories helper functions.
|
||||
|
||||
"""
|
||||
cat: Category = {
|
||||
"name": name,
|
||||
"level": 1,
|
||||
"help": _escape(info.get("category_help", "")),
|
||||
"actions": [],
|
||||
"subs": {},
|
||||
}
|
||||
# Add the category first, the subcategories will be appended later
|
||||
categories: list[Category] = [cat]
|
||||
functions: list[Function] = []
|
||||
|
||||
# Parse actions (before subcategories)
|
||||
actions = []
|
||||
for action, action_info in info.get("actions", {}).items():
|
||||
if action_info.get("hide_in_help", False):
|
||||
continue
|
||||
|
||||
act, funcs = parse_actions(action, action_info)
|
||||
actions.append(act)
|
||||
functions.extend(funcs)
|
||||
cat["actions"] = actions
|
||||
|
||||
# Parse subcategories
|
||||
subs = {}
|
||||
for subcategory, subcategory_info in info.get("subcategories", {}).items():
|
||||
if subcategory.startswith("_") or subcategory_info.get("hide_in_help", False):
|
||||
continue
|
||||
|
||||
help, subcategory_dict, funcs = parse_subcategory( # noqa: A001
|
||||
name,
|
||||
subcategory,
|
||||
subcategory_info,
|
||||
)
|
||||
subs[subcategory] = help
|
||||
functions.extend(funcs)
|
||||
# Append subcategory below the category
|
||||
categories.append(subcategory_dict)
|
||||
# Add the list of subcategories to the category
|
||||
cat["subs"] = subs
|
||||
|
||||
return categories, functions
|
||||
|
||||
|
||||
def parse_subcategory(
|
||||
category: str,
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
) -> tuple[str, Category, list[Function]]:
|
||||
"""Parse a sub-category (level 2) for its actions.
|
||||
|
||||
Reminder:
|
||||
yunohost user group list --full --short
|
||||
^ ^ ^ ^
|
||||
(script) | category | subcategory | action | parameters
|
||||
|
||||
A subcategory is treated as a Category (of level 2), with an 'actions' key,
|
||||
but no 'subs' key.
|
||||
The help text of the subcategory is needed to construct the 'subs' dict
|
||||
of the parent category.
|
||||
Like a level-1 category, subcategories may need to define help functions.
|
||||
The list of help functions is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category: str
|
||||
the name of the parent category
|
||||
name: str
|
||||
the subcategory name
|
||||
info: dict[str, Any]
|
||||
the information dict about the subcategory
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str, Category, list[Function]]
|
||||
A tuple of the subcategory help text, the subcategory dict
|
||||
and the list of subcategory helper functions.
|
||||
|
||||
"""
|
||||
full_name = f"{category}_{name}"
|
||||
help = _escape(info.get("subcategory_help", "")) # noqa: A001
|
||||
|
||||
subcat: Category = {"name": full_name, "level": 2, "help": help, "actions": []}
|
||||
functions: list[Function] = []
|
||||
|
||||
# Parse actions (before subcategories)
|
||||
actions = []
|
||||
for action, action_info in info.get("actions", {}).items():
|
||||
if action_info.get("hide_in_help", False):
|
||||
continue
|
||||
|
||||
act, funcs = parse_actions(action, action_info)
|
||||
actions.append(act)
|
||||
functions.extend(funcs)
|
||||
subcat["actions"] = actions
|
||||
|
||||
return help, subcat, functions
|
||||
|
||||
|
||||
def parse_actions(
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
) -> tuple[Action, list[Function]]:
|
||||
"""Parse an Action for it's help text and arguments.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[Action, list[Function]]
|
||||
A tuple of the action dict and the list of action helper functions.
|
||||
|
||||
"""
|
||||
functions: list[Function] = []
|
||||
|
||||
# This is a counter, in case of position dependent paremeters (the ones not
|
||||
# beginning with a `-`)
|
||||
position = 0
|
||||
|
||||
arguments: list[str] = []
|
||||
cases: list[Case] = []
|
||||
|
||||
for _argument_name, argument_info in info.get("arguments", {}).items():
|
||||
#
|
||||
# Forcing to str, as the yaml parser inteprets numbers as integers
|
||||
# (eg.: `firewall allow... -4`)
|
||||
argument_name = str(_argument_name)
|
||||
case: Case | None = None
|
||||
funcs: list[Function] = []
|
||||
|
||||
#
|
||||
# This is an optional parameter, beginning with a `-`
|
||||
if argument_name.startswith("-"):
|
||||
full_argument, case, funcs = parse_argument_optional(
|
||||
argument_name,
|
||||
argument_info,
|
||||
)
|
||||
#
|
||||
# A parameter not beginning with `-` is considered mandatory.
|
||||
else:
|
||||
position += 1
|
||||
full_argument, case, funcs = parse_argument_mandatory(
|
||||
argument_name,
|
||||
argument_info,
|
||||
position,
|
||||
)
|
||||
|
||||
# If action is None, do not display the parameter
|
||||
if not full_argument:
|
||||
continue
|
||||
|
||||
# If case is not None, add a case below the arguments list
|
||||
if case:
|
||||
cases.append(case)
|
||||
|
||||
# Add helper functions
|
||||
functions.extend(funcs)
|
||||
|
||||
# Append argument
|
||||
arguments.append(full_argument)
|
||||
|
||||
help = _escape(info.get("action_help", "")) # noqa: A001
|
||||
action_dict: Action = {
|
||||
"name": name,
|
||||
"help": help,
|
||||
"arguments": arguments,
|
||||
"cases": cases,
|
||||
}
|
||||
return action_dict, functions
|
||||
|
||||
|
||||
def parse_argument_mandatory(
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
position: int = 0,
|
||||
) -> tuple[str, Case | None, list[Function]]:
|
||||
"""Parse a mandatory argument."""
|
||||
#
|
||||
# Initializing the argument dict to make sure all fields are defined
|
||||
# - id: identifier (`-n` or `--name`). If none (e.g. `ynh app install
|
||||
# APP_NAME`), this field is the arguments position or cardinality (from
|
||||
# `nargs`)
|
||||
# - excludes: usually the argument itself. Only used for optional args
|
||||
# - desc: the argument description
|
||||
# - completion: the completion function name
|
||||
#
|
||||
arg = {"excludes": "", "spec": "", "desc": "", "mess": "", "action": "", "func": ""}
|
||||
|
||||
#
|
||||
# Generation of the completion hints
|
||||
#
|
||||
arg["action"], case, functions = parse_argument_action(name, info)
|
||||
|
||||
# Hidden argument
|
||||
if arg["action"] is None:
|
||||
return ("", None, [])
|
||||
|
||||
# This parameter may be used more than once, else we use the position counter
|
||||
if info.get("nargs", "") in ["+", "*"]:
|
||||
if info["nargs"] == "+":
|
||||
arg["spec"] = f"'{{{position!s},*}}'"
|
||||
else: # argument_details["nargs"] == "*":
|
||||
arg["spec"] = "*"
|
||||
else:
|
||||
arg["spec"] = str(position)
|
||||
arg["mess"] = info.get("help", name)
|
||||
|
||||
#
|
||||
# If defined, add the default value as a hint
|
||||
if "default" in info:
|
||||
arg["mess"] += f" (default: {info['default']})"
|
||||
# Escape special character in the description
|
||||
arg["mess"] = _escape(arg["mess"])
|
||||
|
||||
# ----
|
||||
# NOTE: a double colon marks for an optional argument:
|
||||
# ::Username to update:__ynh_user_list
|
||||
# ----
|
||||
placeholder = "'{}{}{}:{}:{}'"
|
||||
|
||||
# Escape special character in the description
|
||||
arg["desc"] = _escape(arg["desc"])
|
||||
argument = placeholder.format(
|
||||
arg["excludes"],
|
||||
arg["spec"],
|
||||
arg["desc"],
|
||||
arg["mess"],
|
||||
arg["action"],
|
||||
)
|
||||
return (argument, case, functions)
|
||||
|
||||
|
||||
def parse_argument_optional(
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
) -> tuple[str, Case | None, list[Function]]:
|
||||
"""Parse an optional argument."""
|
||||
#
|
||||
# Initializing the argument dict to make sure all fields are defined
|
||||
# - id: identifier (`-n` or `--name`). If none (e.g. `ynh app install
|
||||
# APP_NAME`), this field is the arguments position or cardinality (from
|
||||
# `nargs`)
|
||||
# - excludes: usually the argument itself. Only used for optional args
|
||||
# - desc: the argument description
|
||||
# - completion: the completion function name
|
||||
#
|
||||
arg = {"excludes": "", "spec": "", "desc": "", "mess": "", "action": "", "func": ""}
|
||||
|
||||
#
|
||||
# Generation of the completion hints
|
||||
#
|
||||
arg["action"], case, functions = parse_argument_action(name, info)
|
||||
|
||||
# Hidden argument
|
||||
if arg["action"] is None:
|
||||
return ("", None, [])
|
||||
|
||||
# `full` is the extended form of the argument (e.g.: -n is short for --number)
|
||||
if "full" in info:
|
||||
full_name = info["full"]
|
||||
arg["mess"] = str(full_name).lstrip("-")
|
||||
arg["spec"] = f"'{{{name},{full_name}}}'"
|
||||
arg["excludes"] = f"({name} {full_name})"
|
||||
else:
|
||||
arg["mess"] = str(name).lstrip("-")
|
||||
arg["spec"] = name
|
||||
# Escape special character in the description
|
||||
arg["mess"] = _escape(arg["mess"])
|
||||
|
||||
# The description of the parameter
|
||||
# Getting the `help` field if any, else simply by using it's name
|
||||
help = info.get("help", arg["mess"]) # noqa: A001
|
||||
arg["desc"] = f"[{help}]"
|
||||
|
||||
has_action = True
|
||||
# Add a pattern field to match multiple arguments
|
||||
if info.get("nargs", "") in ["+", "*"]:
|
||||
if arg["excludes"]:
|
||||
# suppose that `arg["excludes"] = (-f --foo)`
|
||||
arg["excludes"] = "(* " + arg["excludes"][1:]
|
||||
else:
|
||||
arg["excludes"] = "(*)"
|
||||
arg["mess"] = "*:" + arg["mess"]
|
||||
has_action = True
|
||||
|
||||
# Options without arguments should skip the message and action fields
|
||||
elif info.get("action", "").startswith("store_"):
|
||||
has_action = False
|
||||
|
||||
# Place holder for the parameters
|
||||
placeholder = "'{}{}{}:{}:{}'" if has_action else "'{}{}{}'"
|
||||
# Escape special character in the description
|
||||
arg["desc"] = _escape(arg["desc"])
|
||||
argument = placeholder.format(
|
||||
arg["excludes"],
|
||||
arg["spec"],
|
||||
arg["desc"],
|
||||
arg["mess"],
|
||||
arg["action"],
|
||||
)
|
||||
return (argument, case, functions)
|
||||
|
||||
|
||||
def parse_argument_action( # noqa: C901, PLR0911, PLR0912
|
||||
name: str,
|
||||
info: dict[str, Any],
|
||||
) -> tuple[str, Case | None, list[Function]]:
|
||||
"""Parse an argument action."""
|
||||
functions: list[Function] = []
|
||||
#
|
||||
# Finds the completion function for the given argument, if defined.
|
||||
#
|
||||
# `functions` hold the elements needed to generate it. The
|
||||
# actual creation of this function will be done by build_completion_functions(),
|
||||
# called near the end of this script.
|
||||
# `choices` and `autocomplete` should not be present at the same time
|
||||
# (`choices` takes precedence)
|
||||
|
||||
#
|
||||
# A list of choices is defined
|
||||
if "choices" in info:
|
||||
all_choices = " ".join(info["choices"])
|
||||
action = f"({all_choices})"
|
||||
return (action, None, functions)
|
||||
|
||||
#
|
||||
# Look for an autocompletion function, but it is not defined
|
||||
if "extra" not in info or "autocomplete" not in info["extra"]:
|
||||
return ("", None, functions)
|
||||
|
||||
#
|
||||
# An autocompletion function is defined
|
||||
autocomplete = info["extra"]["autocomplete"]
|
||||
|
||||
#
|
||||
# Check if the argument should be hidden (API only)
|
||||
if autocomplete.get("hide_in_help", False):
|
||||
return ("", None, functions)
|
||||
|
||||
#
|
||||
# This is a combinaision of YunoHost and jq commands
|
||||
#
|
||||
if "ynh_selector" in autocomplete and "jq_selector" in autocomplete:
|
||||
#
|
||||
# Function dependent on previous arguments
|
||||
#
|
||||
if "depends" in autocomplete and autocomplete["depends"] == "previous":
|
||||
# Create cases that depend on the previous argument.
|
||||
#
|
||||
# First, build the shell command that returns the completions
|
||||
call = (
|
||||
f"sudo yunohost {autocomplete['ynh_selector']} "
|
||||
f'"${{previous}}" --output-as json '
|
||||
f"| jq -cr '{autocomplete['jq_selector']}' | xargs"
|
||||
)
|
||||
# If a cache is needed, wrap the call in the caching function
|
||||
if autocomplete.get("use_cache", False):
|
||||
call = '__get_ynh_cache YNH_{}_"${{previous}}" "{}"'.format(
|
||||
_norm_name(autocomplete["ynh_selector"]),
|
||||
# Remove the double-quote in "{previous}"
|
||||
# because the whole cmd will be encased in quotes.
|
||||
call.replace('"', ""),
|
||||
)
|
||||
|
||||
function_name = f"->{name}"
|
||||
case: Case = {"name": name, "shell_call": call}
|
||||
return (function_name, case, functions)
|
||||
|
||||
# Create this function's name
|
||||
function_name = _remove_special_chars(
|
||||
"__ynh_" + _norm_name(autocomplete["ynh_selector"]),
|
||||
)
|
||||
#
|
||||
# Add a helper function
|
||||
#
|
||||
# First, build the shell command that returns the completions
|
||||
call = "sudo yunohost {} --output-as json | jq -cr '{}'".format(
|
||||
autocomplete["ynh_selector"],
|
||||
autocomplete["jq_selector"],
|
||||
)
|
||||
# If a cache is needed, wrap the call in the caching function
|
||||
if autocomplete.get("use_cache", False):
|
||||
call = "__get_ynh_cache 'YNH_{}' \"{}\"".format(
|
||||
_norm_name(autocomplete["ynh_selector"]),
|
||||
call,
|
||||
)
|
||||
# Lastly, save the content
|
||||
func: Function = {"name": function_name, "shell_call": call}
|
||||
functions.append(func)
|
||||
return (function_name, None, functions)
|
||||
|
||||
#
|
||||
# The autocompletion is done by a grep
|
||||
#
|
||||
if "shell_call" in autocomplete:
|
||||
# Create this function's name
|
||||
function_name = _remove_special_chars(
|
||||
"__ynh_" + _norm_name(autocomplete["shell_call"]),
|
||||
)
|
||||
#
|
||||
# Add a helper function
|
||||
#
|
||||
# First, build the shell command that returns the completions
|
||||
call = autocomplete["shell_call"]
|
||||
|
||||
# If a cache is needed, wrap the call in the caching function
|
||||
# Note: not tested with grep, only with YunoHost's commands
|
||||
if autocomplete.get("use_cache", False):
|
||||
call = "__get_ynh_cache 'YNH_{}' \"{}\"".format(
|
||||
_remove_special_chars(autocomplete["shell_call"]),
|
||||
call,
|
||||
)
|
||||
# Lastly, save the content
|
||||
func = {"name": function_name, "shell_call": call}
|
||||
functions.append(func)
|
||||
return (function_name, None, functions)
|
||||
|
||||
#
|
||||
# This is a combinaision of two other completion functions
|
||||
#
|
||||
if "aggregate" in autocomplete:
|
||||
# Create this function's name
|
||||
function_name = "__ynh"
|
||||
for subcall in autocomplete["aggregate"]:
|
||||
if "ynh_selector" in subcall:
|
||||
function_name += "_" + _norm_name(subcall["ynh_selector"])
|
||||
|
||||
#
|
||||
# Add a helper function
|
||||
aggregation = ""
|
||||
for subcall in autocomplete["aggregate"]:
|
||||
if "name" in subcall and "ynh_selector" in subcall:
|
||||
aggregation += "\n'{}:{}:{}' \\".format(
|
||||
subcall["name"],
|
||||
subcall["name"],
|
||||
_norm_name("__ynh_" + subcall["ynh_selector"]),
|
||||
)
|
||||
# Lastly, save the content
|
||||
func = {"name": function_name, "aggregated": aggregation}
|
||||
functions.append(func)
|
||||
return (function_name, None, functions)
|
||||
|
||||
#
|
||||
# The autocompletion is done by a ZSH function
|
||||
#
|
||||
if "zsh_completion" in autocomplete:
|
||||
return (autocomplete["zsh_completion"], None, functions)
|
||||
|
||||
#
|
||||
# No autocompletion schema was defined
|
||||
#
|
||||
return ("", None, functions)
|
||||
|
||||
|
||||
def render_zsh(categories: list[Category], functions: list[Function]) -> str:
|
||||
"""Render the jinja template with the parsed categories and helper functions."""
|
||||
template_file = YUNOHOST_SRCDIR / "doc" / "zsh_completion.j2"
|
||||
template = Template(
|
||||
template_file.read_text(),
|
||||
keep_trailing_newline=True,
|
||||
comment_start_string="disabled because bash contains {#",
|
||||
)
|
||||
|
||||
return template.render(
|
||||
categories=categories,
|
||||
functions=functions,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Utility functions, mainly string manipulation
|
||||
#
|
||||
|
||||
|
||||
def _norm_name(string: str) -> str:
|
||||
"""Normalize a string to make it look like a function name.
|
||||
|
||||
Apply the transformations:
|
||||
- lowercase
|
||||
- spaces replaced by underscores
|
||||
- no dashs
|
||||
|
||||
:param str string: the string to norm.
|
||||
:return str: The normed string
|
||||
"""
|
||||
return (
|
||||
string.lower()
|
||||
.replace(" ", "_")
|
||||
.replace("-", "")
|
||||
.replace("/", "_")
|
||||
.replace(".", "_")
|
||||
)
|
||||
|
||||
|
||||
def _escape(string: str) -> str:
|
||||
r"""Escape any special character.
|
||||
|
||||
Escape the characters:
|
||||
- single quotes (') are put in a separate double quoted string ('"'"')
|
||||
- colons (:) and other characters are preceded by a backslash (\:)
|
||||
|
||||
:param str string: The string to escape
|
||||
:return str: The escaped string
|
||||
"""
|
||||
return string.replace("'", "'\"'\"'").replace(":", r"\:")
|
||||
|
||||
|
||||
def _remove_special_chars(string: str) -> str:
|
||||
"""Remove any character with a special meaning in ZSH.
|
||||
|
||||
Example of characters to remove:
|
||||
`$`, `{`, `(`, `[`, ...
|
||||
|
||||
:param str string: The string to clean
|
||||
:return str: The cleaned string
|
||||
"""
|
||||
# NOTE: this list may not be comprehensive and should be extended if needed
|
||||
return re.sub(r'[- =\^+:\?\'"$(){}\[\]/\\\\]', "", string).replace(".", "")
|
||||
|
||||
|
||||
#
|
||||
# Get action map
|
||||
#
|
||||
|
||||
|
||||
def get_action_map() -> dict[str, Any]:
|
||||
"""Load the actionmap from a YAML file."""
|
||||
actionsmap = YUNOHOST_SRCDIR / "share" / "actionsmap.yml"
|
||||
return cast("dict[str, Any]", yaml.safe_load(actionsmap.open()))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Generate the completion file for Zsh."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", "-o", type=Path, required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
yunohost_map = get_action_map()
|
||||
|
||||
categories, functions = get_actions_zsh(yunohost_map)
|
||||
result = render_zsh(categories, functions)
|
||||
|
||||
args.output.write_text(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
121
doc/manpage.template
Normal file
121
doc/manpage.template
Normal file
@@ -0,0 +1,121 @@
|
||||
.TH YunoHost "1" "{{ month }} {{ year }}" "YunoHost Collectif"
|
||||
.SH NAME
|
||||
YunoHost \- yunohost server administration command
|
||||
|
||||
.SH SYNOPSIS
|
||||
yunohost \fI\,CATEGORY\/\fR \fI\,COMMAND\/\fR [\fI\,SUBCOMMAND\/\fR] [\fI\,ARGUMENTS\/\fR]... [\fI\,OPTIONS\/\fR]...
|
||||
|
||||
{# generale command format #}
|
||||
.SH DESCRIPTION
|
||||
usage: yunohost
|
||||
{{ '{' }}{{ ",".join(categories) }}{{ '}' }}
|
||||
\&...
|
||||
[\-h|\-\-help] [\-\-no\-cache] [\-\-output\-as {json,plain,none}] [\-\-debug]
|
||||
[\-\-quiet] [\-\-timeout ==SUPPRESS==] [\-\-admin\-password PASSWORD]
|
||||
[\-v|\-\-version]
|
||||
|
||||
.SS "optional arguments:"
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
show this help message and exit
|
||||
|
||||
.SS "categories:"
|
||||
.IP
|
||||
{{ '{' }}{{ ",".join(categories) }}{{ '}' }}
|
||||
{% for name, value in categories.items() %}
|
||||
.TP
|
||||
{{ name }}
|
||||
{{ value["category_help"] }}
|
||||
{% endfor %}
|
||||
|
||||
.SS "global arguments:"
|
||||
.TP
|
||||
\fB\-\-no\-cache\fR
|
||||
Don't use actions map cache
|
||||
.TP
|
||||
\fB\-\-output\-as\fR {json,plain,none}
|
||||
Output result in another format
|
||||
.TP
|
||||
\fB\-\-debug\fR
|
||||
Log and print debug messages
|
||||
.TP
|
||||
\fB\-\-quiet\fR
|
||||
Don't produce any output
|
||||
.TP
|
||||
\fB\-\-timeout\fR SECONDS
|
||||
Number of seconds before this command will timeout
|
||||
because it can't acquire the lock (meaning that
|
||||
another command is currently running), by default
|
||||
there is no timeout and the command will wait until it
|
||||
can get the lock
|
||||
.TP
|
||||
\fB\-\-admin\-password\fR PASSWORD
|
||||
The admin password to use to authenticate
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-version\fR
|
||||
Display YunoHost packages versions
|
||||
|
||||
{# each categories #}
|
||||
{% for name, value in categories.items() %}
|
||||
.SH YUNOHOST {{ name.upper() }}
|
||||
usage: yunohost {{ name }} {{ '{' }}{{ ",".join(value.get("actions", {}).keys()) }}{{ '}' }}
|
||||
\&...
|
||||
.SS "description:"
|
||||
.IP
|
||||
{{ value["category_help"] }}
|
||||
|
||||
{# each command of each category #}
|
||||
{% for action, action_value in value.get("actions", {}).items() %}
|
||||
.SS "yunohost {{ name }} {{ action }} \
|
||||
{% for argument_name, argument_value in action_value.get("arguments", {}).items() %}\
|
||||
{% set required=(not str(argument_name).startswith("-")) or argument_value.get("extra", {}).get("required", False) %}\
|
||||
{% if not required %}[{% endif %}\
|
||||
\fI\,{{ argument_name }}\/\fR{% if argument_value.get("full") %}|\fI\,{{ argument_value["full"] }}\fR{% endif %}\
|
||||
{% if str(argument_name).startswith("-") and not argument_value.get("action") == "store_true" %} {{ (argument_value.get("full", argument_name)).lstrip("-") }}{% endif %}\
|
||||
{% if not required %}]{% endif %} \
|
||||
{% endfor %}"
|
||||
|
||||
{# help of the command #}
|
||||
{{ action_value["action_help"] }}
|
||||
|
||||
{# arguments of the command #}
|
||||
{% if "arguments" in action_value %}
|
||||
{% for argument_name, argument_value in action_value["arguments"].items() %}
|
||||
.TP
|
||||
\fB{{ argument_name }}\fR{% if argument_value.get("full") %}, \fB{{ argument_value["full"] }}\fR{% endif %}\
|
||||
{% if str(argument_name).startswith("-") and not argument_value.get("action") == "store_true" %} \fI\,{{ (argument_value.get("full", argument_name)).lstrip("-") }}\fR {% if "default" in argument_value %}(default: {{ argument_value["default"] }}){% endif %}{% endif %}
|
||||
{{ argument_value.get("help", "")}}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# each subcategory #}
|
||||
{% for subcategory_name, subcategory in value.get("subcategories", {}).items() %}
|
||||
{% for action, action_value in subcategory["actions"].items() %}
|
||||
.SS "yunohost {{ name }} {{ subcategory_name }} {{ action }} \
|
||||
{% for argument_name, argument_value in action_value.get("arguments", {}).items() %}\
|
||||
{% set required=(not str(argument_name).startswith("-")) or argument_value.get("extra", {}).get("required", False) %}\
|
||||
{% if not required %}[{% endif %}\
|
||||
\fI\,{{ argument_name }}\/\fR{% if argument_value.get("full") %}|\fI\,{{ argument_value["full"] }}\fR{% endif %}\
|
||||
{% if str(argument_name).startswith("-") and not argument_value.get("action") == "store_true" %} {{ (argument_value.get("full", argument_name)).lstrip("-") }}{% endif %}\
|
||||
{% if not required %}]{% endif %} \
|
||||
{% endfor %}"
|
||||
|
||||
{# help of the command #}
|
||||
{{ action_value["action_help"] }}
|
||||
|
||||
{# arguments of the command #}
|
||||
{% if "arguments" in action_value %}
|
||||
{% for argument_name, argument_value in action_value["arguments"].items() %}
|
||||
.TP
|
||||
\fB{{ argument_name }}\fR{% if argument_value.get("full") %}, \fB{{ argument_value["full"] }}\fR{% endif %}\
|
||||
{% if str(argument_name).startswith("-") and not argument_value.get("action") == "store_true" %} \fI\,{{ (argument_value.get("full", argument_name)).lstrip("-") }}\fR {% if "default" in argument_value %}(default: {{ argument_value["default"] }}){% endif %}{% endif %}
|
||||
{{ argument_value.get("help", "")}}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
219
doc/zsh_completion.j2
Normal file
219
doc/zsh_completion.j2
Normal file
@@ -0,0 +1,219 @@
|
||||
#compdef yunohost
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# Description
|
||||
# -----------
|
||||
# Completion script for yunohost, automatically generated from the action map
|
||||
# decribed by `yunohost.yml`
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
local state line curcontext
|
||||
|
||||
# For debug purposes only
|
||||
__log() {
|
||||
echo $@ >> '/tmp/zsh-completion.log'
|
||||
}
|
||||
|
||||
# First argument: The name of the completion list
|
||||
# 2nd argument: The command to get it
|
||||
# (( $+functions[__get_ynh_cache] )) ||
|
||||
function __get_ynh_cache() {
|
||||
# Checking a global cache policy is defined,
|
||||
# and linkage to ynh-cache-policy
|
||||
local update_policy completion_items
|
||||
zstyle -s ":completion:${curcontext}:" cache-policy update_policy
|
||||
if [[ -z "$update_policy" ]]; then
|
||||
zstyle ":completion:${curcontext}:" cache-policy __yunohost_cache_policy
|
||||
fi
|
||||
# If the cache is invalid (too old), regenerate it
|
||||
if _cache_invalid $1 || ! _retrieve_cache $1; then
|
||||
completion_items=(`eval $2`)
|
||||
_store_cache $1 completion_items
|
||||
else
|
||||
_retrieve_cache $1
|
||||
fi
|
||||
echo $completion_items
|
||||
}
|
||||
|
||||
# (( $+functions[__yunohost_cache_policy] )) ||
|
||||
__yunohost_cache_policy(){
|
||||
local cache_file="$1"
|
||||
# Rebuild if the yunohost executable is newer than cache
|
||||
[[ "${commands[yunohost]}" -nt "${cache_file}" ]] && return
|
||||
|
||||
# Rebuild if cache is more than a week old
|
||||
local -a oldp
|
||||
# oldp=( "$1"(mM+1) ) # month
|
||||
# oldp=( "$1"(Nm+7) ) # 1 week
|
||||
oldp=( "$1"(Nmd+1) ) # 1 day
|
||||
(( $#oldp )) && return
|
||||
return 1
|
||||
}
|
||||
|
||||
#
|
||||
# Routing function, used to go through $words and find the correct subfunction
|
||||
# (Suggestions welcome to improve that design... =/ )
|
||||
# (( $+functions[__jump] )) ||
|
||||
function __jump() {
|
||||
local cmd
|
||||
|
||||
# Remember the subcommand name
|
||||
if (( ${#@} == 0 )); then
|
||||
local cmd=${words[2]}
|
||||
else
|
||||
cmd=$1 # < no more used?
|
||||
fi
|
||||
|
||||
# Set the context for the subcommand
|
||||
ynhcommand="${ynhcommand}_${cmd}"
|
||||
# Narrow the range of words we are looking at to exclude `yunohost`
|
||||
(( CURRENT-- ))
|
||||
shift words
|
||||
# Run the completion for the subcommand
|
||||
if ! _call_function ret ${ynhcommand#:*:}; then
|
||||
_default && ret=0
|
||||
fi
|
||||
return ret
|
||||
}
|
||||
|
||||
#-----------------------------------------
|
||||
# Command
|
||||
#-----------------------------------------
|
||||
#
|
||||
# Principal entry point with general options and list of commands
|
||||
# (( $+functions[_yunohost] )) ||
|
||||
function _yunohost() {
|
||||
local curcontext="${curcontext}" state line ret=1
|
||||
local mode
|
||||
# `ynhcommand` is where `__jump` builds the name of the completion function
|
||||
ynhcommand='_yunohost'
|
||||
|
||||
typeset -ag common_options; common_options=(
|
||||
'(-h --help)'{-h,--help}'[Show this help message and exit]:help:'
|
||||
'--version[Display YunoHost packages versions]:version:'
|
||||
'--output-as[Output result in another format]:output-as:(json plain none)'
|
||||
'--debug[Log and print debug messages]'
|
||||
'--quiet[Don'"'"'t produce any output]'
|
||||
'--timeout[Number of seconds before this command will timeout because it can'"'"'t acquire the lock (meaning that another command is currently running), by default there is no timeout and the command will wait until it can get the lock]:timeout:'
|
||||
)
|
||||
|
||||
if (( CURRENT > 2 )); then
|
||||
__jump
|
||||
else
|
||||
local -a yunohost_categories; yunohost_categories=(
|
||||
{%- for catinfo in categories %}
|
||||
{%- if catinfo.help and catinfo.level == 1 %}
|
||||
'{{ catinfo.name }}:{{ catinfo.help }}'
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
)
|
||||
_describe -V -t yunohost-commands 'yunohost category' yunohost_categories "$@"
|
||||
fi
|
||||
|
||||
_arguments -s -C $common_options
|
||||
# unset common_option
|
||||
}
|
||||
|
||||
#-----------------------------------------
|
||||
# Subcommands
|
||||
#-----------------------------------------
|
||||
|
||||
{%- for catinfo in categories %}
|
||||
{%- if catinfo.actions or catinfo.subs %}
|
||||
#-----------------------------------------
|
||||
# {{ catinfo.name }}
|
||||
#-----------------------------------------
|
||||
|
||||
# (( $+functions[_yunohost_{{ catinfo.name }}] )) ||
|
||||
function _yunohost_{{ catinfo.name }}() {
|
||||
if (( CURRENT > 2 )); then
|
||||
__jump
|
||||
else
|
||||
{%- if catinfo.actions %}
|
||||
local -a yunohost_{{ catinfo.name }}; yunohost_{{ catinfo.name }}=(
|
||||
{%- for actioninfo in catinfo.actions %}
|
||||
{%- if actioninfo.help %}
|
||||
'{{ actioninfo.name }}:{{ actioninfo.help }}'
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
)
|
||||
_describe -V -t yunohost-{{ catinfo.name }} 'yunohost {{ catinfo.name }} category' yunohost_{{ catinfo.name }} "$@"
|
||||
|
||||
{%- endif %}
|
||||
{% if catinfo.subs %}
|
||||
local -a yunohost_{{ catinfo.name }}_subcategories; yunohost_{{ catinfo.name }}_subcategories=(
|
||||
{%- for subcat_name, subcat_desc in catinfo.subs.items() %}
|
||||
{%- if subcat_desc %}
|
||||
'{{ subcat_name }}:{{ subcat_desc }}'
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
)
|
||||
_describe -V -t yunohost-{{ catinfo.name }}-subcategories 'yunohost {{ catinfo.name }} subcategories' yunohost_{{ catinfo.name }}_subcategories "$@"
|
||||
{%- endif %}
|
||||
fi
|
||||
}
|
||||
|
||||
{% if catinfo.actions %}
|
||||
{% for actioninfo in catinfo.actions %}
|
||||
# (( $+functions[_yunohost_{{ catinfo.name }}_{{ actioninfo.name }}] )) ||
|
||||
{%- if actioninfo.arguments %}
|
||||
function _yunohost_{{ catinfo.name }}_{{ actioninfo.name }}() {
|
||||
{%- if actioninfo.cases %}
|
||||
local context state state_descr line
|
||||
typeset -A opt_args
|
||||
{% endif %}
|
||||
_arguments -s -C \
|
||||
{%- for argumentinfo in actioninfo.arguments %}
|
||||
{{ argumentinfo }} {% if loop.revindex != 1 %}\{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if actioninfo.cases %}
|
||||
|
||||
if (($CURRENT > 2)); then
|
||||
case "$state" in
|
||||
{%- for case in actioninfo.cases %}
|
||||
{{ case.name }})
|
||||
local previous="$words[${CURRENT} - 1]"
|
||||
local cmd_ret=$({{ case.shell_call }})
|
||||
if (( ${#cmd_ret} != 0 )); then
|
||||
local -a cmd_list=("${(s/ /)cmd_ret}")
|
||||
_values '{{ case.name }}' $cmd_list
|
||||
fi
|
||||
;;
|
||||
{%- endfor %}
|
||||
esac
|
||||
fi
|
||||
return $?
|
||||
{%- endif %}
|
||||
}
|
||||
|
||||
{%- else %}
|
||||
function _yunohost_{{ catinfo.name }}_{{ actioninfo.name }}() { }
|
||||
|
||||
{%- endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
|
||||
#-----------------------------------------
|
||||
# Completion functions
|
||||
#-----------------------------------------
|
||||
|
||||
{% for funcinfo in functions %}
|
||||
{%- if funcinfo.aggregated %}
|
||||
|
||||
# (( $+functions[{{ funcinfo.name }}] )) ||
|
||||
function {{ funcinfo.name }}() {
|
||||
_alternative \
|
||||
{{ funcinfo.aggregated }}
|
||||
}
|
||||
{%- elif funcinfo.shell_call %}
|
||||
|
||||
# (( $+functions[{{ funcinfo.name }}] )) ||
|
||||
function {{ funcinfo.name }}() {
|
||||
compadd "$@" -- ${(@)$({{ funcinfo.shell_call }})}
|
||||
}
|
||||
{%- endif %}
|
||||
{% endfor %}
|
||||
|
||||
_yunohost "$@"
|
||||
Reference in New Issue
Block a user