Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-osc-tiny for openSUSE:Factory 
checked in at 2022-07-03 18:26:52
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-osc-tiny (Old)
 and      /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-osc-tiny"

Sun Jul  3 18:26:52 2022 rev:15 rq:986371 version:0.6.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-osc-tiny/python-osc-tiny.changes  
2022-06-18 22:06:29.843682325 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548/python-osc-tiny.changes    
    2022-07-03 18:26:53.320735019 +0200
@@ -1,0 +2,9 @@
+Thu Jun 30 08:48:29 UTC 2022 - Andreas Hasenkopf <ahasenk...@suse.com>
+
+- Release 0.6.2
+  * Added `cmd` method to `Build` extension
+  * Fixes for sessions and authentication:
+    * Use thread-safe sessions and support huge trees again
+    * Support for server returning multiple `WWW-Authenticate` headers
+
+-------------------------------------------------------------------

Old:
----
  osc-tiny-0.6.1.tar.gz

New:
----
  osc-tiny-0.6.2.tar.gz

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

Other differences:
------------------
++++++ python-osc-tiny.spec ++++++
--- /var/tmp/diff_new_pack.fePfgJ/_old  2022-07-03 18:26:53.732735627 +0200
+++ /var/tmp/diff_new_pack.fePfgJ/_new  2022-07-03 18:26:53.732735627 +0200
@@ -19,7 +19,7 @@
 %{?!python_module:%define python_module() python-%{**} python3-%{**}}
 %define skip_python2 1
 Name:           python-osc-tiny
-Version:        0.6.1
+Version:        0.6.2
 Release:        0
 Summary:        Client API for openSUSE BuildService
 License:        MIT

++++++ osc-tiny-0.6.1.tar.gz -> osc-tiny-0.6.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/PKG-INFO new/osc-tiny-0.6.2/PKG-INFO
--- old/osc-tiny-0.6.1/PKG-INFO 2022-06-17 11:47:36.354644500 +0200
+++ new/osc-tiny-0.6.2/PKG-INFO 2022-06-30 10:46:34.485521000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: osc-tiny
-Version: 0.6.1
+Version: 0.6.2
 Summary: Client API for openSUSE BuildService
 Home-page: http://github.com/crazyscientist/osc-tiny
 Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osc_tiny.egg-info/PKG-INFO 
new/osc-tiny-0.6.2/osc_tiny.egg-info/PKG-INFO
--- old/osc-tiny-0.6.1/osc_tiny.egg-info/PKG-INFO       2022-06-17 
11:47:35.000000000 +0200
+++ new/osc-tiny-0.6.2/osc_tiny.egg-info/PKG-INFO       2022-06-30 
10:46:33.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: osc-tiny
-Version: 0.6.1
+Version: 0.6.2
 Summary: Client API for openSUSE BuildService
 Home-page: http://github.com/crazyscientist/osc-tiny
 Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osctiny/__init__.py 
new/osc-tiny-0.6.2/osctiny/__init__.py
--- old/osc-tiny-0.6.1/osctiny/__init__.py      2022-06-17 11:47:24.000000000 
+0200
+++ new/osc-tiny-0.6.2/osctiny/__init__.py      2022-06-30 10:46:21.000000000 
+0200
@@ -6,4 +6,4 @@
 
 __all__ = ['Osc', 'bs_requests', 'buildresults', 'comments', 'packages',
            'projects', 'search', 'users']
-__version__ = "0.6.1"
+__version__ = "0.6.2"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osctiny/extensions/buildresults.py 
new/osc-tiny-0.6.2/osctiny/extensions/buildresults.py
--- old/osc-tiny-0.6.1/osctiny/extensions/buildresults.py       2022-06-17 
11:47:24.000000000 +0200
+++ new/osc-tiny-0.6.2/osctiny/extensions/buildresults.py       2022-06-30 
10:46:21.000000000 +0200
@@ -137,3 +137,26 @@
         )
 
         return response.text
