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 2023-11-30 22:03:58
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-osc-tiny (Old)
 and      /work/SRC/openSUSE:Factory/.python-osc-tiny.new.25432 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-osc-tiny"

Thu Nov 30 22:03:58 2023 rev:29 rq:1129978 version:0.8.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-osc-tiny/python-osc-tiny.changes  
2023-03-06 18:56:46.137037468 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-osc-tiny.new.25432/python-osc-tiny.changes   
    2023-11-30 22:05:16.573981173 +0100
@@ -1,0 +2,12 @@
+Wed Nov  29 18:03:47 UTC 2023 - Chen Huang <chhu...@suse.com>
+
+- Release 0.8.0
+  * Added the attributes extension
+  * Project.get_meta: target the /_project path to really get specific 
revisions
+  * Add an optional rev parameter to Project.get_meta
+  * Reusable function to extract error message from responses and converted 
get_objectified_xml into standalone function
+  * Removed backport of lru_cache
+  * Session optimizations
+  * Add Build.get_log
+
+-------------------------------------------------------------------

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

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

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

Other differences:
------------------
++++++ python-osc-tiny.spec ++++++
--- /var/tmp/diff_new_pack.dlsPBR/_old  2023-11-30 22:05:17.234005494 +0100
+++ /var/tmp/diff_new_pack.dlsPBR/_new  2023-11-30 22:05:17.234005494 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-osc-tiny
 #
-# Copyright (c) 2022 SUSE LLC
+# Copyright (c) 2023 SUSE LLC
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,7 +18,7 @@
 
 %define skip_python2 1
 Name:           python-osc-tiny
-Version:        0.7.12
+Version:        0.8.0
 Release:        0
 Summary:        Client API for openSUSE BuildService
 License:        MIT

++++++ osc-tiny-0.7.12.tar.gz -> osc-tiny-0.8.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/PKG-INFO new/osc-tiny-0.8.0/PKG-INFO
--- old/osc-tiny-0.7.12/PKG-INFO        2023-03-06 07:03:02.507382900 +0100
+++ new/osc-tiny-0.8.0/PKG-INFO 2023-11-29 15:48:58.173556800 +0100
@@ -1,11 +1,12 @@
 Metadata-Version: 2.1
 Name: osc-tiny
-Version: 0.7.12
+Version: 0.8.0
 Summary: Client API for openSUSE BuildService
-Home-page: http://github.com/crazyscientist/osc-tiny
-Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
+Home-page: https://github.com/SUSE/osc-tiny
 Author: Andreas Hasenkopf
 Author-email: ahasenk...@suse.com
+Maintainer: SUSE Maintenance Automation Engineering team
+Maintainer-email: maintenance-automation-t...@suse.de
 License: MIT
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Intended Audience :: Developers
@@ -18,8 +19,14 @@
 Classifier: Programming Language :: Python :: 3.9
 Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
 Description-Content-Type: text/markdown
 License-File: LICENSE
+Requires-Dist: lxml
+Requires-Dist: requests
+Requires-Dist: python-dateutil
+Requires-Dist: pytz
+Requires-Dist: pyyaml
 
 OSC Tiny
 ========
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osc_tiny.egg-info/PKG-INFO 
new/osc-tiny-0.8.0/osc_tiny.egg-info/PKG-INFO
--- old/osc-tiny-0.7.12/osc_tiny.egg-info/PKG-INFO      2023-03-06 
07:03:02.000000000 +0100
+++ new/osc-tiny-0.8.0/osc_tiny.egg-info/PKG-INFO       2023-11-29 
15:48:58.000000000 +0100
@@ -1,11 +1,12 @@
 Metadata-Version: 2.1
 Name: osc-tiny
-Version: 0.7.12
+Version: 0.8.0
 Summary: Client API for openSUSE BuildService
-Home-page: http://github.com/crazyscientist/osc-tiny
-Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
+Home-page: https://github.com/SUSE/osc-tiny
 Author: Andreas Hasenkopf
 Author-email: ahasenk...@suse.com
+Maintainer: SUSE Maintenance Automation Engineering team
+Maintainer-email: maintenance-automation-t...@suse.de
 License: MIT
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Intended Audience :: Developers
@@ -18,8 +19,14 @@
 Classifier: Programming Language :: Python :: 3.9
 Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
 Description-Content-Type: text/markdown
 License-File: LICENSE
