1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-03-01 13:42:12 +01:00

Merge pull request #1502 from dmach/xmlmodel-request

Add Request and PackageSources XML models
This commit is contained in:
Daniel Mach 2024-03-04 15:34:58 +01:00 committed by GitHub
commit 2129908dd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1196 additions and 67 deletions

View File

@ -4634,53 +4634,37 @@ def create_submit_request(
src_package: Optional[str] = None, src_package: Optional[str] = None,
dst_project: Optional[str] = None, dst_project: Optional[str] = None,
dst_package: Optional[str] = None, dst_package: Optional[str] = None,
message="", message: str = "",
orev=None, orev: Optional[str] = None,
src_update=None, src_update: Optional[str] = None,
dst_updatelink=None, dst_updatelink: Optional[bool] = None,
): ):
options_block = "" from . import obs_api
package = ""
if src_package:
package = f"""package="{src_package}" """
options_block = "<options>"
if src_update:
options_block += f"""<sourceupdate>{src_update}</sourceupdate>"""
if dst_updatelink:
options_block += """<updatelink>true</updatelink>"""
options_block += "</options>"
# Yes, this kind of xml construction is horrible req = obs_api.Request(
targetxml = "" action_list=[
if dst_project: {
packagexml = "" "type": "submit",
if dst_package: "source": {
packagexml = f"""package="{dst_package}" """ "project": src_project,
targetxml = f"""<target project="{dst_project}" {packagexml} /> """ "package": src_package,
# XXX: keep the old template for now in order to work with old obs instances "rev": orev or show_upstream_rev(apiurl, src_project, src_package),
xml = """\ },
<request> "target": {
<action type="submit"> "project": dst_project,
<source project="%s" %s rev="%s"/> "package": dst_package,
%s },
%s "options": {
</action> "sourceupdate": src_update,
<state name="new"/> "updatelink": "true" if dst_updatelink else None,
<description>%s</description> }
</request> },
""" % (src_project, ],
package, description=message,
orev or show_upstream_rev(apiurl, src_project, src_package), )
targetxml,
options_block,
_html_escape(message))
u = makeurl(apiurl, ["request"], query={"cmd": "create"})
r = None
try: try:
f = http_POST(u, data=xml) new_req = req.cmd_create(apiurl)
root = ET.parse(f).getroot()
r = root.get('id')
except HTTPError as e: except HTTPError as e:
if e.hdrs.get('X-Opensuse-Errorcode') == "submit_request_rejected": if e.hdrs.get('X-Opensuse-Errorcode') == "submit_request_rejected":
print('WARNING: As the project is in maintenance, a maintenance incident request is') 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.") raise oscerr.APIError("Server did not define a default maintenance project, can't submit.")
tproject = project.get('name') tproject = project.get('name')
r = create_maintenance_request(apiurl, src_project, [src_package], tproject, dst_project, src_update, message, rev=orev) r = create_maintenance_request(apiurl, src_project, [src_package], tproject, dst_project, src_update, message, rev=orev)
r = r.reqid return r.reqid
else: else:
raise raise
return r return new_req.id
def get_request(apiurl: str, reqid): def get_request(apiurl: str, reqid):

View File

@ -1,2 +1,4 @@
from .package import Package from .package import Package
from .package_sources import PackageSources
from .project import Project from .project import Project
from .request import Request

View File

@ -67,6 +67,13 @@ class LocalRole(str, Enum):
READER = "reader" READER = "reader"
class ObsRatings(str, Enum):
LOW = "low"
MODERATE = "moderate"
IMPORTANT = "important"
CRITICAL = "critical"
class RebuildModes(str, Enum): class RebuildModes(str, Enum):
TRANSITIVE = "transitive" TRANSITIVE = "transitive"
DIRECT = "direct" DIRECT = "direct"
@ -77,3 +84,13 @@ class ReleaseTriggers(str, Enum):
MANUAL = "manual" MANUAL = "manual"
MAINTENANCE = "maintenance" MAINTENANCE = "maintenance"
OBSGENDIFF = "obsgendiff" OBSGENDIFF = "obsgendiff"
class RequestStates(str, Enum):
REVIEW = "review"
NEW = "new"
ACCEPTED = "accepted"
DECLINED = "declined"
REVOKED = "revoked"
SUPERSEDED = "superseded"
DELETED = "deleted"

41
osc/obs_api/linkinfo.py Normal file
View File

@ -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,
)

View File