+
+    def cmd(self, project, cmd, **params):
+        """
+        Execute ``cmd`` for ``project`` and get response
+
+        .. versionadded:: 0.6.2
+
+        :param str project: Project name
+        :param str cmd: Command to execute
+        :param params: Additional parameters
+        """
+        allowed_cmds = ["rebuild", "abortbuild", "restartbuild", "unpublish", 
"sendsysrq",
+                        "wipe"]
+        if cmd not in allowed_cmds:
+            raise ValueError(f"Invalid command: '{cmd}'. Use one of: {', 
'.join(allowed_cmds)}")
+
+        params["cmd"] = cmd
+        response = self.osc.request(
+            url=urljoin(self.osc.url, f"{self.base_path}/{project}"),
+            method="POST",
+            params=params
+        )
+        return self.osc.get_objectified_xml(response)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osctiny/osc.py 
new/osc-tiny-0.6.2/osctiny/osc.py
--- old/osc-tiny-0.6.1/osctiny/osc.py   2022-06-17 11:47:24.000000000 +0200
+++ new/osc-tiny-0.6.2/osctiny/osc.py   2022-06-30 10:46:21.000000000 +0200
@@ -4,6 +4,7 @@
 """
 from __future__ import unicode_literals
 
+from base64 import b64encode
 import typing
 from io import BufferedReader, BytesIO, StringIO
 import gc
@@ -12,11 +13,12 @@
 import re
 from ssl import get_default_verify_paths
 import time
+import threading
 from urllib.parse import quote
 import warnings
 
 # pylint: disable=no-name-in-module
-from lxml.objectify import fromstring
+from lxml.objectify import fromstring, makeparser
 from requests import Session, Request
 from requests.auth import HTTPBasicAuth
 from requests.cookies import RequestsCookieJar, cookiejar_from_dict
@@ -41,6 +43,8 @@
 except ImportError:
     CacheControl = None
 
+THREAD_LOCAL = threading.local()
+
 
 # pylint: disable=too-many-instance-attributes,too-many-arguments
 # pylint: disable=too-many-locals
@@ -120,8 +124,6 @@
     url = 'https://api.opensuse.org'
     username = ''
     password = ''
-    session = None
-    _registered = {}
     default_timeout = (60, 300)
     default_connection_retries = 5
     default_retry_timeout = 5
@@ -159,59 +161,82 @@
         self.search = Search(osc_obj=self)
         self.users = Person(osc_obj=self)
 
-        self._session, self.session = None, None
+        hash_value = 
b64encode(f'{self.username}@{self.url}@{self.ssh_key}'.encode())
+        self._session_id = f"session_{hash_value}"
 
     def __del__(self):
         # Just in case ;-)
         gc.collect()
 
     @property
+    def _session(self) -> Session:
+        """
+        Session object
+        """
+        session = getattr(THREAD_LOCAL, self._session_id, None)
+        if not session:
+            session = Session()
+            session.verify = self.verify or get_default_verify_paths().capath
+
+            if self.ssh_key is not None:
+                session.auth = HttpSignatureAuth(username=self.username, 
password=self.password,
+                                                 ssh_key_file=self.ssh_key)
+            else:
+                session.auth = HTTPBasicAuth(self.username, self.password)
+
+            setattr(THREAD_LOCAL, self._session_id, session)
+
+        return session
+
+    @property
+    def session(self) -> typing.Union[CacheControl, Session]:
+        """
+        Session object
+
+        Possibly wrapped in CacheControl, if installed.
+        """
+        key = f"cached_{self._session_id}"
+        session = getattr(THREAD_LOCAL, key, None)
+        if not session:
+            if self.cache:
+                # pylint: disable=broad-except
+                try:
+                    session = CacheControl(self._session)
+                except Exception as error:
+                    session = self._session
+                    warnings.warn("Cannot use the cache: {}".format(error), 
RuntimeWarning)
+            else:
+                session = self._session
+            setattr(THREAD_LOCAL, key, session)
+
+        return session
+
+    @property
     def cookies(self) -> RequestsCookieJar:
         """
         Access session cookies
         """
-        if self._session is None:
-            self._init_session()
-
-        return self.session.cookies
+        return self._session.cookies
 
     @cookies.setter
     def cookies(self, value: RequestsCookieJar):
         if not isinstance(value, (RequestsCookieJar, dict)):
             raise TypeError(f"Expected a cookie jar or dict. Got instead: 
{type(value)}")
 
-        if self._session is None:
-            self._init_session()
-
         if isinstance(value, RequestsCookieJar):
             self._session.cookies = value
         else:
             self._session.cookies = cookiejar_from_dict(value)
 
-    def _init_session(self):
+    @property
+    def parser(self):
         """
-        Lazy session initialization
+        Explicit parser instance
         """
-        self._session = Session()
-        self._session.verify = self.verify or get_default_verify_paths().capath
-
-        if self.ssh_key is not None:
-            self._session.auth = HttpSignatureAuth(username=self.username, 
password=self.password,
-                                                   ssh_key_file=self.ssh_key)
-        else:
-            self._session.auth = HTTPBasicAuth(self.username, self.password)
+        if not hasattr(THREAD_LOCAL, "parser"):
+            THREAD_LOCAL.parser = makeparser(huge_tree=True)
 
-        # Cache
-        if self.cache:
-            # pylint: disable=broad-except
-            try:
-                self.session = CacheControl(self._session)
-            except Exception as error:
-                self.session = self._session
-                warnings.warn("Cannot use the cache: {}".format(error),
-                              RuntimeWarning)
-        else:
-            self.session = self._session
+        return THREAD_LOCAL.parser
 
     def request(self, url, method="GET", stream=False, data=None, params=None,
                 raise_for_status=True, timeout=None):
@@ -266,8 +291,6 @@
             https://2.python-requests.org/en/master/user/advanced/#timeouts
         """
         timeout = timeout or self.default_timeout
-        if self._session is None:
-            self._init_session()
 
         if stream:
             session = self._session
@@ -380,7 +403,7 @@
             text = response.text
 
         try:
-            return fromstring(text)
+            return fromstring(text, self.parser)
         except ValueError:
             # Just in case OBS returns a Unicode string with encoding
             # declaration
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osctiny/tests/test_utils.py 
new/osc-tiny-0.6.2/osctiny/tests/test_utils.py
--- old/osc-tiny-0.6.1/osctiny/tests/test_utils.py      2022-06-17 
11:47:24.000000000 +0200
+++ new/osc-tiny-0.6.2/osctiny/tests/test_utils.py      2022-06-30 
10:46:21.000000000 +0200
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+import re
 from base64 import b64encode
 from bz2 import compress
 from unittest import TestCase, mock
@@ -12,7 +13,10 @@
 
 from dateutil.parser import parse
 from pytz import _UTC, timezone
+from requests import Response
+import responses
 
+from ..osc import Osc
 from ..utils.changelog import ChangeLog, Entry
 from ..utils.conf import get_config_path, get_credentials
 from ..utils.mapping import Mappable
@@ -388,3 +392,79 @@
         finally:
             os.remove(path1)
             os.remove(path2)
+
+
+@mock.patch("osctiny.utils.auth.time", return_value=123456)
+class TestAuth(TestCase):
+    def setUp(self):
+        super().setUp()
+        mocked_path = mock.MagicMock(spec=Path)
+        mocked_path.configure_mock(**{"is_file.return_value": True})
+        self.osc = Osc("https://api.example.com";, "nemo", "password", 
ssh_key_file=mocked_path)
+        self.osc.session.auth.ssh_sign = lambda *args, **kwargs: "Hello World"
+
+    def setup_response(self, headers: dict):
+        responses.reset()
+        responses.add(
+                responses.GET,
+                re.compile("https?://.*"),
+                adding_headers=headers,
+                body="Bla bla",
+                status=401
+            )
+
+    def do_assertions(self, response: Response, expected_challenge: bool):
+        self.assertEqual(401, response.status_code)
+        if expected_challenge:
+            self.assertEqual(
+                {'realm': 'Use your developer account', 'headers': 
['created'], 'created': 123456},
+                self.osc.session.auth._thread_local.chal
+            )
+        else:
+            self.assertEqual(0, len(self.osc.session.auth._thread_local.chal))
+
+    @responses.activate
+    def test_handle_401(self, *_):
+        with self.subTest("No WWW-Authenticate header"):
+            self.setup_response({"Foo": "Bar"})
+            response = 
self.osc.session.get("https://api.example.com/hello-world";)
+            self.do_assertions(response, False)
+
+        with self.subTest("WWW-Authenticate: Only Basic"):
+            self.setup_response({"www-authenticate": "Basic realm=\"Use your 
developer account\""})
+            response = 
self.osc.session.get("https://api.example.com/hello-world";)
+            self.do_assertions(response, False)
+
+        with self.subTest("WWW-Authenticate: Only Signature"):
+            self.setup_response({"www-authenticate":
+                                     "Signature realm=\"Use your developer 
account\","
+                                     "headers=\"(created)\""})
+            response = 
self.osc.session.get("https://api.example.com/hello-world";)
+            self.do_assertions(response, True)
+
+        responses.reset()
+        responses.add(
+            responses.GET,
+            re.compile("https?://.*"),
+            adding_headers={"www-authenticate": "Basic realm=\"Use your 
developer account\", "
+                                                "Signature realm=\"Use your 
developer account\","
+                                                "headers=\"(created)\""},
+            body="Bla bla",
+            status=401
+        )
+
+        with self.subTest("WWW-Authenticate: Basic & Signature"):
+            self.setup_response({"www-authenticate":
+                                     "Basic realm=\"Use your developer 
account\", "
+                                     "Signature realm=\"Use your developer 
account\","
+                                     "headers=\"(created)\""})
+            response = 
self.osc.session.get("https://api.example.com/hello-world";)
+            self.do_assertions(response, True)
+
+        with self.subTest("WWW-Authenticate: Signature & Basic"):
+            self.setup_response({"www-authenticate":
+                                     "Signature realm=\"Use your developer 
account\","
+                                     "headers=\"(created)\", "
+                                     "Basic realm=\"Use your developer 
account\", "})
+            response = 
self.osc.session.get("https://api.example.com/hello-world";)
+            self.do_assertions(response, True)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/osctiny/utils/auth.py 
new/osc-tiny-0.6.2/osctiny/utils/auth.py
--- old/osc-tiny-0.6.1/osctiny/utils/auth.py    2022-06-17 11:47:24.000000000 
+0200
+++ new/osc-tiny-0.6.2/osctiny/utils/auth.py    2022-06-30 10:46:21.000000000 
+0200
@@ -89,6 +89,31 @@
         return f'Signature 
keyId="{self.username}",algorithm="ssh",signature={self.ssh_sign()},' \
                
f'headers="{headers}",created={self._thread_local.chal["created"]}'
 
+    def get_auth_header(self, r: Response) -> str:
+        """
+        Extract the relevant header for Signature authentication
+
+        :param r: Response
+        :return: Header text
+        """
+        try:
+            # pylint: disable=protected-access
+            headers = [header
+                       for header in 
r.raw._original_response.headers.get_all("www-authenticate")
+                       if "signature" in header.lower()]
+            if headers:
+                return headers[0]
+        except AttributeError:
+            headers = r.headers.get("www-authenticate")
+            if headers:
+                parts = headers.split(",")
+                start = [p for p in parts if "signature" in p.lower()]
+                if start:
+                    start_index = parts.index(start[0])
+                    return ",".join(parts[start_index:start_index + 2]).strip()
+
+        return ""
+
     def handle_401(self, r: Response, **kwargs) -> Response:
         """
         Handle authentication in case of 401
@@ -103,9 +128,9 @@
             # Rewind the file position indicator of the body to where
             # it was to resend the request.
             r.request.body.seek(self._thread_local.pos)
-        s_auth = r.headers.get('www-authenticate', '')
+        s_auth = self.get_auth_header(r)
 
-        if s_auth.lower().startswith("signature") and 
self._thread_local.num_401_calls < 2:
+        if "signature" in s_auth.lower() and self._thread_local.num_401_calls 
< 2:
             self._thread_local.num_401_calls += 1
 
             _, challenge = s_auth.split(" ", maxsplit=1)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.6.1/setup.py new/osc-tiny-0.6.2/setup.py
--- old/osc-tiny-0.6.1/setup.py 2022-06-17 11:47:24.000000000 +0200
+++ new/osc-tiny-0.6.2/setup.py 2022-06-30 10:46:21.000000000 +0200
@@ -19,7 +19,7 @@
 
 setup(
     name='osc-tiny',
-    version='0.6.1',
+    version='0.6.2',
     description='Client API for openSUSE BuildService',
     long_description=long_description,
     long_description_content_type="text/markdown",

Reply via email to