+Requires-Dist: lxml
+Requires-Dist: requests
+Requires-Dist: python-dateutil
+Requires-Dist: pytz
+Requires-Dist: pyyaml
 
 OSC Tiny
 ========
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osc_tiny.egg-info/SOURCES.txt 
new/osc-tiny-0.8.0/osc_tiny.egg-info/SOURCES.txt
--- old/osc-tiny-0.7.12/osc_tiny.egg-info/SOURCES.txt   2023-03-06 
07:03:02.000000000 +0100
+++ new/osc-tiny-0.8.0/osc_tiny.egg-info/SOURCES.txt    2023-11-29 
15:48:58.000000000 +0100
@@ -13,6 +13,7 @@
 osctiny/__init__.py
 osctiny/osc.py
 osctiny/extensions/__init__.py
+osctiny/extensions/attributes.py
 osctiny/extensions/bs_requests.py
 osctiny/extensions/buildresults.py
 osctiny/extensions/comments.py
@@ -25,9 +26,9 @@
 osctiny/extensions/users.py
 osctiny/tests/__init__.py
 osctiny/tests/base.py
+osctiny/tests/test_attributes.py
 osctiny/tests/test_basic.py
 osctiny/tests/test_build.py
-osctiny/tests/test_cache.py
 osctiny/tests/test_comments.py
 osctiny/tests/test_datadir.py
 osctiny/tests/test_distributions.py
@@ -47,4 +48,5 @@
 osctiny/utils/changelog.py
 osctiny/utils/conf.py
 osctiny/utils/errors.py
-osctiny/utils/mapping.py
\ No newline at end of file
+osctiny/utils/mapping.py
+osctiny/utils/xml.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/__init__.py 
new/osc-tiny-0.8.0/osctiny/__init__.py
--- old/osc-tiny-0.7.12/osctiny/__init__.py     2023-03-06 07:02:53.000000000 
+0100
+++ new/osc-tiny-0.8.0/osctiny/__init__.py      2023-11-29 15:48:50.000000000 
+0100
@@ -6,4 +6,4 @@
 
 __all__ = ['Osc', 'bs_requests', 'buildresults', 'comments', 'packages',
            'projects', 'search', 'users']
-__version__ = "0.7.12"
+__version__ = "0.8.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/extensions/attributes.py 
new/osc-tiny-0.8.0/osctiny/extensions/attributes.py
--- old/osc-tiny-0.7.12/osctiny/extensions/attributes.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/extensions/attributes.py 2023-11-29 
15:48:50.000000000 +0100
@@ -0,0 +1,73 @@
+"""
+Attributes extension
+--------------------
+
+.. versionadded:: 0.8.0
+"""
+import typing
+from urllib.parse import urljoin
+
+from lxml.objectify import ObjectifiedElement
+
+from ..utils.base import ExtensionBase
+
+
+class Attribute(ExtensionBase):
+    """
+    Access attribute namespaces and definitions
+    """
+    base_path = "/attribute"
+
+    def list_namespaces(self) -> typing.List[str]:
+        """
+        Get a list of all namespaces
+
+        :return: List of namespace names
+        """
+        response = self.osc.request(
+            url=urljoin(self.osc.url, f"{self.base_path}/"),
+            method="GET"
+        )
+        content = self.osc.get_objectified_xml(response)
+        return [entry.get("name") for entry in content.findall("entry")]
+
+    def get_namespace_meta(self, namespace: str) -> ObjectifiedElement:
+        """
+        Get the meta of the namespace
+
+        :param namespace: namespace name
+        :return: Objectified XML element
+        """
+        response = self.osc.request(
+            url=urljoin(self.osc.url, f"{self.base_path}/{namespace}/_meta"),
+            method="GET"
+        )
+        return self.osc.get_objectified_xml(response)
+
+    def list_attributes(self, namespace: str) -> typing.List[str]:
+        """
+        List the attributes available in namespace
+
+        :param namespace: Namespace name
+        :return: List of attribute names
+        """
+        response = self.osc.request(
+            url=urljoin(self.osc.url, f"{self.base_path}/{namespace}"),
+            method="GET"
+        )
+        content = self.osc.get_objectified_xml(response)
+        return [entry.get("name") for entry in content.findall("entry")]
+
+    def get_attribute_meta(self, namespace: str, name: str) -> 
ObjectifiedElement:
+        """
+        Get meta data for attribute
+
+        :param namespace: Namespace name
+        :param name: Attribute name
+        :return: Objectified XML element
+        """
+        response = self.osc.request(
+            url=urljoin(self.osc.url, 
f"{self.base_path}/{namespace}/{name}/_meta"),
+            method="GET"
+        )
+        return self.osc.get_objectified_xml(response)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/extensions/buildresults.py 
new/osc-tiny-0.8.0/osctiny/extensions/buildresults.py
--- old/osc-tiny-0.7.12/osctiny/extensions/buildresults.py      2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/extensions/buildresults.py       2023-11-29 
15:48:50.000000000 +0100
@@ -68,6 +68,28 @@
 
         return self.osc.get_objectified_xml(response)
 
