quapp-dwave-ocean 0.0.1.dev3__tar.gz → 0.0.1.dev4__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 (36) hide show
  1. {quapp_dwave_ocean-0.0.1.dev3/quapp_dwave_ocean.egg-info → quapp_dwave_ocean-0.0.1.dev4}/PKG-INFO +3 -2
  2. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/pyproject.toml +7 -3
  3. quapp_dwave_ocean-0.0.1.dev4/quapp_dwave_ocean/__init__.py +7 -0
  4. quapp_dwave_ocean-0.0.1.dev4/quapp_dwave_ocean/error_rules.py +136 -0
  5. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/factory/d_wave_device_factory.py +2 -2
  6. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/factory/d_wave_provider_factory.py +2 -2
  7. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/provider/d_wave_system_provider.py +2 -2
  8. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/provider/quapp_d_wave_provider.py +2 -2
  9. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4/quapp_dwave_ocean.egg-info}/PKG-INFO +3 -2
  10. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean.egg-info/SOURCES.txt +3 -1
  11. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean.egg-info/requires.txt +2 -1
  12. quapp_dwave_ocean-0.0.1.dev4/tests/test_error_logging.py +304 -0
  13. quapp_dwave_ocean-0.0.1.dev3/quapp_dwave_ocean/__init__.py +0 -3
  14. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/LICENSE +0 -0
  15. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/README.md +0 -0
  16. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/async_tasks/__init__.py +0 -0
  17. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/async_tasks/d_wave_ocean_circuit_export_task.py +0 -0
  18. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/component/__init__.py +0 -0
  19. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/component/backend/__init__.py +0 -0
  20. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/component/backend/d_wave_ocean_invocation.py +0 -0
  21. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/component/backend/d_wave_ocean_job_fetching.py +0 -0
  22. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/factory/__init__.py +0 -0
  23. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/factory/d_wave_handler_factory.py +0 -0
  24. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/handler/__init__.py +0 -0
  25. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/handler/invocation_handler.py +0 -0
  26. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/handler/job_fetching_handler.py +0 -0
  27. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/__init__.py +0 -0
  28. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/device/__init__.py +0 -0
  29. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/device/d_wave_device.py +0 -0
  30. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/device/d_wave_hybrid_device.py +0 -0
  31. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/device/d_wave_system_device.py +0 -0
  32. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/device/quapp_d_wave_device.py +0 -0
  33. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean/model/provider/__init__.py +0 -0
  34. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean.egg-info/dependency_links.txt +0 -0
  35. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/quapp_dwave_ocean.egg-info/top_level.txt +0 -0
  36. {quapp_dwave_ocean-0.0.1.dev3 → quapp_dwave_ocean-0.0.1.dev4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-dwave-ocean
3
- Version: 0.0.1.dev3
3
+ Version: 0.0.1.dev4
4
4
  Summary: Quapp D-Wave Ocean library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -19,12 +19,13 @@ Classifier: Programming Language :: Python :: 3
19
19
  Requires-Python: >=3.7
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: quapp-common==0.0.11.dev7
22
+ Requires-Dist: quapp-common==0.0.14.dev10
23
23
  Requires-Dist: dwave-cloud-client==0.13.4
24
24
  Requires-Dist: dwave-system==1.30.0
25
25
  Requires-Dist: dwave-samplers==1.5.0
26
26
  Requires-Dist: dimod==0.12.20
27
27
  Requires-Dist: numpy==2.2.4
28
+ Requires-Dist: matplotlib
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: black; extra == "dev"
30
31
  Requires-Dist: bumpver; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quapp-dwave-ocean"
7
- version = "0.0.1.dev3"
7
+ version = "0.0.1.dev4"
8
8
  description = "Quapp D-Wave Ocean library supporting Quapp Platform for Quantum Computing"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "CITYNOW Co. Ltd. ", email = "corp@citynow.vn" }]
@@ -16,12 +16,16 @@ classifiers = [
16
16
  ]
