Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package osc for openSUSE:Factory checked in 
at 2023-04-03 21:49:23
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/osc (Old)
 and      /work/SRC/openSUSE:Factory/.osc.new.9019 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "osc"

Mon Apr  3 21:49:23 2023 rev:174 rq:1077044 version:1.1.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/osc/osc.changes  2023-03-17 17:05:32.066082506 
+0100
+++ /work/SRC/openSUSE:Factory/.osc.new.9019/osc.changes        2023-04-03 
21:49:25.104879589 +0200
@@ -1,0 +2,15 @@
+Mon Apr  3 11:58:12 UTC 2023 - Daniel Mach <daniel.m...@suse.com>
+
+- Update to 1.1.0
+  - Command-line:
+    - New class-based commands
+    - Sort commands before printing help
+    - No longer read plugins from /var/lib/osc-plugins
+  - Configuration:
+    - Do not error out on setting oscrc permissions if the file is owned by 
another user
+  - Library:
+    - Restore 'include_request_from_project' conf option functionality
+    - Simplify how babysitter works with options and config
+    - Prefer f-strings over c-style string expansion
+
+-------------------------------------------------------------------

Old:
----
  osc-1.0.1.tar.gz

New:
----
  osc-1.1.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ osc.spec ++++++
--- /var/tmp/diff_new_pack.b2pIoh/_old  2023-04-03 21:49:25.868884003 +0200
+++ /var/tmp/diff_new_pack.b2pIoh/_new  2023-04-03 21:49:25.872884026 +0200
@@ -49,7 +49,7 @@
 %endif
 
 Name:           osc
-Version:        1.0.1
+Version:        1.1.0
 Release:        0
 Summary:        Command-line client for the Open Build Service
 License:        GPL-2.0-or-later

++++++ PKGBUILD ++++++
--- /var/tmp/diff_new_pack.b2pIoh/_old  2023-04-03 21:49:25.908884233 +0200
+++ /var/tmp/diff_new_pack.b2pIoh/_new  2023-04-03 21:49:25.912884257 +0200
@@ -1,5 +1,5 @@
 pkgname=osc
-pkgver=1.0.1
+pkgver=1.1.0
 pkgrel=0
 pkgdesc="Command-line client for the Open Build Service"
 arch=('x86_64')

++++++ debian.changelog ++++++
--- /var/tmp/diff_new_pack.b2pIoh/_old  2023-04-03 21:49:25.960884534 +0200
+++ /var/tmp/diff_new_pack.b2pIoh/_new  2023-04-03 21:49:25.964884557 +0200
@@ -1,2 +1,2 @@
-osc (1.0.1-0) unstable; urgency=low
+osc (1.1.0-0) unstable; urgency=low
 

++++++ osc-1.0.1.tar.gz -> osc-1.1.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/NEWS new/osc-1.1.0/NEWS
--- old/osc-1.0.1/NEWS  2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/NEWS  2023-04-03 13:45:36.000000000 +0200
@@ -1,3 +1,15 @@
+- 1.1.0
+  - Command-line:
+    - New class-based commands
+    - Sort commands before printing help
+    - No longer read plugins from /var/lib/osc-plugins
+  - Configuration:
+    - Do not error out on setting oscrc permissions if the file is owned by 
another user
+  - Library:
+    - Restore 'include_request_from_project' conf option functionality
+    - Simplify how babysitter works with options and config
+    - Prefer f-strings over c-style string expansion
+
 - 1.0.1
   - Configuration:
     - Fix a cut&paste error in setting 'disable_hdrmd5_check' config option
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/api/osc.commandline.rst 
new/osc-1.1.0/doc/api/osc.commandline.rst
--- old/osc-1.0.1/doc/api/osc.commandline.rst   2023-03-17 16:05:07.000000000 
+0100
+++ new/osc-1.1.0/doc/api/osc.commandline.rst   2023-04-03 13:45:36.000000000 
+0200
@@ -2,7 +2,16 @@
 ===========
 
 
-The `osc.commandline` module provides argument parsing functionality to osc 
plugins.
+The ``osc.commandline`` module provides functionality for creating osc 
command-line plugins.
+
+
+.. autoclass:: osc.commandline.OscCommand
+   :inherited-members:
+   :members:
+
+
+.. autoclass:: osc.commandline.OscMainCommand
+   :members: main
 
 
 .. automodule:: osc.commandline
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/index.rst new/osc-1.1.0/doc/index.rst
--- old/osc-1.0.1/doc/index.rst 2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/doc/index.rst 2023-04-03 13:45:36.000000000 +0200
@@ -20,6 +20,7 @@
    :maxdepth: 2
 
    api/modules
