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",