diff --git a/Makefile b/Makefile index fca82a11f62..7b8d2fd4a31 100644 --- a/Makefile +++ b/Makefile @@ -35,8 +35,8 @@ install: done # completion install -d -m 755 ${buildroot}usr/share/bash-completion/completions - $(python) helper/completion_generator.py \ - > ${buildroot}usr/share/bash-completion/completions/kiwi-ng + install -m 644 completions/bash-completion \ + ${buildroot}usr/share/bash-completion/completions/kiwi-ng # kiwi default configuration install -d -m 755 ${buildroot}etc install -m 644 kiwi.yml ${buildroot}etc/kiwi.yml @@ -80,16 +80,6 @@ valid: fi \ done -git_attributes: - # the following is required to update the $Format:%H$ git attribute - # for details on when this target is called see setup.py - git archive HEAD kiwi/version.py | tar -x - -clean_git_attributes: - # cleanup version.py to origin state - # for details on when this target is called see setup.py - git checkout kiwi/version.py - setup: poetry install --all-extras @@ -162,7 +152,7 @@ prepare_for_pypi: clean setup # ci-publish-to-pypi.yml github action poetry build --format=sdist -clean: clean_git_attributes +clean: rm -rf dist rm -rf doc/build rm -rf doc/dist diff --git a/completions/bash-completion b/completions/bash-completion new file mode 100644 index 00000000000..ef535711988 --- /dev/null +++ b/completions/bash-completion @@ -0,0 +1,86 @@ +#======================================== +# _kiwi +#---------------------------------------- +function setupCompletionLine { + local comp_line=$(echo $COMP_LINE | sed -e 's@kiwi-ng@kiwi@') + local result_comp_line + local prev_was_option=0 + for item in $comp_line; do + if [ $prev_was_option = 1 ];then + prev_was_option=0 + continue + fi + if [[ $item =~ -.* ]];then + prev_was_option=1 + continue + fi + result_comp_line="$result_comp_line $item" + done + echo $result_comp_line +} + +function _kiwi { + local cur prev opts + _get_comp_words_by_ref cur prev + local cmd=$(setupCompletionLine | awk -F ' ' '{ print $NF }') + for comp in $prev $cmd;do + case "$comp" in + "image") + __comp_reply "info resize" + return 0 + ;; + "result") + __comp_reply "bundle list" + return 0 + ;; + "system") + __comp_reply "build create prepare update" + return 0 + ;; + "build") + __comp_reply "--add-bootstrap-package --add-container-label --add-package --add-repo --add-repo-credentials --allow-existing-root --clear-cache --delete-package --description --help --ignore-repos --ignore-repos-used-for-build --set-container-derived-from --set-container-tag --set-release-version --set-repo --set-repo-credentials --set-type-attr= [] - kiwi-ng image info -h | --help + kiwi-ng image info --help kiwi-ng image info --description= [--resolve-package-list] [--list-profiles] @@ -18,7 +18,6 @@ SYNOPSIS [--ignore-repos] [--add-repo=...] [--print-xml|--print-yaml] - kiwi-ng image info help .. _db_image_info_desc: diff --git a/doc/source/commands/image_resize.rst b/doc/source/commands/image_resize.rst index aba9e05dee8..972ef3e4a70 100644 --- a/doc/source/commands/image_resize.rst +++ b/doc/source/commands/image_resize.rst @@ -12,10 +12,9 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng image resize -h | --help + kiwi-ng image resize --help kiwi-ng image resize --target-dir= --size= [--root=] - kiwi-ng image resize help .. _db_kiwi_image_resize_desc: diff --git a/doc/source/commands/kiwi.rst b/doc/source/commands/kiwi.rst index 68f2578ff21..7c238fd1ff2 100644 --- a/doc/source/commands/kiwi.rst +++ b/doc/source/commands/kiwi.rst @@ -8,47 +8,13 @@ SYNOPSIS .. code:: bash - kiwi-ng [global options] service [] - - kiwi-ng -h | --help - kiwi-ng [--profile=...] - [--setenv=...] - [--temp-dir=] - [--type=] - [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - [--kiwi-file=] - image [...] - kiwi-ng [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - result [...] - kiwi-ng [--profile=...] - [--setenv=...] - [--shared-cache-dir=] - [--temp-dir=] - [--target-arch=] - [--type=] - [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - [--kiwi-file=] - system [...] - kiwi-ng -v | --version - kiwi-ng help + kiwi-ng --help | --version + + kiwi-ng [global options] image [...] + kiwi-ng [global options] result [...] + kiwi-ng [global options] system [...] + + kiwi-ng help [kiwi::COMMAND::SUBCOMMAND] .. _db_commands_kiwi_desc: @@ -97,8 +63,8 @@ GLOBAL OPTIONS --config= Use specified runtime configuration file. If not specified, the - runtime configuration is expected to be in the :file:`~/.config/kiwi/config.yml` - or :file:`/etc/kiwi.yml` files. + runtime configuration is expected to be in the + :file:`~/.config/kiwi/config.yml` or :file:`/etc/kiwi.yml` files. --debug diff --git a/doc/source/commands/result_bundle.rst b/doc/source/commands/result_bundle.rst index d7cc500ecd7..cd8ea1eaf99 100644 --- a/doc/source/commands/result_bundle.rst +++ b/doc/source/commands/result_bundle.rst @@ -10,13 +10,12 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng result bundle -h | --help + kiwi-ng result bundle --help kiwi-ng result bundle --target-dir= --id= --bundle-dir= [--bundle-format=] [--zsync_source=] [--package-as-rpm] [--no-compress] - kiwi-ng result bundle help .. _db_kiwi_result_bundle_desc: diff --git a/doc/source/commands/result_list.rst b/doc/source/commands/result_list.rst index 6c0be92e462..9048bb1871b 100644 --- a/doc/source/commands/result_list.rst +++ b/doc/source/commands/result_list.rst @@ -10,9 +10,8 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng result list -h | --help + kiwi-ng result list --help kiwi-ng result list --target-dir= - kiwi-ng result list help .. _db_kiwi_result_list_desc: diff --git a/doc/source/commands/system_build.rst b/doc/source/commands/system_build.rst index 131c2012c2b..7705885bfa4 100644 --- a/doc/source/commands/system_build.rst +++ b/doc/source/commands/system_build.rst @@ -12,7 +12,7 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng system build -h | --help + kiwi-ng system build --help kiwi-ng system build --description= --target-dir= [--allow-existing-root] [--clear-cache] @@ -31,7 +31,6 @@ SYNOPSIS [--set-type-attr=...] [--set-release-version=] [--signing-key=...] - kiwi-ng system build help .. _db_kiwi_system_build_desc: diff --git a/doc/source/commands/system_create.rst b/doc/source/commands/system_create.rst index ac7a71a9cc6..3405effe5a5 100644 --- a/doc/source/commands/system_create.rst +++ b/doc/source/commands/system_create.rst @@ -12,10 +12,9 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng system create -h | --help + kiwi-ng system create --help kiwi-ng system create --root= --target-dir= [--signing-key=...] - kiwi-ng system create help .. _db_kiwi_system_create_desc: diff --git a/doc/source/commands/system_prepare.rst b/doc/source/commands/system_prepare.rst index c5c1ee30a13..b363ebe3b3f 100644 --- a/doc/source/commands/system_prepare.rst +++ b/doc/source/commands/system_prepare.rst @@ -10,7 +10,7 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng system prepare -h | --help + kiwi-ng system prepare --help kiwi-ng system prepare --description= --root= [--allow-existing-root] [--clear-cache] @@ -29,7 +29,6 @@ SYNOPSIS [--set-type-attr=...] [--set-release-version=] [--signing-key=...] - kiwi-ng system prepare help .. _db_kiwi_system_prepare_desc: diff --git a/doc/source/commands/system_update.rst b/doc/source/commands/system_update.rst index 558ac8b2184..f63eacc9c13 100644 --- a/doc/source/commands/system_update.rst +++ b/doc/source/commands/system_update.rst @@ -10,11 +10,10 @@ SYNOPSIS kiwi-ng [global options] service [] - kiwi-ng system update -h | --help + kiwi-ng system update --help kiwi-ng system update --root= [--add-package=...] [--delete-package=...] - kiwi-ng system update help .. _db_kiwi_system_update_desc: diff --git a/doc/source/conf.py b/doc/source/conf.py index 4914c9c5f1c..4884144a332 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -35,22 +35,6 @@ 'sphinx_rtd_theme' ] -docopt_ignore = [ - 'kiwi.cli', - 'kiwi.tasks.system_build', - 'kiwi.tasks.system_prepare', - 'kiwi.tasks.system_update', - 'kiwi.tasks.system_create', - 'kiwi.tasks.result_list', - 'kiwi.tasks.result_bundle', - 'kiwi.tasks.image_resize', - 'kiwi.tasks.image_info' -] - -def remove_module_docstring(app, what, name, obj, options, lines): - if what == "module" and name in docopt_ignore: - del lines[:] - def prologReplace(app, docname, source): result = source[0] for key in app.config.prolog_replacements: @@ -60,7 +44,6 @@ def prologReplace(app, docname, source): def setup(app): app.add_config_value('prolog_replacements', {}, True) app.connect('source-read', prologReplace) - app.connect("autodoc-process-docstring", remove_module_docstring) prolog_replacements = { diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index 9c79416c28d..ec544b02ace 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -62,12 +62,20 @@ Create a Python Virtual Development Environment ----------------------------------------------- The following commands initializes and activates a development -environment for Python 3: +environment for the current default Python version: .. code:: shell-session $ poetry install +.. note:: + + To create the python virtual env for another version of + Python, e.g. 3.11, call the following prior the poetry install + :: + + $ poetry env use python3.11 + The command above automatically creates the application script called :command:`kiwi-ng`, which allows you to run {kiwi} from the Python sources inside the virtual environment using Poetry: @@ -76,7 +84,6 @@ Python sources inside the virtual environment using Poetry: $ poetry run kiwi-ng --help - Running the Unit Tests ---------------------- diff --git a/doc/source/contributing/kiwi_plugin_architecture.rst b/doc/source/contributing/kiwi_plugin_architecture.rst index fbe0c4877fe..a64e85f0420 100644 --- a/doc/source/contributing/kiwi_plugin_architecture.rst +++ b/doc/source/contributing/kiwi_plugin_architecture.rst @@ -2,24 +2,27 @@ Plugin Architecture =================== Each command provided by {kiwi} is written as a task plugin under the -**kiwi.tasks** namespace. As a developer, you can extend {kiwi} with custom task -plugins, following the conventions below. +**kiwi.tasks** namespace. As a developer, you can extend the {kiwi} +system command space with custom task plugins, following the conventions +below. Naming conventions ------------------ Task plugin file name The file name of a task plugin must follow the pattern - :file:`_.py`. This allows you to invoke the task - with :command:`kiwi-ng service command ...` + :file:`system_.py`. This allows you to invoke the task + with :command:`kiwi-ng system command ...` Task plugin option handling - {kiwi} uses the docopt module to handle options. Each task plugin - must use docopt to allow option handling. + {kiwi} uses the typer module to handle options. Each task plugin + must use typer to allow option handling. The typer definition + must be provided in a file named :file:`cli.py` and must live in the + toplevel of the plugin python namespace. Task plugin class The implementation of the plugin must be a class that matches the naming - convention :class:`Task`. The class must inherit from the + convention :class:`SystemTask`. The class must inherit from the :class:`CliTask` base class. On the plugin startup, {kiwi} expects an implementation of the :file:`process` method. @@ -27,7 +30,7 @@ Task plugin entry point Registration of the plugin must be done in :file:`pyproject.toml` using the ``tool.poetry.plugins`` concept. - .. code:: python + .. code:: [tool.poetry] name = "kiwi_plugin" @@ -38,7 +41,7 @@ Task plugin entry point [tool.poetry.plugins] [tool.poetry.plugins."kiwi.tasks"] - service_command = "kiwi_plugin.tasks.service_command" + system_ = "kiwi__plugin.tasks.system_" Example plugin -------------- @@ -53,11 +56,10 @@ Example plugin 2. Create the entry point in :command:`pyproject.toml`. - Assuming we want to create the service named **relax** that has - the command **justdoit**, this is the required plugin - definition in :file:`pyproject.toml`: + Assuming we want to create the system command **justdoit**, this is + the following entry point definition in :file:`pyproject.toml`: - .. code:: python + .. code:: toml [tool.poetry] name = "kiwi_relax_plugin" @@ -68,34 +70,55 @@ Example plugin [tool.poetry.plugins] [tool.poetry.plugins."kiwi.tasks"] - relax_justdoit = "kiwi_relax_plugin.tasks.relax_justdoit" + system_justdoit = "kiwi_relax_plugin.tasks.system_justdoit" + +3. Create the typer cli interface in the file + :file:`kiwi_relax_plugin/cli.py` with the following + content: + + .. code:: python -3. Create the plugin code in the file - :file:`kiwi_relax_plugin/tasks/relax_justdoit.py` with the following + import typer + from typing import Annotated + + # typers variable must be provided for kiwi plugins + typers = { + 'justdoit': typer.Typer(add_completion=False) + } + + system = typers['justdoit'] + + @system.callback( + help='What is it good for' + invoke_without_command=True, + subcommand_metavar='' + ) + def justdoit( + ctx: typer.Context, + now: Annotated[str, typer.Option(help='For --now option')] + ): + Cli=ctx.obj + Cli.subcommand_args['justdoit'] = { + '--now': now, + 'help': False + } + Cli.global_args['command'] = 'justdoit' + Cli.global_args['system'] = True + Cli.cli_ok = True + +4. Create the plugin code in the file + :file:`kiwi_relax_plugin/tasks/system_justdoit.py` with the following content: .. code:: python - """ - usage: kiwi-ng relax justdoit -h | --help - kiwi-ng relax justdoit --now - - commands: - justdoit - time to relax - - options: - --now - right now. For more details about docopt - see: http://docopt.org - """ # These imports requires kiwi to be part of your environment # It can be either installed from pip into a virtual development # environment or from the distribution package manager from kiwi.tasks.base import CliTask from kiwi.help import Help - class RelaxJustdoitTask(CliTask): + class SystemJustdoitTask(CliTask): def process(self): self.manual = Help() if self.command_args.get('help') is True: @@ -105,12 +128,13 @@ Example plugin # installed by the plugin return self.manual.show('kiwi::relax::justdoit') - print( - 'https://genius.com/Frankie-goes-to-hollywood-relax-lyrics' - ) + if self.command_args.get('--now'): + print( + 'https://genius.com/Frankie-goes-to-hollywood-relax-lyrics' + ) -4. Test the plugin +5. Test the plugin .. code:: bash - $ poetry run kiwi-ng relax justdoit --now + $ poetry run kiwi-ng system justdoit --now diff --git a/helper/completion_generator.py b/helper/completion_generator.py deleted file mode 100755 index e04cefe8c68..00000000000 --- a/helper/completion_generator.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/python3 - -from textwrap import dedent - -import subprocess -import re - -import collections - - -class AutoVivification(dict): - def __getitem__(self, item): - try: - return dict.__getitem__(self, item) - except KeyError: - value = self[item] = type(self)() - return value - - -class AppHash: - def __init__(self): # noqa: C901 - tasks = [ - 'kiwi/cli.py', - 'kiwi/tasks/*.py' - ] - call_parm = ['bash', '-c', 'cat %s' % ' '.join(tasks)] - cmd = subprocess.Popen( - call_parm, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - begin = False - cur_path = '' - self.result = AutoVivification() - for line in cmd.communicate()[0].decode().split('\n'): - usage = re.search('^usage: (.*)', line) - if usage: - begin = True - if re.match('.*--help', line): - mod_line = re.sub('[\[\]\|]', '', usage.group(1)) - mod_line = re.sub('-h ', '', mod_line) - key_list = mod_line.split() - result_keys = self.validate(key_list) - cur_path = self.merge(result_keys, self.result) - else: - key_list = usage.group(1).split() - result_keys = self.validate(key_list) - cur_path = self.merge(result_keys, self.result) - elif begin: - if not line: - begin = False - else: - if re.match('.*--version', line): - mod_line = re.sub('[\[\]\|]', '', line) - mod_line = re.sub('-v ', '', mod_line) - key_list = mod_line.split() - result_keys = self.validate(key_list) - cur_path = self.merge(result_keys, self.result) - elif re.match('.*kiwi-ng --compat', line): - mod_line = re.sub('[\[\]\|]', '', line) - mod_line = re.sub('...', '', mod_line) - key_list = mod_line.split() - result_keys = self.validate(key_list) - cur_path = self.merge(result_keys, self.result) - elif re.match('.*kiwi-ng \[', line): - line = line.replace('[', '') - line = line.replace(']', '') - line = line.replace('|', '') - key_list = line.split() - key_list.pop(0) - for global_opt in key_list: - result_keys = self.validate( - ['kiwi-ng', global_opt] - ) - cur_path = self.merge(result_keys, self.result) - elif re.match(' \[', line): - line = line.replace('[', '') - line = line.replace(']', '') - line = line.replace('|', '') - key_list = line.split() - for global_opt in key_list: - result_keys = self.validate( - ['kiwi-ng', global_opt] - ) - cur_path = self.merge(result_keys, self.result) - elif re.match(' \[', line): - opt_val = re.search(' \[(--.*)', line) - global_opt = opt_val.group(1) - global_opt = global_opt.replace('[', '') - global_opt = global_opt.replace(']', '') - global_opt = global_opt.replace('|', '') - result_keys = self.validate( - ['kiwi-ng', global_opt] - ) - cur_path = self.merge(result_keys, self.result) - elif re.match('.*kiwi', line): - mandatory_options = re.search( - '(.*kiwi-ng.*?) (--.*)', line - ) - if mandatory_options: - line = mandatory_options.group(1) - key_list = line.split() - result_keys = self.validate(key_list) - cur_path = self.merge(result_keys, self.result) - if mandatory_options: - for option in mandatory_options.group(2).split(): - mod_line = cur_path + ' ' + option - key_list = mod_line.split() - result_keys = self.validate(key_list) - self.merge(result_keys, self.result) - else: - if 'kiwi-ng --' in cur_path: - cur_path = '' - for mod_line in line.strip().split('|'): - mod_line = cur_path + ' ' + mod_line - mod_line = re.sub('[\[\]]', '', mod_line) - mod_line = re.sub('-h ', '', mod_line) - key_list = mod_line.split() - result_keys = self.validate(key_list) - self.merge(result_keys, self.result) - - def merge(self, key_list, result): - raw_key_path = " ".join(key_list) - key_path = '' - for key in key_list: - key_path += '[\'' + key + '\']' - expression = 'self.result' + key_path - exec(expression) - return raw_key_path - - def validate(self, key_list): - result_keys = [] - for key in key_list: - option = re.search('^\[(--.*)=|^\[(.*)\]', key) - mandatory = re.search('^(--.*)=', key) - if option: - if option.group(1): - result_keys.append(option.group(1)) - elif mandatory: - if mandatory.group(1): - result_keys.append(mandatory.group(1)) - elif re.search('||...', key): - pass - else: - key = key.replace('<', '__') - key = key.replace('>', '__') - result_keys.append(key) - return result_keys - - -class AppTree: - def __init__(self): - self.completion = AppHash() - self.level_dict = {} - - def traverse(self, tree=None, level=0, origin=None): - if not tree: - tree = self.completion.result['kiwi-ng'] - if not origin: - origin = 'kiwi-ng' - for key in tree: - try: - if self.level_dict[str(level)]: - pass - except KeyError: - self.level_dict[str(level)] = {} - try: - if self.level_dict[str(level)][origin]: - pass - except KeyError: - self.level_dict[str(level)][origin] = [] - - if key not in self.level_dict[str(level)][origin]: - self.level_dict[str(level)][origin].append(key) - - if tree[key]: - self.traverse(tree[key], level + 1, key) - - -tree = AppTree() -tree.traverse() - -# helpful for debugging -# pp = pprint.PrettyPrinter(indent=4) -# pp.pprint(tree.completion.result) - -sorted_levels = collections.OrderedDict( - sorted(tree.level_dict.items()) -) - -print(dedent(''' -#======================================== -# _kiwi -#---------------------------------------- -function setupCompletionLine { - local comp_line=$(echo $COMP_LINE | sed -e 's@kiwi-ng@kiwi@') - local result_comp_line - local prev_was_option=0 - for item in $comp_line; do - if [ $prev_was_option = 1 ];then - prev_was_option=0 - continue - fi - if [[ $item =~ -.* ]];then - prev_was_option=1 - continue - fi - result_comp_line="$result_comp_line $item" - done - echo $result_comp_line -} - -function _kiwi { - local cur prev opts - _get_comp_words_by_ref cur prev - local cmd=$(setupCompletionLine | awk -F ' ' '{ print $NF }') -''').strip()) - -print(' for comp in $prev $cmd;do') -print(' case "$comp" in') -for level in sorted_levels: - if level == '0': - continue - for sub in sorted(sorted_levels[level]): - print(' "%s")' % (sub)) - print( - ' __comp_reply "{0}"'.format( - (" ".join(sorted(sorted_levels[level][sub]))) - ) - ) - print(' return 0') - print(' ;;') -print(' esac') -print(' done') -print( - ' __comp_reply "{0}"'.format( - (" ".join(sorted(sorted_levels['0']['kiwi-ng']))) - ) -) -print(' return 0') -print('}') -print(dedent(''' -#======================================== -# comp_reply -#---------------------------------------- -function __comp_reply { - word_list=$@ - COMPREPLY=($(compgen -W "$word_list" -- ${cur})) -} - -complete -F _kiwi -o default kiwi -complete -F _kiwi -o default kiwi-ng -''').strip()) diff --git a/helper/update_changelog.py b/helper/update_changelog.py index 2dda1b10e31..20819cc7ade 100755 --- a/helper/update_changelog.py +++ b/helper/update_changelog.py @@ -1,162 +1,191 @@ -#!/usr/bin/python3 -""" -usage: update_changelog (--since=|--file=) - [--utc] - [--fix] - -arguments: - --since= - changes since the latest entry in the reference file - --file= - changes from the given file - --utc - print date/time in UTC - --fix - lookup .fix files and apply them -""" -import docopt +#!/usr/bin/env python3 +import typer import os import glob import subprocess import sys from dateutil import parser from dateutil import tz +from typing import ( + Annotated, Optional +) +from pathlib import Path -# Commandline arguments -arguments = docopt.docopt(__doc__) - -# Latest date of the given reference file -date_reference = None - -# List of skipped commits older than date_reference -skip_list = [] - -# hash of git history log entries -log_data = {} - -# raw list of log lines from git history or reference file -log_lines = [] - -# Author and Date -log_author = None -log_date = None - -# changelog header line -log_start = '-' * 67 + os.linesep - -# date format for rpm changelog -date_format = '%a %b %d %T %Z %Y' - -# commit message -commit_message = [] - -# Open reference log file -reference_file = arguments['--since'] or arguments['--file'] - -# Custom fix files -fix_dict = {} -if arguments['--fix']: - for fix in glob.iglob(f'{os.path.dirname(reference_file)}/*.fix'): - sys.stderr.write(f'Reading fix: {fix}{os.linesep}') - with open(fix, 'r') as fixlog: - commit = fixlog.readline() - fix_dict[commit] = fixlog.read() - -if arguments['--since']: - # Read latest date from reference file - with open(reference_file, 'r') as gitlog: - # read commit and author - gitlog.readline() - gitlog.readline() - # read date - latest_date = gitlog.readline().replace('AuthorDate:', '').strip() - date_reference = parser.parse(latest_date) - - # Read git history since latest entry from reference file - process = subprocess.Popen( - [ - 'git', 'log', '--no-merges', '--format=fuller', - '--since="{0}"'.format(latest_date) - ], stdout=subprocess.PIPE - ) - from_git_log = True - for line in iter(process.stdout.readline, b''): - if line.startswith(b'commit'): - commit = line.decode() - if from_git_log and fix_dict.get(commit): - sys.stderr.write(f' Apply fix for: {commit}') - from_git_log = False + +def main( + since: Annotated[ + Optional[Path], typer.Option( + help='changes since the latest entry in the reference file' + ) + ] = None, + file: Annotated[ + Optional[Path], typer.Option( + help='changes from the given file' + ) + ] = None, + utc: Annotated[ + Optional[bool], typer.Option( + '--utc', + help='print date/time in UTC' + ) + ] = False, + fix: Annotated[ + Optional[bool], typer.Option( + '--fix', + help='lookup .fix files and apply them' + ) + ] = False +): + if not since and not file: + print('Either --since or --file must be specified') + sys.exit(1) + if since and file: + print('Only one of --since or --file must be specified') + sys.exit(1) + + # Commandline arguments + arguments = { + '--since': since, + '--file': file, + '--utc': utc, + '--fix': fix + } + + # Latest date of the given reference file + date_reference = None + + # List of skipped commits older than date_reference + skip_list = [] + + # hash of git history log entries + log_data = {} + + # raw list of log lines from git history or reference file + log_lines = [] + + # Author and Date + log_author = None + log_date = None + + # changelog header line + log_start = '-' * 67 + os.linesep + + # date format for rpm changelog + date_format = '%a %b %d %T %Z %Y' + + # commit message + commit_message = [] + + # Open reference log file + reference_file = arguments['--since'] or arguments['--file'] + + # Custom fix files + fix_dict = {} + if arguments['--fix']: + for fix in glob.iglob(f'{os.path.dirname(reference_file)}/*.fix'): + sys.stderr.write(f'Reading fix: {fix}{os.linesep}') + with open(fix, 'r') as fixlog: + commit = fixlog.readline() + fix_dict[commit] = fixlog.read() + + if arguments['--since']: + # Read latest date from reference file + with open(reference_file, 'r') as gitlog: + # read commit and author + gitlog.readline() + gitlog.readline() + # read date + latest_date = gitlog.readline().replace('AuthorDate:', '').strip() + date_reference = parser.parse(latest_date) + + # Read git history since latest entry from reference file + process = subprocess.Popen( + [ + 'git', 'log', '--no-merges', '--format=fuller', + '--since="{0}"'.format(latest_date) + ], stdout=subprocess.PIPE + ) + from_git_log = True + for line in iter(process.stdout.readline, b''): + if line.startswith(b'commit'): + commit = line.decode() + if from_git_log and fix_dict.get(commit): + sys.stderr.write(f' Apply fix for: {commit}') + from_git_log = False + log_lines.append(line) + for fix_line in fix_dict.get(commit).split(os.linesep): + log_lines.append(fix_line.encode()) + elif not from_git_log: + from_git_log = True + + if from_git_log: log_lines.append(line) - for fix_line in fix_dict.get(commit).split(os.linesep): - log_lines.append(fix_line.encode()) - elif not from_git_log: - from_git_log = True - - if from_git_log: - log_lines.append(line) -else: - with open(reference_file, 'rb') as gitlog: - log_lines = gitlog.readlines() - -# Iterate over log data and convert to changelog format -for line_data in log_lines: - line = line_data.decode(encoding='utf-8') - if line.startswith('commit'): - if commit_message: - commit_message.pop(0) - message_header = commit_message.pop(0).lstrip() - message_body = [] - for line in commit_message: - message_line = line.lstrip() - if not message_line: - message_body.append(os.linesep) - else: - message_body.append( - ' {0}{1}'.format(message_line, os.linesep) - ) - log_data[log_date] = ''.join( - [ - log_start, - '{0} - {1}{2}{2}'.format( - log_date.astimezone( - tz.UTC if arguments['--utc'] else tz.tzlocal() - ).strftime(date_format), log_author, os.linesep - ), - '- {0}{1}'.format( - message_header, os.linesep - ) - ] + message_body - ) - commit_message = [] - elif line.startswith('Author:'): - log_author = line.replace('Author:', '').strip() - elif line.startswith('AuthorDate:'): - log_date = parser.parse(line.replace('AuthorDate:', '').strip()) - elif line.startswith('Commit:'): - pass - elif line.startswith('CommitDate:'): - pass else: - commit_message.append(line.strip()) - -# print in changelog format on stdout -for author_date in reversed(sorted(log_data.keys())): - if date_reference: - if date_reference < author_date: - sys.stdout.write(log_data[author_date]) + with open(reference_file, 'rb') as gitlog: + log_lines = gitlog.readlines() + + # Iterate over log data and convert to changelog format + for line_data in log_lines: + line = line_data.decode(encoding='utf-8') + if line.startswith('commit'): + if commit_message: + commit_message.pop(0) + message_header = commit_message.pop(0).lstrip() + message_body = [] + for line in commit_message: + message_line = line.lstrip() + if not message_line: + message_body.append(os.linesep) + else: + message_body.append( + ' {0}{1}'.format(message_line, os.linesep) + ) + log_data[log_date] = ''.join( + [ + log_start, + '{0} - {1}{2}{2}'.format( + log_date.astimezone( + tz.UTC if arguments['--utc'] else tz.tzlocal() + ).strftime(date_format), log_author, os.linesep + ), + '- {0}{1}'.format( + message_header, os.linesep + ) + ] + message_body + ) + commit_message = [] + elif line.startswith('Author:'): + log_author = line.replace('Author:', '').strip() + elif line.startswith('AuthorDate:'): + log_date = parser.parse(line.replace('AuthorDate:', '').strip()) + elif line.startswith('Commit:'): + pass + elif line.startswith('CommitDate:'): + pass else: - skip_list.append(author_date) - else: - sys.stdout.write(log_data[author_date]) - -# print inconsistencies if any on stderr -if skip_list: - sys.stderr.write( - 'Reference Date: {0}{1}'.format(date_reference, os.linesep) - ) - for date in skip_list: + commit_message.append(line.strip()) + + # print in changelog format on stdout + for author_date in reversed(sorted(log_data.keys())): + if date_reference: + if date_reference < author_date: + sys.stdout.write(log_data[author_date]) + else: + skip_list.append(author_date) + else: + sys.stdout.write(log_data[author_date]) + + # print inconsistencies if any on stderr + if skip_list: sys.stderr.write( - ' + Skipped: {0}: past reference{1}'.format( - date, os.linesep - ) + 'Reference Date: {0}{1}'.format(date_reference, os.linesep) ) + for date in skip_list: + sys.stderr.write( + ' + Skipped: {0}: past reference{1}'.format( + date, os.linesep + ) + ) + +if __name__ == "__main__": + typer.run(main) diff --git a/kiwi/cli.py b/kiwi/cli.py index 88898e0c322..ef18484bb25 100644 --- a/kiwi/cli.py +++ b/kiwi/cli.py @@ -15,123 +15,25 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng -h | --help - kiwi-ng [--profile=...] - [--setenv=...] - [--temp-dir=] - [--target-arch=] - [--type=] - [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - [--kiwi-file=] - image [...] - kiwi-ng [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - result [...] - kiwi-ng [--profile=...] - [--setenv=...] - [--shared-cache-dir=] - [--temp-dir=] - [--target-arch=] - [--type=] - [--logfile=] - [--logsocket=] - [--loglevel=] - [--debug] - [--debug-run-scripts-in-screen] - [--color-output] - [--config=] - [--kiwi-file=] - system [...] - kiwi-ng -v | --version - kiwi-ng help - -global options: - --color-output - use colors for warning and error messages - --config= - use specified runtime configuration file. If - not specified the runtime configuration is looked - up at ~/.config/kiwi/config.yml or /etc/kiwi.yml - --logfile= - create a log file containing all log information including - debug information even if this was not requested by the - debug switch. The special call: '--logfile stdout' sends all - information to standard out instead of writing to a file - --logsocket= - send log data to the given Unix Domain socket in the same - format as with --logfile - --loglevel= - specify logging level as number. Details about the - available log levels can be found at: - https://docs.python.org/3/library/logging.html#logging-levels - Setting a log level causes all message >= level to be - displayed. - --debug - print debug information, same as: '--loglevel 10' - --debug-run-scripts-in-screen - run scripts called by kiwi in a screen session - -v --version - show program version - help - show manual page - -global options for services: image, system - --profile= - profile name, multiple profiles can be selected by passing - this option multiple times - --setenv= - export environment variable and its value into the caller - environment. This option can be specified multiple times - --shared-cache-dir= - specify an alternative shared cache directory. The directory - is shared via bind mount between the build host and image - root system and contains information about package repositories - and their cache and meta data. - --temp-dir= - specify an alternative base temporary directory. The - provided path is used as base directory to store temporary - files and directories. By default /var/tmp is used. - --type= - image build type. If not set the default XML specified - build type will be used - --kiwi-file= - Basename of kiwi file which contains the main image - configuration elements. If not specified kiwi searches for - a file named config.xml or a file matching *.kiwi - -global options for services: image, system - --target-arch= - set the image architecture. By default the host architecture is - used as the image architecture. If the specified architecture name - does not match the host architecture and is therefore requesting - a cross architecture image build, it's important to understand that - for this process to work a preparatory step to support the image - architecture and binary format on the building host is required - and not a responsibility of kiwi. -""" +import typer import logging import sys import os -from importlib.metadata import entry_points -from docopt import docopt +from unittest.mock import patch +from importlib.metadata import ( + entry_points, EntryPoint +) +from pathlib import Path +from typing import ( + Annotated, Dict, Optional, List +) # project from kiwi.exceptions import ( KiwiUnknownServiceName, KiwiCommandNotLoaded, - KiwiLoadCommandUndefined + KiwiLoadCommandUndefined, + KiwiLoadPluginError ) from kiwi.version import __version__ from kiwi.help import Help @@ -148,23 +50,862 @@ class Cli: application and implements methods to load further command plugins which itself provides their own command line interface """ + global_args: Dict = {} + subcommand_args: Dict = {} + plugins: Dict[str, typer.Typer] = {} + cli_ok = False + + # system + system = typer.Typer( + help='system command for building images. The system space ' + 'can also be extended by custom command plugins.' + ) + + # result + result = typer.Typer( + help='result command for listing image result information ' + 'and create result bundles.' + ) + + # image + image = typer.Typer( + help='image command for retrieving image information ' + 'prior building.' + ) + + cli = typer.Typer(add_completion=False) + cli.add_typer(system, name='system') + cli.add_typer(result, name='result') + cli.add_typer(image, name='image') + def __init__(self): - self.all_args = docopt( - __doc__, - version='KIWI (next generation) version ' + __version__, - options_first=True + Cli.cli_ok = False + exit_code = 1 + # load plugins... + Cli.plugins.update( + self.load_plugin_cli() + ) + # add plugin cli's if there are any loaded + for system_subcommand in sorted(Cli.plugins.keys()): + Cli.system.add_typer( + Cli.plugins[system_subcommand], name=system_subcommand, + context_settings={ + 'obj': Cli + } + ) + with patch('sys.exit') as sys_exit: + # This is unfortunately needed to integrate the + # typer interface with the former docopt based + # option handling to kiwi in a way that is not + # too intrusive. Usually typer quits after the + # invocation of the commmand function. But in case + # of kiwi we need the result of the typer processing + # to be used in the task classes such that typer + # should not exit from its operation. + Cli.cli() + (exit_code,) = sys_exit.call_args[0] + if not Cli.cli_ok: + sys.exit(exit_code) + + self.command_args = self.get_command_args( + raise_if_no_command=False ) - self.command_args = self.all_args[''] self.command_loaded = None - def show_and_exit_on_help_request(self): + def version(self, perform: bool): + if perform: + print(f'KIWI (next generation) version {__version__}') + raise typer.Exit(0) + + @staticmethod + @cli.callback() + def main( + color_output: Annotated[ + bool, typer.Option( + '--color-output', + help='use colors for warning and error messages' + ) + ] = False, + config: Annotated[ + Optional[Path], typer.Option( + help='use specified runtime configuration file. If ' + 'not specified the runtime configuration is looked ' + 'up at ~/.config/kiwi/config.yml or /etc/kiwi.yml' + ) + ] = None, + debug: Annotated[ + bool, typer.Option( + '--debug', + help='print debug information, same as: --loglevel 10' + ) + ] = False, + debug_run_scripts_in_screen: Annotated[ + bool, typer.Option( + '--debug-run-scripts-in-screen', + help='run scripts called by kiwi in a screen session' + ) + ] = False, + kiwi_file: Annotated[ + Optional[str], typer.Option( + help=' Basename of kiwi file which contains ' + 'the main image configuration elements. If not specified ' + 'kiwi searches for a file named config.xml or a file ' + 'matching *.kiwi' + ) + ] = None, + logfile: Annotated[ + Optional[Path], typer.Option( + help=' create a log file containing all log ' + 'information including debug information even if this ' + 'was not requested by the debug switch. The special ' + 'call: "--logfile stdout" sends all information to ' + 'standard out instead of writing to a file' + ) + ] = None, + logsocket: Annotated[ + Optional[Path], typer.Option( + help=' send log data to the given Unix ' + 'Domain socket in the same format as with --logfile' + ) + ] = None, + loglevel: Annotated[ + Optional[int], typer.Option( + help=' specify logging level as number. ' + 'Details about the available log levels can be found at: ' + 'https://docs.python.org/3/library/logging.html#logging-levels ' + 'Setting a log level causes all message >= level to be ' + 'displayed.' + ) + ] = None, + profile: Annotated[ + Optional[List[str]], typer.Option( + help=' Profile name, multiple profiles can be selected ' + 'by passing this option multiple times' + ) + ] = [], + setenv: Annotated[ + Optional[List[str]], typer.Option( + help=' export environment variable and its ' + 'value into the caller environment. This option can be ' + 'specified multiple times ' + ) + ] = [], + shared_cache_dir: Annotated[ + Optional[Path], typer.Option( + help=' An alternative shared cache directory. ' + 'The directory is shared via bind mount between the ' + 'build host and image root system and contains ' + 'information about package repositories and their ' + 'cache and meta data.' + ) + ] = Path(os.sep + Defaults.get_shared_cache_location()), + target_arch: Annotated[ + Optional[str], typer.Option( + help=' set the image architecture. By default the host ' + 'architecture is used as the image architecture. If the ' + 'specified architecture name does not match the host ' + 'architecture and is therefore requesting a cross ' + 'architecture image build, it is important to understand ' + 'that for this process to work a preparatory step to ' + 'support the image architecture and binary format on the ' + 'building host is required first.' + ) + ] = None, + temp_dir: Annotated[ + Optional[Path], typer.Option( + help=' An alternative base temporary directory. ' + 'The provided path is used as base directory to store ' + 'temporary files and directories.' + ) + ] = Path(Defaults.get_temp_location()), + type: Annotated[ + Optional[str], typer.Option( + help=' Image build type. If not set the ' + 'default XML specified build type will be used' + ) + ] = None, + version: Annotated[ + Optional[bool], typer.Option( + '--version', help='show program version', callback=version + ) + ] = None + ): + """ + KIWI - Appliance Builder + """ + Cli.global_args['--color-output'] = color_output + Cli.global_args['--config'] = config + Cli.global_args['--debug'] = debug + Cli.global_args['--debug-run-scripts-in-screen'] = \ + debug_run_scripts_in_screen + Cli.global_args['--kiwi-file'] = kiwi_file + Cli.global_args['--logfile'] = logfile + Cli.global_args['--loglevel'] = loglevel + Cli.global_args['--logsocket'] = logsocket + Cli.global_args['--profile'] = profile + Cli.global_args['--setenv'] = setenv + Cli.global_args['--shared-cache-dir'] = f'{shared_cache_dir}' + Cli.global_args['--target-arch'] = target_arch + Cli.global_args['--temp-dir'] = f'{temp_dir}' + Cli.global_args['--type'] = type + Cli.global_args['command'] = None + Cli.global_args['image'] = False + Cli.global_args['result'] = False + Cli.global_args['system'] = False + + @staticmethod + @cli.command(help='[kiwi::COMMAND:SUBCOMMAND]') + def help(command: Annotated[str, typer.Argument()] = 'kiwi'): + manual = Help() + manual.show(command) + + @staticmethod + @image.command() + def info( + description: Annotated[ + Path, typer.Option( + help=' The description must be a directory ' + 'containing a kiwi XML description and ' + 'optional metadata files' + ) + ], + resolve_package_list: Annotated[ + Optional[bool], typer.Option( + '--resolve-package-list', + help='solve package dependencies and return a ' + 'list of all packages including their attributes ' + 'e.g. size, shasum, etc...' + ) + ] = False, + list_profiles: Annotated[ + Optional[bool], typer.Option( + '--list-profiles', + help='list profiles available for the selected/default type' + ) + ] = False, + print_kiwi_env: Annotated[ + Optional[bool], typer.Option( + '--print-kiwi-env', + help='list profiles available for the selected/default type' + ) + ] = False, + ignore_repos: Annotated[ + Optional[bool], typer.Option( + '--ignore-repos', + help='ignore all repos from the XML configuration' + ) + ] = False, + add_repo: Annotated[ + Optional[List[str]], typer.Option( + help=' Add repository ' + 'with given source, type, alias and priority. The ' + 'option can be specified multiple times' + ) + ] = [], + print_xml: Annotated[ + Optional[bool], typer.Option( + '--print-xml', + help='print image description in XML' + ) + ] = False, + print_yaml: Annotated[ + Optional[bool], typer.Option( + '--print-yaml', + help='print image description in YAML' + ) + ] = False, + print_toml: Annotated[ + Optional[bool], typer.Option( + '--print-toml', + help='print image description in TOML' + ) + ] = False + ): + """ + Provide information about the specified image description + """ + Cli.subcommand_args['info'] = { + '--description': f'{description}', + '--resolve-package-list': resolve_package_list, + '--list-profiles': list_profiles, + '--print-kiwi-env': print_kiwi_env, + '--ignore-repos': ignore_repos, + '--add-repo': add_repo, + '--print-xml': print_xml, + '--print-yaml': print_yaml, + '--print-toml': print_toml, + 'help': False + } + Cli.global_args['info'] = True + Cli.global_args['command'] = 'info' + Cli.global_args['image'] = True + Cli.cli_ok = True + + @staticmethod + @image.command() + def resize( + target_dir: Annotated[ + Path, typer.Option( + help=' The target directory ' + 'to expect image build results' + ) + ], + size: Annotated[ + str, typer.Option( + help=' New size of the image. The value is either ' + 'a size in bytes or can be specified with m=MB ' + 'or g=GB. Example: 20g' + ) + ], + root: Annotated[ + Optional[Path], typer.Option( + help=' The path to the root directory, if not ' + 'specified kiwi searches the root directory ' + 'in build/image-root below the specified target ' + 'directory' + ) + ] = None + ): + """ + For disk based images, allow to resize the image to a new + disk geometry. The additional space is free and not in use + by the image. In order to make use of the additional free + space a repartition process is required like it is provided + by kiwi's oem boot code. Therefore the resize operation is + useful for oem image builds most of the time + """ + Cli.subcommand_args['resize'] = { + '--target-dir': f'{target_dir}', + '--size': size, + '--root': root, + 'help': False + } + Cli.global_args['resize'] = True + Cli.global_args['command'] = 'resize' + Cli.global_args['image'] = True + Cli.cli_ok = True + + @staticmethod + @result.command() + def list( + target_dir: Annotated[ + Path, typer.Option( + help='the target directory to expect image build results' + ) + ] + ): + """ + List result information from a previously built image + """ + Cli.subcommand_args['list'] = { + '--target-dir': target_dir, + 'help': False + } + Cli.global_args['list'] = True + Cli.global_args['command'] = 'list' + Cli.global_args['result'] = True + Cli.cli_ok = True + + @staticmethod + @result.command() + def bundle( + target_dir: Annotated[ + Path, typer.Option( + help=' The target directory to ' + 'expect image build results' + ) + ], + id: Annotated[ + str, typer.Option( + help=' The bundle id. A free form text ' + 'appended to the version information of the result ' + 'image filename' + ) + ], + bundle_dir: Annotated[ + str, typer.Option( + help=' Directory to store the bundle results' + ) + ], + bundle_format: Annotated[ + Optional[str], typer.Option( + help=' The bundle format to create the bundle. ' + 'If provided this setting will overwrite an eventually ' + 'provided bundle_format attribute from the main ' + 'image description' + ) + ] = None, + zsync_source: Annotated[ + Optional[str], typer.Option( + help=' Specify the download ' + 'location from which the bundle file(s) can be ' + 'fetched from. The information is effective if zsync ' + 'is used to sync the bundle. The zsync control file ' + 'is only created for those bundle files which are ' + 'marked for compression because in a kiwi build ' + 'only those are meaningful for a partial binary ' + 'file download. It is expected that all files from a ' + 'bundle are placed to the same download location' + ) + ] = None, + package_as_rpm: Annotated[ + Optional[bool], typer.Option( + '--package-as-rpm', + help='Take all result files and create an rpm package out of it' + ) + ] = False + ): + """ + Create result bundle from the image build results in the + specified target directory. Each result image will contain + the specified bundle identifier as part of its filename. + Uncompressed image files will also become xz compressed + and a sha sum will be created from every result image. + """ + Cli.subcommand_args['bundle'] = { + '--target-dir': target_dir, + '--id': id, + '--bundle-dir': bundle_dir, + '--bundle-format': bundle_format, + '--zsync-source': zsync_source, + '--package-as-rpm': package_as_rpm, + 'help': False + } + Cli.global_args['bundle'] = True + Cli.global_args['command'] = 'bundle' + Cli.global_args['result'] = True + Cli.cli_ok = True + + @staticmethod + @system.command() + def build( + description: Annotated[ + Path, typer.Option( + help=' The description must be a ' + 'directory containing a kiwi XML description ' + 'and optional metadata files' + ) + ], + target_dir: Annotated[ + Path, typer.Option( + help=' The target directory to ' + 'store the system image file(s)' + ) + ], + allow_existing_root: Annotated[ + Optional[bool], typer.Option( + '--allow-existing-root', + help='Allow to use an existing root directory ' + 'from an earlier build attempt. Use with caution ' + 'this could cause an inconsistent root tree if the ' + 'existing contents does not fit to the former ' + 'image type setup' + ) + ] = False, + clear_cache: Annotated[ + Optional[bool], typer.Option( + '--clear-cache', + help='Delete repository cache for each of the ' + 'used repositories before installing any package' + ) + ] = False, + ignore_repos: Annotated[ + Optional[bool], typer.Option( + '--ignore-repos', + help='Ignore all repos from the XML configuration' + ) + ] = False, + ignore_repos_used_for_build: Annotated[ + Optional[bool], typer.Option( + '--ignore-repos-used-for-build', + help='Ignore all repos from the XML configuration ' + 'except the ones marked as imageonly' + ) + ] = False, + set_repo: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the first XML ' + 'listed repository source, type, alias, priority, ' + 'imageinclude(true|false), package_gpgcheck(true|false), ' + 'list of signing_keys enclosed in curly brackets delimited ' + 'by a colon, component list for debian based repos as string ' + 'delimited by a space, main distribution name for ' + 'debian based repos, repo_gpgcheck(true|false) and ' + 'repo_sourcetype(metalink|baseurl|mirrorlist)' + ) + ] = None, + set_repo_credentials: Annotated[ + Optional[str], typer.Option( + help=' ' + 'For repo sources of the form: uri://user:pass@location, ' + 'set the user and password connected to the set-repo ' + 'specification. If the provided value describes a ' + 'filename in the filesystem, the first line of that ' + 'file is read and used as credentials information.' + ) + ] = None, + add_repo: Annotated[ + Optional[List[str]], typer.Option( + help='Same as --set-repo, but it adds the repo to the ' + 'current list of repositories. The option can be specified ' + 'multiple times' + ) + ] = [], + add_repo_credentials: Annotated[ + Optional[List[str]], typer.Option( + help='Same as --set-repo-credentials, but The first ' + '--add-repo-credentials is connected with the first ' + '--add-repo specification and so on. The option can be ' + 'specified multiple times' + ) + ] = [], + add_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Install the given package name ' + 'The option can be specified multiple times' + ) + ] = [], + add_bootstrap_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Install the given package name as ' + 'part of the early bootstrap process. The option ' + 'can be specified multiple times' + ) + ] = [], + delete_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Delete the given package name ' + 'The option can be specified multiple times' + ) + ] = [], + set_container_derived_from: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the source location of the ' + 'base container for the selected image type. ' + 'The setting is only effective if the configured ' + 'image type is setup with an initial derived_from reference' + ) + ] = None, + set_container_tag: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the container tag in the ' + 'container configuration. The setting is only ' + 'effective if the container configuraiton provides ' + 'an initial tag value' + ) + ] = None, + add_container_label: Annotated[ + Optional[List[str]], typer.Option( + help=' Add a container label in the ' + 'container configuration metadata. The label with ' + 'the provided key-value pair will be overwritten ' + 'in case it was already defined in the XML description. ' + 'The option can be specified multiple times' + ) + ] = [], + set_type_attr: Annotated[ + Optional[List[str]], typer.Option( + help=' Overwrite/set the attribute ' + 'with the provided value in the selected build type ' + 'section. The option can be specified multiple times' + ) + ] = [], + set_release_version: Annotated[ + Optional[str], typer.Option( + help=' Overwrite/set the release-version ' + 'element in the selected build type preferences section' + ) + ] = None, + signing_key: Annotated[ + Optional[List[Path]], typer.Option( + help=' Includes the given key-file as a trusted ' + 'key for package manager validations. The option can be ' + 'specified multiple times' + ) + ] = [], + ): + """ + Build a system image from the specified description. The + build command combines the prepare and create commands. + """ + Cli.subcommand_args['build'] = { + '--description': f'{description}', + '--target-dir': f'{target_dir}', + '--allow-existing-root': allow_existing_root, + '--clear-cache': clear_cache, + '--ignore-repos': ignore_repos, + '--ignore-repos-used-for-build': ignore_repos_used_for_build, + '--set-repo': set_repo, + '--set-repo-credentials': set_repo_credentials, + '--add-repo': add_repo, + '--add-repo-credentials': add_repo_credentials, + '--add-package': add_package, + '--add-bootstrap-package': add_bootstrap_package, + '--delete-package': delete_package, + '--set-container-derived-from': set_container_derived_from, + '--set-container-tag': set_container_tag, + '--add-container-label': add_container_label, + '--set-type-attr': set_type_attr, + '--set-release-version': set_release_version, + '--signing-key': signing_key, + 'help': False + } + Cli.global_args['build'] = True + Cli.global_args['command'] = 'build' + Cli.global_args['system'] = True + Cli.cli_ok = True + + @staticmethod + @system.command() + def prepare( + description: Annotated[ + Path, typer.Option( + help=' The description must be a ' + 'directory containing a kiwi XML description ' + 'and optional metadata files' + ) + ], + root: Annotated[ + Path, typer.Option( + help=' The path to the new root ' + 'directory of the system ' + ) + ], + allow_existing_root: Annotated[ + Optional[bool], typer.Option( + '--allow-existing-root', + help='Allow to use an existing root directory ' + 'from an earlier build attempt. Use with caution ' + 'this could cause an inconsistent root tree if the ' + 'existing contents does not fit to the former ' + 'image type setup' + ) + ] = False, + clear_cache: Annotated[ + Optional[bool], typer.Option( + '--clear-cache', + help='Delete repository cache for each of the ' + 'used repositories before installing any package' + ) + ] = False, + ignore_repos: Annotated[ + Optional[bool], typer.Option( + '--ignore-repos', + help='Ignore all repos from the XML configuration' + ) + ] = False, + ignore_repos_used_for_build: Annotated[ + Optional[bool], typer.Option( + '--ignore-repos-used-for-build', + help='Ignore all repos from the XML configuration ' + 'except the ones marked as imageonly' + ) + ] = False, + set_repo: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the first XML ' + 'listed repository source, type, alias, priority, ' + 'imageinclude(true|false), package_gpgcheck(true|false), ' + 'list of signing_keys enclosed in curly brackets delimited ' + 'by a colon, component list for debian based repos as string ' + 'delimited by a space, main distribution name for ' + 'debian based repos, repo_gpgcheck(true|false) and ' + 'repo_sourcetype(metalink|baseurl|mirrorlist)' + ) + ] = None, + set_repo_credentials: Annotated[ + Optional[str], typer.Option( + help=' ' + 'For repo sources of the form: uri://user:pass@location, ' + 'set the user and password connected to the set-repo ' + 'specification. If the provided value describes a ' + 'filename in the filesystem, the first line of that ' + 'file is read and used as credentials information.' + ) + ] = None, + add_repo: Annotated[ + Optional[List[str]], typer.Option( + help='Same as --set-repo, but it adds the repo to the ' + 'current list of repositories. The option can be specified ' + 'multiple times' + ) + ] = [], + add_repo_credentials: Annotated[ + Optional[List[str]], typer.Option( + help='Same as --set-repo-credentials, but The first ' + '--add-repo-credentials is connected with the first ' + '--add-repo specification and so on. The option can be ' + 'specified multiple times' + ) + ] = [], + add_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Install the given package name ' + 'The option can be specified multiple times' + ) + ] = [], + add_bootstrap_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Install the given package name as ' + 'part of the early bootstrap process. The option ' + 'can be specified multiple times' + ) + ] = [], + delete_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Delete the given package name ' + 'The option can be specified multiple times' + ) + ] = [], + set_container_derived_from: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the source location of the ' + 'base container for the selected image type. ' + 'The setting is only effective if the configured ' + 'image type is setup with an initial derived_from reference' + ) + ] = None, + set_container_tag: Annotated[ + Optional[str], typer.Option( + help=' Overwrite the container tag in the ' + 'container configuration. The setting is only ' + 'effective if the container configuraiton provides ' + 'an initial tag value' + ) + ] = None, + add_container_label: Annotated[ + Optional[List[str]], typer.Option( + help=' Add a container label in the ' + 'container configuration metadata. The label with ' + 'the provided key-value pair will be overwritten ' + 'in case it was already defined in the XML description. ' + 'The option can be specified multiple times' + ) + ] = [], + set_type_attr: Annotated[ + Optional[List[str]], typer.Option( + help=' Overwrite/set the attribute ' + 'with the provided value in the selected build type ' + 'section. The option can be specified multiple times' + ) + ] = [], + set_release_version: Annotated[ + Optional[str], typer.Option( + help=' Overwrite/set the release-version ' + 'element in the selected build type preferences section' + ) + ] = None, + signing_key: Annotated[ + Optional[List[Path]], typer.Option( + help=' Includes the given key-file as a trusted ' + 'key for package manager validations. The option can be ' + 'specified multiple times' + ) + ] = [], + ): + """ + Prepare and install a new system root tree for chroot access. + """ + Cli.subcommand_args['prepare'] = { + '--description': f'{description}', + '--root': f'{root}', + '--allow-existing-root': allow_existing_root, + '--clear-cache': clear_cache, + '--ignore-repos': ignore_repos, + '--ignore-repos-used-for-build': ignore_repos_used_for_build, + '--set-repo': set_repo, + '--set-repo-credentials': set_repo_credentials, + '--add-repo': add_repo, + '--add-repo-credentials': add_repo_credentials, + '--add-package': add_package, + '--add-bootstrap-package': add_bootstrap_package, + '--delete-package': delete_package, + '--set-container-derived-from': set_container_derived_from, + '--set-container-tag': set_container_tag, + '--add-container-label': add_container_label, + '--set-type-attr': set_type_attr, + '--set-release-version': set_release_version, + '--signing-key': signing_key, + 'help': False + } + Cli.global_args['prepare'] = True + Cli.global_args['command'] = 'prepare' + Cli.global_args['system'] = True + Cli.cli_ok = True + + @staticmethod + @system.command() + def update( + root: Annotated[ + Path, typer.Option( + help=' The path to the root directory' + ) + ], + add_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Install the given package name ' + 'The option can be specified multiple times' + ) + ] = [], + delete_package: Annotated[ + Optional[List[str]], typer.Option( + help=' Delete the given package name ' + 'The option can be specified multiple times' + ) + ] = [] + ): + """ + Update root system with latest repository updates + and optionally allow to add or delete packages. + """ + Cli.subcommand_args['update'] = { + '--root': root, + '--add-package': add_package, + '--delete-package': delete_package + } + Cli.global_args['update'] = True + Cli.global_args['command'] = 'update' + Cli.global_args['system'] = True + Cli.cli_ok = True + + @staticmethod + @system.command() + def create( + root: Annotated[ + Path, typer.Option( + help=' The path to the root directory' + ) + ], + target_dir: Annotated[ + Path, typer.Option( + help=' The target directory to ' + 'store the system image file(s)' + ) + ], + signing_key: Annotated[ + Optional[List[Path]], typer.Option( + help=' Includes the given key-file as a trusted ' + 'key for package manager validations. The option can be ' + 'specified multiple times' + ) + ] = [], + ): """ - Execute man to show the selected manual page + Create an image from the specified root directory. """ - if self.all_args['help']: - manual = Help() - manual.show('kiwi') - sys.exit(0) + Cli.subcommand_args['create'] = { + '--root': root, + '--target-dir': target_dir, + '--signing-key': signing_key + } + Cli.global_args['create'] = True + Cli.global_args['command'] = 'create' + Cli.global_args['system'] = True + Cli.cli_ok = True def get_servicename(self): """ @@ -174,11 +915,11 @@ def get_servicename(self): :rtype: str """ - if self.all_args.get('image') is True: + if Cli.global_args.get('image') is True: return 'image' - elif self.all_args.get('system') is True: + elif Cli.global_args.get('system') is True: return 'system' - elif self.all_args.get('result') is True: + elif Cli.global_args.get('result') is True: return 'result' else: raise KiwiUnknownServiceName( @@ -192,11 +933,12 @@ def get_command(self): :return: command name :rtype: str """ - return self.all_args[''] + return Cli.global_args['command'] - def get_command_args(self): + def get_command_args(self, raise_if_no_command: bool = True) -> Dict: """ - Extract argument dict for selected command + Get argument dict for selected command + including global options :return: Contains dictionary of command arguments @@ -204,16 +946,26 @@ def get_command_args(self): .. code:: python { - '--command-option': 'value' + '--some-option-name': 'value' } :rtype: dict """ - return self._load_command_args() + result = Cli.global_args + command = self.get_command() + if Cli.subcommand_args.get(command): + result.update( + Cli.subcommand_args.get(command) or {} + ) + elif raise_if_no_command: + raise KiwiCommandNotLoaded( + f'{command} command not loaded' + ) + return result def get_global_args(self): """ - Extract argument dict for global arguments + Get argument dict for global arguments :return: Contains dictionary of global arguments @@ -221,25 +973,21 @@ def get_global_args(self): .. code:: python { - '--global-option': 'value' + '--some-global-option': 'value' } :rtype: dict """ result = {} - for arg, value in list(self.all_args.items()): - if not arg == '' and not arg == '': + for arg, value in list(Cli.global_args.items()): + if not arg == 'command': if arg == '--type' and value == 'vmx': log.warning( 'vmx type is now a subset of oem, --type set to oem' ) value = 'oem' - if arg == '--shared-cache-dir' and not value: - value = os.sep + Defaults.get_shared_cache_location() if arg == '--shared-cache-dir' and value: Defaults.set_shared_cache_location(value) - if arg == '--temp-dir' and not value: - value = Defaults.get_temp_location() if arg == '--temp-dir' and value: Defaults.set_temp_location(value) if arg == '--target-arch' and value: @@ -249,6 +997,45 @@ def get_global_args(self): result[arg] = value return result + def load_plugin_cli(self) -> Dict[str, typer.Typer]: + """ + Loads plugin command line interface + + The loading is based on the plugin entry point name + and requires the presence of a Typer based cli.py. + The implementation of cli.py in the plugin also + requires to provide a dict named typers. The matching + of an entry point to be a valid kiwi plugin requires + the entry point group to be set to kiwi.tasks and + the entry point value must container the _plugin + substring. + + :return: list of typer.Typer instances + + :rtype: list + """ + plugin_typers = {} + for entry in self._get_module_entries(): + if '_plugin' in entry.value: + module_name = entry.value.split('.')[0] + plugin_entry = EntryPoint( + name='cli', + value=f'{module_name}.cli', + group='kiwi.tasks' + ) + try: + plugin = plugin_entry.load() + plugin_typers.update(plugin.typers) + except ModuleNotFoundError: + # plugin gets skipped if it does not provide + # a typer based cli + pass + except Exception as issue: + raise KiwiLoadPluginError( + f'{plugin_entry}: {issue}' + ) + return plugin_typers + def load_command(self): """ Loads task class plugin according to service and command name @@ -258,14 +1045,8 @@ def load_command(self): :rtype: object """ discovered_tasks = {} - if sys.version_info >= (3, 12): - for entry in list(entry_points()): # pragma: no cover - if entry.group == 'kiwi.tasks': - discovered_tasks[entry.name] = entry.load() - else: # pragma: no cover - module_entries = dict.get(entry_points(), 'kiwi.tasks') - for entry in module_entries: - discovered_tasks[entry.name] = entry.load() + for entry in self._get_module_entries(): + discovered_tasks[entry.name] = entry.load() service = self.get_servicename() command = self.get_command() @@ -276,7 +1057,7 @@ def load_command(self): ) self.command_loaded = discovered_tasks.get( - service + '_' + command + f'{service}_{command}' ) if not self.command_loaded: prefix = 'usage:' @@ -294,13 +1075,16 @@ def load_command(self): ) return self.command_loaded - def _load_command_args(self): - try: - argv = [ - self.get_servicename(), self.get_command() - ] + self.command_args - return docopt(self.command_loaded.__doc__, argv=argv) - except Exception: - raise KiwiCommandNotLoaded( - f'{self.get_command()} command not loaded' - ) + def _get_module_entries(self) -> List[EntryPoint]: + """ + Lookup module entries matching the kiwi.tasks entry group + """ + entries: List[EntryPoint] = [] + if sys.version_info >= (3, 12): + for entry in list(entry_points()): # pragma: no cover + if entry.group == 'kiwi.tasks': + entries.append(entry) + else: # pragma: no cover + for entry in dict.get(entry_points(), 'kiwi.tasks') or {}: + entries.append(entry) + return entries diff --git a/kiwi/exceptions.py b/kiwi/exceptions.py index 7f91736d355..a07335296d9 100644 --- a/kiwi/exceptions.py +++ b/kiwi/exceptions.py @@ -387,6 +387,12 @@ class KiwiLoadCommandUndefined(KiwiError): """ +class KiwiLoadPluginError(KiwiError): + """ + Exception raised if loading the plugin has failed + """ + + class KiwiLogFileSetupFailed(KiwiError): """ Exception raised if the log file could not be created. diff --git a/kiwi/kiwi.py b/kiwi/kiwi.py index e9eea023135..ede1f4bb837 100644 --- a/kiwi/kiwi.py +++ b/kiwi/kiwi.py @@ -16,43 +16,15 @@ # along with kiwi. If not, see # import sys -import docopt import logging # project from kiwi.app import App from kiwi.exceptions import KiwiError -from kiwi.defaults import Defaults log = logging.getLogger('kiwi') -def extras(help_, version, options, doc): - """ - Overwritten method from docopt - - Shows our own usage message for -h|--help - - :param bool help_: indicate to show help - :param string version: version string - :param list options: - - list of option tuples - - .. code:: python - - [option(name='name', value='value')] - - :param string doc: docopt doc string - """ - if help_ and any((o.name in ('-h', '--help')) and o.value for o in options): - usage(doc.strip("\n")) - sys.exit(1) - if version and any(o.name == '--version' and o.value for o in options): - print(version) - sys.exit(0) - - def main(): """ kiwi - main application entry point @@ -63,7 +35,6 @@ def main(): which is handled as unexpected error including the python backtrace """ - docopt.__dict__['extras'] = extras try: App() except KiwiError as e: @@ -73,10 +44,6 @@ def main(): except KeyboardInterrupt: log.error('kiwi aborted by keyboard interrupt') sys.exit(1) - except docopt.DocoptExit as e: - # exception thrown by docopt, results in usage message - usage(e) - sys.exit(1) except SystemExit as e: # user exception, program aborted by user sys.exit(e) @@ -84,38 +51,3 @@ def main(): # exception we did no expect, show python backtrace log.error('Unexpected error:') raise - - -def usage(command_usage): - """ - Instead of the docopt way to show the usage information we - provide a kiwi specific usage information. The usage - data now always consists out of: - - 1. the generic call - kiwi-ng [global options] service [] - - 2. the command specific usage defined by the docopt string - short form by default, long form with -h | --help - - 3. the global options - - :param string command_usage: usage data - """ - with open(Defaults.project_file('cli.py'), 'r') as cli: - program_code = cli.readlines() - - global_options = '\n' - process_lines = False - for line in program_code: - if line.rstrip().startswith('global options'): - process_lines = True - if line.rstrip() == '"""': - process_lines = False - if process_lines: - global_options += format(line) - - print('usage: kiwi-ng [global options] service []\n') - print(format(command_usage).replace('usage:', ' ')) - if 'global options' not in format(command_usage): - print(format(global_options)) diff --git a/kiwi/tasks/base.py b/kiwi/tasks/base.py index 21a1ee92789..2bb0c1cbdd0 100644 --- a/kiwi/tasks/base.py +++ b/kiwi/tasks/base.py @@ -56,9 +56,6 @@ def __init__(self, should_perform_task_setup: bool = True) -> None: # initialize runtime checker self.runtime_checker: Optional[RuntimeChecker] = None - # help requested - self.cli.show_and_exit_on_help_request() - # load/import task module self.task = self.cli.load_command() diff --git a/kiwi/tasks/image_info.py b/kiwi/tasks/image_info.py index fe07838b7ad..4637faccd6c 100644 --- a/kiwi/tasks/image_info.py +++ b/kiwi/tasks/image_info.py @@ -15,40 +15,6 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng image info -h | --help - kiwi-ng image info --description= - [--resolve-package-list] - [--list-profiles] - [--print-kiwi-env] - [--ignore-repos] - [--add-repo=...] - [--print-xml|--print-yaml|--print-toml] - kiwi-ng image info help - -commands: - info - provide information about the specified image description - -options: - --add-repo= - add repository with given source, type, alias and priority - --description= - the description must be a directory containing a kiwi XML - description and optional metadata files - --ignore-repos - ignore all repos from the XML configuration - --resolve-package-list - solve package dependencies and return a list of all - packages including their attributes e.g size, - shasum, etc... - --list-profiles - list profiles available for the selected/default type - --print-kiwi-env - print kiwi profile environment variables - --print-xml|--print-yaml|--print-toml - print image description in specified format -""" import os # project diff --git a/kiwi/tasks/image_resize.py b/kiwi/tasks/image_resize.py index f21c01f794d..a83abc63122 100644 --- a/kiwi/tasks/image_resize.py +++ b/kiwi/tasks/image_resize.py @@ -15,34 +15,6 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng image resize -h | --help - kiwi-ng image resize --target-dir= --size= - [--root=] - kiwi-ng image resize help - -commands: - resize - for disk based images, allow to resize the image to a new - disk geometry. The additional space is free and not in use - by the image. In order to make use of the additional free - space a repartition process is required like it is provided - by kiwi's oem boot code. Therefore the resize operation is - useful for oem image builds most of the time - -options: - --root= - the path to the root directory, if not specified kiwi - searches the root directory in build/image-root below - the specified target directory - - --size= - new size of the image. The value is either a size in bytes - or can be specified with m=MB or g=GB. Example: 20g - - --target-dir= - the target directory to expect image build results -""" import os import logging diff --git a/kiwi/tasks/result_bundle.py b/kiwi/tasks/result_bundle.py index 89debcd85bb..0a740e7ab93 100644 --- a/kiwi/tasks/result_bundle.py +++ b/kiwi/tasks/result_bundle.py @@ -15,49 +15,6 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng result bundle -h | --help - kiwi-ng result bundle --target-dir= --id= --bundle-dir= - [--bundle-format=] - [--zsync-source=] - [--package-as-rpm] - [--no-compress] - kiwi-ng result bundle help - -commands: - bundle - create result bundle from the image build results in the - specified target directory. Each result image will contain - the specified bundle identifier as part of its filename. - Uncompressed image files will also become xz compressed - and a sha sum will be created from every result image. - -options: - --bundle-dir= - directory to store the bundle results - --id= - the bundle id. A free form text appended to the version - information of the result image filename - --target-dir= - the target directory to expect image build results - --zsync-source= - specify the download location from which the bundle file(s) - can be fetched from. The information is effective if zsync is - used to sync the bundle. The zsync control file is only created - for those bundle files which are marked for compression because - in a kiwi build only those are meaningful for a partial binary - file download. It is expected that all files from a bundle - are placed to the same download location - --package-as-rpm - Take all result files and create an rpm package out of it - --bundle-format= - specify the bundle format to create the bundle. - If provided this setting will overwrite an eventually - provided bundle_format attribute from the main - image description - --no-compress - Do not compress the result image file(s) -""" from collections import OrderedDict from textwrap import dedent from typing import List diff --git a/kiwi/tasks/result_list.py b/kiwi/tasks/result_list.py index cb43b17320c..7af2211f854 100644 --- a/kiwi/tasks/result_list.py +++ b/kiwi/tasks/result_list.py @@ -15,19 +15,6 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng result list -h | --help - kiwi-ng result list --target-dir= - kiwi-ng result list help - -commands: - list - list result information from a previous system command - -options: - --target-dir= - the target directory as it was used in a system command -""" import os import logging diff --git a/kiwi/tasks/system_build.py b/kiwi/tasks/system_build.py index 59d5c681830..9ee9f593c94 100644 --- a/kiwi/tasks/system_build.py +++ b/kiwi/tasks/system_build.py @@ -15,108 +15,6 @@ # You should have received a copy of the GNU General Public License # along with kiwi. If not, see # -""" -usage: kiwi-ng system build -h | --help - kiwi-ng system build --description= --target-dir= - [--allow-existing-root] - [--clear-cache] - [--ignore-repos] - [--ignore-repos-used-for-build] - [--set-repo=] - [--set-repo-credentials=] - [--add-repo=...] - [--add-repo-credentials=...] - [--add-package=...] - [--add-bootstrap-package=...] - [--delete-package=...] - [--set-container-derived-from=] - [--set-container-tag=] - [--add-container-label=