Repository: dwavesystems/dwave-cloud-client
Pull request #685 (2025-03-05 22:27:57)

Quality of life improvement: flexible low level problem submit


Patch:
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index 213b36c..d4c4514 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -164,6 +164,14 @@ class ProblemJob(BaseModel):
     type: constants.ProblemType
     label: Optional[str] = None
 
+    @classmethod
+    def from_info(cls, info: ProblemInfo):
+        return cls(data=info.data,
+                   params=info.params,
+                   solver=info.metadata.solver,
+                   type=info.metadata.type,
+                   label=info.metadata.label)
+
 
 class BatchItemError(BaseModel):
     error_code: int
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index a442b00..6b59a8c 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -293,22 +293,26 @@ class Problems(ResourceBase):
         return orjson.loads(response.content)
 
     @accepts(media_type='application/vnd.dwave.sapi.problems+json', version='>=2.1,<3')
-    @compress_if(lambda obj, **_: obj.client.config.get('compress_qpu_problem_data'))
-    def submit_problem(self, *,
-                       data: models.ProblemData,
-                       params: dict,
-                       solver: str,
-                       type: constants.ProblemType,
+    @compress_if(lambda obj, *_, **__: obj.client.config.get('compress_qpu_problem_data'))
+    def submit_problem(self,
+                       problem: Optional[models.ProblemJob] = None,
+                       *,
+                       data: Optional[models.ProblemData] = None,
+                       params: Optional[dict] = None,
+                       solver: Optional[str] = None,
+                       type: Optional[constants.ProblemType] = None,
                        label: Optional[str] = None) -> \
             Union[models.ProblemStatusMaybeWithAnswer, models.ProblemSubmitError]:
         """Blocking problem submit with timeout, returning final status and
         answer, if problem is solved within the (undisclosed) time limit.
         """
         path = ''
+        job_dict = problem.model_dump() if problem is not None else {}
+        updates = dict(data=data.model_dump() if data is not None else None,
+                       params=params, solver=solver, type=type, label=label)
+        job_dict.update((k, v) for k, v in updates.items() if v is not None)
         # note: orjson.dumps(model.model_dump()) is about 6-7x faster then
         # model.model_dump_json() - 45us vs 300us (including model construction)
-        job_dict = dict(data=data.model_dump(), params=params, solver=solver,
-                        type=type, label=label)
         data = orjson.dumps(job_dict, option=orjson.OPT_SERIALIZE_NUMPY)
         headers = {'Content-Type': 'application/json'}
         response = self.session.post(path, data=data, headers=headers)
diff --git a/releasenotes/notes/qol-api-problem-job-submit-6b6ad79dad323c47.yaml b/releasenotes/notes/qol-api-problem-job-submit-6b6ad79dad323c47.yaml
new file mode 100644
index 0000000..e9ff012
--- /dev/null
+++ b/releasenotes/notes/qol-api-problem-job-submit-6b6ad79dad323c47.yaml
@@ -0,0 +1,9 @@
+---
+features:
+  - |
+    Add ``dwave.cloud.api.models.ProblemJob.from_info()`` helper factory method
+    that constructs a problem job model from problem info model.
+  - |
+    Allow ``dwave.cloud.api.resources.Problems.submit_problem()`` to accept a
+    problem described by a ``ProblemJob`` model, with individual attributes
+    optionally modified by keyword arguments.


Test patch:
diff --git a/tests/api/resources/test_problems.py b/tests/api/resources/test_problems.py
index dffcad9..01b7a1c 100644
--- a/tests/api/resources/test_problems.py
+++ b/tests/api/resources/test_problems.py
@@ -302,6 +302,27 @@ class ProblemResourcesBaseTests(abc.ABC):
             self.assertIsInstance(status.answer, models.ProblemAnswer)
             self.verify_problem_answer(status.answer)
 
+    def test_problem_job_submit(self):
+        """ProblemJob submitted."""
+
+        job = models.ProblemJob(
+            data=self.problem_data,
+            params=self.params,
+            solver=self.solver_id,
+            type=self.problem_type,
+        )
+
+        status = self.api.submit_problem(job)
+
+        self.assertIsInstance(status, models.ProblemStatusMaybeWithAnswer)
+        self.assertEqual(status.type, self.problem_type)
+        self.assertEqual(status.solver, self.solver_id)
+        self.assertIsNotNone(status.submitted_on)
+
+        if status.status is constants.ProblemStatus.COMPLETED:
+            self.assertIsInstance(status.answer, models.ProblemAnswer)
+            self.verify_problem_answer(status.answer)
+
     def test_problem_submit_errors(self):
         """Problem submit fails due to invalid parameters."""
 
diff --git a/tests/api/test_models.py b/tests/api/test_models.py
new file mode 100644
index 0000000..1e6230e
--- /dev/null
+++ b/tests/api/test_models.py
@@ -0,0 +1,64 @@
+# Copyright 2025 D-Wave
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+from dwave.cloud.api import models
+
+from tests.api.mocks import StructuredSapiMockResponses
+
+
+class TestModels(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.sapi = StructuredSapiMockResponses()
+
+    def test_construction(self):
+        with self.subTest('ProblemStatus'):
+            status = models.ProblemStatus(**self.sapi.complete_no_answer_reply())
+
+        with self.subTest('ProblemStatusWithAnswer'):
+            status = models.ProblemStatusWithAnswer(**self.sapi.complete_reply())
+
+        with self.subTest('ProblemAnswer'):
+            answer = models.ProblemAnswer(**self.sapi.answer)
+
+        with self.subTest('ProblemStatusMaybeWithAnswer'):
+            s1 = models.ProblemStatusMaybeWithAnswer(**self.sapi.complete_no_answer_reply())
+            s2 = models.ProblemStatusMaybeWithAnswer(**self.sapi.complete_reply())
+            self.assertEqual(s1.id, s2.id)
+            self.assertIsNone(s1.answer)
+            self.assertEqual(s2.answer, answer)
+
+        with self.subTest('ProblemData'):
+            data = models.ProblemData(**self.sapi.problem_data())
+
+        with self.subTest('ProblemMetadata'):
+            metadata = models.ProblemMetadata(**self.sapi.problem_metadata())
+
+            self.assertEqual(metadata.label, status.label)
+            self.assertEqual(metadata.status, status.status)
+
+        with self.subTest('ProblemInfo'):
+            info = models.ProblemInfo(**self.sapi.problem_info())
+
+        with self.subTest('ProblemJob'):
+            job = models.ProblemJob.from_info(info)
+
+            self.assertEqual(job.data, data)
+            self.assertEqual(job.params, info.params)
+            self.assertEqual(job.solver, status.solver)
+            self.assertEqual(job.type, status.type)
+            self.assertEqual(job.label, status.label)