+    def get_log(self, project, repo, arch, package):
+        """
+        Get the build log of a package
+
+        :param project: Project name
+        :param repo: Repository name
+        :param arch: Architecture name
+        :param package: Package name
+        :return: The package build log file
+        :rtype: str
+
+        .. versionadded:: 0.8.0
+        """
+
+        response = self.osc.request(
+            method="GET",
+            url=urljoin(self.osc.url, 
"{}/{}/{}/{}/{}/_log".format(self.base_path,
+                                                             
project,repo,arch,package))
+        )
+
+        return response.text
+
     def get_package_list(self, project, repo, arch):
         """
         Get a list of packages for which build results exist
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/extensions/issues.py 
new/osc-tiny-0.8.0/osctiny/extensions/issues.py
--- old/osc-tiny-0.7.12/osctiny/extensions/issues.py    2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/extensions/issues.py     2023-11-29 
15:48:50.000000000 +0100
@@ -2,11 +2,11 @@
 Issues extension
 ----------------
 """
+from functools import lru_cache
 import os
 
 from urllib.parse import urljoin
 
-from ..utils.backports import lru_cache
 from ..utils.base import ExtensionBase
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/extensions/origin.py 
new/osc-tiny-0.8.0/osctiny/extensions/origin.py
--- old/osc-tiny-0.7.12/osctiny/extensions/origin.py    2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/extensions/origin.py     2023-11-29 
15:48:50.000000000 +0100
@@ -22,12 +22,13 @@
 """
 # pylint: disable=too-many-ancestors,ungrouped-imports
 from collections import defaultdict
+from functools import lru_cache
 import re
 from warnings import warn
 
 from yaml import load
 
-from ..utils.backports import lru_cache, cached_property
+from ..utils.backports import cached_property
 from ..utils.base import ExtensionBase
 from ..utils.mapping import LazyOscMappable
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/extensions/projects.py 
new/osc-tiny-0.8.0/osctiny/extensions/projects.py
--- old/osc-tiny-0.7.12/osctiny/extensions/projects.py  2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/extensions/projects.py   2023-11-29 
15:48:50.000000000 +0100
@@ -42,20 +42,26 @@
 
         return self.osc.get_objectified_xml(response)
 
-    def get_meta(self, project):
+    def get_meta(self, project, rev=None):
         """
         Get project metadata
+        
+        .. versionchanged:: 0.8.0
+            Added the ``rev`` parameter
 
         :param project: name of project