+   plugins/index
 
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/index.rst 
new/osc-1.1.0/doc/plugins/index.rst
--- old/osc-1.0.1/doc/plugins/index.rst 1970-01-01 01:00:00.000000000 +0100
+++ new/osc-1.1.0/doc/plugins/index.rst 2023-04-03 13:45:36.000000000 +0200
@@ -0,0 +1,54 @@
+Extending osc with plugins
+==========================
+
+
+.. note::
+    New in osc 1.1.0
+
+
+This is a simple tutorial.
+More details can be found in the :py:class:`osc.commandline.OscCommand` 
reference.
+
+
+Steps
+-----
+1. First, we choose a location where to put the plugin
+
+  .. include:: plugin_locations.rst
+
+2. Then we pick a file name
+
+  - The file should contain a single command and its name should correspond 
with the command name.
+  - The file name should be prefixed with parent command(s) (only if 
applicable).
+  - Example: Adding ``list`` subcommand to ``osc request`` -> 
``request_list.py``.
+
+3. And then we write a class that inherits from 
:py:class:`osc.commandline.OscCommand` and implements our command.
+
+  - The class name should also correspond with the command name incl. the 
parent prefix.
+  - Examples follow...
+
+
+
+
+A simple command
+----------------
+
+``simple.py``
+
+    .. literalinclude:: simple.py
+
+
+Command with subcommands
+------------------------
+
+``request.py``
+
+    .. literalinclude:: request.py
+
+``request_list.py``
+
+    .. literalinclude:: request_list.py
+
+``request_accept.py``
+
+    .. literalinclude:: request_accept.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/plugin_locations.rst 
new/osc-1.1.0/doc/plugins/plugin_locations.rst
--- old/osc-1.0.1/doc/plugins/plugin_locations.rst      1970-01-01 
01:00:00.000000000 +0100
+++ new/osc-1.1.0/doc/plugins/plugin_locations.rst      2023-04-03 
13:45:36.000000000 +0200
@@ -0,0 +1,5 @@
+ - The directory from where the ``osc.commands`` module gets loaded.
+ - /usr/lib/osc-plugins
+ - /usr/local/lib/osc-plugins
+ - ~/.local/lib/osc-plugins
+ - ~/.osc-plugins
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/request.py 
new/osc-1.1.0/doc/plugins/request.py
--- old/osc-1.0.1/doc/plugins/request.py        1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-1.1.0/doc/plugins/request.py        2023-04-03 13:45:36.000000000 
+0200
@@ -0,0 +1,18 @@
+import osc.commandline
+
+
+class RequestCommand(osc.commandline.OscCommand):
+    """
+    Manage requests
+    """
+
+    name = "request"
+    aliases = ["rq"]
+
+    # arguments specified here will get inherited to all subcommands 
automatically
+    def init_arguments(self):
+        self.add_argument(
+            "-m",
+            "--message",
+            metavar="TEXT",
+        )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/request_accept.py 
new/osc-1.1.0/doc/plugins/request_accept.py
--- old/osc-1.0.1/doc/plugins/request_accept.py 1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-1.1.0/doc/plugins/request_accept.py 2023-04-03 13:45:36.000000000 
+0200
@@ -0,0 +1,19 @@
+import osc.commandline
+
+
+class RequestAcceptCommand(osc.commandline.OscCommand):
+    """
+    Accept request
+    """
+
+    name = "accept"
+    parent = "RequestCommand"
+
+    def init_arguments(self):
+        self.add_argument(
+            "id",
+            type=int,
+        )
+
+    def run(self, args):
+        print(f"Accepting request '{args.id}'")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/request_list.py 
new/osc-1.1.0/doc/plugins/request_list.py
--- old/osc-1.0.1/doc/plugins/request_list.py   1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-1.1.0/doc/plugins/request_list.py   2023-04-03 13:45:36.000000000 
+0200
@@ -0,0 +1,13 @@
+import osc.commandline
+
+
+class RequestListCommand(osc.commandline.OscCommand):
+    """
+    List requests
+    """
+
+    name = "list"
+    parent = "RequestCommand"
+
+    def run(self, args):
+        print("Listing requests")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/doc/plugins/simple.py 
new/osc-1.1.0/doc/plugins/simple.py
--- old/osc-1.0.1/doc/plugins/simple.py 1970-01-01 01:00:00.000000000 +0100
+++ new/osc-1.1.0/doc/plugins/simple.py 2023-04-03 13:45:36.000000000 +0200
@@ -0,0 +1,32 @@
+import osc.commandline
+
+
+class SimpleCommand(osc.commandline.OscCommand):
+    """
+    A command that does nothing
+
+    More description
+    of what the command does.
+    """
+
+    # command name
+    name = "simple"
+
+    # options and positional arguments
+    def init_arguments(self):
+        self.add_argument(
+            "--bool-option",
+            action="store_true",
+            help="...",
+        )
+        self.add_argument(
+            "arguments",
+            metavar="arg",
+            nargs="+",
+            help="...",
+        )
+
+    # code of the command
+    def run(self, args):
+        print(f"Bool option is {args.bool_option}")
+        print(f"Positional arguments are {args.arguments}")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/__init__.py 
new/osc-1.1.0/osc/__init__.py
--- old/osc-1.0.1/osc/__init__.py       2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/__init__.py       2023-04-03 13:45:36.000000000 +0200
@@ -13,7 +13,7 @@
 
 
 from .util import git_version
-__version__ = git_version.get_version('1.0.1')
+__version__ = git_version.get_version('1.1.0')
 
 
 # vim: sw=4 et
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/babysitter.py 
new/osc-1.1.0/osc/babysitter.py
--- old/osc-1.0.1/osc/babysitter.py     2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/babysitter.py     2023-04-03 13:45:36.000000000 +0200
@@ -18,6 +18,7 @@
 
 from . import _private
 from . import commandline
+from . import conf as osc_conf
 from . import oscerr
 from .OscConfigParser import configparser
 from .oscssl import CertVerificationError
@@ -52,19 +53,21 @@
 def run(prg, argv=None):
     try:
         try:
-            if '--debugger' in sys.argv:
+            # we haven't parsed options yet, that's why we rely on argv 
directly
+            if "--debugger" in (argv or sys.argv[1:]):
                 pdb.set_trace()
-            # here we actually run the program:
-            return prg.main(argv)
+            # here we actually run the program
+            prg.main(argv)
+            return 0
         except:
