diff --git a/osc/core.py b/osc/core.py index 27ba092b..682b0096 100644 --- a/osc/core.py +++ b/osc/core.py @@ -4634,53 +4634,37 @@ def create_submit_request( src_package: Optional[str] = None, dst_project: Optional[str] = None, dst_package: Optional[str] = None, - message="", - orev=None, - src_update=None, - dst_updatelink=None, + message: str = "", + orev: Optional[str] = None, + src_update: Optional[str] = None, + dst_updatelink: Optional[bool] = None, ): - options_block = "" - package = "" - if src_package: - package = f"""package="{src_package}" """ - options_block = "" - if src_update: - options_block += f"""{src_update}""" - if dst_updatelink: - options_block += """true""" - options_block += "" + from . import obs_api - # Yes, this kind of xml construction is horrible - targetxml = "" - if dst_project: - packagexml = "" - if dst_package: - packagexml = f"""package="{dst_package}" """ - targetxml = f""" """ - # XXX: keep the old template for now in order to work with old obs instances - xml = """\ - - - - %s - %s - - - %s - -""" % (src_project, - package, - orev or show_upstream_rev(apiurl, src_project, src_package), - targetxml, - options_block, - _html_escape(message)) + req = obs_api.Request( + action_list=[ + { + "type": "submit", + "source": { + "project": src_project, + "package": src_package, + "rev": orev or show_upstream_rev(apiurl, src_project, src_package), + }, + "target": { + "project": dst_project, + "package": dst_package, + }, + "options": { + "sourceupdate": src_update, + "updatelink": "true" if dst_updatelink else None, + } + }, + ], + description=message, + ) - u = makeurl(apiurl, ["request"], query={"cmd": "create"}) - r = None try: - f = http_POST(u, data=xml) - root = ET.parse(f).getroot() - r = root.get('id') + new_req = req.cmd_create(apiurl) except HTTPError as e: if e.hdrs.get('X-Opensuse-Errorcode') == "submit_request_rejected": print('WARNING: As the project is in maintenance, a maintenance incident request is') @@ -4700,11 +4684,11 @@ def create_submit_request( raise oscerr.APIError("Server did not define a default maintenance project, can't submit.") tproject = project.get('name') r = create_maintenance_request(apiurl, src_project, [src_package], tproject, dst_project, src_update, message, rev=orev) - r = r.reqid + return r.reqid else: raise - return r + return new_req.id def get_request(apiurl: str, reqid): diff --git a/osc/obs_api/__init__.py b/osc/obs_api/__init__.py index 7216b78b..7bc9a365 100644 --- a/osc/obs_api/__init__.py +++ b/osc/obs_api/__init__.py @@ -1,2 +1,4 @@ from .package import Package +from .package_sources import PackageSources from .project import Project +from .request import Request diff --git a/osc/obs_api/enums.py b/osc/obs_api/enums.py index a5c143cb..f7c55be7 100644 --- a/osc/obs_api/enums.py +++ b/osc/obs_api/enums.py @@ -67,6 +67,13 @@ class LocalRole(str, Enum): READER = "reader" +class ObsRatings(str, Enum): + LOW = "low" + MODERATE = "moderate" + IMPORTANT = "important" + CRITICAL = "critical" + + class RebuildModes(str, Enum): TRANSITIVE = "transitive" DIRECT = "direct" @@ -77,3 +84,13 @@ class ReleaseTriggers(str, Enum): MANUAL = "manual" MAINTENANCE = "maintenance" OBSGENDIFF = "obsgendiff" + + +class RequestStates(str, Enum): + REVIEW = "review" + NEW = "new" + ACCEPTED = "accepted" + DECLINED = "declined" + REVOKED = "revoked" + SUPERSEDED = "superseded" + DELETED = "deleted" diff --git a/osc/obs_api/linkinfo.py b/osc/obs_api/linkinfo.py new file mode 100644 index 00000000..64253732 --- /dev/null +++ b/osc/obs_api/linkinfo.py @@ -0,0 +1,41 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class Linkinfo(XmlModel): + XML_TAG = "linkinfo" + + project: str = Field( + xml_attribute=True, + ) + + package: str = Field( + xml_attribute=True, + ) + + lsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + xsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + baserev: Optional[str] = Field( + xml_attribute=True, + ) + + rev: Optional[str] = Field( + xml_attribute=True, + ) + + srcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + error: Optional[str] = Field( + xml_attribute=True, + ) + + missingok: Optional[bool] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/package.py b/osc/obs_api/package.py index 83783e9e..8c2967d7 100644 --- a/osc/obs_api/package.py +++ b/osc/obs_api/package.py @@ -69,12 +69,13 @@ class Package(XmlModel): @classmethod def from_api(cls, apiurl, project, package, *, rev=None): + # ``rev`` is metadata revision, not revision of the source code url_path = ["source", project, package, "_meta"] url_query = { "rev": rev, } response = cls.xml_request("GET", apiurl, url_path, url_query) - return cls.from_file(response) + return cls.from_file(response, apiurl=apiurl) def to_api(self, apiurl, *, project=None, package=None): project = project or self.project @@ -82,7 +83,7 @@ class Package(XmlModel): url_path = ["source", project, package, "_meta"] url_query = {} response = self.xml_request("PUT", apiurl, url_path, url_query, data=self.to_string()) - return Status.from_file(response) + return Status.from_file(response, apiurl=apiurl) @classmethod def cmd_release( @@ -124,4 +125,4 @@ class Package(XmlModel): "nodelay": nodelay, } response = cls.xml_request("POST", apiurl, url_path, url_query) - return Status.from_string(response.read()) + return Status.from_file(response, apiurl=apiurl) diff --git a/osc/obs_api/package_sources.py b/osc/obs_api/package_sources.py new file mode 100644 index 00000000..51c74f2f --- /dev/null +++ b/osc/obs_api/package_sources.py @@ -0,0 +1,68 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .linkinfo import Linkinfo +from .package_sources_file import PackageSourcesFile +from .serviceinfo import Serviceinfo + + +class PackageSources(XmlModel): + XML_TAG = "directory" + + name: str = Field( + xml_attribute=True, + ) + + rev: str = Field( + xml_attribute=True, + ) + + vrev: Optional[str] = Field( + xml_attribute=True, + ) + + srcmd5: str = Field( + xml_attribute=True, + ) + + linkinfo: Optional[Linkinfo] = Field( + ) + + serviceinfo: Optional[Serviceinfo] = Field( + ) + + file_list: Optional[List[PackageSourcesFile]] = Field( + xml_name="entry", + ) + + @classmethod + def from_api( + cls, + apiurl: str, + project: str, + package: str, + *, + deleted: Optional[bool] = None, + expand: Optional[bool] = None, + meta: Optional[bool] = None, + rev: Optional[str] = None, + ): + """ + :param deleted: Set to ``True`` to list source files of a deleted package. + Throws 400: Bad Request if such package exists. + :param expand: Expand links. + :param meta: Set to ``True`` to list metadata file (``_meta``) instead of the sources. + :param rev: Show sources of the specified revision. + """ + from ..core import revision_is_empty + + if revision_is_empty(rev): + rev = None + + url_path = ["source", project, package] + url_query = { + "deleted": deleted, + "expand": expand, + "meta": meta, + "rev": rev, + } + response = cls.xml_request("GET", apiurl, url_path, url_query) + return cls.from_file(response, apiurl=apiurl) diff --git a/osc/obs_api/package_sources_file.py b/osc/obs_api/package_sources_file.py new file mode 100644 index 00000000..add82edd --- /dev/null +++ b/osc/obs_api/package_sources_file.py @@ -0,0 +1,28 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class PackageSourcesFile(XmlModel): + XML_TAG = "entry" + + name: str = Field( + xml_attribute=True, + ) + + md5: str = Field( + xml_attribute=True, + ) + + mtime: int = Field( + xml_attribute=True, + ) + + size: int = Field( + xml_attribute=True, + ) + + skipped: Optional[bool] = Field( + xml_attribute=True, + ) + + def _get_cmp_data(self): + return (self.name, self.mtime, self.size, self.md5, self.skipped or False) diff --git a/osc/obs_api/project.py b/osc/obs_api/project.py index ecec9b50..f439a3a2 100644 --- a/osc/obs_api/project.py +++ b/osc/obs_api/project.py @@ -112,14 +112,14 @@ class Project(XmlModel): url_path = ["source", project, "_meta"] url_query = {} response = cls.xml_request("GET", apiurl, url_path, url_query) - return cls.from_file(response) + return cls.from_file(response, apiurl=apiurl) def to_api(self, apiurl, *, project=None): project = project or self.name url_path = ["source", project, "_meta"] url_query = {} response = self.xml_request("PUT", apiurl, url_path, url_query, data=self.to_string()) - return Status.from_file(response) + return Status.from_file(response, apiurl=apiurl) def resolve_repository_flags(self, package_obj=None): """ diff --git a/osc/obs_api/request.py b/osc/obs_api/request.py new file mode 100644 index 00000000..62d8e9cd --- /dev/null +++ b/osc/obs_api/request.py @@ -0,0 +1,140 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .enums import ObsRatings +from .request_action import RequestAction +from .request_history import RequestHistory +from .request_review import RequestReview +from .request_state import RequestState + + +class Request(XmlModel): + XML_TAG = "request" + + id: Optional[str] = Field( + xml_attribute=True, + ) + + actions: Optional[int] = Field( + xml_attribute=True, + ) + + creator: Optional[str] = Field( + xml_attribute=True, + ) + + action_list: List[RequestAction] = Field( + xml_name="action", + ) + + state: Optional[RequestState] = Field( + ) + + description: Optional[str] = Field( + ) + + priority: Optional[ObsRatings] = Field( + ) + + review_list: Optional[List[RequestReview]] = Field( + xml_name="review", + ) + + history_list: Optional[List[RequestHistory]] = Field( + xml_name="history", + ) + + title: Optional[str] = Field( + ) + + accept_at: Optional[str] = Field( + ) + + @classmethod + def from_api( + cls, + apiurl: str, + request_id: int, + *, + with_history: Optional[bool] = None, + with_full_history: Optional[bool] = None + ) -> "Request": + """ + Return the specified request. + + :param request_id: Id of the request. + :param withhistory: Include the request history in the results. + :param withfullhistory: Includes both, request and review history in the results. + """ + url_path = ["request", request_id] + url_query = { + "withhistory": with_history, + "withfullhistory": with_full_history, + } + response = cls.xml_request("GET", apiurl, url_path, url_query) + return cls.from_file(response, apiurl=apiurl) + + @classmethod + def cmd_diff( + cls, + apiurl: str, + request_id: int, + *, + with_issues: Optional[bool] = None, + with_description_issues: Optional[bool] = None, + diff_to_superseded: Optional[int] = None + ) -> "Request": + """ + Return the specified request including a diff of all packages in the request. + + :param request_id: Id of the request. + :param with_issues: Include parsed issues from referenced sources in the change files. + :param with_description_issues: Include parsed issues from request description. + :param diff_to_superseded: Diff relatively to the given superseded request. + """ + url_path = ["request", str(request_id)] + url_query = { + "cmd": "diff", + "view": "xml", + "withissues": with_issues, + "withdescriptionissues": with_description_issues, + "diff_to_superseded": diff_to_superseded, + } + response = cls.xml_request("POST", apiurl, url_path, url_query) + return cls.from_file(response, apiurl=apiurl) + + def get_issues(self): + """ + Aggregate issues from action/sourcediff into a single list. + The list may contain duplicates. + + To get any issues returned, it is crucial to load the request with the issues + by calling ``cmd_diff()`` with appropriate arguments first. + """ + result = [] + for action in self.action_list or []: + if action.sourcediff is None: + continue + for issue in action.sourcediff.issue_list or []: + result.append(issue) + return result + + def cmd_create(self, + apiurl: str, + *, + add_revision: Optional[bool] = None, + enforce_branching: Optional[bool] = None, + ignore_build_state: Optional[bool] = None, + ignore_delegate: Optional[bool] = None, + ): + """ + :param add_revision: Ask the server to add revisions of the current sources to the request. + :param ignore_build_state: Skip the build state check. + :param ignore_delegate: Enforce a new package instance in a project which has OBS:DelegateRequestTarget set. + """ + url_path = ["request"] + url_query = { + "cmd": "create", + "addrevision": add_revision, + "ignore_delegate": ignore_delegate, + } + response = self.xml_request("POST", apiurl, url_path, url_query, data=self.to_string()) + return Request.from_file(response, apiurl=apiurl) diff --git a/osc/obs_api/request_action.py b/osc/obs_api/request_action.py new file mode 100644 index 00000000..8fa6298e --- /dev/null +++ b/osc/obs_api/request_action.py @@ -0,0 +1,236 @@ +import urllib.error + +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .package import Package +from .package_sources import PackageSources +from .request_action_acceptinfo import RequestActionAcceptinfo +from .request_action_group import RequestActionGroup +from .request_action_grouped import RequestActionGrouped +from .request_action_options import RequestActionOptions +from .request_action_person import RequestActionPerson +from .request_action_source import RequestActionSource +from .request_action_target import RequestActionTarget +from .request_sourcediff import RequestSourcediff + + +class RequestAction(XmlModel): + XML_TAG = "action" + + class TypeEnum(str, Enum): + SUBMIT = "submit" + DELETE = "delete" + CHANGE_DEVEL = "change_devel" + ADD_ROLE = "add_role" + SET_BUGOWNER = "set_bugowner" + MAINTENANCE_INCIDENT = "maintenance_incident" + MAINTENANCE_RELEASE = "maintenance_release" + RELEASE = "release" + GROUP = "group" + + type: TypeEnum = Field( + xml_attribute=True, + ) + + source: Optional[RequestActionSource] = Field( + ) + + target: Optional[RequestActionTarget] = Field( + ) + + person: Optional[RequestActionPerson] = Field( + ) + + group: Optional[RequestActionGroup] = Field( + ) + + grouped_list: Optional[List[RequestActionGrouped]] = Field( + xml_name="grouped", + ) + + options: Optional[RequestActionOptions] = Field( + ) + + sourcediff: Optional[RequestSourcediff] = Field( + ) + + acceptinfo: Optional[RequestActionAcceptinfo] = Field( + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._allow_new_attributes = True + # source and target always come from ``self._apiurl`` while devel and factory projects may live elsewhere + self._devel_apiurl = self._apiurl + self._factory_apiurl = self._apiurl + self._factory_project = "openSUSE:Factory" + self._props = {} + self._allow_new_attributes = False + + def _get_package(self, package_type): + key = f"{package_type}_package" + if key not in self._props: + func = getattr(self, f"_get_{package_type}_apiurl_project_package") + apiurl, project, package = func() + if apiurl is None: + self._props[key] = None + else: + try: + self._props[key] = Package.from_api(apiurl, project, package) + except urllib.error.HTTPError as e: + if e.code != 404: + raise + self._props[key] = None + return self._props[key] + + def _get_package_sources(self, package_type, *, rev=None): + key = f"{package_type}_package_sources" + if key not in self._props: + func = getattr(self, f"_get_{package_type}_apiurl_project_package") + apiurl, project, package = func() + if apiurl is None: + self._props[key] = None + else: + try: + self._props[key] = PackageSources.from_api(apiurl, project, package, rev=rev) + except urllib.error.HTTPError as e: + if e.code != 404: + raise + self._props[key] = None + return self._props[key] + + def _get_source_apiurl_project_package(self): + return self._apiurl, self.source.project, self.source.package + + @property + def source_package(self) -> Optional[Package]: + """ + Return a ``Package`` object that encapsulates metadata of the source package. + """ + return self._get_package("source") + + @property + def source_package_sources(self) -> Optional[PackageSources]: + """ + Return a ``PackageSources`` object that contains information about the ``source.rev`` revision of the source package sources in OBS SCM. + """ + if self.source is None: + return None + return self._get_package_sources("source", rev=self.source.rev) + + def _get_target_apiurl_project_package(self): + if self.target is None: + return None, None, None + target_project, target_package = self.get_actual_target_project_package() + return self._apiurl, target_project, target_package + + @property + def target_package(self) -> Optional[Package]: + """ + Return a ``Package`` object that encapsulates metadata of the target package. + """ + return self._get_package("target") + + @property + def target_package_sources(self) -> Optional[PackageSources]: + """ + Return a ``PackageSources`` object that contains information about the current revision of the target package sources in OBS SCM. + """ + return self._get_package_sources("target") + + def _get_factory_apiurl_project_package(self): + if self.target is None: + # a new package was submitted, it doesn't exist on target; let's read the package name from the source + target_project, target_package = None, self.source.package + else: + target_project, target_package = self.get_actual_target_project_package() + + if (self._apiurl, target_project) == (self._factory_apiurl, self._factory_project): + # factory package equals the target package + return None, None, None + + return self._factory_apiurl, self._factory_project, target_package + + @property + def factory_package(self) -> Optional[Package]: + """ + Return a ``Package`` object that encapsulates metadata of the package in the factory project. + The name of the package equals the target package name. + """ + return self._get_package("factory") + + @property + def factory_package_sources(self) -> Optional[PackageSources]: + """ + Return a ``PackageSources`` object that contains information about the current revision of the factory package sources in OBS SCM. + """ + return self._get_package_sources("factory") + + def _get_devel_apiurl_project_package(self): + if self.factory_package is None: + return None, None, None + + devel = self.factory_package.devel + if devel is None: + return None, None, None + + return ( + self._devel_apiurl, + devel.project, + devel.package or self.factory_package.name, + ) + + @property + def devel_package(self) -> Optional[Package]: + """ + Return a ``Package`` object that encapsulates metadata of the package in the devel project. + The devel project name and package name come from ``self.factory_package.devel``. + If the devel package name is not set, target package name is used. + """ + return self._get_package("devel") + + @property + def devel_package_sources(self) -> Optional[PackageSources]: + """ + Return a ``PackageSources`` object that contains information about the current revision of the devel package sources in OBS SCM. + """ + return self._get_package_sources("devel") + + def get_actual_target_project_package(self) -> Tuple[str, str]: + """ + Return the target project and package names because maintenance incidents require special handling. + + The target project for maintenance incidents is virtual and cannot be queried. + The actual target project is specified in target's ``releaseproject`` field. + + Also the target package for maintenance incidents is not set explicitly. + It is extracted from ``releasename`` field from the source metadata. + If ``releasename`` is not defined, source package name is used. + """ + + if self.type == "maintenance_incident": + # dmach's note on security: + # The ``releaseproject`` is baked into the target information in the request and that's perfectly ok. + # The ``releasename`` is part of the source package metadata and *may change* after the request is created. + # After consulting this with OBS developers, I believe this doesn't represent any security issue + # because the project is fixed and tampering with ``releasename`` might only lead to inconsistent naming, + # the package would still end up it the same project. + + # target.releaseproject is always set for a maintenance_incident + assert self.target + assert self.target.releaseproject + project = self.target.releaseproject + + # the target package is not specified + # we need to extract it from source package's metadata or use source package name as a fallback + assert self.source_package + if self.source_package.releasename: + package = self.source_package.releasename.split(".")[0] + else: + package = self.source_package.name + + return project, package + + assert self.target + assert self.target.project + assert self.target.package + return self.target.project, self.target.package diff --git a/osc/obs_api/request_action_acceptinfo.py b/osc/obs_api/request_action_acceptinfo.py new file mode 100644 index 00000000..7eff63fd --- /dev/null +++ b/osc/obs_api/request_action_acceptinfo.py @@ -0,0 +1,33 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionAcceptinfo(XmlModel): + XML_TAG = "acceptinfo" + + rev: str = Field( + xml_attribute=True, + ) + + srcmd5: str = Field( + xml_attribute=True, + ) + + osrcmd5: str = Field( + xml_attribute=True, + ) + + oproject: Optional[str] = Field( + xml_attribute=True, + ) + + opackage: Optional[str] = Field( + xml_attribute=True, + ) + + xsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + oxsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_action_group.py b/osc/obs_api/request_action_group.py new file mode 100644 index 00000000..d9620f28 --- /dev/null +++ b/osc/obs_api/request_action_group.py @@ -0,0 +1,13 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionGroup(XmlModel): + XML_TAG = "group" + + name: str = Field( + xml_attribute=True, + ) + + role: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_action_grouped.py b/osc/obs_api/request_action_grouped.py new file mode 100644 index 00000000..57f56914 --- /dev/null +++ b/osc/obs_api/request_action_grouped.py @@ -0,0 +1,9 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionGrouped(XmlModel): + XML_TAG = "grouped" + + id: str = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_action_options.py b/osc/obs_api/request_action_options.py new file mode 100644 index 00000000..14a7b969 --- /dev/null +++ b/osc/obs_api/request_action_options.py @@ -0,0 +1,27 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionOptions(XmlModel): + XML_TAG = "options" + + class SourceupdateEnum(str, Enum): + UPDATE = "update" + NOUPDATE = "noupdate" + CLEANUP = "cleanup" + + sourceupdate: Optional[SourceupdateEnum] = Field( + ) + + class UpdatelinkEnum(str, Enum): + TRUE = "true" + FALSE = "false" + + updatelink: Optional[UpdatelinkEnum] = Field( + ) + + class MakeoriginolderEnum(str, Enum): + TRUE = "true" + FALSE = "false" + + makeoriginolder: Optional[MakeoriginolderEnum] = Field( + ) diff --git a/osc/obs_api/request_action_person.py b/osc/obs_api/request_action_person.py new file mode 100644 index 00000000..2bda470c --- /dev/null +++ b/osc/obs_api/request_action_person.py @@ -0,0 +1,13 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionPerson(XmlModel): + XML_TAG = "person" + + name: str = Field( + xml_attribute=True, + ) + + role: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_action_source.py b/osc/obs_api/request_action_source.py new file mode 100644 index 00000000..67a86fbf --- /dev/null +++ b/osc/obs_api/request_action_source.py @@ -0,0 +1,17 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionSource(XmlModel): + XML_TAG = "source" + + project: str = Field( + xml_attribute=True, + ) + + package: Optional[str] = Field( + xml_attribute=True, + ) + + rev: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_action_target.py b/osc/obs_api/request_action_target.py new file mode 100644 index 00000000..a7352f75 --- /dev/null +++ b/osc/obs_api/request_action_target.py @@ -0,0 +1,21 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestActionTarget(XmlModel): + XML_TAG = "target" + + project: str = Field( + xml_attribute=True, + ) + + package: Optional[str] = Field( + xml_attribute=True, + ) + + releaseproject: Optional[str] = Field( + xml_attribute=True, + ) + + repository: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_history.py b/osc/obs_api/request_history.py new file mode 100644 index 00000000..c38d96ef --- /dev/null +++ b/osc/obs_api/request_history.py @@ -0,0 +1,19 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestHistory(XmlModel): + XML_TAG = "history" + + who: str = Field( + xml_attribute=True, + ) + + when: str = Field( + xml_attribute=True, + ) + + description: str = Field( + ) + + comment: Optional[str] = Field( + ) diff --git a/osc/obs_api/request_review.py b/osc/obs_api/request_review.py new file mode 100644 index 00000000..02048f59 --- /dev/null +++ b/osc/obs_api/request_review.py @@ -0,0 +1,57 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .enums import RequestStates +from .request_review_history import RequestReviewHistory + + +class RequestReview(XmlModel): + XML_TAG = "review" + + state: RequestStates = Field( + xml_attribute=True, + ) + + created: Optional[str] = Field( + xml_attribute=True, + ) + + by_user: Optional[str] = Field( + xml_attribute=True, + ) + + by_group: Optional[str] = Field( + xml_attribute=True, + ) + + by_project: Optional[str] = Field( + xml_attribute=True, + ) + + by_package: Optional[str] = Field( + xml_attribute=True, + ) + + who: Optional[str] = Field( + xml_attribute=True, + ) + + when: Optional[str] = Field( + xml_attribute=True, + ) + + comment: Optional[str] = Field( + ) + + history_list: Optional[List[RequestReviewHistory]] = Field( + xml_name="history", + ) + + def get_user_and_type(self): + if self.by_user: + return (self.by_user, "user") + if self.by_group: + return (self.by_group, "group") + if self.by_package: + return (f"{self.by_project}/{self.by_package}", "package") + if self.by_project: + return (self.by_project, "project") + raise RuntimeError("Unable to determine user and its type") diff --git a/osc/obs_api/request_review_history.py b/osc/obs_api/request_review_history.py new file mode 100644 index 00000000..af842669 --- /dev/null +++ b/osc/obs_api/request_review_history.py @@ -0,0 +1,19 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestReviewHistory(XmlModel): + XML_TAG = "history" + + who: str = Field( + xml_attribute=True, + ) + + when: str = Field( + xml_attribute=True, + ) + + description: str = Field( + ) + + comment: Optional[str] = Field( + ) diff --git a/osc/obs_api/request_sourcediff.py b/osc/obs_api/request_sourcediff.py new file mode 100644 index 00000000..15618935 --- /dev/null +++ b/osc/obs_api/request_sourcediff.py @@ -0,0 +1,30 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .request_sourcediff_files_file import RequestSourcediffFilesFile +from .request_sourcediff_issue import RequestSourcediffIssue +from .request_sourcediff_new import RequestSourcediffNew +from .request_sourcediff_old import RequestSourcediffOld + + +class RequestSourcediff(XmlModel): + XML_TAG = "sourcediff" + + key: str = Field( + xml_attribute=True, + ) + + old: Optional[RequestSourcediffOld] = Field( + ) + + new: Optional[RequestSourcediffNew] = Field( + ) + + files_list: List[RequestSourcediffFilesFile] = Field( + xml_name="files", + xml_wrapped=True, + ) + + issue_list: Optional[List[RequestSourcediffIssue]] = Field( + xml_name="issues", + xml_wrapped=True, + xml_item_name="issue", + ) diff --git a/osc/obs_api/request_sourcediff_file_diff.py b/osc/obs_api/request_sourcediff_file_diff.py new file mode 100644 index 00000000..bca84c11 --- /dev/null +++ b/osc/obs_api/request_sourcediff_file_diff.py @@ -0,0 +1,13 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffFileDiff(XmlModel): + XML_TAG = "diff" + + lines: int = Field( + xml_attribute=True, + ) + + text: str = Field( + xml_set_text=True, + ) diff --git a/osc/obs_api/request_sourcediff_file_new.py b/osc/obs_api/request_sourcediff_file_new.py new file mode 100644 index 00000000..2f8f0dca --- /dev/null +++ b/osc/obs_api/request_sourcediff_file_new.py @@ -0,0 +1,17 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffFileNew(XmlModel): + XML_TAG = "new" + + name: str = Field( + xml_attribute=True, + ) + + md5: str = Field( + xml_attribute=True, + ) + + size: int = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_sourcediff_file_old.py b/osc/obs_api/request_sourcediff_file_old.py new file mode 100644 index 00000000..2853dd32 --- /dev/null +++ b/osc/obs_api/request_sourcediff_file_old.py @@ -0,0 +1,17 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffFileOld(XmlModel): + XML_TAG = "old" + + name: str = Field( + xml_attribute=True, + ) + + md5: str = Field( + xml_attribute=True, + ) + + size: int = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_sourcediff_files_file.py b/osc/obs_api/request_sourcediff_files_file.py new file mode 100644 index 00000000..ec262f87 --- /dev/null +++ b/osc/obs_api/request_sourcediff_files_file.py @@ -0,0 +1,21 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .request_sourcediff_file_diff import RequestSourcediffFileDiff +from .request_sourcediff_file_new import RequestSourcediffFileNew +from .request_sourcediff_file_old import RequestSourcediffFileOld + + +class RequestSourcediffFilesFile(XmlModel): + XML_TAG = "file" + + state: str = Field( + xml_attribute=True, + ) + + old: Optional[RequestSourcediffFileOld] = Field( + ) + + new: Optional[RequestSourcediffFileNew] = Field( + ) + + diff: RequestSourcediffFileDiff = Field( + ) diff --git a/osc/obs_api/request_sourcediff_issue.py b/osc/obs_api/request_sourcediff_issue.py new file mode 100644 index 00000000..7cffa613 --- /dev/null +++ b/osc/obs_api/request_sourcediff_issue.py @@ -0,0 +1,25 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffIssue(XmlModel): + XML_TAG = "issue" + + state: str = Field( + xml_attribute=True, + ) + + tracker: str = Field( + xml_attribute=True, + ) + + name: str = Field( + xml_attribute=True, + ) + + label: str = Field( + xml_attribute=True, + ) + + url: str = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_sourcediff_new.py b/osc/obs_api/request_sourcediff_new.py new file mode 100644 index 00000000..f996ea61 --- /dev/null +++ b/osc/obs_api/request_sourcediff_new.py @@ -0,0 +1,21 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffNew(XmlModel): + XML_TAG = "new" + + project: str = Field( + xml_attribute=True, + ) + + package: str = Field( + xml_attribute=True, + ) + + rev: str = Field( + xml_attribute=True, + ) + + srcmd5: str = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_sourcediff_old.py b/osc/obs_api/request_sourcediff_old.py new file mode 100644 index 00000000..9b194348 --- /dev/null +++ b/osc/obs_api/request_sourcediff_old.py @@ -0,0 +1,21 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class RequestSourcediffOld(XmlModel): + XML_TAG = "old" + + project: str = Field( + xml_attribute=True, + ) + + package: str = Field( + xml_attribute=True, + ) + + rev: str = Field( + xml_attribute=True, + ) + + srcmd5: str = Field( + xml_attribute=True, + ) diff --git a/osc/obs_api/request_state.py b/osc/obs_api/request_state.py new file mode 100644 index 00000000..c29db662 --- /dev/null +++ b/osc/obs_api/request_state.py @@ -0,0 +1,33 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import +from .enums import RequestStates + + +class RequestState(XmlModel): + XML_TAG = "state" + + name: RequestStates = Field( + xml_attribute=True, + ) + + who: Optional[str] = Field( + xml_attribute=True, + ) + + when: Optional[str] = Field( + xml_attribute=True, + ) + + created: Optional[str] = Field( + xml_attribute=True, + ) + + superseded_by: Optional[int] = Field( + xml_attribute=True, + ) + + approver: Optional[str] = Field( + xml_attribute=True, + ) + + comment: Optional[str] = Field( + ) diff --git a/osc/obs_api/serviceinfo.py b/osc/obs_api/serviceinfo.py new file mode 100644 index 00000000..70ab1a92 --- /dev/null +++ b/osc/obs_api/serviceinfo.py @@ -0,0 +1,21 @@ +from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import + + +class Serviceinfo(XmlModel): + XML_TAG = "serviceinfo" + + xsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + lsrcmd5: Optional[str] = Field( + xml_attribute=True, + ) + + error: Optional[str] = Field( + xml_attribute=True, + ) + + code: Optional[str] = Field( + xml_attribute=True, + ) diff --git a/osc/util/models.py b/osc/util/models.py index 7f03bacc..13d1db83 100644 --- a/osc/util/models.py +++ b/osc/util/models.py @@ -7,6 +7,7 @@ This module IS NOT a supported API, it is meant for osc internal use only. """ import copy +import functools import inspect import sys import tempfile @@ -71,8 +72,9 @@ NotSet = NotSetClass() class FromParent(NotSetClass): - def __init__(self, field_name): + def __init__(self, field_name, *, fallback=NotSet): self.field_name = field_name + self.fallback = fallback def __repr__(self): return f"FromParent(field_name={self.field_name})" @@ -256,7 +258,7 @@ class Field(property): for num, i in enumerate(result): if isinstance(i, dict): klass = self.inner_type - result[num] = klass(**i) + result[num] = klass(**i, _parent=obj) if self.get_callback is not None: result = self.get_callback(obj, result) @@ -279,7 +281,10 @@ class Field(property): if isinstance(self.default, FromParent): if obj._parent is None: - raise RuntimeError(f"The field '{self.name}' has default {self.default} but the model has no parent set") + if self.default.fallback is not NotSet: + return self.default.fallback + else: + raise RuntimeError(f"The field '{self.name}' has default {self.default} but the model has no parent set") return getattr(obj._parent, self.default.field_name or self.name) if self.default is NotSet: @@ -308,20 +313,23 @@ class Field(property): if self.is_model and isinstance(value, dict): # initialize a model instance from a dictionary klass = self.origin_type - value = klass(**value) # pylint: disable=not-callable + value = klass(**value, _parent=obj) # pylint: disable=not-callable elif self.is_model_list and isinstance(value, list): new_value = [] for i in value: if isinstance(i, dict): klass = self.inner_type - new_value.append(klass(**i)) + new_value.append(klass(**i, _parent=obj)) else: + i._parent = obj new_value.append(i) value = new_value elif self.is_model and isinstance(value, str) and hasattr(self.origin_type, "XML_TAG_FIELD"): klass = self.origin_type key = getattr(self.origin_type, "XML_TAG_FIELD") - value = klass(**{key: value}) + value = klass(**{key: value}, _parent=obj) + elif self.is_model and value is not None: + value._parent = obj self.validate_type(value) obj._values[self.name] = value @@ -361,6 +369,7 @@ class ModelMeta(type): return new_cls +@functools.total_ordering class BaseModel(metaclass=ModelMeta): __fields__: Dict[str, Field] @@ -372,6 +381,7 @@ class BaseModel(metaclass=ModelMeta): raise AttributeError(f"Setting attribute '{self.__class__.__name__}.{name}' is not allowed") def __init__(self, **kwargs): + self._allow_new_attributes = True self._defaults = {} # field defaults cached in field.get() self._values = {} # field values explicitly set after initializing the model self._parent = kwargs.pop("_parent", None) @@ -404,10 +414,28 @@ class BaseModel(metaclass=ModelMeta): self._allow_new_attributes = False + def _get_cmp_data(self): + result = [] + for name, field in self.__fields__.items(): + if field.exclude: + continue + value = getattr(self, name) + if isinstance(value, dict): + value = sorted(list(value.items())) + result.append((name, value)) + return result + def __eq__(self, other): if type(self) != type(other): return False - return self.dict() == other.dict() + if self._get_cmp_data() != other._get_cmp_data(): + print(self._get_cmp_data(), other._get_cmp_data()) + return self._get_cmp_data() == other._get_cmp_data() + + def __lt__(self, other): + if type(self) != type(other): + return False + return self._get_cmp_data() < other._get_cmp_data() def dict(self): result = {} @@ -440,6 +468,11 @@ class BaseModel(metaclass=ModelMeta): class XmlModel(BaseModel): XML_TAG = None + _apiurl: Optional[str] = Field( + exclude=True, + default=FromParent("_apiurl", fallback=None), + ) + def to_xml(self) -> ET.Element: xml_tag = None @@ -459,6 +492,8 @@ class XmlModel(BaseModel): root = ET.Element(xml_tag) for field_name, field in self.__fields__.items(): + if field.exclude: + continue xml_attribute = field.extra.get("xml_attribute", False) xml_set_tag = field.extra.get("xml_set_tag", False) xml_set_text = field.extra.get("xml_set_text", False) @@ -510,20 +545,20 @@ class XmlModel(BaseModel): return root @classmethod - def from_string(cls, string: str) -> "XmlModel": + def from_string(cls, string: str, *, apiurl: Optional[str] = None) -> "XmlModel": """ Instantiate model from string. """ root = ET.fromstring(string) - return cls.from_xml(root) + return cls.from_xml(root, apiurl=apiurl) @classmethod - def from_file(cls, file: Union[str, typing.IO]) -> "XmlModel": + def from_file(cls, file: Union[str, typing.IO], *, apiurl: Optional[str] = None) -> "XmlModel": """ Instantiate model from file. """ root = ET.parse(file).getroot() - return cls.from_xml(root) + return cls.from_xml(root, apiurl=apiurl) def to_bytes(self) -> bytes: """ @@ -581,7 +616,7 @@ class XmlModel(BaseModel): parent.remove(node) @classmethod - def from_xml(cls, root: ET.Element): + def from_xml(cls, root: ET.Element, *, apiurl: Optional[str] = None): """ Instantiate model from a XML root. """ @@ -661,7 +696,7 @@ class XmlModel(BaseModel): for node in nodes: if field.is_model_list: klass = field.inner_type - entry = klass.from_xml(node) + entry = klass.from_xml(node, apiurl=apiurl) # clear node as it was checked in from_xml() already node.text = None @@ -691,7 +726,7 @@ class XmlModel(BaseModel): if node is None: continue klass = field.origin_type - kwargs[field_name] = klass.from_xml(node) + kwargs[field_name] = klass.from_xml(node, apiurl=apiurl) # clear node as it was checked in from_xml() already node.text = None @@ -707,16 +742,16 @@ class XmlModel(BaseModel): continue value = cls.value_from_string(field, node.text) node.text = None + cls._remove_processed_node(root, node) if value is None: if field.is_optional: continue value = "" kwargs[field_name] = value - cls._remove_processed_node(root, node) cls._remove_processed_node(None, root) - obj = cls(**kwargs) + obj = cls(**kwargs, _apiurl=apiurl) obj.__dict__["_root"] = orig_root return obj @@ -760,7 +795,7 @@ class XmlModel(BaseModel): while True: run_editor(f.name) try: - edited_obj = self.__class__.from_file(f.name) + edited_obj = self.__class__.from_file(f.name, apiurl=self._apiurl) f.seek(0) edited_data = f.read() break diff --git a/tests/test_models.py b/tests/test_models.py index 84ae542b..29d0a6a3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -291,6 +291,49 @@ class Test(unittest.TestCase): self.assertEqual(c.field, "new-text") self.assertEqual(c.field2, "text") + def test_parent_fallback(self): + class SubModel(BaseModel): + field: str = Field(default=FromParent("field", fallback="submodel-fallback")) + + class Model(BaseModel): + field: str = Field(default=FromParent("field", fallback="model-fallback")) + sub: Optional[SubModel] = Field() + sub_list: Optional[List[SubModel]] = Field() + + m = Model() + s = SubModel(_parent=m) + m.sub = s + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub.field, "model-fallback") + + m = Model(sub={}) + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub.field, "model-fallback") + + m = Model(sub=SubModel()) + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub.field, "model-fallback") + + m = Model() + s = SubModel(_parent=m) + m.sub_list = [s] + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub_list[0].field, "model-fallback") + + m = Model(sub_list=[{}]) + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub_list[0].field, "model-fallback") + + m = Model(sub_list=[SubModel()]) + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub_list[0].field, "model-fallback") + + m = Model() + m.sub_list = [] + m.sub_list.append({}) + self.assertEqual(m.field, "model-fallback") + self.assertEqual(m.sub_list[0].field, "model-fallback") + def test_get_callback(self): class Model(BaseModel): quiet: bool = Field( @@ -352,6 +395,57 @@ class Test(unittest.TestCase): self.assertIsInstance(m.field[0], BaseModel) self.assertEqual(m.field[0].text, "value") + def test_ordering(self): + class TestSubmodel(BaseModel): + txt: Optional[str] = Field() + + class TestModel(BaseModel): + num: Optional[int] = Field() + txt: Optional[str] = Field() + sub: Optional[TestSubmodel] = Field() + dct: Optional[Dict[str, TestSubmodel]] = Field() + + m1 = TestModel() + m2 = TestModel() + self.assertEqual(m1, m2) + + m1 = TestModel(num=1) + m2 = TestModel(num=2) + self.assertNotEqual(m1, m2) + self.assertLess(m1, m2) + self.assertGreater(m2, m1) + + m1 = TestModel(txt="a") + m2 = TestModel(txt="b") + self.assertNotEqual(m1, m2) + self.assertLess(m1, m2) + self.assertGreater(m2, m1) + + m1 = TestModel(sub={}) + m2 = TestModel(sub={}) + self.assertEqual(m1, m2) + + m1 = TestModel(sub={"txt": "a"}) + m2 = TestModel(sub={"txt": "b"}) + self.assertNotEqual(m1, m2) + self.assertLess(m1, m2) + self.assertGreater(m2, m1) + + m1 = TestModel(dct={}) + m2 = TestModel(dct={}) + self.assertEqual(m1, m2) + + m1 = TestModel(dct={"a": TestSubmodel()}) + m2 = TestModel(dct={"b": TestSubmodel()}) + self.assertNotEqual(m1, m2) + self.assertLess(m1, m2) + self.assertGreater(m2, m1) + + # dict ordering doesn't matter + m1 = TestModel(dct={"a": TestSubmodel(), "b": TestSubmodel()}) + m2 = TestModel(dct={"b": TestSubmodel(), "a": TestSubmodel()}) + self.assertEqual(m1, m2) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_models_xmlmodel.py b/tests/test_models_xmlmodel.py index 36f3942b..665db5e2 100644 --- a/tests/test_models_xmlmodel.py +++ b/tests/test_models_xmlmodel.py @@ -1,3 +1,4 @@ +import io import textwrap import unittest @@ -149,6 +150,41 @@ class TestXmlModel(unittest.TestCase): m = ParentModel.from_string(expected) self.assertEqual(m.to_string(), expected) + def test_apiurl(self): + class ChildModel(XmlModel): + XML_TAG = "child" + value: str = Field() + + class ParentModel(XmlModel): + XML_TAG = "parent" + text: str = Field() + child: List[ChildModel] = Field(xml_wrapped=True, xml_name="children") + + # serialize the model and load it with apiurl set + m = ParentModel(text="TEXT", child=[{"value": "FOO"}, {"value": "BAR"}]) + xml = m.to_string() + + apiurl = "https://api.example.com" + + m = ParentModel.from_string(xml, apiurl=apiurl) + m.child.append({"value": "BAZ"}) + + self.assertEqual(m._apiurl, apiurl) + self.assertEqual(m.child[0]._apiurl, apiurl) + self.assertEqual(m.child[1]._apiurl, apiurl) + self.assertEqual(m.child[2]._apiurl, apiurl) + + # test the same as above but with a file + f = io.StringIO(xml) + + m = ParentModel.from_file(f, apiurl=apiurl) + m.child.append({"value": "BAZ"}) + + self.assertEqual(m._apiurl, apiurl) + self.assertEqual(m.child[0]._apiurl, apiurl) + self.assertEqual(m.child[1]._apiurl, apiurl) + self.assertEqual(m.child[2]._apiurl, apiurl) + if __name__ == "__main__": unittest.main()