+        :param rev: optional revision ID
+        :type rev: int
         :return: Objectified XML element
         :rtype: lxml.objectify.ObjectifiedElement
         """
         response = self.osc.request(
             url=urljoin(
                 self.osc.url,
-                "{}/{}/_meta".format(self.base_path, project)
+                "{}/{}/_project/_meta".format(self.base_path, project)
             ),
-            method="GET"
+            method="GET",
+            params={"rev": rev} if rev else None
         )
 
         return self.osc.get_objectified_xml(response)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/osc.py 
new/osc-tiny-0.8.0/osctiny/osc.py
--- old/osc-tiny-0.7.12/osctiny/osc.py  2023-03-06 07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/osc.py   2023-11-29 15:48:50.000000000 +0100
@@ -7,6 +7,7 @@
 from base64 import b64encode
 import typing
 import errno
+from http.cookiejar import CookieJar
 from io import BufferedReader, BytesIO, StringIO
 import gc
 import logging
@@ -19,13 +20,12 @@
 from urllib.parse import quote, parse_qs, urlparse
 import warnings
 
-# pylint: disable=no-name-in-module
-from lxml.objectify import fromstring, makeparser
 from requests import Session, Request
 from requests.auth import HTTPBasicAuth
 from requests.cookies import RequestsCookieJar, cookiejar_from_dict
 from requests.exceptions import ConnectionError as _ConnectionError
 
+from .extensions.attributes import Attribute
 from .extensions.buildresults import Build
 from .extensions.comments import Comment
 from .extensions.distributions import Distribution
@@ -38,13 +38,10 @@
 from .extensions.users import Group, Person
 from .utils.auth import HttpSignatureAuth
 from .utils.backports import cached_property
-from .utils.conf import BOOLEAN_PARAMS, get_credentials
+from .utils.conf import BOOLEAN_PARAMS, get_credentials, get_cookie_jar
 from .utils.errors import OscError
+from .utils.xml import get_xml_parser, get_objectified_xml
 
-try:
-    from cachecontrol import CacheControl
-except ImportError:
-    CacheControl = None
 
 THREAD_LOCAL = threading.local()
 
@@ -88,13 +85,14 @@
           - :py:attr:`distributions`
         * - :py:class:`osctiny.extensions.origin.Origin`
           - :py:attr:`origins`
+        * - :py:class:`osctiny.extensions.attributes.Attribute`
+          - :py:attr:`attributes`
 
     :param url: API URL of a BuildService instance
     :param username: Username
     :param password: Password; this is either the user password 
(``ssh_key_file`` is ``None``) or
                      the SSH passphrase, if ``ssh_key_file`` is defined
     :param verify: See `SSL Cert Verification`_ for more details
-    :param cache: Store API responses in a cache
     :param ssh_key_file: Path to SSH private key file
     :raises osctiny.errors.OscError: if no credentials are provided
 
@@ -120,6 +118,10 @@
         Support for 2FA authentication (i.e. added the ``ssh_key_file`` 
parameter and changed the
         meaning of the ``password`` parameter
 
+    .. versionchanged:: 0.8.0
+        * Removed the ``cache`` parameter
+        * Added the ``attributes`` extensions
+
     .. _SSL Cert Verification:
         http://docs.python-requests.org/en/master/user/advanced/
         #ssl-cert-verification
@@ -133,14 +135,12 @@
 
     def __init__(self, url: typing.Optional[str] = None, username: 
typing.Optional[str] = None,
                  password: typing.Optional[str] = None, verify: 
typing.Optional[str] = None,
-                 cache: bool = False,
                  ssh_key_file: typing.Optional[typing.Union[Path, str]] = 
None):
         # Basic URL and authentication settings
         self.url = url or self.url
         self.username = username or self.username
         self.password = password or self.password
         self.verify = verify
-        self.cache = cache
         self.ssh_key = ssh_key_file
         if self.ssh_key is not None and not isinstance(self.ssh_key, Path):
             self.ssh_key = Path(self.ssh_key)
@@ -152,6 +152,7 @@
                 raise OscError from error
 
         # API endpoints
+        self.attributes = Attribute(osc_obj=self)
         self.build = Build(osc_obj=self)
         self.comments = Comment(osc_obj=self)
         self.distributions = Distribution(osc_obj=self)
@@ -174,7 +175,7 @@
         return f"session_{session_hash}_{os.getpid()}_{threading.get_ident()}"
 
     @property
-    def _session(self) -> Session:
+    def session(self) -> Session:
         """
         Session object
         """
@@ -183,6 +184,11 @@
             session = Session()
             session.verify = self.verify or get_default_verify_paths().capath
 
+            cookies = get_cookie_jar()
+            if cookies is not None:
+                cookies.load()
+                session.cookies = cookies
+
             if self.ssh_key is not None:
                 session.auth = HttpSignatureAuth(username=self.username, 
password=self.password,
                                                  ssh_key_file=self.ssh_key)
@@ -194,49 +200,31 @@
         return session
 
     @property
-    def session(self) -> typing.Union[CacheControl, Session]:
-        """
-        Session object
-
-        Possibly wrapped in CacheControl, if installed.
-        """
-        if not self.cache or CacheControl is None:
-            return self._session
-
-        key = f"cached_{self._session_id}"
-        session = getattr(THREAD_LOCAL, key, None)
-        if not session:
-            session = CacheControl(self._session)
-            setattr(THREAD_LOCAL, key, session)
-
-        return session
-
-    @property
     def cookies(self) -> RequestsCookieJar:
         """
         Access session cookies
         """
-        return self._session.cookies
+        return self.session.cookies
 
     @cookies.setter
-    def cookies(self, value: RequestsCookieJar):
-        if not isinstance(value, (RequestsCookieJar, dict)):
+    def cookies(self, value: typing.Union[CookieJar, dict]):
+        if not isinstance(value, (CookieJar, dict)):
             raise TypeError(f"Expected a cookie jar or dict. Got instead: 
{type(value)}")
 
-        if isinstance(value, RequestsCookieJar):
-            self._session.cookies = value
+        if isinstance(value, CookieJar):
+            self.session.cookies = value
         else:
-            self._session.cookies = cookiejar_from_dict(value)
+            self.session.cookies = cookiejar_from_dict(value)
 
     @property
     def parser(self):
         """
         Explicit parser instance