-            # look for an option in the prg.options object and in the config
-            # dict print stack trace, if desired
-            if getattr(prg.options, 'traceback', None) or getattr(prg.conf, 
'config', {}).get('traceback', None) or \
-               getattr(prg.options, 'post_mortem', None) or getattr(prg.conf, 
'config', {}).get('post_mortem', None):
+            # If any of these was set via the command-line options,
+            # the config values are expected to be changed accordingly.
+            # That's why we're working only with the config.
+            if osc_conf.config["traceback"] or osc_conf.config["post_mortem"]:
                 traceback.print_exc(file=sys.stderr)
                 # we could use 
http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52215
             # enter the debugger, if desired
-            if getattr(prg.options, 'post_mortem', None) or getattr(prg.conf, 
'config', {}).get('post_mortem', None):
+            if osc_conf.config["post_mortem"]:
                 if sys.stdout.isatty() and not hasattr(sys, 'ps1'):
                     pdb.post_mortem(sys.exc_info()[2])
                 else:
@@ -80,7 +83,7 @@
     except oscerr.APIError as e:
         print('BuildService API error:', e.msg, file=sys.stderr)
     except oscerr.LinkExpandError as e:
-        print('Link "%s/%s" cannot be expanded:\n' % (e.prj, e.pac), e.msg, 
file=sys.stderr)
+        print(f'Link "{e.prj}/{e.pac}" cannot be expanded:\n', e.msg, 
file=sys.stderr)
         print('Use "osc repairlink" to fix merge conflicts.\n', 
file=sys.stderr)
     except oscerr.WorkingCopyWrongVersion as e:
         print(e, file=sys.stderr)
@@ -104,8 +107,7 @@
         except AttributeError:
             body = ''
 
-        if getattr(prg.options, 'debug', None) or \
-           getattr(prg.conf, 'config', {}).get('debug', None):
+        if osc_conf.config["debug"]:
             print(e.hdrs, file=sys.stderr)
             print(body, file=sys.stderr)
 
@@ -116,11 +118,11 @@
                 msg = _private.api.xml_escape(msg)
                 print(decode_it(msg), file=sys.stderr)
         if e.code >= 500 and e.code <= 599:
-            print('\nRequest: %s' % e.filename)
+            print(f'\nRequest: {e.filename}')
             print('Headers:')
             for h, v in e.hdrs.items():
                 if h != 'Set-Cookie':
-                    print("%s: %s" % (h, v))
+                    print(f"{h}: {v}")
 
     except BadStatusLine as e:
         print('Server returned an invalid response:', e, file=sys.stderr)
@@ -130,7 +132,7 @@
     except URLError as e:
         msg = 'Failed to reach a server'
         if hasattr(e, '_osc_host_port'):
-            msg += ' (%s)' % e._osc_host_port
+            msg += f' ({e._osc_host_port})'
         msg += ':\n'
         print(msg, e.reason, file=sys.stderr)
     except ssl.SSLError as e:
@@ -151,8 +153,7 @@
         print(e.message, file=sys.stderr)
     except oscerr.OscIOError as e:
         print(e.msg, file=sys.stderr)
-        if getattr(prg.options, 'debug', None) or \
-           getattr(prg.conf, 'config', {}).get('debug', None):
+        if osc_conf.config["debug"]:
             print(e.e, file=sys.stderr)
     except (oscerr.WrongOptions, oscerr.WrongArgs) as e:
         print(e, file=sys.stderr)
@@ -174,7 +175,7 @@
     except oscerr.PackageError as e:
         print(e.msg, file=sys.stderr)
     except PackageError as e:
-        print('%s:' % e.fname, e.msg, file=sys.stderr)
+        print(f'{e.fname}:', e.msg, file=sys.stderr)
     except RPMError as e:
         print(e, file=sys.stderr)
     except CertVerificationError as e:
@@ -207,6 +208,6 @@
         sys.stdout = os.fdopen(sys.stdout.fileno(), sys.stdout.mode, 1)
         sys.stderr = os.fdopen(sys.stderr.fileno(), sys.stderr.mode, 1)
 
-    sys.exit(run(commandline.Osc()))
+    sys.exit(run(commandline.OscMainCommand()))
 
 # vim: sw=4 et
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/cmdln.py new/osc-1.1.0/osc/cmdln.py
--- old/osc-1.0.1/osc/cmdln.py  2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/cmdln.py  2023-04-03 13:45:36.000000000 +0200
@@ -81,7 +81,9 @@
     def _format_action(self, action):
         if isinstance(action, argparse._SubParsersAction):
             parts = []
-            for i in action._get_subactions():
+            subactions = action._get_subactions()
+            subactions.sort(key=lambda x: x.metavar)
+            for i in subactions:
                 if i.help == argparse.SUPPRESS:
                     # don't display commands with suppressed help
                     continue
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/commandline.py 
new/osc-1.1.0/osc/commandline.py
--- old/osc-1.0.1/osc/commandline.py    2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/commandline.py    2023-04-03 13:45:36.000000000 +0200
@@ -6,9 +6,11 @@
 import argparse
 import getpass
 import glob
+import importlib
 import importlib.util
 import inspect
 import os
+import pkgutil
 import re
 import subprocess
 import sys
@@ -26,6 +28,7 @@
 from . import _private
 from . import build as osc_build
 from . import cmdln
+from . import commands as osc_commands
 from . import conf
 from . import oscerr
 from . import store as osc_store
@@ -36,6 +39,490 @@
 from .util.helper import _html_escape, format_table
 
 
