🍽 Fork YunoHost — snapshot mangé par la machine à tsoins
Some checks failed
Check for new n releases / updater (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

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:
machine-a-tsoins
2026-07-03 17:20:06 +00:00
commit edb4c397df
395 changed files with 105504 additions and 0 deletions

42
doc/api.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 "$@"