@ -69,12 +69,13 @@ class Package(XmlModel):
@classmethod @classmethod
def from_api(cls, apiurl, project, package, *, rev=None): 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_path = ["source", project, package, "_meta"]
url_query = { url_query = {
"rev": rev, "rev": rev,
} }
response = cls.xml_request("GET", apiurl, url_path, 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, package=None): def to_api(self, apiurl, *, project=None, package=None):
project = project or self.project project = project or self.project
@ -82,7 +83,7 @@ class Package(XmlModel):
url_path = ["source", project, package, "_meta"] url_path = ["source", project, package, "_meta"]
url_query = {} url_query = {}
response = self.xml_request("PUT", apiurl, url_path, url_query, data=self.to_string()) 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 @classmethod
def cmd_release( def cmd_release(
@ -124,4 +125,4 @@ class Package(XmlModel):
"nodelay": nodelay, "nodelay": nodelay,
} }
response = cls.xml_request("POST", apiurl, url_path, url_query) response = cls.xml_request("POST", apiurl, url_path, url_query)
return Status.from_string(response.read()) return Status.from_file(response, apiurl=apiurl)

View File

@ -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)

View File

@ -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)

View File

@ -112,14 +112,14 @@ class Project(XmlModel):
url_path = ["source", project, "_meta"] url_path = ["source", project, "_meta"]
url_query = {} url_query = {}
response = cls.xml_request("GET", apiurl, url_path, 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): def to_api(self, apiurl, *, project=None):
project = project or self.name project = project or self.name
url_path = ["source", project, "_meta"] url_path = ["source", project, "_meta"]
url_query = {} url_query = {}
response = self.xml_request("PUT", apiurl, url_path, url_query, data=self.to_string()) 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): def resolve_repository_flags(self, package_obj=None):
""" """

140
osc/obs_api/request.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(
)

View File

@ -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")

View File

@ -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(
)

View File

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

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(
)

View File

@ -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,
)

View File

