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 2024-05-13 17:58:32 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-osc-tiny (Old) and /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1880 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-osc-tiny" Mon May 13 17:58:32 2024 rev:32 rq:1173655 version:0.9.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-osc-tiny/python-osc-tiny.changes 2024-03-25 21:15:53.994149926 +0100 +++ /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1880/python-osc-tiny.changes 2024-05-13 17:59:07.836192877 +0200 @@ -1,0 +2,12 @@ +Mon May 13 11:49:37 UTC 2024 - Gabriel Niebler <gnieb...@suse.com> + +- Release 0.9.0 + * Staging extension (closes #151) (#154) + * Type annotations and models + +------------------------------------------------------------------- +Mon May 13 10:16:52 UTC 2024 - Robert Frohl <rfr...@suse.com> + +- Use sle15allpythons to build for all SLE/Leap python versions + +------------------------------------------------------------------- Old: ---- osc-tiny-0.8.2.tar.gz New: ---- osc-tiny-0.9.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-osc-tiny.spec ++++++ --- /var/tmp/diff_new_pack.Y199LL/_old 2024-05-13 17:59:08.284209223 +0200 +++ /var/tmp/diff_new_pack.Y199LL/_new 2024-05-13 17:59:08.288209369 +0200 @@ -17,13 +17,14 @@ %define skip_python2 1 +%{?sle15allpythons} Name: python-osc-tiny -Version: 0.8.2 +Version: 0.9.0 Release: 0 Summary: Client API for openSUSE BuildService License: MIT Group: Development/Languages/Python -URL: https://github.com/crazyscientist/osc-tiny +URL: https://github.com/SUSE/osc-tiny Source: https://files.pythonhosted.org/packages/source/o/osc-tiny/osc-tiny-%{version}.tar.gz BuildRequires: %{python_module PyYAML} BuildRequires: %{python_module lxml} ++++++ osc-tiny-0.8.2.tar.gz -> osc-tiny-0.9.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/PKG-INFO new/osc-tiny-0.9.0/PKG-INFO --- old/osc-tiny-0.8.2/PKG-INFO 2024-03-22 11:50:35.555877000 +0100 +++ new/osc-tiny-0.9.0/PKG-INFO 2024-04-22 17:26:59.603191600 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: osc-tiny -Version: 0.8.2 +Version: 0.9.0 Summary: Client API for openSUSE BuildService Home-page: https://github.com/SUSE/osc-tiny Author: Andreas Hasenkopf diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osc_tiny.egg-info/PKG-INFO new/osc-tiny-0.9.0/osc_tiny.egg-info/PKG-INFO --- old/osc-tiny-0.8.2/osc_tiny.egg-info/PKG-INFO 2024-03-22 11:50:35.000000000 +0100 +++ new/osc-tiny-0.9.0/osc_tiny.egg-info/PKG-INFO 2024-04-22 17:26:59.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: osc-tiny -Version: 0.8.2 +Version: 0.9.0 Summary: Client API for openSUSE BuildService Home-page: https://github.com/SUSE/osc-tiny Author: Andreas Hasenkopf diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osc_tiny.egg-info/SOURCES.txt new/osc-tiny-0.9.0/osc_tiny.egg-info/SOURCES.txt --- old/osc-tiny-0.8.2/osc_tiny.egg-info/SOURCES.txt 2024-03-22 11:50:35.000000000 +0100 +++ new/osc-tiny-0.9.0/osc_tiny.egg-info/SOURCES.txt 2024-04-22 17:26:59.000000000 +0200 @@ -23,7 +23,10 @@ osctiny/extensions/packages.py osctiny/extensions/projects.py osctiny/extensions/search.py +osctiny/extensions/staging.py osctiny/extensions/users.py +osctiny/models/__init__.py +osctiny/models/staging.py osctiny/tests/__init__.py osctiny/tests/base.py osctiny/tests/test_attributes.py @@ -38,6 +41,7 @@ osctiny/tests/test_projects.py osctiny/tests/test_requests.py osctiny/tests/test_search.py +osctiny/tests/test_staging.py osctiny/tests/test_utils.py osctiny/tests/osc/__init__.py osctiny/tests/osc/conf.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/__init__.py new/osc-tiny-0.9.0/osctiny/__init__.py --- old/osc-tiny-0.8.2/osctiny/__init__.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/__init__.py 2024-04-22 17:26:55.000000000 +0200 @@ -6,4 +6,4 @@ __all__ = ['Osc', 'bs_requests', 'buildresults', 'comments', 'packages', 'projects', 'search', 'users'] -__version__ = "0.8.2" +__version__ = "0.9.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/extensions/bs_requests.py new/osc-tiny-0.9.0/osctiny/extensions/bs_requests.py --- old/osc-tiny-0.8.2/osctiny/extensions/bs_requests.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/extensions/bs_requests.py 2024-04-22 17:26:55.000000000 +0200 @@ -2,10 +2,13 @@ Requests extension ------------------ """ +import typing from urllib.parse import urljoin from lxml.etree import XMLSyntaxError +from lxml.objectify import ObjectifiedElement +from ..models import IntOrString, ParamsType from ..utils.base import ExtensionBase @@ -16,7 +19,7 @@ base_path = "/request/" @staticmethod - def _validate_id(request_id): + def _validate_id(request_id: IntOrString) -> str: request_id = str(request_id) if not request_id.isnumeric(): raise ValueError( @@ -24,7 +27,7 @@ ) return request_id - def get_list(self, **params): + def get_list(self, **params: ParamsType) -> ObjectifiedElement: """ Get a list or request objects @@ -40,7 +43,8 @@ return self.osc.get_objectified_xml(response) - def get(self, request_id, withhistory=False, withfullhistory=False): + def get(self, request_id: IntOrString, withhistory: bool = False, + withfullhistory: bool = False) -> ObjectifiedElement: """ Get one request object @@ -65,7 +69,8 @@ return self.osc.get_objectified_xml(response) - def update(self, request_id, **kwargs): + def update(self, request_id: IntOrString, **kwargs: ParamsType) \ + -> typing.Union[ObjectifiedElement, str]: """ Update request or execute command @@ -96,7 +101,8 @@ except XMLSyntaxError: return response.text - def cmd(self, request_id, cmd="diff", **kwargs): + def cmd(self, request_id: IntOrString, cmd: str = "diff", **kwargs: ParamsType) \ + -> typing.Union[ObjectifiedElement, str]: """ Get the result of the specified command @@ -130,7 +136,8 @@ request_id = self._validate_id(request_id) return self.update(request_id=request_id, **kwargs) - def add_comment(self, request_id, comment, parent_id=None): + def add_comment(self, request_id: IntOrString, comment: str, + parent_id: typing.Optional[str] = None) -> bool: """ Add a comment to a request @@ -152,7 +159,7 @@ parent_id=parent_id ) - def get_comments(self, request_id): + def get_comments(self, request_id: IntOrString) -> ObjectifiedElement: """ Get a list of comments for request diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/extensions/buildresults.py new/osc-tiny-0.9.0/osctiny/extensions/buildresults.py --- old/osc-tiny-0.8.2/osctiny/extensions/buildresults.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/extensions/buildresults.py 2024-04-22 17:26:55.000000000 +0200 @@ -111,6 +111,28 @@ return self.osc.get_objectified_xml(response) + def get_status_and_build_id(self, project, repo, arch): + """ + Get build status and build ID + + :param project: Project name + :param repo: Repository name + :param arch: Architecture name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + + .. versionadded:: 0.9.0 + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, "{}/{}/{}/{}".format( + self.base_path, project, repo, arch + )), + params={"view": "status"} + ) + + return self.osc.get_objectified_xml(response) + def get_binary_list(self, project, repo, arch, package, **params): """ Get a list of built RPMs diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/extensions/projects.py new/osc-tiny-0.9.0/osctiny/extensions/projects.py --- old/osc-tiny-0.8.2/osctiny/extensions/projects.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/extensions/projects.py 2024-04-22 17:26:55.000000000 +0200 @@ -45,7 +45,7 @@ def get_meta(self, project, rev=None): """ Get project metadata - + .. versionchanged:: 0.8.0 Added the ``rev`` parameter diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/extensions/staging.py new/osc-tiny-0.9.0/osctiny/extensions/staging.py --- old/osc-tiny-0.8.2/osctiny/extensions/staging.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/extensions/staging.py 2024-04-22 17:26:55.000000000 +0200 @@ -0,0 +1,384 @@ +""" +Staging extension +----------------- + +This extension provides access to the staging workflow of OpenBuildService. + +.. seealso:: + + https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.stagingworkflow + + https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.best-practices.webuiusage#staging_how_to + +.. versionadded:: 0.9.0 +""" +import typing +from urllib.parse import urljoin + +from lxml.objectify import ObjectifiedElement, Element, SubElement + +from ..models.staging import E, ExcludedRequest, CheckReport +from ..utils.base import ExtensionBase + + +class Staging(ExtensionBase): + """ + Osc extension for interacting with staging workflows + """ + base_path_staging = "/staging" + base_path_status = "/status_reports" + + def get_backlog(self, project: str) -> ObjectifiedElement: + """ + List the requests in the staging backlog + + :param project: Project name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, "{}/{}/backlog".format(self.base_path_staging, project)) + ) + + return self.osc.get_objectified_xml(response) + + def get_excluded_requests(self, project: str) -> ObjectifiedElement: + """ + List the requests excluded from a staging workflow + + :param project: Project name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, "{}/{}/excluded_requests".format(self.base_path_staging, + project)) + ) + + return self.osc.get_objectified_xml(response) + + def set_excluded_requests(self, project: str, *requests: ExcludedRequest) -> bool: + """ + Exclude requests from the staging workflow. + + :param project: Project name + :param requests: Requests to exclude with optional reason/description + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + response = self.osc.request( + method="POST", + url=urljoin(self.osc.url, "{}/{}/excluded_requests".format(self.base_path_staging, + project)), + data=E.excluded_requests(*(request.asxml() for request in requests)) + ) + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def delete_excluded_requests(self, project: str, *requests: ExcludedRequest) -> bool: + """ + Remove requests from list of excluded requests + + :param project: Project name + :param requests: Requests to exclude with optional reason/description + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + response = self.osc.request( + method="DELETE", + url=urljoin(self.osc.url, "{}/{}/excluded_requests".format(self.base_path_staging, + project)), + data=E.excluded_requests(*(request.asxml() for request in requests)) + ) + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def get_staging_projects(self, project: str) -> ObjectifiedElement: + """ + List all the staging projects of a staging workflow. + + :param project: Project name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, + "{}/{}/staging_projects".format(self.base_path_staging, project)) + ) + + return self.osc.get_objectified_xml(response) + + # pylint: disable=too-many-arguments + def get_status(self, project: str, staging_project: str, requests: bool = False, + status: bool = False, history: bool = False) -> ObjectifiedElement: + """ + Get the overall state of a staging project + + :param project: Project name + :param staging_project: Staging project name + :param requests: Include statistics about staged, untracked and obsolete requests as well as + missing reviews + :param status: Include the overall state + :param history: Include the history of the staging project + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, + "{}/{}/staging_projects/{}".format(self.base_path_staging, project, + staging_project)), + params={"requests": requests, "status": status, "history": history} + ) + + return self.osc.get_objectified_xml(response) + + def accept(self, project: str, staging_project: str) -> bool: + """ + This accepts all staged requests and sets the project state back to 'empty' + + :param project: Project name + :param staging_project: Staging project name + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + response = self.osc.request( + method="POST", + url=urljoin(self.osc.url, "{}/{}/staging_projects/{}/accept".format( + self.base_path_staging, project, staging_project)) + ) + + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def get_staged_requests(self, project: str, staging_project: str) ->ObjectifiedElement: + """ + List all the staged requests of a staging project + + :param project: Project name + :param staging_project: Staging project name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, + "{}/{}/staging_projects/{}/staged_requests".format( + self.base_path_staging, project, staging_project)), + ) + + return self.osc.get_objectified_xml(response) + + def add_staged_requests(self, project: str, staging_project: str, *request_ids: int) -> bool: + """ + Add requests to the staging project. + + :param project: Project name + :param staging_project: Staging project name + :param request_ids: Request IDs + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + requests = Element("requests") + for request_id in request_ids: + SubElement(requests, "request", id=str(request_id)) + response = self.osc.request( + method="POST", + url=urljoin(self.osc.url, "{}/{}/staging_projects/{}/staged_requests".format( + self.base_path_staging, project, staging_project)), + data=requests + ) + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def delete_staged_requests(self, project: str, staging_project: str, *request_ids: int) -> bool: + """ + Delete requests from the staging project + + :param project: Project name + :param staging_project: Staging project name + :param request_ids: Request IDs + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + requests = Element("requests") + for request_id in request_ids: + SubElement(requests, "request", id=str(request_id)) + response = self.osc.request( + method="DELETE", + url=urljoin(self.osc.url, "{}/{}/staging_projects/{}/staged_requests".format( + self.base_path_staging, project, staging_project)), + data=requests + ) + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def get_required_checks(self, project: str, repo: typing.Optional[str] = None, + arch: typing.Optional[str] = None) -> ObjectifiedElement: + """ + Get list of required checks + + If `repo`` and ``arch`` are specified, required checks from the built repository are + returned. If only ``repo`` is specified, required checks from the repository are + returned. Otherwise, required checks from the project are returned + + :param project: (Staging) project name + :param repo: Repository name (optional) + :param arch: Architecture name (optional) + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + if repo and arch: + url_path = "{}/built_repositories/{}/{}/{}/required_checks".format( + self.base_path_status, project, repo, arch + ) + elif repo: + url_path = "{}/repositories/{}/{}/required_checks".format( + self.base_path_status, project, repo + ) + else: + url_path = "{}/projects/{}/required_checks".format(self.base_path_status, project) + + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, url_path), + ) + + return self.osc.get_objectified_xml(response) + + def set_required_checks(self, project: str, checks: typing.List[str], + repo: typing.Optional[str] = None, + arch: typing.Optional[str] = None) -> bool: + """ + Submit a new or modified required checks list + + If ``repo`` and ``arch`` are specified, required checks of built repository are + updated. If only ``repo`` is specified, required checks of the repository are updated. + Otherwise, required checks of the project are updated. + + :param project: (Staging) project name + :param checks: List of check names + :param repo: Repository name (optional) + :param arch: Architecture name (optional) + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + kwargs = {"project": project} + if repo and arch: + url_path = "{}/built_repositories/{}/{}/{}/required_checks".format( + self.base_path_status, project, repo, arch + ) + kwargs.update({"repository": repo, "architecture": arch}) + elif repo: + url_path = "{}/repositories/{}/{}/required_checks".format( + self.base_path_status, project, repo + ) + kwargs["repository"] = repo + else: + url_path = "{}/projects/{}/required_checks".format(self.base_path_status, project) + + response = self.osc.request( + method="POST", + url=urljoin(self.osc.url, url_path), + data=E.required_checks(*(E.name(check) for check in checks)) + ) + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False + + def get_status_report(self, project: str, repo: str, build_id: str, + arch: typing.Optional[str] = None) -> ObjectifiedElement: + """ + Get list of checks + + If ``arch`` is specified, status report for built project is retrieved. Otherwise, status + report for published project is retrieved. + + :param project: (Staging) project name + :param repo: Repository name + :param build_id: Build ID (Can be obtained via + :py:meth:`osctiny.extensions.buildresults.Build.get_status_and_build_id`) + :param arch: Architecture name + :return: Objectified XML element + :rtype: lxml.objectify.ObjectifiedElement + """ + if arch: + url_path = "{}/built/{}/{}/{}/reports/{}".format( + self.base_path_status, project, repo, arch, build_id + ) + else: + url_path = "{}/published/{}/{}/reports/{}".format( + self.base_path_status, project, repo, build_id + ) + + response = self.osc.request( + method="GET", + url=urljoin(self.osc.url, url_path) + ) + + return self.osc.get_objectified_xml(response) + + def set_status_report(self, project: str, repo: str, build_id: str, report: CheckReport, + arch: typing.Optional[str] = None) -> bool: + """ + Submit a check to a status report + + If ``arch`` is specified, the status report for built project is set. Otherwise, the status + report for published project is set. + + :param project: (Staging) project name + :param repo: Repository name + :param build_id: Build ID (Can be obtained via + :py:meth:`osctiny.extensions.buildresults.Build.get_status_and_build_id`) + :param report: The status upate + :param arch: Architecture name + :return: ``True``, if successful. + :raises HTTPError: if comment was not saved correctly. The raised exception contains the + full response object and API response. + """ + if arch: + url_path = "{}/built/{}/{}/{}/reports/{}".format( + self.base_path_status, project, repo, arch, build_id + ) + else: + url_path = "{}/published/{}/{}/reports/{}".format( + self.base_path_status, project, repo, build_id + ) + + response = self.osc.request( + method="POST", + url=urljoin(self.osc.url, url_path), + data=report.asxml() + ) + + parsed = self.osc.get_objectified_xml(response) + if response.status_code == 200 and parsed.get("code") == "ok": + return True + + return False diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/models/__init__.py new/osc-tiny-0.9.0/osctiny/models/__init__.py --- old/osc-tiny-0.8.2/osctiny/models/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/models/__init__.py 2024-04-22 17:26:55.000000000 +0200 @@ -0,0 +1,12 @@ +""" +Common model/type definitions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +""" +from io import BufferedReader, BytesIO, StringIO +import typing + +from lxml.objectify import ObjectifiedElement + + +ParamsType = typing.Union[bytes, str, StringIO, BytesIO, BufferedReader, dict, ObjectifiedElement] +IntOrString = typing.Union[int, str] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/models/staging.py new/osc-tiny-0.9.0/osctiny/models/staging.py --- old/osc-tiny-0.8.2/osctiny/models/staging.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/models/staging.py 2024-04-22 17:26:55.000000000 +0200 @@ -0,0 +1,69 @@ +""" +Models for Staging +^^^^^^^^^^^^^^^^^^ +""" +# pylint: disable=missing-class-docstring,missing-function-docstring +import enum +import typing + +from lxml.objectify import ObjectifiedElement, ElementMaker + + +E = ElementMaker(annotate=False) + + +class ExcludedRequest(typing.NamedTuple): + id: int + description: typing.Optional[str] = None + + def asdict(self) -> typing.Dict[str, str]: + d = {"id": str(self.id)} + if self.description: + d["description"] = self.description + + return d + + def asxml(self) -> ObjectifiedElement: + return E.request(**self.asdict()) + + +class CheckState(enum.Enum): + PENDING = "pending" + ERROR = "error" + FAILURE = "failure" + SUCCESS = "success" + + +class CheckReport(typing.NamedTuple): + name: str + required: bool + state: CheckState + short_description: typing.Optional[str] = None + url: typing.Optional[str] = None + + @property + def required_str(self) -> str: + return "true" if self.required else "false" + + def _optional_fields(self) -> typing.Generator[typing.Tuple[str, str], None, None]: + for key in ('url', 'short_description'): + value = getattr(self, key, None) + if value: + yield key, str(value) + + def asdict(self) -> typing.Dict[str, str]: + d = {"name": self.name, "required": self.required_str, + "state": self.state.value} + + for key, value in self._optional_fields(): + d[key] = value + + return d + + def asxml(self) -> ObjectifiedElement: + sub_elems = [E.state(self.state.value)] + for key, value in self._optional_fields(): + _E = getattr(E, key) + sub_elems.append(_E(value)) + + return E.check(*sub_elems, name=self.name, required=self.required_str) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/osc.py new/osc-tiny-0.9.0/osctiny/osc.py --- old/osc-tiny-0.8.2/osctiny/osc.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/osc.py 2024-04-22 17:26:55.000000000 +0200 @@ -20,7 +20,9 @@ from urllib.parse import quote, parse_qs, urlparse import warnings -from requests import Session, Request +from lxml.etree import tostring +from lxml.objectify import ObjectifiedElement +from requests import Session, Request, Response from requests.auth import HTTPBasicAuth from requests.cookies import RequestsCookieJar, cookiejar_from_dict from requests.exceptions import ConnectionError as _ConnectionError @@ -35,7 +37,9 @@ from .extensions.projects import Project from .extensions.bs_requests import Request as BsRequest from .extensions.search import Search +from .extensions.staging import Staging from .extensions.users import Group, Person +from .models import ParamsType from .utils.auth import HttpSignatureAuth from .utils.backports import cached_property from .utils.conf import BOOLEAN_PARAMS, get_credentials, get_cookie_jar @@ -87,6 +91,8 @@ - :py:attr:`origins` * - :py:class:`osctiny.extensions.attributes.Attribute` - :py:attr:`attributes` + * - :py:class:`osctiny.extensions.staging.Staging` + - :py:attr:`staging` :param url: API URL of a BuildService instance :param username: Username @@ -120,7 +126,10 @@ .. versionchanged:: 0.8.0 * Removed the ``cache`` parameter - * Added the ``attributes`` extensions + * Added the ``attributes`` extension + + .. versionchanged:: 0.9.0 + * Added the ``staging`` extension .. _SSL Cert Verification: http://docs.python-requests.org/en/master/user/advanced/ @@ -163,6 +172,7 @@ self.projects = Project(osc_obj=self) self.requests = BsRequest(osc_obj=self) self.search = Search(osc_obj=self) + self.staging = Staging(osc_obj=self) self.users = Person(osc_obj=self) def __del__(self): @@ -222,12 +232,15 @@ Explicit parser instance .. versionchanged:: 0.8.0 - Content moved to :py:fun:`osctiny.utils.xml.get_xml_parser` + Content moved to :py:func:`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): + def request(self, url: str, method: str = "GET", stream: bool = False, + data: typing.Optional[ParamsType] = None, + params: typing.Optional[ParamsType] = None, + raise_for_status: bool = True, timeout: typing.Optional[int] = None) \ + -> typing.Optional[Response]: """ Perform HTTP(S) request @@ -351,9 +364,8 @@ return () - def handle_params(self, url: str, method: str, - params: typing.Union[bytes, str, StringIO, BytesIO, BufferedReader, dict]) \ - -> bytes: + def handle_params(self, url: str, method: str, params: ParamsType) \ + -> bytes: # pylint: disable=too-many-return-statements """ Translate request parameters to API conform format @@ -369,7 +381,6 @@ /9715 :param params: Request parameter - :type params: dict or str or io.BufferedReader :param url: URL to which the parameters will be sent :type url: str :param method: HTTP method to send request @@ -380,6 +391,10 @@ .. versionchanged:: 0.7.3 Added the ``url`` and ``method`` parameters + + .. versionchanged:: 0.9.0 + + Instances of ``ObjectifiedElement`` are accepted for argument ``params`` """ if isinstance(params, bytes): return params @@ -395,6 +410,9 @@ params.seek(0) return params + if isinstance(params, ObjectifiedElement): + return tostring(params, encoding="utf-8", xml_declaration=True) + if not isinstance(params, dict): return {} @@ -422,7 +440,8 @@ if value is not None ).encode() - def download(self, url, destdir, destfile=None, overwrite=False, **params): + def download(self, url: str, destdir: Path, destfile: typing.Optional[str] = None, + overwrite: bool = False, **params: ParamsType) -> Path: """ Shortcut for a streaming GET request @@ -457,7 +476,7 @@ return target - def get_objectified_xml(self, response): + def get_objectified_xml(self, response: Response) -> ObjectifiedElement: """ Return API response as an XML object @@ -471,6 +490,6 @@ .. versionchanged:: 0.8.0 - Content moved to :py:fun:`osctiny.utils.xml.get_objectified_xml` + Content moved to :py:func:`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.8.2/osctiny/tests/test_staging.py new/osc-tiny-0.9.0/osctiny/tests/test_staging.py --- old/osc-tiny-0.8.2/osctiny/tests/test_staging.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/tests/test_staging.py 2024-04-22 17:26:55.000000000 +0200 @@ -0,0 +1,248 @@ +import re + +import responses + +from osctiny.models.staging import ExcludedRequest, CheckState, CheckReport + +from .base import OscTest + + +class StagingTest(OscTest): + @staticmethod + def _mock_generic_request(): + responses.add(method=responses.GET, + url=re.compile("http://api.example.com/(staging|status_reports)/.*"), + body="<dummy><foo/><bar>Hello World</bar></dummy>", + status=200) + + @responses.activate + def test_get_backlog(self): + self._mock_generic_request() + response = self.osc.staging.get_backlog("Dummy:Project") + self.assertEqual(response.tag, "dummy") + + @responses.activate + def test_get_excluded_requests(self): + self._mock_generic_request() + response = self.osc.staging.get_excluded_requests("Dummy:Project") + self.assertEqual(response.tag, "dummy") + + @responses.activate + def test_set_excluded_requests(self): + def callback(request): + element = self.osc.get_objectified_xml(response=request.body) + self.assertEqual(element.request[0].get("id"), '1') + self.assertEqual(element.request[0].get("description"), "Foo Bar") + self.assertEqual(element.request[1].get("id"), '2') + self.assertEqual(element.request[1].get("description"), "Hello World") + self.assertEqual(element.request[2].get("id"), '3') + return 200, {}, "<status code=\"ok\"/>" + + self.mock_request( + method="POST", + url="http://api.example.com/staging/Dummy:Project/excluded_requests", + callback=callback + ) + + result = self.osc.staging.set_excluded_requests("Dummy:Project", + ExcludedRequest(1, "Foo Bar"), + ExcludedRequest(2, "Hello World"), + ExcludedRequest(3)) + self.assertTrue(result) + + @responses.activate + def test_delete_excluded_requests(self): + def callback(request): + element = self.osc.get_objectified_xml(response=request.body) + self.assertEqual(element.request[0].get("id"), '1') + return 200, {}, "<status code=\"ok\"/>" + + self.mock_request( + method="DELETE", + url="http://api.example.com/staging/Dummy:Project/excluded_requests", + callback=callback + ) + + result = self.osc.staging.delete_excluded_requests("Dummy:Project", ExcludedRequest(1)) + self.assertTrue(result) + + @responses.activate + def test_get_staging_projects(self): + self._mock_generic_request() + response = self.osc.staging.get_staging_projects("Dummy:Project") + self.assertEqual(response.tag, "dummy") + + @responses.activate + def test_get_status(self): + self._mock_generic_request() + response = self.osc.staging.get_status("Dummy:Project", "Dummy:Project:Staging:A") + self.assertEqual(response.tag, "dummy") + + @responses.activate + def test_accept(self): + responses.add(method="POST", + url="http://api.example.com/staging/Dummy:Project/staging_projects/Dummy:Project:Staging:A/accept", + status=200, + body="<status code=\"ok\"/>") + self.assertTrue(self.osc.staging.accept("Dummy:Project", "Dummy:Project:Staging:A")) + + @responses.activate + def test_get_staged_requests(self): + self._mock_generic_request() + response = self.osc.staging.get_staged_requests("Dummy:Project", "Dummy:Project:Staging:A") + self.assertEqual(response.tag, "dummy") + + @responses.activate + def test_add_staged_requests(self): + def callback(request): + element = self.osc.get_objectified_xml(request.body) + self.assertEqual(3, len(element.request)) + self.assertEqual(["1", "2", "3"], [elem.get("id") for elem in element.request]) + return 200, {}, "<status code=\"ok\"/>" + + responses.add_callback(method="POST", + url="http://api.example.com/staging/Dummy:Project/staging_projects/Dummy:Project:Staging:A/staged_requests", + callback=callback) + result = self.osc.staging.add_staged_requests("Dummy:Project", "Dummy:Project:Staging:A", + 1, 2, 3) + self.assertTrue(result) + + @responses.activate + def test_delete_staged_requests(self): + def callback(request): + element = self.osc.get_objectified_xml(request.body) + self.assertEqual(3, len(element.request)) + self.assertEqual(["1", "2", "3"], [elem.get("id") for elem in element.request]) + return 200, {}, "<status code=\"ok\"/>" + + responses.add_callback(method="DELETE", + url="http://api.example.com/staging/Dummy:Project/staging_projects/Dummy:Project:Staging:A/staged_requests", + callback=callback) + result = self.osc.staging.delete_staged_requests("Dummy:Project", "Dummy:Project:Staging:A", + 1, 2, 3) + self.assertTrue(result) + + @responses.activate + def test_get_required_checks(self): + responses.add( + method="GET", + url="http://api.example.com/status_reports/built_repositories/Dummy:Project/repo/x86_64/required_checks", + body="<dummyrepoarch/>", + status=200 + ) + responses.add( + method="GET", + url="http://api.example.com/status_reports/repositories/Dummy:Project/repo/required_checks", + body="<dummyrepo/>", + status=200 + ) + responses.add( + method="GET", + url="http://api.example.com/status_reports/projects/Dummy:Project/required_checks", + body="<dummyproject/>", + status=200 + ) + + with self.subTest("Repo + Arch"): + response = self.osc.staging.get_required_checks("Dummy:Project", "repo", "x86_64") + self.assertEqual(response.tag, "dummyrepoarch") + + with self.subTest("Repo"): + response = self.osc.staging.get_required_checks("Dummy:Project", "repo") + self.assertEqual(response.tag, "dummyrepo") + + with self.subTest("Project"): + response = self.osc.staging.get_required_checks("Dummy:Project") + self.assertEqual(response.tag, "dummyproject") + + @responses.activate + def test_set_required_checks(self): + def callback(request): + elem = self.osc.get_objectified_xml(request.body) + + if "/projects/" in request.url: + self.assertTrue(all("project" in child.text for child in elem.name)) + if "/repositories/" in request.url: + self.assertTrue(all("repo" in child.text for child in elem.name)) + if "/built_repositories/" in request.url: + self.assertTrue(all("built" in child.text for child in elem.name)) + + return 200, {}, "<status code=\"ok\"/>" + + responses.add_callback( + method="POST", + url=re.compile("http://api.example.com/status_reports/(projects|repositories|built_repositories)/.*"), + callback=callback + ) + + with self.subTest("Repo + Arch"): + result = self.osc.staging.set_required_checks( + "Dummy:Project", [f"built-{i}" for i in range(1, 3)], "repo", "x86_64" + ) + self.assertTrue(result) + + with self.subTest("Repo"): + result = self.osc.staging.set_required_checks( + "Dummy:Project", [f"repo-{i}" for i in range(1, 3)], "repo" + ) + self.assertTrue(result) + + with self.subTest("Project"): + result = self.osc.staging.set_required_checks( + "Dummy:Project", [f"project-{i}" for i in range(1, 3)] + ) + self.assertTrue(result) + + @responses.activate + def test_get_status_report(self): + responses.add( + method="GET", + url="http://api.example.com/status_reports/built/Dummy:Project/repo/x86_64/reports/1", + body="<dummyarch/>", + status=200 + ) + responses.add( + method="GET", + url="http://api.example.com/status_reports/published/Dummy:Project/repo/reports/1", + body="<dummyrepo/>", + status=200 + ) + + with self.subTest("Repo + Arch"): + response = self.osc.staging.get_status_report("Dummy:Project", "repo", "1", "x86_64") + self.assertEqual(response.tag, "dummyarch") + + with self.subTest("Repo"): + response = self.osc.staging.get_status_report("Dummy:Project", "repo", "1") + self.assertEqual(response.tag, "dummyrepo") + + @responses.activate + def test_set_status_report(self): + report = CheckReport( + name="dummy-check", + required=False, + state=CheckState.FAILURE, + short_description="Lorem ipsum dolor sit", + url="http://example.com/lorem-ipsum" + ) + + def callback(request): + elem = self.osc.get_objectified_xml(request.body) + self.assertEqual(report.name, elem.get("name")) + self.assertEqual("false", elem.get("required")) + self.assertEqual(elem.state.text, report.state.value) + self.assertEqual(elem.short_description.text, report.short_description) + self.assertEqual(elem.url.text, report.url) + + return 200, {}, "<status code=\"ok\"/>" + + responses.add_callback( + method="POST", + url=re.compile("http://api.example.com/status_reports/(published|built)"), + callback=callback + ) + + with self.subTest("Built"): + result = self.osc.staging.set_status_report("Dummy:Project", "repo", "id-1", report, + "x86_64") + self.assertTrue(result) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/utils/base.py new/osc-tiny-0.9.0/osctiny/utils/base.py --- old/osc-tiny-0.8.2/osctiny/utils/base.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/utils/base.py 2024-04-22 17:26:55.000000000 +0200 @@ -4,6 +4,8 @@ """ # pylint: disable=too-few-public-methods, import os +import typing +from pathlib import Path from lxml.etree import tounicode @@ -12,7 +14,7 @@ """ Base class for extensions of the :py:class:`Ocs` entry point. """ - def __init__(self, osc_obj): + def __init__(self, osc_obj: "Osc"): self.osc = osc_obj @@ -25,7 +27,8 @@ osclib_version_string = "1.0" # pylint: disable=too-many-arguments - def __init__(self, osc, path, project, package=None, overwrite=False): + def __init__(self, osc: "Osc", path: Path, project: str, package: typing.Optional[str] = None, + overwrite: bool = False): self.osc = osc self.path = os.path.join(path, self.data_dir) self.project = project @@ -42,7 +45,7 @@ if overwrite: self.write_dir_contents() - def write_dir_contents(self): + def write_dir_contents(self) -> None: """ Create files with default content in ``.osc`` sub-directory """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/utils/changelog.py new/osc-tiny-0.9.0/osctiny/utils/changelog.py --- old/osc-tiny-0.8.2/osctiny/utils/changelog.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/utils/changelog.py 2024-04-22 17:26:55.000000000 +0200 @@ -11,6 +11,7 @@ .. versionadded:: 0.1.11 """ +import typing from datetime import datetime from io import TextIOBase import re @@ -20,7 +21,10 @@ from pytz import _UTC -def is_aware(timestamp): +Parsable = typing.Union[TextIOBase, str, bytes] + + +def is_aware(timestamp: datetime) -> bool: """ Check whether timestamp is timezone aware @@ -54,12 +58,13 @@ All lines until the beginning of the next entry; except empty lines at the beginning and end """ - timestamp = None - packager = None + timestamp: datetime = None + packager: str = None content = "" default_tz = _UTC() - def __init__(self, timestamp=None, packager=None, content=""): + def __init__(self, timestamp: typing.Optional[datetime] = None, + packager: typing.Optional[str] = None, content: str =""): if not isinstance(timestamp, datetime) and timestamp is not None: raise TypeError("`timestamp` needs to be a datetime object!") if timestamp and not is_aware(timestamp): @@ -68,13 +73,13 @@ self.packager = packager self.content = content - def __bool__(self): + def __bool__(self) -> bool: return bool(self.timestamp and self.packager and self.content) - def __len__(self): + def __len__(self) -> int: return 1 if self.timestamp and self.packager and self.content else 0 - def now(self): + def now(self) -> datetime: """ Return current UTC timestamp @@ -83,7 +88,7 @@ return datetime.now(tz=self.default_tz) @property - def formatted_timestamp(self): + def formatted_timestamp(self) -> str: """ Return properly formatted timestamp @@ -96,11 +101,11 @@ .astimezone(self.default_tz)\ .strftime("%a %b %d %H:%M:%S %Z %Y") - def __str__(self): + def __str__(self) -> str: return "{sep}\n{self.formatted_timestamp} - {self.packager}\n\n" \ "{self.content}\n\n".format(sep="-" * 67, self=self) - def __unicode__(self): + def __unicode__(self) -> str: return self.__str__() @@ -138,7 +143,8 @@ def __init__(self): self.entries = [] - def _parse(self, handle): + def _parse(self, handle: Parsable) \ + -> typing.Generator[Entry, None, None]: """ Actual method for parsing. @@ -208,7 +214,7 @@ handle.close() @classmethod - def parse(cls, path, generative=True): + def parse(cls, path: Parsable, generative: bool = True) -> "ChangeLog": """ Parse a changes file @@ -247,7 +253,7 @@ return new - def write(self, path): + def write(self, path: Parsable) -> None: """ Write entries to file/stream diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/osctiny/utils/xml.py new/osc-tiny-0.9.0/osctiny/utils/xml.py --- old/osc-tiny-0.8.2/osctiny/utils/xml.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/osctiny/utils/xml.py 2024-04-22 17:26:55.000000000 +0200 @@ -30,7 +30,7 @@ return THREAD_LOCAL.parser -def get_objectified_xml(response: typing.Union[Response, str]) -> ObjectifiedElement: +def get_objectified_xml(response: typing.Union[Response, str, bytes]) -> ObjectifiedElement: """ Return API response as an XML object @@ -46,11 +46,15 @@ Carved out from ``Osc`` class + .. versionchanged:: 0.9.0 + + Accepts also bytes + :param response: An API response or XML string :rtype response: :py:class:`requests.Response` :return: :py:class:`lxml.objectify.ObjectifiedElement` """ - if isinstance(response, str): + if isinstance(response, (str, bytes)): text = response elif isinstance(response, Response): text = response.text diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.8.2/setup.py new/osc-tiny-0.9.0/setup.py --- old/osc-tiny-0.8.2/setup.py 2024-03-22 11:50:30.000000000 +0100 +++ new/osc-tiny-0.9.0/setup.py 2024-04-22 17:26:55.000000000 +0200 @@ -26,7 +26,7 @@ setup( name='osc-tiny', - version='0.8.2', + version='0.9.0', description='Client API for openSUSE BuildService', long_description=long_description, long_description_content_type="text/markdown",