diff --git a/.gitlab/ci/doc.gitlab-ci.yml b/.gitlab/ci/doc.gitlab-ci.yml index 183d153a4..d76fcdee1 100644 --- a/.gitlab/ci/doc.gitlab-ci.yml +++ b/.gitlab/ci/doc.gitlab-ci.yml @@ -13,9 +13,11 @@ generate-helpers-doc: - cd doc - python3 generate_helper_doc.py - python3 generate_resource_doc.py > resources.md + - python3 generate_configpanel_and_formoptions_doc.py > forms.md - hub clone https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/doc.git doc_repo - cp helpers.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/11.helpers/packaging_apps_helpers.md - cp resources.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/15.appresources/packaging_apps_resources.md + - cp forms doc_repo/pages/06.contribute/15.dev/03.forms/forms.md - cd doc_repo # replace ${CI_COMMIT_REF_NAME} with ${CI_COMMIT_TAG} ? - hub checkout -b "${CI_COMMIT_REF_NAME}" diff --git a/doc/generate_configpanel_and_formoptions_doc.py b/doc/generate_configpanel_and_formoptions_doc.py new file mode 100644 index 000000000..061ebf77c --- /dev/null +++ b/doc/generate_configpanel_and_formoptions_doc.py @@ -0,0 +1,171 @@ +import sys +import ast +import datetime +import subprocess + +version = open("../debian/changelog").readlines()[0].split()[1].strip("()") +today = datetime.datetime.now().strftime("%d/%m/%Y") + + +def get_current_commit(): + p = subprocess.Popen( + "git rev-parse --verify HEAD", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout, stderr = p.communicate() + + current_commit = stdout.strip().decode("utf-8") + return current_commit + + +current_commit = get_current_commit() + +def print_config_panel_docs(): + + fname = "../src/utils/configpanel.py" + content = open(fname).read() + + # NB: This magic is because we want to be able to run this script outside of a YunoHost context, + # in which we cant really 'import' the file because it will trigger a bunch of moulinette/yunohost imports... + tree = ast.parse(content) + + ConfigPanelClasses = reversed( + [ + c + for c in tree.body + if isinstance(c, ast.ClassDef) + and c.name in {"SectionModel", "PanelModel", "ConfigPanelModel"} + ] + ) + + + print("## Configuration panel structure") + + for c in ConfigPanelClasses: + doc = ast.get_docstring(c) + print("") + print(f"### {c.name.replace('Model', '')}") + print("") + print(doc) + print("") + print("---") + + +def print_form_doc(): + + fname = "../src/utils/form.py" + content = open(fname).read() + + # NB: This magic is because we want to be able to run this script outside of a YunoHost context, + # in which we cant really 'import' the file because it will trigger a bunch of moulinette/yunohost imports... + tree = ast.parse(content) + + OptionClasses = [ + c for c in tree.body if isinstance(c, ast.ClassDef) and c.name.endswith("Option") + ] + + OptionDocString = {} + + print("## List of all option types") + + for c in OptionClasses: + if not isinstance(c.body[0], ast.Expr): + continue + option_type = None + + if c.name in {"BaseOption", "BaseInputOption"}: + option_type = c.name + elif c.body[1].target.id == "type": + option_type = c.body[1].value.attr + + generaltype = c.bases[0].id.replace("Option", "").replace("Base", "").lower() if c.bases else None + + docstring = ast.get_docstring(c) + if docstring: + if "#### Properties" not in docstring: + docstring += """ +#### Properties + +- [common properties](#common-properties)""" + OptionDocString[option_type] = {"doc": docstring, "generaltype": generaltype} + + # Dirty hack to have "BaseOption" as first and "BaseInputOption" as 2nd in list + + base = OptionDocString.pop("BaseOption") + baseinput = OptionDocString.pop("BaseInputOption") + OptionDocString2 = { + "BaseOption": base, + "BaseInputOption": baseinput, + } + OptionDocString2.update(OptionDocString) + + for option_type, infos in OptionDocString2.items(): + if option_type == "display_text": + # display_text is kind of legacy x_x + continue + print("") + if option_type == "BaseOption": + print("### Common properties") + elif option_type == "BaseInputOption": + print("### Common inputs properties") + else: + print(f"### `{option_type}`" + (f" ({infos['generaltype']})" if infos["generaltype"] else "")) + print("") + print(infos["doc"]) + print("") + print("---") + +print( + f"""--- +title: Technical details for config panel structure and form option types +template: docs +taxonomy: + category: docs +routes: + default: '/dev/forms' +--- + +Doc auto-generated by [this script](https://github.com/YunoHost/yunohost/blob/{current_commit}/doc/generate_options_doc.py) on {today} (YunoHost version {version}) + +## Glossary + +You may encounter some named types which are used for simplicity. + +- `Translation`: a translated property + - used for properties: `ask`, `help` and `Pattern.error` + - a `dict` with locales as keys and translations as values: + ```toml + ask.en = "The text in english" + ask.fr = "Le texte en français" + ``` + It is not currently possible for translators to translate those string in weblate. + - a single `str` for a single english default string + ```toml + help = "The text in english" + ``` +- `JSExpression`: a `str` JS expression to be evaluated to `true` or `false`: + - used for properties: `visible` and `enabled` + - operators availables: `==`, `!=`, `>`, `>=`, `<`, `<=`, `!`, `&&`, `||`, `+`, `-`, `*`, `/`, `%` and `match()` +- `Binding`: bind a value to a file/property/variable/getter/setter/validator + - save the value in `settings.yaml` when not defined + - nothing at all with `"null"` + - a custom getter/setter/validator with `"null"` + a function starting with `get__`, `set__`, `validate__` in `scripts/config` + - a variable/property in a file with `:__FINALPATH__/my_file.php` + - a whole file with `__FINALPATH__/my_file.php` +- `Pattern`: a `dict` with a regex to match the value against and an error message + ```toml + pattern.regexp = '^[A-F]\d\d$' + pattern.error = "Provide a room number such as F12: one uppercase and 2 numbers" + # or with translated error + pattern.error.en = "Provide a room number such as F12: one uppercase and 2 numbers" + pattern.error.fr = "Entrez un numéro de salle comme F12: une lettre majuscule et deux chiffres." + ``` + - IMPORTANT: your `pattern.regexp` should be between simple quote, not double. + +""" +) + +print_config_panel_docs() +print_form_doc() diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py index e3ceeff88..3dd283c22 100644 --- a/src/utils/configpanel.py +++ b/src/utils/configpanel.py @@ -84,6 +84,38 @@ class ContainerModel(BaseModel): class SectionModel(ContainerModel, OptionsModel): + """ + Sections are, basically, options grouped together. Sections are `dict`s defined inside a Panel and require a unique id (in the below example, the id is `customization` prepended by the panel's id `main`). Keep in mind that this combined id will be used in CLI to refer to the section, so choose something short and meaningfull. Also make sure to not make a typo in the panel id, which would implicitly create an other entire panel. + + If at least one `button` is present it then become an action section. + Options in action sections are not considered settings and therefor are not saved, they are more like parameters that exists only during the execution of an action. + FIXME i'm not sure we have this in code. + + #### Examples + ```toml + [main] + + [main.customization] + name.en = "Advanced configuration" + name.fr = "Configuration avancée" + help = "Every form items in this section are not saved." + services = ["__APP__", "nginx"] + + [main.customization.option_id] + type = "string" + # …refer to Options doc + ``` + + #### Properties + - `name` (optional): `Translation` or `str`, displayed as the section's title if any + - `help`: `Translation` or `str`, text to display before the first option + - `services` (optional): `list` of services names to `reload-or-restart` when any option's value contained in the section changes + - `"__APP__` will refer to the app instance name + - `optional`: `bool` (default: `true`), set the default `optional` prop of all Options in the section + - `visible`: `bool` or `JSExpression` (default: `true`), allow to conditionally display a section depending on user's answers to previous questions. + - Be careful that the `visible` property should only refer to **previous** options's value. Hence, it should not make sense to have a `visible` property on the very first section. + """ + visible: Union[bool, str] = True optional: bool = True is_action_section: bool = False @@ -138,6 +170,28 @@ class SectionModel(ContainerModel, OptionsModel): class PanelModel(ContainerModel): + """ + Panels are, basically, sections grouped together. Panels are `dict`s defined inside a ConfigPanel file and require a unique id (in the below example, the id is `main`). Keep in mind that this id will be used in CLI to refer to the panel, so choose something short and meaningfull. + + #### Examples + ```toml + [main] + name.en = "Main configuration" + name.fr = "Configuration principale" + help = "" + services = ["__APP__", "nginx"] + + [main.customization] + # …refer to Sections doc + ``` + #### Properties + - `name`: `Translation` or `str`, displayed as the panel title + - `help` (optional): `Translation` or `str`, text to display before the first section + - `services` (optional): `list` of services names to `reload-or-restart` when any option's value contained in the panel changes + - `"__APP__` will refer to the app instance name + - `actions`: FIXME not sure what this does + """ + # FIXME what to do with `actions? actions: dict[str, Translation] = {"apply": {"en": "Apply"}} sections: list[SectionModel] @@ -177,6 +231,26 @@ class PanelModel(ContainerModel): class ConfigPanelModel(BaseModel): + """ + This is the 'root' level of the config panel toml file + + #### Examples + + ```toml + version = 1.0 + + [config] + # …refer to Panels doc + ``` + + #### Properties + + - `version`: `float` (default: `1.0`), version that the config panel supports in terms of features. + - `i18n` (optional): `str`, an i18n property that let you internationalize options text. + - However this feature is only available in core configuration panel (like `yunohost domain config`), prefer the use `Translation` in `name`, `help`, etc. + + """ + version: float = CONFIG_PANEL_VERSION_SUPPORTED i18n: Union[str, None] = None panels: list[PanelModel] diff --git a/src/utils/form.py b/src/utils/form.py index bd373badb..91b5dbe27 100644 --- a/src/utils/form.py +++ b/src/utils/form.py @@ -304,6 +304,59 @@ class Pattern(BaseModel): class BaseOption(BaseModel): + """ + Options are fields declaration that renders as form items, button, alert or text in the web-admin and printed or prompted in CLI. + They are used in app manifests to declare the before installation form and in config panels. + + [Have a look at the app config panel doc](/packaging_apps_config_panels) for details about Panels and Sections. + + ! IMPORTANT: as for Panels and Sections you have to choose an id, but this one should be unique in all this document, even if the question is in an other panel. + + #### Example + + ```toml + [section.my_option_id] + type = "string" + # ask as `str` + ask = "The text in english" + # ask as `dict` + ask.en = "The text in english" + ask.fr = "Le texte en français" + # advanced props + visible = "my_other_option_id != 'success'" + readonly = true + # much advanced: config panel only? + bind = "null" + ``` + + #### Properties + + - `type`: the actual type of the option, such as 'markdown', 'password', 'number', 'email', ... + - `ask`: `Translation` (default to the option's `id` if not defined): + - text to display as the option's label for inputs or text to display for readonly options + - in config panels, questions are displayed on the left side and therefore have not much space to be rendered. Therefore, it is better to use a short question, and use the `help` property to provide additional details if necessary. + - `visible` (optional): `bool` or `JSExpression` (default: `true`) + - define if the option is diplayed/asked + - if `false` and used alongside `readonly = true`, you get a context only value that can still be used in `JSExpression`s + - `readonly` (optional): `bool` (default: `false`, forced to `true` for readonly types): + - If `true` for input types: forbid mutation of its value + - `bind` (optional): `Binding`, config panels only! A powerful feature that let you configure how and where the setting will be read, validated and written + - if not specified, the value will be read/written in the app `settings.yml` + - if `"null"`: + - the value will not be stored at all (can still be used in context evaluations) + - if in `scripts/config` there's a function named: + - `get__my_option_id`: the value will be gathered from this custom getter + - `set__my_option_id`: the value will be passed to this custom setter where you can do whatever you want with the value + - `validate__my_option_id`: the value will be passed to this custom validator before any custom setter + - if `bind` is a file path: + - if the path starts with `:`, the value be saved as its id's variable/property counterpart + - this only works for first level variables/properties and simple types (no array) + - else the value will be stored as the whole content of the file + - you can use `__FINALPATH__` or `__INSTALL_DIR__` in your path to point to dynamic install paths + - FIXME are other global variables accessible? + - [refer to `bind` doc for explaination and examples](#read-and-write-values-the) + """ + type: OptionType id: str ask: Union[Translation, None] @@ -375,10 +428,34 @@ class BaseReadonlyOption(BaseOption): class DisplayTextOption(BaseReadonlyOption): + """ + Display simple multi-line content. + + #### Example + + ```toml + [section.my_option_id] + type = "display_text" + ask = "Simple text rendered as is." + ``` + """ + type: Literal[OptionType.display_text] = OptionType.display_text class MarkdownOption(BaseReadonlyOption): + """ + Display markdown multi-line content. + Markdown is currently only rendered in the web-admin + + #### Example + ```toml + [section.my_option_id] + type = "display_text" + ask = "Text **rendered** in markdown." + ``` + """ + type: Literal[OptionType.markdown] = OptionType.markdown @@ -390,6 +467,27 @@ class State(str, Enum): class AlertOption(BaseReadonlyOption): + """ + Alerts displays a important message with a level of severity. + You can use markdown in `ask` but will only be rendered in the web-admin. + + #### Example + + ```toml + [section.my_option_id] + type = "alert" + ask = "The configuration seems to be manually modified..." + style = "warning" + icon = "warning" + ``` + #### Properties + + - [common properties](#common-properties) + - `style`: any of `"success|info|warning|danger"` (default: `"info"`) + - `icon` (optional): any icon name from [Fork Awesome](https://forkaweso.me/Fork-Awesome/icons/) + - Currently only displayed in the web-admin + """ + type: Literal[OptionType.alert] = OptionType.alert style: State = State.info icon: Union[str, None] = None @@ -406,6 +504,45 @@ class AlertOption(BaseReadonlyOption): class ButtonOption(BaseReadonlyOption): + """ + Triggers actions. + Available only in config panels. + Renders as a `button` in the web-admin and can be called with `yunohost [app|domain|settings] action run ` in CLI. + + Every options defined in an action section (a config panel section with at least one `button`) is guaranted to be shown/asked to the user and available in `scripts/config`'s scope. + [check examples in advanced use cases](#actions). + + #### Example + + ```toml + [section.my_option_id] + type = "button" + ask = "Break the system" + style = "danger" + icon = "bug" + # enabled only if another option's value (a `boolean` for example) is positive + enabled = "aknowledged" + ``` + + To be able to trigger an action we have to add a bash function starting with `run__` in your `scripts/config` + + ```bash + run__my_action_id() { + ynh_print_info "Running 'my_action_id' action" + } + ``` + + #### Properties + + - [common properties](#common-properties) + - `bind`: forced to `"null"` + - `style`: any of `"success|info|warning|danger"` (default: `"success"`) + - `enabled`: `JSExpression` or `bool` (default: `true`) + - when used with `JSExpression` you can enable/disable the button depending on context + - `icon` (optional): any icon name from [Fork Awesome](https://forkaweso.me/Fork-Awesome/icons/) + - Currently only displayed in the web-admin + """ + type: Literal[OptionType.button] = OptionType.button bind: Literal["null"] = "null" help: Union[Translation, None] = None @@ -426,6 +563,35 @@ class ButtonOption(BaseReadonlyOption): class BaseInputOption(BaseOption): + """ + Rest of the option types available are considered `inputs`. + + #### Example + + ```toml + [section.my_option_id] + type = "string" + # …any common props… + + optional = false + redact = false + default = "some default string" + help = "You can enter almost anything!" + example = "an example string" + placeholder = "write something…" + ``` + + #### Properties + + - [common properties](#common-properties) + - `optional`: `bool` (default: `false`, but `true` in config panels) + - `redact`: `bool` (default: `false`), to redact the value in the logs when the value contain private information + - `default`: depends on `type`, the default value to assign to the option + - in case of readonly values, you can use this `default` to assign a value (or return a dynamic `default` from a custom getter) + - `help` (optional): `Translation`, to display a short help message in cli and web-admin + - `example` (optional): `str`, to display an example value in web-admin only + - `placeholder` (optional): `str`, shown in the web-admin fields only + """ + help: Union[Translation, None] = None example: Union[str, None] = None placeholder: Union[str, None] = None @@ -574,10 +740,44 @@ class BaseStringOption(BaseInputOption): class StringOption(BaseStringOption): + """ + Ask for a simple string. + + #### Example + ```toml + [section.my_option_id] + type = "string" + default = "E10" + pattern.regexp = '^[A-F]\d\d$' + pattern.error = "Provide a room like F12 : one uppercase and 2 numbers" + ``` + + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + - `pattern` (optional): `Pattern`, a regex to match the value against + """ + type: Literal[OptionType.string] = OptionType.string class TextOption(BaseStringOption): + """ + Ask for a multiline string. + Renders as a `textarea` in the web-admin and by opening a text editor on the CLI. + + #### Example + ```toml + [section.my_option_id] + type = "text" + default = "multi\\nline\\ncontent" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + - `pattern` (optional): `Pattern`, a regex to match the value against + """ + type: Literal[OptionType.text] = OptionType.text @@ -585,6 +785,22 @@ FORBIDDEN_PASSWORD_CHARS = r"{}" class PasswordOption(BaseInputOption): + """ + Ask for a password. + The password is tested as a regular user password (at least 8 chars) + + #### Example + ```toml + [section.my_option_id] + type = "password" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: forced to `""` + - `redact`: forced to `true` + - `example`: forbidden + """ + type: Literal[OptionType.password] = OptionType.password example: Literal[None] = None default: Literal[None] = None @@ -621,6 +837,21 @@ class PasswordOption(BaseInputOption): class ColorOption(BaseInputOption): + """ + Ask for a color represented as a hex value (with possibly an alpha channel). + Renders as color picker in the web-admin and as a prompt that accept named color like `yellow` in CLI. + + #### Example + ```toml + [section.my_option_id] + type = "color" + default = "#ff0" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + """ + type: Literal[OptionType.color] = OptionType.color default: Union[str, None] _annotation = Color @@ -653,6 +884,26 @@ class ColorOption(BaseInputOption): class NumberOption(BaseInputOption): + """ + Ask for a number (an integer). + + #### Example + ```toml + [section.my_option_id] + type = "number" + default = 100 + min = 50 + max = 200 + step = 5 + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `type`: `number` or `range` (input or slider in the web-admin) + - `min` (optional): minimal int value inclusive + - `max` (optional): maximal int value inclusive + - `step` (optional): currently only used in the webadmin as the `` step jump + """ + # `number` and `range` are exactly the same, but `range` does render as a slider in web-admin type: Literal[OptionType.number, OptionType.range] = OptionType.number default: Union[int, None] @@ -707,6 +958,27 @@ class NumberOption(BaseInputOption): class BooleanOption(BaseInputOption): + """ + Ask for a boolean. + Renders as a switch in the web-admin and a yes/no prompt in CLI. + + #### Example + ```toml + [section.my_option_id] + type = "boolean" + default = 1 + yes = "agree" + no = "disagree" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `0` + - `yes` (optional): (default: `1`) define as what the thruthy value should output + - can be `true`, `True`, `"yes"`, etc. + - `no` (optional): (default: `0`) define as what the thruthy value should output + - can be `0`, `"false"`, `"n"`, etc. + """ + type: Literal[OptionType.boolean] = OptionType.boolean yes: Any = 1 no: Any = 0 @@ -812,6 +1084,23 @@ class BooleanOption(BaseInputOption): class DateOption(BaseInputOption): + """ + Ask for a date in the form `"2025-06-14"`. + Renders as a date-picker in the web-admin and a regular prompt in CLI. + + Can also take a timestamp as value that will output as an ISO date string. + + #### Example + ```toml + [section.my_option_id] + type = "date" + default = "2070-12-31" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + """ + type: Literal[OptionType.date] = OptionType.date default: Union[str, None] _annotation = datetime.date @@ -827,6 +1116,21 @@ class DateOption(BaseInputOption): class TimeOption(BaseInputOption): + """ + Ask for an hour in the form `"22:35"`. + Renders as a date-picker in the web-admin and a regular prompt in CLI. + + #### Example + ```toml + [section.my_option_id] + type = "time" + default = "12:26" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + """ + type: Literal[OptionType.time] = OptionType.time default: Union[str, int, None] _annotation = datetime.time @@ -846,12 +1150,41 @@ class TimeOption(BaseInputOption): class EmailOption(BaseInputOption): + """ + Ask for an email. Validation made with [python-email-validator](https://github.com/JoshData/python-email-validator) + + #### Example + ```toml + [section.my_option_id] + type = "email" + default = "Abc.123@test-example.com" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + """ + type: Literal[OptionType.email] = OptionType.email default: Union[EmailStr, None] _annotation = EmailStr class WebPathOption(BaseStringOption): + """ + Ask for an web path (the part of an url after the domain). Used by default in app install to define from where the app will be accessible. + + #### Example + ```toml + [section.my_option_id] + type = "path" + default = "/" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + - `pattern` (optional): `Pattern`, a regex to match the value against + """ + type: Literal[OptionType.path] = OptionType.path @staticmethod @@ -885,6 +1218,21 @@ class WebPathOption(BaseStringOption): class URLOption(BaseStringOption): + """ + Ask for any url. + + #### Example + ```toml + [section.my_option_id] + type = "url" + default = "https://example.xn--zfr164b/@handle/" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + - `pattern` (optional): `Pattern`, a regex to match the value against + """ + type: Literal[OptionType.url] = OptionType.url _annotation = HttpUrl @@ -893,6 +1241,25 @@ class URLOption(BaseStringOption): class FileOption(BaseInputOption): + """ + Ask for file. + Renders a file prompt in the web-admin and ask for a path in CLI. + + #### Example + ```toml + [section.my_option_id] + type = "file" + accept = ".json" + # bind the file to a location to save the file there + bind = "/tmp/my_file.json" + ``` + #### Properties + - [common inputs properties](#common-inputs-properties) + - `default`: `""` + - `accept`: a comma separated list of extension to accept like `".conf, .ini` + - /!\ currently only work on the web-admin + """ + type: Literal[OptionType.file] = OptionType.file # `FilePath` for CLI (path must exists and must be a file) # `bytes` for API (a base64 encoded file actually) @@ -1002,6 +1369,24 @@ class BaseChoicesOption(BaseInputOption): class SelectOption(BaseChoicesOption): + """ + Ask for value from a limited set of values. + Renders as a regular `