quapp-common 0.0.14.dev8__tar.gz → 0.0.14.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {quapp_common-0.0.14.dev8/quapp_common.egg-info → quapp_common-0.0.14.dev10}/PKG-INFO +1 -1
  2. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/pyproject.toml +1 -1
  3. quapp_common-0.0.14.dev10/quapp_common/component/callback/update_job_metadata.py +157 -0
  4. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/dispatcher.py +0 -4
  5. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/error_classifier.py +17 -0
  6. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10/quapp_common.egg-info}/PKG-INFO +1 -1
  7. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common.egg-info/SOURCES.txt +1 -0
  8. quapp_common-0.0.14.dev10/tests/test_callback_payload.py +173 -0
  9. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_dispatcher_runner_error.py +3 -3
  10. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_language_dispatcher.py +2 -8
  11. quapp_common-0.0.14.dev8/quapp_common/component/callback/update_job_metadata.py +0 -95
  12. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/LICENSE +0 -0
  13. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/README.md +0 -0
  14. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/__init__.py +0 -0
  15. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/async_tasks/__init__.py +0 -0
  16. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/async_tasks/async_invocation_task.py +0 -0
  17. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/async_tasks/async_task.py +0 -0
  18. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/async_tasks/export_circuit_task.py +0 -0
  19. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/async_tasks/post_processing_task.py +0 -0
  20. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/__init__.py +0 -0
  21. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/__init__.py +0 -0
  22. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/invocation.py +0 -0
  23. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/job_fetcher.py +0 -0
  24. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/job_fetching.py +0 -0
  25. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/job_heartbeat.py +0 -0
  26. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/backend/job_manager.py +0 -0
  27. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/bridge.py +0 -0
  28. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/callback/__init__.py +0 -0
  29. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/circuit_adapter.py +0 -0
  30. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/device/__init__.py +0 -0
  31. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/device/device_selection.py +0 -0
  32. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/component/result_serializer.py +0 -0
  33. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/config/__init__.py +0 -0
  34. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/config/log_context.py +0 -0
  35. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/config/logging_config.py +0 -0
  36. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/config/thread_config.py +0 -0
  37. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/__init__.py +0 -0
  38. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/async_task/__init__.py +0 -0
  39. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/async_task/circuit_export/__init__.py +0 -0
  40. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/async_task/circuit_export/backend_holder.py +0 -0
  41. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/async_task/circuit_export/circuit_holder.py +0 -0
  42. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/backend/__init__.py +0 -0
  43. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/backend/backend_information.py +0 -0
  44. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/callback/__init__.py +0 -0
  45. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/callback/callback_url.py +0 -0
  46. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/device/__init__.py +0 -0
  47. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/device/circuit_running_option.py +0 -0
  48. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/promise/__init__.py +0 -0
  49. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/promise/post_processing_promise.py +0 -0
  50. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/promise/promise.py +0 -0
  51. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/request/__init__.py +0 -0
  52. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/request/invocation_request.py +0 -0
  53. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/request/job_fetching_request.py +0 -0
  54. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/request/request.py +0 -0
  55. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/response/__init__.py +0 -0
  56. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/response/authentication.py +0 -0
  57. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/response/custom_header.py +0 -0
  58. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/response/error_report.py +0 -0
  59. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/data/response/job_response.py +0 -0
  60. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/__init__.py +0 -0
  61. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/base_enum.py +0 -0
  62. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/error_category.py +0 -0
  63. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/http_header.py +0 -0
  64. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/invocation_step.py +0 -0
  65. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/language.py +0 -0
  66. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/media_type.py +0 -0
  67. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/processing_unit.py +0 -0
  68. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/provider_tag.py +0 -0
  69. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/runtime_phase.py +0 -0
  70. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/sdk.py +0 -0
  71. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/status/__init__.py +0 -0
  72. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/status/job_status.py +0 -0
  73. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/status/status_code.py +0 -0
  74. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/enum/token_type.py +0 -0
  75. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/factory/__init__.py +0 -0
  76. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/factory/device_factory.py +0 -0
  77. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/factory/handler_factory.py +0 -0
  78. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/factory/provider_factory.py +0 -0
  79. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/handler/__init__.py +0 -0
  80. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/handler/handler.py +0 -0
  81. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/__init__.py +0 -0
  82. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/device/__init__.py +0 -0
  83. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/device/custom_device.py +0 -0
  84. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/device/device.py +0 -0
  85. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/invocation.py +0 -0
  86. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/provider/__init__.py +0 -0
  87. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/model/provider/provider.py +0 -0
  88. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/__init__.py +0 -0
  89. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/file_utils.py +0 -0
  90. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/http_utils.py +0 -0
  91. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/invocation_failure.py +0 -0
  92. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/json_parser_utils.py +0 -0
  93. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common/util/response_utils.py +0 -0
  94. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common.egg-info/dependency_links.txt +0 -0
  95. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common.egg-info/requires.txt +0 -0
  96. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/quapp_common.egg-info/top_level.txt +0 -0
  97. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/setup.cfg +0 -0
  98. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_circuit_adapter.py +0 -0
  99. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_error_classifier.py +0 -0
  100. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_error_log_dedupe.py +0 -0
  101. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_error_report.py +0 -0
  102. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_invocation_failure.py +0 -0
  103. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_job_heartbeat.py +0 -0
  104. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_json_parser_utils.py +0 -0
  105. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_log_secret_leak.py +0 -0
  106. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_logging_config.py +0 -0
  107. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_no_sdk_dependency.py +0 -0
  108. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_phase_coverage.py +0 -0
  109. {quapp_common-0.0.14.dev8 → quapp_common-0.0.14.dev10}/tests/test_result_serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-common
