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