@ -7,6 +7,7 @@ This module IS NOT a supported API, it is meant for osc internal use only.
""" """
import copy import copy
import functools
import inspect import inspect
import sys import sys
import tempfile import tempfile
@ -71,8 +72,9 @@ NotSet = NotSetClass()
class FromParent(NotSetClass): class FromParent(NotSetClass):
def __init__(self, field_name): def __init__(self, field_name, *, fallback=NotSet):
self.field_name = field_name self.field_name = field_name
self.fallback = fallback
def __repr__(self): def __repr__(self):
return f"FromParent(field_name={self.field_name})" return f"FromParent(field_name={self.field_name})"
@ -256,7 +258,7 @@ class Field(property):
for num, i in enumerate(result): for num, i in enumerate(result):
if isinstance(i, dict): if isinstance(i, dict):
klass = self.inner_type klass = self.inner_type
result[num] = klass(**i) result[num] = klass(**i, _parent=obj)
if self.get_callback is not None: if self.get_callback is not None:
result = self.get_callback(obj, result) result = self.get_callback(obj, result)
@ -279,7 +281,10 @@ class Field(property):
if isinstance(self.default, FromParent): if isinstance(self.default, FromParent):
if obj._parent is None: 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) return getattr(obj._parent, self.default.field_name or self.name)
if self.default is NotSet: if self.default is NotSet:
@ -308,20 +313,23 @@ class Field(property):
if self.is_model and isinstance(value, dict): if self.is_model and isinstance(value, dict):
# initialize a model instance from a dictionary # initialize a model instance from a dictionary
klass = self.origin_type 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): elif self.is_model_list and isinstance(value, list):
new_value = [] new_value = []
for i in value: for i in value:
if isinstance(i, dict): if isinstance(i, dict):
klass = self.inner_type klass = self.inner_type
new_value.append(klass(**i)) new_value.append(klass(**i, _parent=obj))
else: else:
i._parent = obj
new_value.append(i) new_value.append(i)
value = new_value value = new_value
elif self.is_model and isinstance(value, str) and hasattr(self.origin_type, "XML_TAG_FIELD"): elif self.is_model and isinstance(value, str) and hasattr(self.origin_type, "XML_TAG_FIELD"):
klass = self.origin_type klass = self.origin_type
key = getattr(self.origin_type, "XML_TAG_FIELD") 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) self.validate_type(value)
obj._values[self.name] = value obj._values[self.name] = value
@ -361,6 +369,7 @@ class ModelMeta(type):
return new_cls return new_cls
@functools.total_ordering
class BaseModel(metaclass=ModelMeta): class BaseModel(metaclass=ModelMeta):
__fields__: Dict[str, Field] __fields__: Dict[str, Field]
@ -372,6 +381,7 @@ class BaseModel(metaclass=ModelMeta):
raise AttributeError(f"Setting attribute '{self.__class__.__name__}.{name}' is not allowed") raise AttributeError(f"Setting attribute '{self.__class__.__name__}.{name}' is not allowed")
def __init__(self, **kwargs): def __init__(self, **kwargs):
self._allow_new_attributes = True
self._defaults = {} # field defaults cached in field.get() self._defaults = {} # field defaults cached in field.get()
self._values = {} # field values explicitly set after initializing the model self._values = {} # field values explicitly set after initializing the model
self._parent = kwargs.pop("_parent", None) self._parent = kwargs.pop("_parent", None)
@ -404,10 +414,28 @@ class BaseModel(metaclass=ModelMeta):
self._allow_new_attributes = False 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): def __eq__(self, other):
if type(self) != type(other): if type(self) != type(other):
return False 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): def dict(self):
result = {} result = {}
@ -440,6 +468,11 @@ class BaseModel(metaclass=ModelMeta):
class XmlModel(BaseModel): class XmlModel(BaseModel):
XML_TAG = None XML_TAG = None
_apiurl: Optional[str] = Field(
exclude=True,
default=FromParent("_apiurl", fallback=None),
)
def to_xml(self) -> ET.Element: def to_xml(self) -> ET.Element:
xml_tag = None xml_tag = None
@ -459,6 +492,8 @@ class XmlModel(BaseModel):
root = ET.Element(xml_tag) root = ET.Element(xml_tag)
for field_name, field in self.__fields__.items(): for field_name, field in self.__fields__.items():
if field.exclude:
continue
xml_attribute = field.extra.get("xml_attribute", False) xml_attribute = field.extra.get("xml_attribute", False)
xml_set_tag = field.extra.get("xml_set_tag", False) xml_set_tag = field.extra.get("xml_set_tag", False)
xml_set_text = field.extra.get("xml_set_text", False) xml_set_text = field.extra.get("xml_set_text", False)
@ -510,20 +545,20 @@ class XmlModel(BaseModel):
return root return root
@classmethod @classmethod
def from_string(cls, string: str) -> "XmlModel": def from_string(cls, string: str, *, apiurl: Optional[str] = None) -> "XmlModel":
""" """
Instantiate model from string. Instantiate model from string.
""" """
root = ET.fromstring(string) root = ET.fromstring(string)
return cls.from_xml(root) return cls.from_xml(root, apiurl=apiurl)
@classmethod @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. Instantiate model from file.
""" """
root = ET.parse(file).getroot() root = ET.parse(file).getroot()
return cls.from_xml(root) return cls.from_xml(root, apiurl=apiurl)
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
""" """
@ -581,7 +616,7 @@ class XmlModel(BaseModel):
parent.remove(node) parent.remove(node)
@classmethod @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. Instantiate model from a XML root.
""" """
@ -661,7 +696,7 @@ class XmlModel(BaseModel):
for node in nodes: for node in nodes:
if field.is_model_list: if field.is_model_list:
klass = field.inner_type 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 # clear node as it was checked in from_xml() already
node.text = None node.text = None
@ -691,7 +726,7 @@ class XmlModel(BaseModel):
if node is None: if node is None:
continue continue
klass = field.origin_type 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 # clear node as it was checked in from_xml() already
node.text = None node.text = None
@ -707,16 +742,16 @@ class XmlModel(BaseModel):
continue continue
value = cls.value_from_string(field, node.text) value = cls.value_from_string(field, node.text)
node.text = None node.text = None
cls._remove_processed_node(root, node)
if value is None: if value is None:
if field.is_optional: if field.is_optional:
continue continue
value = "" value = ""
kwargs[field_name] = value kwargs[field_name] = value
cls._remove_processed_node(root, node)
cls._remove_processed_node(None, root) cls._remove_processed_node(None, root)
obj = cls(**kwargs) obj = cls(**kwargs, _apiurl=apiurl)
obj.__dict__["_root"] = orig_root obj.__dict__["_root"] = orig_root
return obj return obj
@ -760,7 +795,7 @@ class XmlModel(BaseModel):
while True: while True:
run_editor(f.name) run_editor(f.name)
try: try:
edited_obj = self.__class__.from_file(f.name) edited_obj = self.__class__.from_file(f.name, apiurl=self._apiurl)
f.seek(0) f.seek(0)
edited_data = f.read() edited_data = f.read()
break break

View File

@ -291,6 +291,49 @@ class Test(unittest.TestCase):
self.assertEqual(c.field, "new-text") self.assertEqual(c.field, "new-text")
self.assertEqual(c.field2, "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): def test_get_callback(self):
class Model(BaseModel): class Model(BaseModel):
quiet: bool = Field( quiet: bool = Field(
@ -352,6 +395,57 @@ class Test(unittest.TestCase):
self.assertIsInstance(m.field[0], BaseModel) self.assertIsInstance(m.field[0], BaseModel)
self.assertEqual(m.field[0].text, "value") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -1,3 +1,4 @@
import io
import textwrap import textwrap
import unittest import unittest
@ -149,6 +150,41 @@ class TestXmlModel(unittest.TestCase):
m = ParentModel.from_string(expected) m = ParentModel.from_string(expected)
self.assertEqual(m.to_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__": if __name__ == "__main__":
unittest.main() unittest.main()