3
- Version: 0.0.14.dev8
3
+ Version: 0.0.14.dev10
4
4
  Summary: Quapp common library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quapp-common"
7
- version = "0.0.14.dev8"
7
+ version = "0.0.14.dev10"
8
8
  description = "Quapp common library supporting Quapp Platform for Quantum Computing"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "CITYNOW Co. Ltd. ", email = "corp@citynow.vn" }]
@@ -0,0 +1,157 @@
1
+ # Quapp Platform Project
2
+ # update_job_metadata.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ import json
6
+ import math
7
+
8
+ import requests
9
+
10
+ from ..backend.job_manager import JobManager
11
+ from ...config.log_context import phase
12
+ from ...config.logging_config import job_logger
13
+ from ...data.response.job_response import JobResponse
14
+ from ...enum.runtime_phase import RuntimePhase
15
+ from ...util.http_utils import create_application_json_header, \
16
+ get_job_id_from_url
17
+ from ...util.response_utils import generate_response
18
+
19
+
20
+ def _sanitize(value):
21
+ """Replace what strict JSON cannot carry, recursively.
22
+
23
+ Only used on the fallback path. NaN/Infinity become None because
24
+ ``allow_nan=False`` rejects them -- which is what requests did too, so the
25
+ fallback still produces valid JSON instead of a bare ``NaN`` literal the
26
+ backend cannot parse.
27
+ """
28
+ if isinstance(value, dict):
29
+ return {key: _sanitize(item) for key, item in value.items()}
30
+ if isinstance(value, (list, tuple)):
31
+ return [_sanitize(item) for item in value]
32
+ if isinstance(value, float) and not math.isfinite(value):
33
+ return None
34
+ return value
35
+
36
+
37
+ def _json_payload(payload: dict, logger) -> str:
38
+ """Serialize the callback body, degrading what json cannot encode to text.
39
+
40
+ ``requests.patch(json=...)`` calls ``json.dumps(..., allow_nan=False)`` with
41
+ no ``default=``, so ONE object json cannot encode anywhere inside
42
+ job_response raised TypeError and the request never left the process. Worse,
43
+ the ``except`` path below then sends the SAME body to the on_error callback,
44
+ which fails identically -- the job is left RUNNING with nothing reported.
45
+
46
+ That really happened: quapp-pennylane put PennyLane's ``Shots`` object into
47
+ job_result and "Object of type Shots is not JSON serializable" took down both
48
+ callbacks of job 2b96f552-787d-40bd-8cfb-b5fe40983fd2.
49
+
50
+ The strict pass runs first, so a well-formed body is serialized exactly as
51
+ requests would have; only a body that would otherwise raise falls back.
52
+ Coercing to text is right here: these values are for display, and losing the
53
+ job is far worse than showing ``Shots(total_shots=1024)``.
54
+ """
55
+ try:
56
+ return json.dumps(payload, allow_nan=False)
57
+ except (TypeError, ValueError) as exception:
58
+ logger.warning(
59
+ f'Callback body is not strictly JSON serializable ({exception}); '
60
+ f'sending it with those values coerced to text. The value comes '
61
+ f'from a provider lib and should be converted to a plain type '
62
+ f'before it reaches job_response.')
63
+ return json.dumps(_sanitize(payload), allow_nan=False, default=str)
64
+
65
+
66
+ def update_job_metadata(job_response: JobResponse, callback_url: str):
67
+ # Helpers to improve readability and remove duplication
68
+ def _log_http_result(tag: str, resp):
69
+ logger.info(
70
+ f"Request to {tag} completed with status code: {resp.status_code}")
71
+ if resp.status_code in (401, 403):
72
+ logger.error(
73
+ f"Request to {tag} was rejected with status code {resp.status_code}. "
74
+ f"The job update is lost, so the platform will not see this state change. "
75
+ f"Check that the response carries the user token and the project header.")
76
+ elif not resp.ok:
77
+ logger.warning(
78
+ f"Unexpected status code received from {tag}: {resp.status_code}, response content: {resp.content}")
79
+
80
+ def _update_local_status() -> None:
81
+ """
82
+ Update local JobManager status based on callback URL semantics:
83
+ - contains 'FAILED' => FAILED
84
+ - contains 'FINALIZATION_PASSED' => DONE
85
+ - otherwise => RUNNING
86
+ """
87
+ url_upper = (callback_url or "").upper()
88
+ if "FAILED" in url_upper:
89
+ JobManager.set_failed(job_id=job_id)
90
+ elif "FINALIZATION_PASSED" in url_upper:
91
+ JobManager.set_done(job_id=job_id)
92
+ else:
93
+ JobManager.set_running(job_id=job_id)
94
+
95
+ def _patch_scheduler() -> None:
96
+ job_details = JobManager.get_job(job_id)
97
+ if job_details is None:
98
+ logger.warning(
99
+ f"Job {job_id} not found in JobManager, skipping scheduler patch")
100
+ return
101
+ scheduler_callback_url = job_details.get('meta', {}).get(
102
+ 'scheduler_callback_url')
103
+ if not scheduler_callback_url:
104
+ logger.debug(
105
+ f"No scheduler callback URL for job {job_id}, skipping")
106
+ return
107
+ scheduler_payload = {'job_id' : job_id,
108
+ 'status' : job_details.get('status'),
109
+ 'updated_at': job_details.get('updated_at')}
110
+ scheduler_response = requests.patch(url=scheduler_callback_url,
111
+ json=scheduler_payload)
112
+ _log_http_result('scheduler', scheduler_response)
113
+
114
+ job_id = get_job_id_from_url(callback_url)
115
+ logger = job_logger(job_id)
116
+ logger.info(
117
+ f'Calling backend to update job metadata at URL: {callback_url}')
118
+
119
+ if not getattr(job_response, 'user_token', None):
120
+ logger.error(
121
+ 'Job update is being sent without a user token, the backend will reject it. '
122
+ 'The response was built without the invocation credentials.')
123
+
124
+ request_body = generate_response(job_response)
125
+
126
+ # The callback runs after the caller's `with phase(...)` has closed, so a
127
+ # failure here was reported with phase=None -- what happened to job
128
+ # 2b96f552-787d-40bd-8cfb-b5fe40983fd2. RuntimePhase.CALLBACK was declared
129
+ # for exactly this and had no place that opened it.
130
+ with phase(RuntimePhase.CALLBACK):
131
+ try:
132
+ request_headers = create_application_json_header(
133
+ token=job_response.user_token,
134
+ project_header=job_response.project_header,
135
+ workspace_header=job_response.workspace_header)
136
+
137
+ # data= rather than json=: the body goes through _json_payload so no
138
+ # provider object can kill this callback. Content-Type is already
139
+ # application/json in the headers above.
140
+ response = requests.patch(
141
+ url=callback_url,
142
+ data=_json_payload({'data': request_body}, logger),
143
+ headers=request_headers)
144
+
145
+ # Log backend response
146
+ _log_http_result('backend', response)
147
+
148
+ # Update local job status and notify scheduler only after backend confirms
149
+ _update_local_status()
150
+ _patch_scheduler()
151
+
152
+ except Exception as exception:
153
+ logger.exception(
154
+ f'Error occurred while calling backend: {exception}',
155
+ exc_info=True)
156
+
157
+ raise exception
@@ -46,10 +46,6 @@ RUNNER_MAP = {
46
46
  'command': [_QSHARP_BINARY],
47
47
  'timeout': 300,
48
48
  },