-        """
-        if not hasattr(THREAD_LOCAL, "parser"):
-            THREAD_LOCAL.parser = makeparser(huge_tree=True)
 
-        return THREAD_LOCAL.parser
+        .. versionchanged:: 0.8.0
+            Content moved to :py:fun:`osctiny.utils.xml.get_xml_parser`
+        """
+        return get_xml_parser()
 
     def request(self, url, method="GET", stream=False, data=None, params=None,
                 raise_for_status=True, timeout=None):
@@ -247,9 +235,6 @@
         a dictionary and contains a key ``comment``, this value is passed on as
         a POST parameter.
 
-        If ``stream`` is True, the server response does not get cached because
-        the returned file might be large or huge.
-
         if ``raise_for_status`` is True, the used ``requests`` framework will
         raise an exception for occured errors.
 
@@ -292,21 +277,16 @@
         """
         timeout = timeout or self.default_timeout
 
-        if stream:
-            session = self._session
-        else:
-            session = self.session
-
         req = Request(
             method,
             url.replace("#", quote("#")).replace("?", quote("?")),
             data=self.handle_params(url=url, method=method, params=data),
             params=self.handle_params(url=url, method=method, params=params)
         )
-        prepped_req = session.prepare_request(req)
+        prepped_req = self.session.prepare_request(req)
         prepped_req.headers['Content-Type'] = "application/octet-stream"
         prepped_req.headers['Accept'] = "application/xml"
-        settings = session.merge_environment_settings(
+        settings = self.session.merge_environment_settings(
             prepped_req.url, {}, None, None, None
         )
         settings["stream"] = stream
@@ -327,7 +307,7 @@
                              else parse_qs(req.params, keep_blank_values=True)
                          ).items()))
             try:
-                response = session.send(prepped_req, **settings)
+                response = self.session.send(prepped_req, **settings)
             except _ConnectionError as error:
                 warnings.warn("Problem connecting to server: {}".format(error))
                 log_method = logger.error if i < 1 else logger.warning
@@ -489,25 +469,8 @@
 
             Allow ``response`` to be a string
 
-        :param response: An API response or XML string
-        :rtype response: :py:class:`requests.Response`
-        :return: :py:class:`lxml.objectify.ObjectifiedElement`
-        """
-        if isinstance(response, str):
-            text = response
-        else:
-            text = response.text
+        .. versionchanged:: 0.8.0
 