17
17
  keywords = ["quapp", "quapp-dwave-ocean", "dwave-ocean", "quantum"]
18
18
  dependencies = [
19
- "quapp-common==0.0.11.dev7",
19
+ "quapp-common==0.0.14.dev10",
20
20
  "dwave-cloud-client==0.13.4",
21
21
  "dwave-system==1.30.0",
22
22
  "dwave-samplers==1.5.0",
23
23
  "dimod==0.12.20",
24
- "numpy==2.2.4"
24
+ "numpy==2.2.4",
25
+ # d_wave_ocean_circuit_export_task.py:9 import matplotlib nhung truoc
26
+ # day khong khai bao: no chi co mat nho quapp-common keo theo. Neu
27
+ # quapp-common bo matplotlib thi lib nay vo luc import, khong bao truoc.
28
+ "matplotlib"
25
29
  ]
26
30
  requires-python = ">=3.7"
27
31
 
@@ -0,0 +1,7 @@
1
+ __version__ = "0.4.9"
2
+
3
+ from .factory.d_wave_handler_factory import DWaveHandlerFactory
4
+
5
+ # Imported for the side effect: register_rules() has to run before the
6
+ # first job fails, so this import cannot be made lazy.
7
+ from . import error_rules as _error_rules # noqa: E402,F401
@@ -0,0 +1,136 @@
1
+ # Quapp Platform Project
2
+ # error_rules.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ """D-Wave Ocean-specific error classification rules.
6
+
7
+ Registered into ``quapp_common.util.error_classifier`` when ``quapp_dwave_ocean``
8
+ is imported, so ``quapp-common`` never has to depend on Ocean.
9
+
10
+ Matching is done on the message text or on the class **NAME as a string**, never
11
+ ``isinstance``, and nothing from ``dwave`` / ``dimod`` is imported: the classifier
12
+ has to work even when the SDK is absent.
13
+
14
+ The first five rules key off messages **this lib itself** raises -- checked
15
+ against the code, not guessed:
16
+
17
+ quapp_dwave_ocean/factory/d_wave_provider_factory.py:52
18
+ ValueError('Authentication details are required for D-Wave provider.')
19
+ quapp_dwave_ocean/factory/d_wave_provider_factory.py:74
20
+ ValueError('Unsupported provider!')
21
+ quapp_dwave_ocean/factory/d_wave_device_factory.py:73
22
+ ValueError('Unsupported device!')
23
+ quapp_dwave_ocean/async_tasks/d_wave_ocean_circuit_export_task.py:169,178
24
+ ValueError('Circuit must not be None')
25
+ ValueError(f'Expected BinaryQuadraticModel, got {type(circuit)}')
26
+ quapp_dwave_ocean/component/backend/d_wave_ocean_invocation.py:150
27
+ AttributeError('... Circuit is not a BinaryQuadraticModel ...')
28
+ quapp_dwave_ocean/model/provider/d_wave_system_provider.py:49
29
+ Exception(f'Failed to connect to DWave provider: {exception}')
30
+
31
+ What makes D-Wave different from the gate-model SDKs: the "circuit" here is a
32
+ ``BinaryQuadraticModel`` (a QUBO/Ising problem), not a gate circuit. So the
33
+ "wrong circuit type" error is very easy to hit when a user writes their handler
34
+ following a gate-model example, and it is USER_CODE rather than an SDK fault.
35
+
36
+ The last two rules match exception names from dwave-cloud-client. If upstream
37
+ renames them the rule becomes inert rather than broken -- the classifier just
38
+ falls back on the phase.
39
+ """
40
+
41
+ from quapp_common.enum.error_category import ErrorCategory
42
+ from quapp_common.util.error_classifier import Rule, register_rules
43
+
44
+ DOC_AUTH = 'errors/dwave-auth'
45
+ DOC_DEVICE = 'errors/dwave-device'
46
+ DOC_CIRCUIT = 'errors/dwave-circuit'
47
+
48
+
49
+ def _message(exception) -> str:
50
+ return str(exception).lower()
51
+
52
+
53
+ def _name(exception) -> str:
54
+ return type(exception).__name__
55
+
56
+
57
+ register_rules(
58
+ # ------------ raised by quapp-dwave-ocean itself (checked against code)
59
+ # d_wave_provider_factory.py:52 -- the Leap token is missing
60
+ Rule(match=lambda e: 'authentication details are required' in _message(e),
61
+ category=ErrorCategory.AUTHENTICATION,
62
+ code='DWAVE_TOKEN_MISSING',
63
+ resolution='Authentication details for the D-Wave provider are '
64
+ 'missing.',
65
+ steps=('Add your D-Wave Leap API token to the workspace',
66
+ 'The token is on cloud.dwavesys.com, under API Token'),
67
+ doc_slug=DOC_AUTH,
68
+ retryable=False),
69
+
70
+ # d_wave_ocean_invocation.py:150 and circuit_export_task.py:169,178
71
+ # The most common user error on D-Wave: writing the handler from a
72
+ # gate-model example and returning a QuantumCircuit instead of a
73
+ # BinaryQuadraticModel.
74
+ Rule(match=lambda e: 'binaryquadraticmodel' in _message(e)
75
+ or 'circuit must not be none' in _message(e),
76
+ category=ErrorCategory.USER_CODE,
77
+ code='DWAVE_INVALID_PROBLEM_TYPE',
78
+ resolution='The problem preparation function did not return a '
79
+ 'BinaryQuadraticModel.',
80
+ steps=('Check what your processing function returns',
81
+ 'D-Wave takes a QUBO/Ising problem, not a gate circuit',
82
+ 'Use dimod.BinaryQuadraticModel to build the problem'),
83
+ doc_slug=DOC_CIRCUIT),
84
+
85
+ # d_wave_device_factory.py:73 and d_wave_provider_factory.py:74
86
+ Rule(match=lambda e: 'unsupported device' in _message(e)
87
+ or 'unsupported provider' in _message(e),
88
+ category=ErrorCategory.PROVIDER_DEVICE,
89
+ code='DWAVE_DEVICE_UNSUPPORTED',
90
+ resolution="This device or provider is not supported by Quapp's "
91
+ 'D-Wave lib yet.',
92
+ steps=('Check the device selected for the job',
93
+ 'quapp-dwave-ocean serves D-Wave System, Hybrid and the '
94
+ 'local simulators'),
95
+ doc_slug=DOC_DEVICE),
96
+
97
+ # d_wave_system_provider.py:49
98
+ Rule(match=lambda e: 'failed to connect to dwave provider' in _message(e),
99
+ category=ErrorCategory.PROVIDER_UNAVAILABLE,
100
+ code='DWAVE_CONNECT_FAILED',
101
+ resolution='D-Wave Leap could not be reached.',
102
+ steps=('Try again in a few minutes',
103
+ 'Check that the token is still valid and the endpoint is '
104
+ 'correct',
105
+ 'Check the service status on cloud.dwavesys.com'),
106
+ doc_slug=DOC_AUTH,
107
+ retryable=True,
108
+ retry_after=120),
109
+
110
+ # ------------------------------- dwave-cloud-client exceptions
111
+ # The solver is gone or no longer serving: the user has to pick another
112
+ # one, waiting and retrying will not help.
113
+ Rule(match=lambda e: _name(e) in {'SolverNotFoundError',
114
+ 'SolverOfflineError',
115
+ 'SolverAuthenticationError',
116
+ 'UnsupportedSolverError'},
117
+ category=ErrorCategory.PROVIDER_DEVICE,
118
+ code='DWAVE_SOLVER_UNAVAILABLE',
119
+ resolution='D-Wave reports the solver does not exist or is '
120
+ 'unavailable.',
121
+ steps=('Check the solver name in the function configuration',
122
+ 'The solver list changes over time -- check Leap'),
123
+ doc_slug=DOC_DEVICE,
124
+ retryable=False),
125
+
126
+ Rule(match=lambda e: _name(e) in {'RequestTimeout', 'PollingTimeout',
127
+ 'ProblemUploadError',
128
+ 'InvalidAPIResponseError'},
129
+ category=ErrorCategory.PROVIDER_UNAVAILABLE,
130
+ code='DWAVE_API_UNAVAILABLE',
131
+ resolution='The D-Wave Leap API could not be reached.',
132
+ steps=('Try again in a few minutes',
133
+ 'A very large problem can also time out during upload'),
134
+ retryable=True,
135
+ retry_after=120),
136
+ )
@@ -2,7 +2,7 @@
2
2
  # d_wave_device_factory.py
3
3
  # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
4
 
5
- from quapp_common.config.logging_config import job_logger
5
+ from quapp_common.config.logging_config import logger
6
6
  from quapp_common.enum.provider_tag import ProviderTag
7
7
  from quapp_common.enum.sdk import Sdk
8
8
  from quapp_common.factory.device_factory import DeviceFactory
@@ -14,7 +14,7 @@ from ..model.device.quapp_d_wave_device import QuappDWaveOceanDevice
14
14
  from ..model.provider.d_wave_system_provider import (system_devices,
15
15
  hybrid_devices)
16
16
 
17
- logger = job_logger(__name__)
17
+ logger = logger.bind(context='DWaveDeviceFactory')
18
18
 
19
19
 
20
20
  class DWaveDeviceFactory(DeviceFactory):
@@ -1,7 +1,7 @@
1
1
  # Quapp Platform Project
2
2
  # d_wave_provider_factory.py
3
3
  # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
- from quapp_common.config.logging_config import job_logger
4
+ from quapp_common.config.logging_config import logger
5
5
  from quapp_common.enum.provider_tag import ProviderTag
6
6
  from quapp_common.enum.sdk import Sdk
7
7
  from quapp_common.factory.provider_factory import ProviderFactory
@@ -9,7 +9,7 @@ from quapp_common.factory.provider_factory import ProviderFactory
9
9
  from ..model.provider.d_wave_system_provider import DWaveSystemProvider
10
10
  from ..model.provider.quapp_d_wave_provider import QuappDWaveProvider
11
11
 
12
- logger = job_logger(__name__)
12
+ logger = logger.bind(context='DWaveProviderFactory')
13
13
 
14
14
 
15
15
  class DWaveProviderFactory(ProviderFactory):
@@ -5,14 +5,14 @@
5
5
  from dwave.cloud import Client
6
6
  from dwave.system import DWaveSampler, AutoEmbeddingComposite
7
7
 
8
- from quapp_common.config.logging_config import job_logger
8
+ from quapp_common.config.logging_config import logger
9
9
  from quapp_common.enum.provider_tag import ProviderTag
10
10
  from quapp_common.model.provider.provider import Provider
11
11
 
12
12
  system_devices = ["Advantage_system4.1"]
13
13
  hybrid_devices = ["hybrid_binary_quadratic_model_version2"]
14
14
 
15
- logger = job_logger(__name__)
15
+ logger = logger.bind(context='DWaveSystemProvider')
16
16
 
17
17
 
18
18
  class DWaveSystemProvider(Provider):
@@ -4,11 +4,11 @@
4
4
 
5
5
  from dwave.samplers import SimulatedAnnealingSampler
6
6
 
7
- from quapp_common.config.logging_config import job_logger
7
+ from quapp_common.config.logging_config import logger
8
8
  from quapp_common.enum.provider_tag import ProviderTag
9
9
  from quapp_common.model.provider.provider import Provider
10
10
 
11
- logger = job_logger(__name__)
11
+ logger = logger.bind(context='QuappDWaveProvider')
12
12
 
13
13
 
14
14
  class QuappDWaveProvider(Provider):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-dwave-ocean
3
- Version: 0.0.1.dev3
3
+ Version: 0.0.1.dev4
4
4
  Summary: Quapp D-Wave Ocean library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -19,12 +19,13 @@ Classifier: Programming Language :: Python :: 3
19
19
  Requires-Python: >=3.7
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: quapp-common==0.0.11.dev7
22
+ Requires-Dist: quapp-common==0.0.14.dev10
23
23
  Requires-Dist: dwave-cloud-client==0.13.4
24
24
  Requires-Dist: dwave-system==1.30.0
25
25
  Requires-Dist: dwave-samplers==1.5.0
26
26
  Requires-Dist: dimod==0.12.20
27
27
  Requires-Dist: numpy==2.2.4
28
+ Requires-Dist: matplotlib
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: black; extra == "dev"
30
31
  Requires-Dist: bumpver; extra == "dev"
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  quapp_dwave_ocean/__init__.py
5
+ quapp_dwave_ocean/error_rules.py
5
6
  quapp_dwave_ocean.egg-info/PKG-INFO
6
7
  quapp_dwave_ocean.egg-info/SOURCES.txt
7
8
  quapp_dwave_ocean.egg-info/dependency_links.txt
@@ -28,4 +29,5 @@ quapp_dwave_ocean/model/device/d_wave_system_device.py
28
29
  quapp_dwave_ocean/model/device/quapp_d_wave_device.py
29
30
  quapp_dwave_ocean/model/provider/__init__.py
30
31
  quapp_dwave_ocean/model/provider/d_wave_system_provider.py
31
- quapp_dwave_ocean/model/provider/quapp_d_wave_provider.py
32
+ quapp_dwave_ocean/model/provider/quapp_d_wave_provider.py
33
+ tests/test_error_logging.py
@@ -1,9 +1,10 @@
1
- quapp-common==0.0.11.dev7
1
+ quapp-common==0.0.14.dev10
2
2
  dwave-cloud-client==0.13.4
3
3
  dwave-system==1.30.0
4
4
  dwave-samplers==1.5.0
5
5
  dimod==0.12.20
6
6
  numpy==2.2.4
7
+ matplotlib
7
8
 
8
9
  [dev]
9
10
  black
@@ -0,0 +1,304 @@
1
+ # Quapp Platform Project
2
+ # test_error_logging.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ """Locks four classes of error-logging bug, all found in real provider-lib code.
6
+
7
+ 1. **A copied hook lost its phase.** Some devices copied ``run_circuit`` /
8
+ ``_on_execution`` / ``_on_analysis`` wholesale from ``Device`` but dropped the
9
+ ``with phase(...)`` blocks, so that lib's log had no ``phase`` field at any
10
+ stage. Same class of bug as ``CustomDevice._on_analysis`` in quapp-common.
11
+
12
+ 2. **The error was flattened into a string.** ``job_response.job_result =
13
+ {"error": str(exception)}`` leaves the Job Log with a bare string: no
14
+ category, no phase, no rootCause -- exactly what the ticket complained about.
15
+
16
+ 3. **The error was logged twice.** ``logger.exception()`` immediately before
17
+ ``build_error_job_response`` emits a second ERROR line for the same failure,
18
+ skewing every metric that counts job_failed.
19
+
20
+ 4. **A fake job_id.** ``job_logger('SomeClass')`` -- the first argument of
21
+ ``job_logger()`` is the **job_id**. Passing a class name there sets a fake
22
+ job_id and it BEATS the real one (``_inject_job_context`` only fills keys that
23
+ are still None), so filtering by jobId in Grafana loses every line that
24
+ component logged.
25
+
26
+ The tests discover files inside the package, so this file works for any lib
27
+ without hardcoding names.
28
+ """
29
+
30
+ import ast
31
+ import io
32
+ import pathlib
33
+ import re
34
+ import tokenize
35
+
36
+ import pytest
37
+
38
+ REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
39
+
40
+ # The first release carrying a phase for analysis/post-processing and one traceId
41
+ # per job.
42
+ MINIMUM_COMMON = '0.0.14.dev6'
43
+
44
+ # Hooks already instrumented in quapp-common: copying one means copying its
45
+ # phases too. _on_execution creates the job and then talks to the provider, which
46
+ # is why it needs both -- a failed transpile and a rejected job are different
47
+ # errors.
48
+ INSTRUMENTED = {'_on_execution': {'COMPILATION', 'EXECUTION'},
49
+ '_on_analysis' : {'ANALYSIS'}}
50
+
51
+
52
+ def package_root() -> pathlib.Path:
53
+ """The provider lib's package directory, inferred from the tree.
54
+
55
+ A few repos hold more than one package -- quapp-hpc also has
56
+ ``quapp_slurm_hpc``, which only contains ``runner.py`` for the Slurm node and
57
+ is not the provider lib. Pick by structural evidence (it has ``model/`` or
58
+ ``factory/``) rather than by name.
59
+ """
60
+ candidates = [d for d in REPO_ROOT.iterdir()
61
+ if d.is_dir() and re.fullmatch(r'qu?app_\w+', d.name)
62
+ and (d / '__init__.py').is_file()]
63
+ assert candidates, 'found no package at all'
64
+
65
+ if len(candidates) > 1:
66
+ providers = [d for d in candidates
67
+ if (d / 'model').is_dir() or (d / 'factory').is_dir()]
68
+ assert len(providers) == 1, (
69
+ f'could not identify the provider package: '
70
+ f'{providers or candidates}')
71
+ return providers[0]
72
+
73
+ return candidates[0]
74
+
75
+
76
+ PACKAGE = package_root()
77
+
78
+
79
+ def code_only(path) -> str:
80
+ """Source with comments stripped out.
81
+
82
+ Needed because the explanatory comments *quote* the very code being banned
83
+ (``{"error": str(exception)}``, ``logger.exception(``). A raw scan would
84
+ report a false positive on the explanation itself.
85
+ """
86
+ source = path.read_text(encoding='utf-8')
87
+ kept = [token for token in
88
+ tokenize.generate_tokens(io.StringIO(source).readline)
89
+ if token.type != tokenize.COMMENT]
90
+ return tokenize.untokenize(kept)
91
+
92
+
93
+ def phases_opened_in(source: str) -> set:
94
+ """The RuntimePhases opened with ``with phase(...)`` in a piece of code."""
95
+ tree = ast.parse(source.lstrip())
96
+ return {
97
+ argument.attr
98
+ for node in ast.walk(tree) if isinstance(node, ast.With)
99
+ for item in node.items if isinstance(item.context_expr, ast.Call)
100
+ if (getattr(item.context_expr.func, 'id', None)
101
+ or getattr(item.context_expr.func, 'attr', None)) == 'phase'
102
+ for argument in item.context_expr.args
103
+ if isinstance(argument, ast.Attribute)
104
+ }
105
+
106
+
107
+ def python_files():
108
+ return sorted(PACKAGE.rglob('*.py'))
109
+
110
+
111
+ # ------------------------------------------- (1) a copied hook keeps its phases
112
+
113
+ def test_copied_hooks_keep_their_phases():
114
+ """Overriding an instrumented hook means copying its `with phase(...)` too."""
115
+ offenders = []
116
+ for path in python_files():
117
+ source = path.read_text(encoding='utf-8')
118
+ tree = ast.parse(source, filename=str(path))
119
+ for node in ast.walk(tree):
120
+ if not isinstance(node, ast.FunctionDef) \
121
+ or node.name not in INSTRUMENTED:
122
+ continue
123
+ opened = phases_opened_in(ast.get_source_segment(source, node))
124
+ missing = INSTRUMENTED[node.name] - opened
125
+ if missing:
126
+ offenders.append(
127
+ f'{path.name}::{node.name} is missing {sorted(missing)}')
128
+
129
+ assert not offenders, (
130
+ f'overridden hooks lost their phase: {offenders}. The original in '
131
+ f'quapp-common has `with phase(...)`; copying it without that leaves the '
132
+ f'log with no phase field.')
133
+
134
+
135
+ def test_at_least_one_phase_is_opened_somewhere():
136
+ """A lib that overrides no hook needs none; one that does needs a phase."""
137
+ overrides = any(
138
+ isinstance(node, ast.FunctionDef) and node.name in INSTRUMENTED
139
+ for path in python_files()
140
+ for node in ast.walk(ast.parse(path.read_text(encoding='utf-8')))
141
+ )
142
+ if not overrides:
143
+ pytest.skip('lib overrides no hook; phases come from quapp-common')
144
+
145
+ opened = set()
146
+ for path in python_files():
147
+ opened |= phases_opened_in(path.read_text(encoding='utf-8'))
148
+ assert opened, 'hooks are overridden but no phase is opened'
149
+
150
+
151
+ # --------------------------------- (2) no hand-rolled error string
152
+
153
+ @pytest.mark.parametrize('path', python_files(), ids=lambda p: p.name)
154
+ def test_no_hand_rolled_error_dict(path):
155
+ """`{"error": str(exception)}` bypasses the entire error envelope."""
156
+ assert '{"error": str(exception)}' not in code_only(path), (
157
+ f'{path.name}: flattens the error into a string, losing '
158
+ f'category/phase/rootCause. Use '
159
+ f'build_error_job_response(..., phase=..., context=...)')
160
+
161
+
162
+ def test_error_envelope_carries_phase_and_context():
163
+ """Every build_error_job_response in the device needs phase= and context=."""
164
+ for path in python_files():
165
+ source = code_only(path)
166
+ calls = source.count('build_error_job_response(')
167
+ if not calls:
168
+ continue
169
+ assert source.count('phase=RuntimePhase.') >= calls, (
170
+ f'{path.name}: has {calls} error envelopes but is missing phase=, '
171
+ f'so the ErrorReport would come out with phase=None')
172
+ assert source.count('context=self._error_context()') >= calls, (
173
+ f'{path.name}: error envelope is missing context=, leaving support '
174
+ f'without the device/provider')
175
+
176
+
177
+ # ------------------------------------------ (3) one error is logged once
178
+
179
+ def test_no_duplicate_error_log_before_envelope():
180
+ """logger.exception()/logger.error() right before the envelope = 2 ERRORs."""
181
+ offenders = []
182
+ for path in python_files():
183
+ source = code_only(path)
184
+ for match in re.finditer(r'logger\.(exception|error)\(', source):
185
+ if 'build_error_job_response(' in source[match.end():match.end() + 400]:
186
+ line = source[:match.start()].count('\n') + 1
187
+ offenders.append(f'{path.name}:{line}')
188
+
189
+ assert not offenders, (
190
+ f'duplicate ERROR log before build_error_job_response at {offenders}; '
191
+ f'the envelope already logs once')
192
+
193
+
194
+ # ------------------------------------------------- (4) no fake job_id
195
+
196
+ @pytest.mark.parametrize('path', python_files(), ids=lambda p: p.name)
197
+ def test_no_fake_job_logger_argument(path):
198
+ """`job_logger()` takes a real job_id; a component name belongs in `context`.
199
+
200
+ Two shapes of fake id are caught: a string literal (`job_logger('Provider')`)
201
+ and `__name__`, which quietly makes the job_id the dotted module path and
202
+ blows up jobId cardinality in Grafana as well as losing the real job.
203
+ """
204
+ tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path))
205
+
206
+ offenders = []
207
+ for node in ast.walk(tree):
208
+ if not isinstance(node, ast.Call) or not node.args:
209
+ continue
210
+ if (getattr(node.func, 'id', None)
211
+ or getattr(node.func, 'attr', None)) != 'job_logger':
212
+ continue
213
+ first = node.args[0]
214
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
215
+ offenders.append(repr(first.value))
216
+ elif isinstance(first, ast.Name) and first.id == '__name__':
217
+ offenders.append('__name__')
218
+
219
+ assert not offenders, (
220
+ f'{path.name}: job_logger() got {offenders} -- those are fake job_ids. '
221
+ f"Use logger.bind(context='...') for the component name")
222
+
223
+
224
+ def test_providers_bind_their_name_to_context():
225
+ """A provider that logs must bind its own name, so lines say where they came
226
+ from."""
227
+ providers = sorted((PACKAGE / 'model' / 'provider').glob('*.py'))
228
+ if not providers:
229
+ pytest.skip('lib has no model/provider directory')
230
+
231
+ offenders = []
232
+ for path in providers:
233
+ if path.name == '__init__.py':
234
+ continue
235
+ # code_only: several providers keep their old logging commented out, and
236
+ # a raw scan would flag those as if they still logged.
237
+ source = code_only(path)
238
+ if 'logger' not in source:
239
+ continue
240
+ if not re.search(r"logger\.bind\(context='[A-Za-z]+'\)", source):
241
+ offenders.append(path.name)
242
+
243
+ assert not offenders, (
244
+ f'providers that log without binding context: {offenders}. The component '
245
+ f'name must be a field, not text inside the message, or Grafana cannot '
246
+ f'filter on it')
247
+
248
+
249
+ # ------------------------------------------ rules + the quapp-common pin
250
+
251
+ def test_error_rules_module_exists():
252
+ assert (PACKAGE / 'error_rules.py').is_file(), \
253
+ "no error_rules.py, so this SDK's errors never get classified"
254
+
255
+
256
+ def test_error_rules_are_registered_on_import():
257
+ init = (PACKAGE / '__init__.py').read_text(encoding='utf-8')
258
+ assert 'error_rules' in init, \
259
+ '__init__.py does not import error_rules, so the rules never register'
260
+
261
+
262
+ def test_error_rules_imports_no_vendor_sdk():
263
+ """The classifier has to run even with the SDK absent, hence name matching."""
264
+ tree = ast.parse((PACKAGE / 'error_rules.py').read_text(encoding='utf-8'))
265
+
266
+ roots = set()
267
+ for node in ast.walk(tree):
268
+ if isinstance(node, ast.Import):
269
+ roots.update(alias.name.split('.')[0] for alias in node.names)
270
+ elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
271
+ roots.add(node.module.split('.')[0])
272
+
273
+ assert roots <= {'quapp_common'}, \
274
+ f'error_rules.py may only import quapp_common, but imports: {sorted(roots)}'
275
+
276
+
277
+ def test_common_pin_is_exact_and_current():
278
+ version = pytest.importorskip('packaging.version')
279
+
280
+ text = (REPO_ROOT / 'pyproject.toml').read_text(encoding='utf-8')
281
+ assert not re.findall(r'"quapp-common\s*(>=|>|~=)', text), (
282
+ 'quapp-common must be pinned with `==`: `>=` lets pip pick a higher '
283
+ 'stable release that is missing the feature (which already happened '
284
+ 'with quapp-qiskit 1.0.14)')
285
+
286
+ match = re.search(r'"quapp-common\s*==\s*([^"]+)"', text)
287
+ assert match, 'no quapp-common pin found'
288
+ assert version.Version(match.group(1)) >= version.Version(MINIMUM_COMMON), (
289
+ f'pin {match.group(1)} < {MINIMUM_COMMON}: that release has no phase for '
290
+ f'analysis/post-processing and still splits one job into two traceIds')
291
+
292
+
293
+ def test_registered_rules_are_reachable():
294
+ """Runs for real: import the lib and read the registry. Skips without the SDK."""
295
+ package_name = PACKAGE.name
296
+ pytest.importorskip(package_name)
297
+ __import__(package_name)
298
+ from quapp_common.util.error_classifier import registered_rules
299
+
300
+ codes = {rule.code for rule in registered_rules()}
301
+ own = {code for code in codes if not code.startswith(
302
+ ('INPUT_', 'DEPENDENCY_', 'PROVIDER_', 'HANDLER_', 'PLATFORM_',
303
+ 'COMPILATION_', 'EXECUTION_', 'ANALYSIS_', 'POST_'))}
304
+ assert own, f'no lib-specific rule registered, only generic ones: {sorted(codes)}'
@@ -1,3 +0,0 @@
1
- __version__ = "0.4.9"
2
-
3
- from .factory.d_wave_handler_factory import DWaveHandlerFactory