49
- Language.C: {
50
- 'command': ['./handler_compiled'],
51
- 'timeout': 300,
52
- },
53
49
  }
54
50
 
55
51
  # Cap the stack/stderr length carried on the exception so job_result stays small.
@@ -329,6 +329,23 @@ register_rules(
329
329
  code='INPUT_TOO_FEW_ITEMS',
330
330
  resolution='A field in the input has fewer items than expected.'),
331
331
 
332
+ # --- serialization -------------------------------------------------
333
+ # A provider lib left a vendor object in job_response and json could not
334
+ # encode it, so the callback body never went out. This is a lib bug, not
335
+ # a user one: it landed as category UNKNOWN before this rule, which told
336
+ # nobody anything (job 2b96f552-787d-40bd-8cfb-b5fe40983fd2, PennyLane's
337
+ # Shots object). Placed above the vaguer rules because the message is
338
+ # unmistakable.
339
+ Rule(match=lambda e: isinstance(e, TypeError)
340
+ and 'is not json serializable' in str(e).lower(),
341
+ category=ErrorCategory.PLATFORM_INTERNAL,
342
+ code='PLATFORM_RESULT_NOT_SERIALIZABLE',
343
+ resolution='An object in the job result cannot be converted to '
344
+ 'JSON, so the platform could not report the result.',
345
+ steps=('Report this to the Quapp team with the job id',
346
+ 'The provider lib has to convert the value to a plain type '
347
+ 'before it goes into job_result')),
348
+
332
349
  # --- dependency ----------------------------------------------------