-        try:
-            return fromstring(text, self.parser)
-        except ValueError:
-            # Just in case OBS returns a Unicode string with encoding
-            # declaration
-            if isinstance(text, str) and \
-                    "encoding=" in text:
-                return fromstring(
-                    re.sub(r'encoding="[^"]+"', "", text)
-                )
-
-            # This might be something else
-            raise
+            Content moved to :py:fun:`osctiny.utils.xml.get_objectified_xml`
+        """
+        return get_objectified_xml(response=response)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/tests/test_attributes.py 
new/osc-tiny-0.8.0/osctiny/tests/test_attributes.py
--- old/osc-tiny-0.7.12/osctiny/tests/test_attributes.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/tests/test_attributes.py 2023-11-29 
15:48:50.000000000 +0100
@@ -0,0 +1,52 @@
+import responses
+
+from .base import OscTest
+
+
+class TestAttribute(OscTest):
+    def setUp(self):
+        super().setUp()
+
+        self.mock_request(
+            method=responses.GET,
+            url=self.osc.url + '/attribute/',
+            body="<directory><entry name='Foo'/><entry 
name='Bar'/></directory>"
+        )
+
+        self.mock_request(
+            method=responses.GET,
+            url=self.osc.url + '/attribute/Foo/_meta',
+            body="<namespace name='Foo'><modifiable_by user='A'/></namespace>"
+        )
+
+        self.mock_request(
+            method=responses.GET,
+            url=self.osc.url + '/attribute/Foo',
+            body="<directory><entry name='Hello'/><entry 
name='World'/></directory>"
+        )
+
+        self.mock_request(
+            method=responses.GET,
+            url=self.osc.url + '/attribute/Foo/Hello/_meta',
+            body="<definition name='Hello' namespace='Foo'><description>Lorem 
ipsum</description>"
+                 "<count>1</count><modifiable_by role='B'/></definition>"
+        )
+
+    @responses.activate
+    def test_list_namespace(self):
+        self.assertEqual(["Foo", "Bar"], self.osc.attributes.list_namespaces())
+
+    @responses.activate
+    def test_get_namespace_meta(self):
+        meta = self.osc.attributes.get_namespace_meta("Foo")
+        self.assertEqual(meta.get("name"), "Foo")
+
+    @responses.activate
+    def test_list_attributes(self):
+        self.assertEqual(["Hello", "World"], 
self.osc.attributes.list_attributes("Foo"))
+
+    @responses.activate
+    def test_get_attribute_meta(self):
+        meta = self.osc.attributes.get_attribute_meta("Foo", "Hello")
+        self.assertEqual(meta.get("name"), "Hello")
+        self.assertEqual(meta.get("namespace"), "Foo")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/tests/test_cache.py 
new/osc-tiny-0.8.0/osctiny/tests/test_cache.py
--- old/osc-tiny-0.7.12/osctiny/tests/test_cache.py     2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/tests/test_cache.py      1970-01-01 
01:00:00.000000000 +0100
@@ -1,24 +0,0 @@
-from unittest import skipUnless
-
-from .test_search import TestSearch
-from osctiny import Osc
-
-try:
-    import cachecontrol
-except ImportError:
-    with_cache = False
-else:
-    with_cache = True
-
-
-@skipUnless(with_cache, "No cache module present, therefore not testing")
-class TestSearch(TestSearch):
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-        cls.osc = Osc(
-            url="http://api.example.com";,
-            username="foobar",
-            password="helloworld",
-            cache=True
-        )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/tests/test_projects.py 
new/osc-tiny-0.8.0/osctiny/tests/test_projects.py
--- old/osc-tiny-0.7.12/osctiny/tests/test_projects.py  2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/tests/test_projects.py   2023-11-29 
15:48:50.000000000 +0100
@@ -93,6 +93,10 @@
                     </status>
                 """
             headers['request-id'] = '728d329e-0e86-11e4-a748-0c84dc037c13'
+            if "rev" in request.params:
+                revision = request.params["rev"]
+                body = body.replace('<project name="Devel:ARM:Factory">',
+                                    f'<project 
name="Devel:ARM:Factory:r{revision}">')
             return status, headers, body
 
         self.mock_request(
@@ -111,6 +115,10 @@
             self.assertRaises(
                 HTTPError, self.osc.projects.get_meta, "Devel:ARM:Fbctory"
             )
+        with self.subTest("existing project with revision"):
+            response = self.osc.projects.get_meta("Devel:ARM:Factory", rev=2)
+            self.assertEqual(response.tag, "project")
+            self.assertEqual(response.get("name"), "Devel:ARM:Factory:r2")
 
     @responses.activate
     def test_set_meta(self):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/tests/test_utils.py 
new/osc-tiny-0.8.0/osctiny/tests/test_utils.py
--- old/osc-tiny-0.7.12/osctiny/tests/test_utils.py     2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/tests/test_utils.py      2023-11-29 
15:48:50.000000000 +0100
@@ -10,10 +10,11 @@
 import sys
 from tempfile import mkstemp
 from types import GeneratorType
+import warnings
 
 from dateutil.parser import parse
 from pytz import _UTC, timezone
-from requests import Response
+from requests import Response, HTTPError
 import responses
 
 from ..osc import Osc, THREAD_LOCAL
@@ -21,6 +22,7 @@
 from ..utils.changelog import ChangeLog, Entry
 from ..utils.conf import get_config_path, get_credentials
 from ..utils.mapping import Mappable
+from ..utils.errors import get_http_error_details
 
 sys.path.append(os.path.dirname(__file__))
 
@@ -489,3 +491,65 @@
                                      "Basic realm=\"Use your developer 
account\", "})
             response = 
self.osc.session.get("https://api.example.com/hello-world";)
             self.do_assertions(response, True)
