diff --git a/doc/generate_options_doc.py b/doc/generate_options_doc.py new file mode 100644 index 000000000..fc6078370 --- /dev/null +++ b/doc/generate_options_doc.py @@ -0,0 +1,365 @@ +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() + + +print( + f"""--- +title: Options +template: docs +taxonomy: + category: docs +routes: + default: '/packaging_apps_options' +--- + +# Doc auto-generated by [this script](https://github.com/YunoHost/yunohost/blob/{current_commit}/doc/generate_options_doc.py) on {today} (YunoHost version {version}) + +# Options + +Options are fields declaration that renders as form items in the web-admin and prompts in cli. +They are used in app manifests to declare the before installation form and in config panels. + +## 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: `==`, `!=`, `!`, `&&`, `||`, `+`, `-`, `*`, `/`, `%`, `match()` + - [examples available in the advanced section](#advanced-use-cases) +- `Binding`: bind a value to a file/property/variable/getter/setter/validator + - save the value in `settings.yaml` when not defined (`None`) + - 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` + - [examples available in the advanced section](#bind) +- `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 like F12: one uppercase and 2 numbers" + # or with translated error + pattern.error.en = "Provide a room like F12: one uppercase and 2 numbers" + pattern.error.fr = "Entrez un numéro de salle comme F12: une lettre majuscule et deux chiffres." + ``` + +""" +) + + +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 = {} + +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 + + docstring = ast.get_docstring(c) + if docstring: + if "##### Properties" not in docstring: + docstring += """ +##### Properties + +- [common properties](#common-option-properties) + """ + OptionDocString[option_type] = docstring + +for option_type, doc in OptionDocString.items(): + print("") + if option_type == "BaseOption": + print("## Common Option properties") + elif option_type == "BaseInputOption": + print("## Common Inputs properties") + elif option_type == "display_text": + print("----------------") + print("## Readonly Options") + print(f"### Option `{option_type}`") + elif option_type == "string": + print("----------------") + print("## Input Options") + print(f"### Option `{option_type}`") + else: + print(f"### Option `{option_type}`") + print("") + print(doc) + print("") + +print( + """ +---------------- + +## Advanced use cases + +### `visible` & `enabled` expression evaluation + +Sometimes we may want to conditionaly display a message or prompt for a value, for this we have the `visible` prop. +And we may want to allow a user to trigger an action only if some condition are met, for this we have the `enabled` prop. + +Expressions are evaluated against a context containing previous values of the current section's options. This quite limited current design exists because on the web-admin or on the CLI we cannot guarantee that a value will be present in the form if the user queried only a single panel/section/option. +In the case of an action, the user will be shown or asked for each of the options of the section in which the button is present. + +The expression has to be written in javascript (this has been designed for the web-admin first and is converted to python on the fly on the cli). + +Available operators are: `==`, `!=`, `>`, `>=`, `<`, `<=`, `!`, `&&`, `||`, `+`, `-`, `*`, `/`, `%` and `match()`. + +##### Examples + +```toml +# simple "my_option_id" is thruthy/falsy +visible = "my_option_id" +visible = "!my_option_id" +# misc +visible = "my_value >= 10" +visible = "-(my_value + 1) < 0" +visible = "!!my_value || my_other_value" +``` +For a more complete set of examples, [check the tests at the end of the file](https://github.com/YunoHost/yunohost/blob/dev/src/tests/test_questions.py). + +##### match() + +For more complex evaluation we can use regex matching. + +```toml +[my_string] +default = "Lorem ipsum dolor et si qua met!" + +[my_boolean] +type = "boolean" +visible = "my_string && match(my_string, '^Lorem [ia]psumE?')" +``` + +Match the content of a file. + +```toml +[my_file] +type = "file" +accept = ".txt" +bind = "/etc/random/lorem.txt" + +[my_boolean] +type = "boolean" +visible = "my_file && match(my_file, '^Lorem [ia]psumE?')" +``` + +with a file with content like: +```txt +Lorem ipsum dolor et si qua met! +``` + + +### `bind` + +Config panels only + +`bind` allows us to alter the generic behavior of option's values which is: get from and set in `settings.yml`. + +We can: +- alter the source the value comes from with getters. +- alter the destination with setters +- parse/validate the value before destination with validators + +##### Getters + +Define an option's custom getter in a bash script `script/config`. +It has to be named after an option's id prepended by `get__`. + +To display a custom alert message for example. We setup a base option in `config_panel.toml`. + +```toml +[panel.section.alert] +type = "alert" +# bind to "null" to inform there's something in `scripts/config` +bind = "null" +# `ask` & `style` will be injected by a custom getter +``` + +Then add a custom getter that output yaml, every properties defined here will override any property previously declared. + +```bash +get__alert() { + if [ "$whatever" ]; then + cat << EOF +style: success +ask: Your VPN is running :) +EOF + else + cat << EOF +style: danger +ask: Your VPN is down +EOF + fi +} +``` + +Or to inject a custom value: + +```toml +[panel.section.my_hidden_value] +type = "number" +bind = "null" +# option will act as an hidden variable that can be used in context evaluation +# (ie: `visible` or `enabled`) +readonly = true +visible = false +# `default` injected by a custom getter +``` + +```bash +get__my_hidden_value() { + if [ "$whatever" ]; then + # if only a value is needed + echo "10" + else + # or if we need to override some other props + # (use `default` or `value` to inject the value) + cat << EOF +ask: Here's a number +visible: true +default: 0 +EOF + fi +} +``` + +##### Setters + +Define an option's custom setter in a bash script `script/config`. +It has to be named after an option's id prepended by `set__`. + +```toml +[panel.section.my_value] +type = "string" +bind = "null" +ask = "gimme complex string" +``` + +```bash +set__my_value() { + if [ -n "$my_value" ]; then + # split the string into multiple elements or idk + fi + # To save the value or modified value as a setting: + ynh_app_setting_set --app=$app --key=my_value --value="$my_value" +} +``` + +##### Validators + +Define an option's custom validator in a bash script `script/config`. +It has to be named after an option's id prepended by `validate__`. + +Validators allows us to return custom error messages depending on the value. + +```toml +[panel.section.my_value] +type = "string" +bind = "null" +ask = "Gimme a long string" +default = "too short" +``` + +```bash +validate__my_value() { + if [[ "${#my_value}" -lt 12 ]]; then echo 'Too short!'; fi +} +``` + +##### Actions + +Define an option's action in a bash script `script/config`. +It has to be named after a `button`'s id prepended by `run__`. + +```toml +[panel.section.my_action] +type = "button" +# no need to set `bind` to "null" it is its hard default +ask = "Run action" +``` + +```bash +run__my_action() { + ynh_print_info "Running 'my_action'..." +} +``` + +A more advanced example could look like: + +```toml +[panel.my_action_section] +name = "Action section" + [panel.my_action_section.my_repo] + type = "url" + bind = "null" # value will not be saved as a setting + ask = "gimme a repo link" + + [panel.my_action_section.my_repo_name] + type = "string" + bind = "null" # value will not be saved as a setting + ask = "gimme a custom folder name" + + [panel.my_action_section.my_action] + type = "button" + ask = "Clone the repo" + # enabled the button only if the above values is defined + enabled = "my_repo && my_repo_name" +``` + +```bash +run__my_action() { + ynh_print_info "Cloning '$my_repo'..." + cd /tmp + git clone "$my_repo" "$my_repo_name" +} +``` +""" +) diff --git a/src/utils/form.py b/src/utils/form.py index 07be55312..f030523d6 100644 --- a/src/utils/form.py +++ b/src/utils/form.py @@ -298,6 +298,76 @@ class Pattern(BaseModel): class BaseOption(BaseModel): + """ + ##### Examples + + ```toml + [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`: + - readonly types: + - [`display_text`](#option-display_text) + - [`markdown`](#option-markdown) + - [`alert`](#option-alert) + - [`button`](#option-button) + - inputs types: + - [`string`](#option-string) + - [`text`](#option-text) + - [`password`](#option-password) + - [`color`](#option-color) + - [`number`](#option-number) + - [`range`](#option-range) + - [`boolean`](#option-boolean) + - [`date`](#option-date) + - [`time`](#option-time) + - [`email`](#option-email) + - [`path`](#option-path) + - [`url`](#option-url) + - [`file`](#option-file) + - [`select`](#option-select) + - [`tags`](#option-tags) + - [`domain`](#option-domain) + - [`app`](#option-app) + - [`user`](#option-user) + - [`group`](#option-group) + - `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 + - `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` (default: `None`): + - (config panels only!) allow to choose where an option's is gathered/stored: + - if not specified, the value will be gathered/stored in the `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__` in your path to point to dynamic install paths + - FIXME are other global variables accessible? + """ + type: OptionType id: str ask: Union[Translation, None] @@ -364,10 +434,34 @@ class BaseReadonlyOption(BaseOption): class DisplayTextOption(BaseReadonlyOption): + """ + Display simple multi-line content. + + ##### Examples + + ```toml + [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 + + ##### Examples + ```toml + [my_option_id] + type = "display_text" + ask = "Text **rendered** in markdown." + ``` + """ + type: Literal[OptionType.markdown] = OptionType.markdown @@ -379,6 +473,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. + + ##### Examples + + ```toml + [my_option_id] + type = "alert" + ask = "The configuration seems to be manually modified..." + style = "warning" + icon = "warning" + ``` + ##### Properties + + - [common properties](#common-option-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 @@ -395,6 +510,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). + + ##### Examples + + ```toml + [my_action_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-option-properties) + - `bind`: forced to `"null"` + - `style`: any of `"success|info|warning|danger"` (default: `"success"`) + - `enabled`: `Binding` or `bool` (default: `true`) + - when used with `Binding` 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 @@ -415,6 +569,35 @@ class ButtonOption(BaseReadonlyOption): class BaseInputOption(BaseOption): + """ + Rest of the option types available are considered `inputs`. + + ##### Examples + + ```toml + [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-option-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 @@ -563,10 +746,44 @@ class BaseStringOption(BaseInputOption): class StringOption(BaseStringOption): + """ + Ask for a simple string. + + ##### Examples + ```toml + [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. + + ##### Examples + ```toml + [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 @@ -574,6 +791,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) + + ##### Examples + ```toml + [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 @@ -610,6 +843,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. + + ##### Examples + ```toml + [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 @@ -642,6 +890,26 @@ class ColorOption(BaseInputOption): class NumberOption(BaseInputOption): + """ + Ask for a number (an integer). + + ##### Examples + ```toml + [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] @@ -696,6 +964,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. + + ##### Examples + ```toml + [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 @@ -801,6 +1090,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. + + ##### Examples + ```toml + [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 @@ -816,6 +1122,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. + + ##### Examples + ```toml + [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 @@ -835,12 +1156,41 @@ class TimeOption(BaseInputOption): class EmailOption(BaseInputOption): + """ + Ask for an email. Validation made with [python-email-validator](https://github.com/JoshData/python-email-validator) + + ##### Examples + ```toml + [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. + + ##### Examples + ```toml + [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 @@ -874,6 +1224,21 @@ class WebPathOption(BaseStringOption): class URLOption(BaseStringOption): + """ + Ask for any url. + + ##### Examples + ```toml + [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 @@ -882,6 +1247,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. + + ##### Examples + ```toml + [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) @@ -991,6 +1375,24 @@ class BaseChoicesOption(BaseInputOption): class SelectOption(BaseChoicesOption): + """ + Ask for value from a limited set of values. + Renders as a regular `