333
350
  Rule(match=lambda e: isinstance(e, ModuleNotFoundError),
334
351
  category=ErrorCategory.DEPENDENCY,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-common
3
- Version: 0.0.14.dev8
3
+ Version: 0.0.14.dev10
4
4
  Summary: Quapp common library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -89,6 +89,7 @@ quapp_common/util/http_utils.py
89
89
  quapp_common/util/invocation_failure.py
90
90
  quapp_common/util/json_parser_utils.py
91
91
  quapp_common/util/response_utils.py
92
+ tests/test_callback_payload.py
92
93
  tests/test_circuit_adapter.py
93
94
  tests/test_dispatcher_runner_error.py
94
95
  tests/test_error_classifier.py
@@ -0,0 +1,173 @@
1
+ # Quapp Platform Project
2
+ # test_callback_payload.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ """One unserializable value must not cost us the whole job.
6
+
7
+ `requests.patch(json=...)` calls `json.dumps(..., allow_nan=False)` with no
8
+ `default=`, so a single object json cannot encode anywhere inside `job_response`
9
+ raised TypeError before the request ever left the process. The `except` path then
10
+ sent the SAME body to the on_error callback, which failed identically -- so the
11
+ backend heard nothing at all and the job stayed RUNNING forever.
12
+
13
+ Seen in production on job 2b96f552-787d-40bd-8cfb-b5fe40983fd2: quapp-pennylane
14
+ put PennyLane's `Shots` object into job_result and both callbacks died with
15
+ "Object of type Shots is not JSON serializable".
16
+
17
+ The provider lib is fixed separately (shots is coerced to int). These tests lock
18
+ the platform-side guarantee: the callback goes out regardless.
19
+ """
20
+
21
+ import json
22
+
23
+ import pytest
24
+
25
+ from quapp_common.component.callback import update_job_metadata as module
26
+ from quapp_common.component.callback.update_job_metadata import (
27
+ _json_payload, update_job_metadata,
28
+ )
29
+ from quapp_common.config.logging_config import job_logger
30
+
31
+
32
+ class Shots:
33
+ """Stands in for pennylane.measurements.Shots: json cannot encode it."""
34
+
35
+ def __init__(self, total):
36
+ self.total_shots = total
37
+
38
+ def __str__(self):
39
+ return f'Shots(total_shots={self.total_shots})'
40
+
41
+
42
+ @pytest.fixture
43
+ def logger():
44
+ return job_logger('job_payload')
45
+
46
+
47
+ # ------------------------------------------- the payload itself
48
+
49
+ def test_serializable_body_is_untouched(logger):
50
+ body = {'data': {'jobResult': {'counts': {'00': 512}}, 'shots': 1024}}
51
+
52
+ assert _json_payload(body, logger) == json.dumps(body, allow_nan=False)
53
+
54
+
55
+ def test_unserializable_value_becomes_text(logger):
56
+ body = {'data': {'shots': Shots(1024)}}
57
+
58
+ payload = json.loads(_json_payload(body, logger))
59
+
60
+ assert payload['data']['shots'] == 'Shots(total_shots=1024)'
61
+
62
+
63
+ def test_nan_does_not_produce_invalid_json(logger):
64
+ """NaN is not JSON. requests rejected it too (allow_nan=False), so the
65
+ fallback has to drop it rather than emit a bare NaN literal the backend
66
+ cannot parse."""
67
+ body = {'data': {'probability': float('nan'), 'shots': Shots(8)}}
68
+
69
+ text = _json_payload(body, logger)
70
+
71
+ assert 'NaN' not in text
72
+ assert json.loads(text)['data']['probability'] is None
73
+
74
+
75
+ def test_nested_unserializable_value_is_reached(logger):
76
+ body = {'data': {'jobResult': {'meta': [{'shots': Shots(2048)}]}}}
77
+
78
+ payload = json.loads(_json_payload(body, logger))
79
+
80
+ assert payload['data']['jobResult']['meta'][0]['shots'] == \
81
+ 'Shots(total_shots=2048)'
82
+
83
+
84
+ # ------------------------------------ the callback actually goes out
85
+
86
+ class FakeResponse:
87
+ status_code = 200
88
+ ok = True
89
+ content = b'{}'
90
+
91
+
92
+ @pytest.fixture
93
+ def sent(monkeypatch):
94
+ """Capture the outgoing PATCH instead of making one."""
95
+ calls = []
96
+
97
+ def _patch(url, data=None, json=None, headers=None, **kwargs):
98
+ calls.append({'url': url, 'data': data, 'json': json})
99
+ return FakeResponse()
100
+
101
+ monkeypatch.setattr(module.requests, 'patch', _patch)
102
+ monkeypatch.setattr(module.JobManager, 'set_running', lambda job_id: None)
103
+ monkeypatch.setattr(module.JobManager, 'set_done', lambda job_id: None)
104
+ monkeypatch.setattr(module.JobManager, 'set_failed', lambda job_id: None)
105
+ monkeypatch.setattr(module.JobManager, 'get_job', lambda job_id: None)
106
+ return calls
107
+
108
+
109
+ def test_callback_is_sent_even_with_a_provider_object(monkeypatch, sent):
110
+ """The regression itself: this used to raise TypeError instead of sending."""
111
+ monkeypatch.setattr(module, 'generate_response',
112
+ lambda job_response: {'shots': Shots(1024)})
113
+
114
+ class Response:
115
+ user_token = 'token'
116
+ project_header = None
117
+ workspace_header = None
118
+
119
+ update_job_metadata(Response(),
120
+ 'http://backend/jobs/job_1/ANALYSIS_PASSED')
121
+
122
+ assert len(sent) == 1, 'the callback never went out'
123
+ assert sent[0]['json'] is None, 'body must be pre-serialized, not json='
124
+ assert 'Shots(total_shots=1024)' in sent[0]['data']
125
+
126
+
127
+ # ------------------------------- the error is classified, not "UNKNOWN"
128
+
129
+ def test_serialization_failure_gets_a_real_category():
130
+ """It arrived as category=UNKNOWN, owner=UNKNOWN, which told nobody anything.
131
+
132
+ A vendor object in job_result is a provider-lib bug, so it belongs to the
133
+ platform team, not the user.
134
+ """
135
+ from quapp_common.enum.error_category import ErrorCategory
136
+ from quapp_common.enum.runtime_phase import RuntimePhase
137
+ from quapp_common.util.error_classifier import classify
138
+
139
+ try:
140
+ json.dumps({'shots': Shots(1024)})
141
+ except TypeError as caught:
142
+ error = caught
143
+
144
+ rule, _ = classify(error, RuntimePhase.ANALYSIS)
145
+
146
+ assert rule.code == 'PLATFORM_RESULT_NOT_SERIALIZABLE'
147
+ assert rule.category is ErrorCategory.PLATFORM_INTERNAL
148
+ assert rule.steps
149
+
150
+
151
+ def test_callback_failure_carries_the_callback_phase(monkeypatch, sent):
152
+ """The caller's `with phase(...)` has already closed by callback time, so the
153
+ report said phase=None. RuntimePhase.CALLBACK was declared for this."""
154
+ from quapp_common.config.log_context import bind_job, effective_phase
155
+ from quapp_common.enum.runtime_phase import RuntimePhase
156
+
157
+ def _boom(url, **kwargs):
158
+ raise ConnectionError('backend unreachable')
159
+
160
+ monkeypatch.setattr(module.requests, 'patch', _boom)
161
+ monkeypatch.setattr(module, 'generate_response', lambda job_response: {})
162
+
163
+ class Response:
164
+ user_token = 'token'
165
+ project_header = None
166
+ workspace_header = None
167
+
168
+ bind_job('job_phase')
169
+ with pytest.raises(ConnectionError):
170
+ update_job_metadata(Response(),
171
+ 'http://backend/jobs/job_phase/ANALYSIS_PASSED')
172
+
173
+ assert effective_phase() is RuntimePhase.CALLBACK
@@ -89,7 +89,7 @@ def test_structured_error_legacy_string_form(fake_run):
89
89
  'status': 'error', 'error': 'invalid JSON on stdin'})))
90
90
 
91
91
  with pytest.raises(HandlerRuntimeError) as info:
92
- dispatch(Language.C, REQUEST)
92
+ dispatch(Language.QSHARP, REQUEST)
93
93
 
94
94
  assert 'invalid JSON on stdin' in str(info.value)
95
95
 
@@ -97,11 +97,11 @@ def test_structured_error_legacy_string_form(fake_run):
97
97
  # ------------------------------------------------------------- (3) killed by signal
98
98
 
99
99
  def test_signal_is_detected(fake_run):
100
- """handler.c segfaults: returncode = -11, stdout empty."""
100
+ """A subprocess handler is killed by a signal: returncode = -11, stdout empty."""
101
101
  fake_run(FakeCompletedProcess(returncode=-11, stdout=''))
102
102
 
103
103
  with pytest.raises(HandlerRuntimeError) as info:
104
- dispatch(Language.C, REQUEST)
104
+ dispatch(Language.QSHARP, REQUEST)
105
105
 
106
106
  error = info.value
107
107
  assert error.error_type == 'Signal'
@@ -10,14 +10,8 @@ class TestLanguageCEnum(unittest.TestCase):
10
10
 
11
11
 
12
12
  class TestRunnerMapLanguageC(unittest.TestCase):
13
- def test_language_c_registered(self):
14
- self.assertIn(Language.C, RUNNER_MAP)
15
-
16
- def test_command_is_compiled_binary(self):
17
- self.assertEqual(RUNNER_MAP[Language.C]['command'], ['./handler_compiled'])
18
-
19
- def test_timeout(self):
20
- self.assertEqual(RUNNER_MAP[Language.C]['timeout'], 300)
13
+ def test_language_c_not_registered(self):
14
+ self.assertNotIn(Language.C, RUNNER_MAP)
21
15
 
22
16
 
23
17
  class TestLanguageCppEnum(unittest.TestCase):
@@ -1,95 +0,0 @@
1
- # Quapp Platform Project
2
- # update_job_metadata.py
3
- # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
-
5
- import requests
6
-
7
- from ..backend.job_manager import JobManager
8
- from ...config.logging_config import job_logger
9
- from ...data.response.job_response import JobResponse
10
- from ...util.http_utils import create_application_json_header, \
11
- get_job_id_from_url
12
- from ...util.response_utils import generate_response
13
-
14
-
15
- def update_job_metadata(job_response: JobResponse, callback_url: str):
16
- # Helpers to improve readability and remove duplication
17
- def _log_http_result(tag: str, resp):
18
- logger.info(
19
- f"Request to {tag} completed with status code: {resp.status_code}")
20
- if resp.status_code in (401, 403):
21
- logger.error(
22
- f"Request to {tag} was rejected with status code {resp.status_code}. "
23
- f"The job update is lost, so the platform will not see this state change. "
24
- f"Check that the response carries the user token and the project header.")
25
- elif not resp.ok:
26
- logger.warning(
27
- f"Unexpected status code received from {tag}: {resp.status_code}, response content: {resp.content}")
28
-
29
- def _update_local_status() -> None:
30
- """
31
- Update local JobManager status based on callback URL semantics:
32
- - contains 'FAILED' => FAILED
33
- - contains 'FINALIZATION_PASSED' => DONE
34
- - otherwise => RUNNING
35
- """
36
- url_upper = (callback_url or "").upper()
37
- if "FAILED" in url_upper:
38
- JobManager.set_failed(job_id=job_id)
39
- elif "FINALIZATION_PASSED" in url_upper:
40
- JobManager.set_done(job_id=job_id)
41
- else:
42
- JobManager.set_running(job_id=job_id)
43
-
44
- def _patch_scheduler() -> None:
45
- job_details = JobManager.get_job(job_id)
46
- if job_details is None:
47
- logger.warning(
48
- f"Job {job_id} not found in JobManager, skipping scheduler patch")
49
- return
50
- scheduler_callback_url = job_details.get('meta', {}).get(
51
- 'scheduler_callback_url')
52
- if not scheduler_callback_url:
53
- logger.debug(
54
- f"No scheduler callback URL for job {job_id}, skipping")
55
- return
56
- scheduler_payload = {'job_id' : job_id,
57
- 'status' : job_details.get('status'),
58
- 'updated_at': job_details.get('updated_at')}
59
- scheduler_response = requests.patch(url=scheduler_callback_url,
60
- json=scheduler_payload)
61
- _log_http_result('scheduler', scheduler_response)
62
-
63
- job_id = get_job_id_from_url(callback_url)
64
- logger = job_logger(job_id)
65
- logger.info(
66
- f'Calling backend to update job metadata at URL: {callback_url}')
67
-
68
- if not getattr(job_response, 'user_token', None):
69
- logger.error(
70
- 'Job update is being sent without a user token, the backend will reject it. '
71
- 'The response was built without the invocation credentials.')
72
-
73
- request_body = generate_response(job_response)
74
-
75
- try:
76
- request_headers = create_application_json_header(
77
- token=job_response.user_token,
78
- project_header=job_response.project_header,
79
- workspace_header=job_response.workspace_header)
80
-
81
- response = requests.patch(url=callback_url, json={'data': request_body},
82
- headers=request_headers)
83
-
84
- # Log backend response
85
- _log_http_result('backend', response)
86
-
87
- # Update local job status and notify scheduler only after backend confirms
88
- _update_local_status()
89
- _patch_scheduler()
90
-
91
- except Exception as exception:
92
- logger.exception(f'Error occurred while calling backend: {exception}',
93
- exc_info=True)
94
-
95
- raise exception