+
+
+class TestError(TestCase):
+    url = "http://example.com";
+    @property
+    def osc(self) -> Osc:
+        return Osc(url=self.url, username="nemo", password="password")
+
+    @responses.activate
+    def test_get_http_error_details(self):
+        status = 400
+        summary = "Bla Bla Bla"
+        responses.add(
+            responses.GET,
+            "http://example.com";,
+            body=f"""<status 
code="foo"><summary>{summary}</summary></status>""",
+            status=status
+        )
+
+        response = self.osc.session.get(self.url)
+
+        with self.subTest("Response"):
+            self.assertEqual(response.status_code, status)
+            self.assertEqual(get_http_error_details(response), summary)
+
+        with self.subTest("Exception"):
+            try:
+                response.raise_for_status()
+            except HTTPError as error:
+                self.assertEqual(get_http_error_details(error), summary)
+            else:
+                self.fail("No exception was raised")
+
+    @responses.activate
+    def test_get_http_error_details__bad_response(self):
+        status = 502
+        responses.add(
+            responses.GET,
+            "http://example.com";,
+            body=f"""Bad Gateway HTML message""",
+            status=status
+        )
+
+        response = self.osc.session.get(self.url)
+
+        with self.subTest("Response"):
+            self.assertEqual(response.status_code, status)
+            with warnings.catch_warnings(record=True) as emitted_warnings:
+                self.assertIn("Server replied with:", 
get_http_error_details(response))
+                self.assertEqual(len(emitted_warnings), 1)
+                self.assertIn("Start tag expected", 
str(emitted_warnings[-1].message))
+
+        with self.subTest("Exception"):
+            try:
+                response.raise_for_status()
+            except HTTPError as error:
+                with warnings.catch_warnings(record=True) as emitted_warnings:
+                    self.assertIn("Server replied with:", 
get_http_error_details(error))
+                    self.assertEqual(len(emitted_warnings), 1)
+                    self.assertIn("Start tag expected", 
str(emitted_warnings[-1].message))
+            else:
+                self.fail("No exception was raised")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/utils/backports.py 
new/osc-tiny-0.8.0/osctiny/utils/backports.py
--- old/osc-tiny-0.7.12/osctiny/utils/backports.py      2023-03-06 
07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/osctiny/utils/backports.py       2023-11-29 
15:48:50.000000000 +0100
@@ -3,21 +3,11 @@
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 .. versionadded:: 0.3.0
-"""
-try:
-    # pylint: disable=unused-import
-    from functools import lru_cache
-except ImportError:
-    # Whoever had the grandiose idea to backport this to Python2?
-    # pylint: disable=unused-argument
-    def lru_cache(*args, **kwargs):
-        """Dummy wrapper"""
-        def wrapper(fun):
-            return fun
-
-        return wrapper
 
+.. versionchanged:: 0.8.0
 
+    Removed function ``lru_cache``
+"""
 try:
     # pylint: disable=unused-import
     from functools import cached_property
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/utils/conf.py 
new/osc-tiny-0.8.0/osctiny/utils/conf.py
--- old/osc-tiny-0.7.12/osctiny/utils/conf.py   2023-03-06 07:02:53.000000000 
+0100
+++ new/osc-tiny-0.8.0/osctiny/utils/conf.py    2023-11-29 15:48:50.000000000 
+0100
@@ -12,6 +12,7 @@
 from base64 import b64decode
 from bz2 import decompress
 from configparser import ConfigParser, NoSectionError
+from http.cookiejar import LWPCookieJar
 import os
 from pathlib import Path
 