+class Command:
+    #: Name of the command as used in the argument parser.
+    name: str = None
+
+    #: Optional aliases to the command.
+    aliases: List[str] = []
+
+    #: Whether the command is hidden from help.
+    #: Defaults to ``False``.
+    hidden: bool = False
+
+    #: Name of the parent command class.
+    #: Can be prefixed if the parent comes from a different location,
+    #: for example ``osc.commands.<ClassName>`` when extending osc command 
with a plugin.
+    #: See ``OscMainCommand.MODULES`` for available prefixes.
+    parent: str = None
+
+    def __init__(self, full_name, parent=None):
+        self.full_name = full_name
+        self.parent = parent
+        self.subparsers = None
+
+        if not self.name:
+            raise ValueError(f"Command '{self.full_name}' has no 'name' set")
+
+        if parent:
+            self.parser = self.parent.subparsers.add_parser(
+                self.name,
+                aliases=self.aliases,
+                help=self.get_help(),
+                description=self.get_description(),
+                formatter_class=cmdln.HelpFormatter,
+                conflict_handler="resolve",
+                prog=f"{self.main_command.name} [global opts] {self.name}",
+            )
+            self.parser.set_defaults(_selected_command=self)
+        else:
+            self.parser = argparse.ArgumentParser(
+                prog=self.name,
+                description=self.get_description(),
+                formatter_class=cmdln.HelpFormatter,
+                usage="%(prog)s [global opts] <command> [--help] [opts] 
[args]",
+            )
+
+        # traverse the parent commands and add their options to the current 
command
+        cmd = self
+        while cmd:
+            cmd.init_arguments()
+            cmd = cmd.parent
+
+    def __repr__(self):
+        return f"<osc plugin {self.full_name} at {self.__hash__():#x}>"
+
+    def get_help(self):
+        """
+        Return the help text of the command.
+        The first line of the docstring is returned by default.
+        """
+        if self.hidden:
+            return argparse.SUPPRESS
+
+        if not self.__doc__:
+            return ""
+
+        help_lines = self.__doc__.strip().splitlines()
+
+        if not help_lines:
+            return ""
+
+        return help_lines[0]
+
+    def get_description(self):
+        """
+        Return the description of the command.
+        The docstring without the first line is returned by default.
+        """
+        if not self.__doc__:
+            return ""
+
+        help_lines = self.__doc__.strip().splitlines()
+
+        if not help_lines:
+            return ""
+
+        # skip the first line that contains help text
+        help_lines.pop(0)
+
+        # remove any leading empty lines
+        while help_lines and not help_lines[0]:
+            help_lines.pop(0)
+
+        result = "\n".join(help_lines)
+        result = textwrap.dedent(result)
+        return result
+
+    @property
+    def main_command(self):
+        """
+        Return reference to the main command that represents the executable
+        and contains the main instance of ArgumentParser.
+        """
+        if not self.parent:
+            return self
+        return self.parent.main_command
+
+    def add_argument(self, *args, **kwargs):
+        """
+        Add a new argument to the command's argument parser.
+        See `argparse <https://docs.python.org/3/library/argparse.html>`_ 
documentation for allowed parameters.
+        """
+        cmd = self
+
+        # Let's inspect if the caller was init_arguments() method.
+        # In such case use the "parser" argument if specified.
+        frame_1 = inspect.currentframe().f_back
+        frame_1_info = inspect.getframeinfo(frame_1)
+        frame_2 = frame_1.f_back
+        frame_2_info = inspect.getframeinfo(frame_2)
+        if (frame_1_info.function, frame_2_info.function) == 
("init_arguments", "__init__"):
+            # this method was called from init_arguments() that was called 
from __init__
+            # let's extract the command class from the 2nd frame and ad 
arguments there
+            cmd = frame_2.f_locals["self"]
+
+            # suppress global options from command help
+            if cmd != self and not self.parent:
+                kwargs["help"] = argparse.SUPPRESS
+
+            # We're adding hidden options from parent commands to their 
subcommands to allow
+            # option intermixing. For all such added hidden options we need to 
suppress their
+            # defaults because they would override any option set in the 
parent command.
+            if cmd != self:
+                kwargs["default"] = argparse.SUPPRESS
+
+        cmd.parser.add_argument(*args, **kwargs)
+
+    def init_arguments(self):
+        """
+        Override to add arguments to the argument parser.
+
+        .. note::
+            Make sure you're adding arguments only by calling 
``self.add_argument()``.
+            Using ``self.parser.add_argument()`` directly is not recommended
+            because it disables argument intermixing.
+        """
+
+    def run(self, args):
+        """
+        Override to implement the command functionality.
+
+        .. note::
+            ``args.positional_args`` is a list containing any unknown 
(unparsed) positional arguments.
+
+        .. note::
+            Consider moving any reusable code into a library,
+            leaving the command-line code only a thin wrapper on top of it.
+
+            If the code is generic enough, it should be added to osc directly.
+            In such case don't hesitate to open an `issue 
<https://github.com/openSUSE/osc/issues>`_.
+        """
+        raise NotImplementedError()
+
+    def register(self, command_class, command_full_name):
+        if not self.subparsers:
+            # instantiate subparsers on first use
+            self.subparsers = self.parser.add_subparsers(dest="command", 
title="commands")
+
+        # Check for parser conflicts.
+        # This is how Python 3.11+ behaves by default.
+        if command_class.name in self.subparsers._name_parser_map:
+            raise argparse.ArgumentError(self.subparsers, f"conflicting 
subparser: {command_class.name}")
+        for alias in command_class.aliases:
+            if alias in self.subparsers._name_parser_map:
+                raise argparse.ArgumentError(self.subparsers, f"conflicting 
subparser alias: {alias}")
+
+        command = command_class(command_full_name, parent=self)
+        return command
+
+
+class MainCommand(Command):
+    MODULES = ()
+
+    def __init__(self):
+        super().__init__(self.__class__.__name__)
+        self.command_classes = {}
+        self.download_progress = None
+
+    def post_parse_args(self, args):
+        pass
+
+    def run(self, args):
+        cmd = getattr(args, "_selected_command", None)
+        if not cmd:
+            self.parser.error("Please specify a command")
+        self.post_parse_args(args)
+        cmd.run(args)
+
+    def load_command(self, cls, module_prefix):
+        mod_cls_name = f"{module_prefix}.{cls.__name__}"
+        parent_name = getattr(cls, "parent", None)
+        if parent_name:
+            # allow relative references to classes in the the same 
module/directory
+            if "." not in parent_name:
+                parent_name = f"{module_prefix}.{parent_name}"
+            try:
+                parent = self.main_command.command_classes[parent_name]
+            except KeyError:
+                msg = f"Failed to load command class '{mod_cls_name}' because 
it references parent '{parent_name}' that doesn't exist"
+                print(msg, file=sys.stderr)
+                return None
+            cmd = parent.register(cls, mod_cls_name)
+        else:
+            cmd = self.main_command.register(cls, mod_cls_name)
+
+        cmd.full_name = mod_cls_name
+        self.main_command.command_classes[mod_cls_name] = cmd
+        return cmd
+
+    def load_commands(self):
+        for module_prefix, module_path in self.MODULES:
+            module_path = os.path.expanduser(module_path)
+            for loader, module_name, _ in 
pkgutil.walk_packages(path=[module_path]):
+                full_name = f"{module_prefix}.{module_name}"
+                spec = loader.find_spec(full_name)
+                mod = importlib.util.module_from_spec(spec)
+                try:
+                    spec.loader.exec_module(mod)
+                except Exception as e:  # pylint: disable=broad-except
+                    msg = f"Failed to load commands from module '{full_name}': 
{e}"
+                    print(msg, file=sys.stderr)
+                    continue
+                for name in dir(mod):
+                    if name.startswith("_"):
+                        continue
+                    cls = getattr(mod, name)
+                    if not inspect.isclass(cls):
+                        continue
+                    if not issubclass(cls, Command):
+                        continue
+                    if cls.__module__ != full_name:
+                        # skip classes that weren't defined directly in the 
loaded plugin module
+                        continue
+                    self.load_command(cls, module_prefix)
+
+    def parse_args(self, *args, **kwargs):
+        namespace, unknown_args = self.parser.parse_known_args(*args, **kwargs)
+
+        unrecognized = [i for i in unknown_args if i.startswith("-")]
+        if unrecognized:
+            self.parser.error(f"unrecognized arguments: " + " 
".join(unrecognized))
+
+        namespace.positional_args = list(unknown_args)
+        return namespace
+
+
+class OscCommand(Command):
+    """
+    Inherit from this class to create new commands.
+
+    The first line of the docstring becomes the help text,
+    the remaining lines become the command description.
+    """
+
+
+class OscMainCommand(MainCommand):
+    name = "osc"
+
+    MODULES = (
+        ("osc.commands", osc_commands.__path__[0]),
+        ("osc.commands.usr_lib", "/usr/lib/osc-plugins"),
+        ("osc.commands.usr_local_lib", "/usr/local/lib/osc-plugins"),
+        ("osc.commands.home_local_lib", "~/.local/lib/osc-plugins"),
+        ("osc.commands.home", "~/.osc-plugins"),
+    )
+
+    def __init__(self):
+        super().__init__()
+        self.args = None
+        self.download_progress = None
+
+    def init_arguments(self):
+        self.add_argument(
+            "-v",
+            "--verbose",
+            action="store_true",
+            help="increase verbosity",
+        )
+        self.add_argument(
+            "-q",
+            "--quiet",
+            action="store_true",
+            help="be quiet, not verbose",
+        )
+        self.add_argument(
+            "--debug",
+            action="store_true",
+            help="print info useful for debugging",
+        )
+        self.add_argument(
+            "--debugger",
+            action="store_true",
+            help="jump into the debugger before executing anything",
+        )
+        self.add_argument(
+            "--post-mortem",
+            action="store_true",
+            help="jump into the debugger in case of errors",
+        )
+        self.add_argument(
+            "--traceback",
+            action="store_true",
+            help="print call trace in case of errors",
+        )
+        self.add_argument(
+            "-H",
+            "--http-debug",
+            action="store_true",
+            help="debug HTTP traffic (filters some headers)",
+        )
+        self.add_argument(
+            "--http-full-debug",
+            action="store_true",
+            help="debug HTTP traffic (filters no headers)",
+        )
+        self.add_argument(
+            "-A",
+            "--apiurl",
+            metavar="URL",
+            help="Open Build Service API URL or a configured alias",
+        )
+        self.add_argument(
+            "--config",
+            dest="conffile",
+            metavar="FILE",
+            help="specify alternate configuration file",
+        )
+        self.add_argument(
+            "--no-keyring",
+            action="store_true",
+            help="disable usage of desktop keyring system",
+        )
+
+    def post_parse_args(self, args):
+        # apiurl hasn't been specified by the user
+        # we need to set it here because the 'default' option of an argument 
doesn't support lazy evaluation
+        if args.apiurl is None:
+            try:
+                # try reading the apiurl from the working copy
+                args.apiurl = osc_store.Store(Path.cwd()).apiurl
+            except oscerr.NoWorkingCopy:
+                # we can't use conf.config["apiurl"] because it contains the 
default "https://api.opensuse.org";
+                # let's leave setting the right value to conf.get_config()
+                pass
+
+        conf.get_config(
+            override_apiurl=args.apiurl,
+            override_conffile=args.conffile,
+            override_debug=args.debug,
+            override_http_debug=args.http_debug,
+            override_http_full_debug=args.http_full_debug,
+            override_no_keyring=args.no_keyring,
+            override_post_mortem=args.post_mortem,
+            override_traceback=args.traceback,
+            override_verbose=args.verbose,
+        )
+
+        # write config values back to args
+        # this is crucial mainly for apiurl to resolve an alias to full url
+        for i in ["apiurl", "debug", "http_debug", "http_full_debug", 
"post_mortem", "traceback", "verbose"]:
+            setattr(args, i, conf.config[i])
+        args.no_keyring = not conf.config["use_keyring"]
+
+        if conf.config["show_download_progress"]:
+            self.download_progress = create_text_meter()
+
+        if not args.apiurl:
+            self.parser.error("Could not determine apiurl, use -A/--apiurl to 
specify one")
+
+        # needed for LegacyOsc class
+        self.args = args
+
+    def _wrap_legacy_command(self, func_):
+        class LegacyCommandWrapper(Command):
+            func = func_
+            __doc__ = getattr(func_, "__doc__", "")
+            aliases = getattr(func_, "aliases", [])
+            hidden = getattr(func_, "hidden", False)
+            name = getattr(func_, "name", func_.__name__[3:])
+
+            def __repr__(self):
+                result = super().__repr__()
+                result += f"({self.func.__name__})"
+                return result
+
+            def init_arguments(self):
+                options = getattr(self.func, "options", [])
+                for option_args, option_kwargs in options:
+                    self.add_argument(*option_args, **option_kwargs)
+
+            def run(self, args):
+                sig = inspect.signature(self.func)
+                arg_names = list(sig.parameters.keys())
+                if arg_names == ["subcmd", "opts"]:
+                    # handler doesn't take positional args via *args
+                    if args.positional_args:
+                        self.parser.error(f"unrecognized arguments: " + " 
".join(args.positional_args))
+                    self.func(args.command, args)
+                else:
+                    # handler takes positional args via *args
+                    self.func(args.command, args, *args.positional_args)
+
+        return LegacyCommandWrapper
+
+    def load_legacy_commands(self):
+        # lazy links of attributes that would normally be initialized in the 
instance of Osc class
+        class LegacyOsc(Osc):  # pylint: disable=used-before-assignment
+            # pylint: disable=no-self-argument
+            @property
+            def argparser(self_):
+                return self.parser
+
+            # pylint: disable=no-self-argument
+            @property
+            def download_progress(self_):
+                return self.download_progress
+
+            # pylint: disable=no-self-argument
+            @property
+            def options(self_):
+                return self.args
+
+            # pylint: disable=no-self-argument
+            @options.setter
+            def options(self_, value):
+                pass
+
+            # pylint: disable=no-self-argument
+            @property
+            def subparsers(self_):
+                return self.subparsers
+
+        osc_instance = LegacyOsc()
+
+        for name in dir(osc_instance):
+            if not name.startswith("do_"):
+                continue
+
+            func = getattr(osc_instance, name)
+
+            if not inspect.ismethod(func) and not inspect.isfunction(func):
+                continue
+
+            cls = self._wrap_legacy_command(func)
+            self.load_command(cls, "osc.commands.old")
+
+    @classmethod
+    def main(cls, argv=None, run=True):
+        """
+        Initialize OscMainCommand, load all commands and run the selected 
command.
+        """
+        cmd = cls()
+        cmd.load_commands()
+        cmd.load_legacy_commands()
+        if run:
+            args = cmd.parse_args(args=argv)
+            cmd.run(args)
+        else:
+            args = None
+        return cmd, args
+
+
+def get_parser():
+    """
+    Needed by argparse-manpage to generate man pages from the argument parser.
+    """
+    main, _ = OscMainCommand.main(run=False)
+    return main.parser
+
+
+# 
================================================================================
+# The legacy code follows.
+# Please do not use it if possible.
+# 
================================================================================
+
+
 HELP_MULTIBUILD_MANY = """Only work with the specified flavors of a multibuild 
package.
 Globs are resolved according to _multibuild file from server.
 Empty string is resolved to a package without a flavor."""
@@ -43,12 +530,6 @@
 HELP_MULTIBUILD_ONE = "Only work with the specified flavor of a multibuild 
package."
 
 
-def get_parser():
-    osc = Osc()
-    osc.create_argparser()
-    return osc.argparser
-
-
 def pop_args(
     args,
     arg1_name: str = None,
@@ -435,7 +916,6 @@
     * http://en.opensuse.org/openSUSE:OSC_plugins
     """
     name = 'osc'
-    conf = None
 
     def __init__(self):
         self.options = None
@@ -9481,7 +9961,6 @@
         plugin_dirs = [
             '/usr/lib/osc-plugins',
             '/usr/local/lib/osc-plugins',
-            '/var/lib/osc-plugins',  # Kept for backward compatibility
             os.path.expanduser('~/.local/lib/osc-plugins'),
             os.path.expanduser('~/.osc-plugins')]
         for plugin_dir in plugin_dirs:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/conf.py new/osc-1.1.0/osc/conf.py
--- old/osc-1.0.1/osc/conf.py   2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/conf.py   2023-04-03 13:45:36.000000000 +0200
@@ -772,8 +772,8 @@
         try:
             os.chmod(conffile, 0o600)
         except OSError as e:
-            if e.errno == errno.EROFS:
-                print('Warning: file \'%s\' may have an insecure mode.', 
conffile)
+            if e.errno in (errno.EROFS, errno.EPERM):
+                print(f"Warning: Configuration file '{conffile}' may have 
insecure file permissions.")
             else:
                 raise e
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/core.py new/osc-1.1.0/osc/core.py
--- old/osc-1.0.1/osc/core.py   2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/osc/core.py   2023-04-03 13:45:36.000000000 +0200
@@ -4778,6 +4778,38 @@
     for root in res.findall('request'):
         r = Request()
         r.read(root)
+
+        # post-process results until we switch back to the /search/request
+        # which seems to be more suitable for such queries
+        exclude = False
+        for action in r.actions:
+            src_project = getattr(action, "src_project", None)
+            src_package = getattr(action, "src_package", None)
+            tgt_project = getattr(action, "tgt_project", None)
+            tgt_package = getattr(action, "tgt_package", None)
+
+            # skip if neither of source and target project matches
+            if "project" in query and query["project"] not in (src_project, 
tgt_project):
+                exclude = True
+                break
+
+            # skip if neither of source and target package matches
+            if "package" in query and query["package"] not in (src_package, 
tgt_package):
+                exclude = True
+                break
+
+            if not conf.config["include_request_from_project"]:
+                if "project" in query and "package" in query:
+                    if (src_project, src_package) == (query["project"], 
query["package"]):
+                        exclude = True
+                        break
+                elif "project" in query:
+                    if src_project == query["project"]:
+                        exclude = True
+                        break
+        if exclude:
+            continue
+
         requests.append(r)
     return requests
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/osc/util/git_version.py 
new/osc-1.1.0/osc/util/git_version.py
--- old/osc-1.0.1/osc/util/git_version.py       2023-03-17 16:05:07.000000000 
+0100
+++ new/osc-1.1.0/osc/util/git_version.py       2023-04-03 13:45:36.000000000 
+0200
@@ -9,7 +9,7 @@
     """
     # the `version` variable contents get substituted during `git archive`
     # it requires adding this to .gitattributes: <path to this file> 
export-subst
-    version = "1.0.1"
+    version = "1.1.0"
     if version.startswith(("$", "%")):
         # "$": version hasn't been substituted during `git archive`
         # "%": "Format:" and "$" characters get removed from the version 
string (a GitHub bug?)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/setup.cfg new/osc-1.1.0/setup.cfg
--- old/osc-1.0.1/setup.cfg     2023-03-17 16:05:07.000000000 +0100
+++ new/osc-1.1.0/setup.cfg     2023-04-03 13:45:36.000000000 +0200
@@ -34,6 +34,7 @@
 packages =
     osc
     osc._private
+    osc.commands
     osc.util
 install_requires =
     cryptography
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/tests/test_commandline.py 
new/osc-1.1.0/tests/test_commandline.py
--- old/osc-1.0.1/tests/test_commandline.py     2023-03-17 16:05:07.000000000 
+0100
+++ new/osc-1.1.0/tests/test_commandline.py     2023-04-03 13:45:36.000000000 
+0200
@@ -1,8 +1,12 @@
+import argparse
 import os
 import shutil
 import tempfile
 import unittest
 
+from osc.commandline import Command
+from osc.commandline import MainCommand
+from osc.commandline import OscMainCommand
 from osc.commandline import pop_project_package_from_args
 from osc.commandline import pop_project_package_repository_arch_from_args
 from osc.commandline import 
pop_project_package_targetproject_targetpackage_from_args
@@ -11,6 +15,131 @@
 from osc.store import Store
 
 
+class TestMainCommand(MainCommand):
+    name = "osc-test"
+
+    def init_arguments(self, command=None):
+        self.add_argument(
+            "-A",
+            "--apiurl",
+        )
+
+
+class TestCommand(Command):
+    name = "test-cmd"
+
+
+OSCRC_LOCALHOST = """
+[general]
+apiurl = https://localhost
+
+[https://localhost]
+user=Admin
+pass=opensuse
+""".lstrip()
+
+
+class TestCommandClasses(unittest.TestCase):
+    def setUp(self):
+        os.environ.pop("OSC_CONFIG", None)
+        self.tmpdir = tempfile.mkdtemp(prefix="osc_test")
+        os.chdir(self.tmpdir)
+        self.oscrc = None
+
+    def tearDown(self):
+        os.environ.pop("OSC_CONFIG", None)
+        try:
+            shutil.rmtree(self.tmpdir)
+        except OSError:
+            pass
+
+    def write_oscrc_localhost(self):
+        self.oscrc = os.path.join(self.tmpdir, "oscrc")
+        with open(self.oscrc, "w") as f:
+            f.write(OSCRC_LOCALHOST)
+
+    def test_load_commands(self):
+        main = TestMainCommand()
+        main.load_commands()
+
+    def test_load_command(self):
+        main = TestMainCommand()
+        cmd = main.load_command(TestCommand, "test.osc.commands")
+        self.assertTrue(str(cmd).startswith("<osc plugin 
test.osc.commands.TestCommand"))
+
+    def test_parent(self):
+        class Parent(TestCommand):
+            name = "parent"
+
+        class Child(TestCommand):
+            name = "child"
+            parent = "Parent"
+
+        main = TestMainCommand()
+        main.load_command(Parent, "test.osc.commands")
+        main.load_command(Child, "test.osc.commands")
+
+        main.parse_args(["parent", "child"])
+
+    def test_invalid_parent(self):
+        class Parent(TestCommand):
+            name = "parent"
+
+        class Child(TestCommand):
+            name = "child"
+            parent = "DoesNotExist"
+
+        main = TestMainCommand()
+        main.load_command(Parent, "test.osc.commands")
+        main.load_command(Child, "test.osc.commands")
+
+    def test_load_twice(self):
+        class AnotherCommand(TestCommand):
+            name = "another-command"
+            aliases = ["test-cmd"]
+
+        main = TestMainCommand()
+        main.load_command(TestCommand, "test.osc.commands")
+
+        # conflict between names
+        self.assertRaises(argparse.ArgumentError, main.load_command, 
TestCommand, "test.osc.commands")
+
+        # conflict between a name and an alias
+        self.assertRaises(argparse.ArgumentError, main.load_command, 
AnotherCommand, "test.osc.commands")
+
+    def test_intermixing(self):
+        main = TestMainCommand()
+        main.load_command(TestCommand, "test.osc.commands")
+
+        args = main.parse_args(["test-cmd", "--apiurl", "https://example.com";])
+        self.assertEqual(args.apiurl, "https://example.com";)
+
+        args = main.parse_args(["--apiurl", "https://example.com";, "test-cmd"])
+        self.assertEqual(args.apiurl, "https://example.com";)
+
+    def test_unknown_options(self):
+        main = TestMainCommand()
+        main.load_command(TestCommand, "test.osc.commands")
+
+        args = main.parse_args(["test-cmd", "unknown-arg"])
+        self.assertEqual(args.positional_args, ["unknown-arg"])
+
+        self.assertRaises(SystemExit, main.parse_args, ["test-cmd", 
"--unknown-option"])
+
+    def test_default_apiurl(self):
+        class TestMainCommand(OscMainCommand):
+            name = "osc-test"
+
+        main = TestMainCommand()
+        main.load_command(TestCommand, "test.osc.commands")
+
+        self.write_oscrc_localhost()
+        os.environ["OSC_CONFIG"] = self.oscrc
+        args = main.parse_args(["test-cmd"])
+        main.post_parse_args(args)
+        self.assertEqual(args.apiurl, "https://localhost";)
+
+
 class TestPopProjectPackageFromArgs(unittest.TestCase):
     def _write_store(self, project=None, package=None):
         store = Store(self.tmpdir, check=False)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.0.1/tests/test_doc_plugins.py 
new/osc-1.1.0/tests/test_doc_plugins.py
--- old/osc-1.0.1/tests/test_doc_plugins.py     1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-1.1.0/tests/test_doc_plugins.py     2023-04-03 13:45:36.000000000 
+0200
@@ -0,0 +1,79 @@
+"""
+These tests make sure that the examples in the documentation
+about osc plugins are not outdated.
+"""
+
+
+import os
+import unittest
+
+
+from osc.commandline import MainCommand
+from osc.commandline import OscMainCommand
+
+
+PLUGINS_DIR = os.path.join(os.path.dirname(__file__), "..", "doc", "plugins")
+
+
+class TestMainCommand(MainCommand):
+    name = "osc-test"
+    MODULES = (
+        ("test.osc.commands", PLUGINS_DIR),
+    )
+
+
+class TestPopProjectPackageFromArgs(unittest.TestCase):
+    def test_load_commands(self):
+        """
+        Test if all plugins from the tutorial can be properly loaded
+        """
+        main = TestMainCommand()
+        main.load_commands()
+
+    def test_simple(self):
+        """
+        Test the 'simple' command
+        """
+        main = TestMainCommand()
+        main.load_commands()
+        args = main.parse_args(["simple", "arg1", "arg2"])
+        self.assertEqual(args.command, "simple")
+        self.assertEqual(args.bool_option, False)
+        self.assertEqual(args.arguments, ["arg1", "arg2"])
+
+    def test_request_list(self):
+        """
+        Test the 'request list' command
+        """
+        main = TestMainCommand()
+        main.load_commands()
+        args = main.parse_args(["request", "list"])
+        self.assertEqual(args.command, "list")
+        self.assertEqual(args.message, None)
+
+    def test_request_accept(self):
+        """
+        Test the 'request accept' command
+        """
+        main = TestMainCommand()
+        main.load_commands()
+        args = main.parse_args(["request", "accept", "-m", "a message", 
"12345"])
+        self.assertEqual(args.command, "accept")
+        self.assertEqual(args.message, "a message")
+        self.assertEqual(args.id, 12345)
+
+    def test_plugin_locations(self):
+        osc_paths = [i[1] for i in OscMainCommand.MODULES]
+        # skip the first line with osc.commands
+        osc_paths = osc_paths[1:]
+
+        path = os.path.join(PLUGINS_DIR, "plugin_locations.rst")
+        with open(path, "r") as f:
+            # s
+            doc_paths = f.readlines()
+            # skip the first line with osc.commands
+            doc_paths = doc_paths[1:]
+            doc_paths = [i.lstrip(" -") for i in doc_paths]
+            doc_paths = [i.rstrip("\n") for i in doc_paths]
+
+        self.assertEqual(doc_paths, osc_paths)

++++++ osc.dsc ++++++
--- /var/tmp/diff_new_pack.b2pIoh/_old  2023-04-03 21:49:26.388887006 +0200
+++ /var/tmp/diff_new_pack.b2pIoh/_new  2023-04-03 21:49:26.392887029 +0200
@@ -1,6 +1,6 @@
 Format: 1.0
 Source: osc
-Version: 1.0.1-0
+Version: 1.1.0-0
 Binary: osc
 Maintainer: Adrian Schroeter <adr...@suse.de>
 Architecture: any

Reply via email to