@@ -189,3 +190,25 @@
         raise ValueError(f"`osc` config provides no password or SSH key for 
URL {url}")
 
     return username, password if sshkey is None else None, sshkey
+
+
+def get_cookie_jar() -> typing.Optional[LWPCookieJar]:
+    """
+    Get cookies from a persistent osc cookiejar
+
+    .. versionadded:: 0.8.0
+    """
+    if _conf is not None:
+        path = _conf._identify_osccookiejar()  # pylint: 
disable=protected-access
+        if os.path.isfile(path):
+            return LWPCookieJar(filename=path)
+
+    path_suffix = Path("osc", "cookiejar")
+    paths = [Path(os.getenv("XDG_STATE_HOME", "/tmp")).joinpath(path_suffix),
+             Path.home().joinpath(".local", "state").joinpath(path_suffix)]
+
+    for path in paths:
+        if path.is_file():
+            return LWPCookieJar(filename=str(path))  # compatibility for 
Python < 3.8
+
+    return None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/utils/errors.py 
new/osc-tiny-0.8.0/osctiny/utils/errors.py
--- old/osc-tiny-0.7.12/osctiny/utils/errors.py 2023-03-06 07:02:53.000000000 
+0100
+++ new/osc-tiny-0.8.0/osctiny/utils/errors.py  2023-11-29 15:48:50.000000000 
+0100
@@ -1,10 +1,42 @@
 """
-Base classes for osc-tiny specific exceptions
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Exception base classes and utilities
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 """
+import typing
+from warnings import warn
+
+from requests import HTTPError, Response
+
+from .xml import get_objectified_xml
+
+
+def get_http_error_details(error: typing.Union[HTTPError, Response]) -> str:
+    """
+    Extract user-friendly error message from exception
+
+    .. versionadded:: 0.8.0
+    """
+    if isinstance(error, HTTPError):
+        response = error.response
+    elif isinstance(error, Response):
+        response = error
+    else:
+        raise TypeError("Expected a Response of HTTPError instance!")
+
+    try:
+        xml_obj = get_objectified_xml(response)
+    except Exception as error2:
+        warn(message=f"Failed to extract error message due to another error: 
{error2}",
+             category=RuntimeWarning)
+    else:
+        summary = xml_obj.find("summary")
+        if summary is not None:
+            return summary.text
+
+    return f"Server replied with: {response.status_code} {response.reason}"
 
 
 class OscError(Exception):
     """
-    Base class for expcetions to be raised by ``osctiny``
+    Base class for exceptions to be raised by ``osctiny``
     """
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/osctiny/utils/xml.py 
new/osc-tiny-0.8.0/osctiny/utils/xml.py
--- old/osc-tiny-0.7.12/osctiny/utils/xml.py    1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-tiny-0.8.0/osctiny/utils/xml.py     2023-11-29 15:48:50.000000000 
+0100
@@ -0,0 +1,74 @@
+"""
+XML parsing
+^^^^^^^^^^^
+
+.. versionadded:: 0.8.0
+"""
+import re
+import threading
+import typing
+
+from lxml.etree import XMLParser
+from lxml.objectify import fromstring, makeparser, ObjectifiedElement
+from requests import Response
+
+
+THREAD_LOCAL = threading.local()
+
+
+def get_xml_parser() -> XMLParser:
+    """
+    Get a parser object
+
+    .. versionchanged:: 0.8.0
+
+        Carved out from the ``Osc`` class
+    """
+    if not hasattr(THREAD_LOCAL, "parser"):
+        THREAD_LOCAL.parser = makeparser(huge_tree=True)
+
+    return THREAD_LOCAL.parser
+
+
+def get_objectified_xml(response: typing.Union[Response, str]) -> 
ObjectifiedElement:
+    """
+    Return API response as an XML object
+
+    .. versionchanged:: 0.1.6
+
+        Allow parsing of "huge" XML inputs
+
+    .. versionchanged:: 0.2.4
+
+        Allow ``response`` to be a string
+
+    .. versionchanged:: 0.8.0
+
+        Carved out from ``Osc`` class
+
+    :param response: An API response or XML string
+    :rtype response: :py:class:`requests.Response`
+    :return: :py:class:`lxml.objectify.ObjectifiedElement`
+    """
+    if isinstance(response, str):
+        text = response
+    elif isinstance(response, Response):
+        text = response.text
+    else:
+        raise TypeError(f"Expected a string or response object. Got  
{type(response)} instead.")
+
+    parser = get_xml_parser()
+
+    try:
+        return fromstring(text, parser)
+    except ValueError:
+        # Just in case OBS returns a Unicode string with encoding
+        # declaration
+        if isinstance(text, str) and \
+                "encoding=" in text:
+            return fromstring(
+                re.sub(r'encoding="[^"]+"', "", text)
+            )
+
+        # This might be something else
+        raise
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-tiny-0.7.12/setup.py new/osc-tiny-0.8.0/setup.py
--- old/osc-tiny-0.7.12/setup.py        2023-03-06 07:02:53.000000000 +0100
+++ new/osc-tiny-0.8.0/setup.py 2023-11-29 15:48:50.000000000 +0100
@@ -26,14 +26,15 @@
 
 setup(
     name='osc-tiny',
-    version='0.7.12',
+    version='0.8.0',
     description='Client API for openSUSE BuildService',
     long_description=long_description,
     long_description_content_type="text/markdown",
     author='Andreas Hasenkopf',
     author_email='ahasenk...@suse.com',
-    url='http://github.com/crazyscientist/osc-tiny',
-    download_url='http://github.com/crazyscientist/osc-tiny/tarball/master',
+    maintainer='SUSE Maintenance Automation Engineering team',
+    maintainer_email='maintenance-automation-t...@suse.de',
+    url='https://github.com/SUSE/osc-tiny',
     packages=find_packages(),
     license='MIT',
     install_requires=get_requires(),
@@ -49,5 +50,6 @@
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
     ]
 )

Reply via email to