localqpu 0.1.0__py3-none-any.whl
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.
- localqpu/__init__.py +10 -0
- localqpu/_compat.py +53 -0
- localqpu/app.py +52 -0
- localqpu/cli.py +158 -0
- localqpu/client.py +57 -0
- localqpu/constants.py +39 -0
- localqpu/context.py +34 -0
- localqpu/control.py +84 -0
- localqpu/control_client.py +58 -0
- localqpu/http_util.py +39 -0
- localqpu/ibm/__init__.py +1 -0
- localqpu/ibm/backends.py +100 -0
- localqpu/ibm/routes.py +274 -0
- localqpu/jobs.py +272 -0
- localqpu/programs/__init__.py +31 -0
- localqpu/programs/base.py +47 -0
- localqpu/programs/executor.py +95 -0
- localqpu/programs/sampler.py +63 -0
- localqpu/pytest_plugin.py +44 -0
- localqpu/scenario.py +351 -0
- localqpu/server.py +290 -0
- localqpu/simulation.py +139 -0
- localqpu-0.1.0.dist-info/METADATA +117 -0
- localqpu-0.1.0.dist-info/RECORD +27 -0
- localqpu-0.1.0.dist-info/WHEEL +4 -0
- localqpu-0.1.0.dist-info/entry_points.txt +5 -0
- localqpu-0.1.0.dist-info/licenses/LICENSE +202 -0
localqpu/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""localqpu: IBM Quantum Platform API를 로컬에서 흉내 내는 테스트용 에뮬레이터."""
|
|
2
|
+
|
|
3
|
+
#: 패키지 버전. pyproject.toml의 version과 같아야 한다.
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
from localqpu.app import start_server # noqa: E402
|
|
7
|
+
from localqpu.client import connect # noqa: E402
|
|
8
|
+
from localqpu.context import ServerConfig # noqa: E402
|
|
9
|
+
|
|
10
|
+
__all__ = ["ServerConfig", "__version__", "connect", "start_server"]
|
localqpu/_compat.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""qiskit-ibm-runtime 내부 모듈 의존을 한곳에 모은다.
|
|
2
|
+
|
|
3
|
+
localqpu는 클라이언트와 똑같이 인코딩·디코딩하려고 공개 API가 아닌 내부 모듈을 쓴다.
|
|
4
|
+
SDK가 바뀌면 이 파일만 고치면 되도록, 다른 모듈은 이 심볼들을 여기서만 가져온다(설계서 9절).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import qiskit_ibm_runtime.fake_provider as _fake_provider_package
|
|
13
|
+
from ibm_quantum_schemas.common.tensor import CompressedTensorModel
|
|
14
|
+
from ibm_quantum_schemas.executor.version_2_0.models import (
|
|
15
|
+
ItemMetadataModel,
|
|
16
|
+
MetadataModel,
|
|
17
|
+
QuantumProgramResultItemModel,
|
|
18
|
+
QuantumProgramResultModel,
|
|
19
|
+
)
|
|
20
|
+
from ibm_quantum_schemas.executor.version_2_0.models import ParamsModel as ExecutorParamsModel
|
|
21
|
+
from qiskit_ibm_runtime.fake_provider.executor.run_quantum_program import run_quantum_program
|
|
22
|
+
from qiskit_ibm_runtime.json import RuntimeDecoder, RuntimeEncoder
|
|
23
|
+
from qiskit_ibm_runtime.options_models.simulator import SimulatorOptions
|
|
24
|
+
from qiskit_ibm_runtime.quantum_program.converters.converters_2_0 import (
|
|
25
|
+
passthrough_data_to_2_0,
|
|
26
|
+
quantum_program_from_2_0,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: qiskit-ibm-runtime가 함께 배포하는 실제 칩 스냅샷(conf_*.json, props_*.json) 폴더.
|
|
30
|
+
FAKE_PROVIDER_BACKENDS_DIR: Path = Path(_fake_provider_package.__file__).parent / "backends"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def semantic_role_of(program: Any) -> str | None:
|
|
34
|
+
"""QuantumProgram의 semantic_role. 클라이언트가 비공개 속성에 두므로 여기서만 읽는다."""
|
|
35
|
+
return getattr(program, "_semantic_role", None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"FAKE_PROVIDER_BACKENDS_DIR",
|
|
40
|
+
"CompressedTensorModel",
|
|
41
|
+
"ExecutorParamsModel",
|
|
42
|
+
"ItemMetadataModel",
|
|
43
|
+
"MetadataModel",
|
|
44
|
+
"QuantumProgramResultItemModel",
|
|
45
|
+
"QuantumProgramResultModel",
|
|
46
|
+
"RuntimeDecoder",
|
|
47
|
+
"RuntimeEncoder",
|
|
48
|
+
"SimulatorOptions",
|
|
49
|
+
"passthrough_data_to_2_0",
|
|
50
|
+
"quantum_program_from_2_0",
|
|
51
|
+
"run_quantum_program",
|
|
52
|
+
"semantic_role_of",
|
|
53
|
+
]
|
localqpu/app.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""구성 요소를 조립해 서버를 띄운다."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from localqpu.context import AppContext, ServerConfig
|
|
6
|
+
from localqpu.control import register_control_routes
|
|
7
|
+
from localqpu.ibm.backends import BackendCatalog
|
|
8
|
+
from localqpu.ibm.routes import register_ibm_routes
|
|
9
|
+
from localqpu.jobs import JobManager
|
|
10
|
+
from localqpu.scenario import ScenarioState, ensure_known_backends
|
|
11
|
+
from localqpu.server import Router, RunningServer, ServerStats, serve
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_context(config: ServerConfig) -> AppContext:
|
|
15
|
+
"""설정으로 카탈로그·시나리오·작업 관리자·통계를 만든다.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
BackendCatalogError: 없는 칩을 지정했을 때.
|
|
19
|
+
ScenarioError: 시나리오가 카탈로그에 없는 백엔드를 가리킬 때.
|
|
20
|
+
"""
|
|
21
|
+
catalog = BackendCatalog(config.backends)
|
|
22
|
+
ensure_known_backends(config.scenario, catalog.names)
|
|
23
|
+
scenario_state = ScenarioState(config.scenario)
|
|
24
|
+
return AppContext(
|
|
25
|
+
config=config,
|
|
26
|
+
catalog=catalog,
|
|
27
|
+
scenario_state=scenario_state,
|
|
28
|
+
jobs=JobManager(scenario_state, config.max_sim_qubits),
|
|
29
|
+
stats=ServerStats(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_router(context: AppContext) -> Router:
|
|
34
|
+
"""모든 라우트를 등록한 라우터."""
|
|
35
|
+
router = Router()
|
|
36
|
+
register_ibm_routes(router, context)
|
|
37
|
+
register_control_routes(router, context)
|
|
38
|
+
return router
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start_server(config: ServerConfig | None = None) -> RunningServer:
|
|
42
|
+
"""localqpu 서버를 백그라운드로 띄운다. 멈출 때 작업 스레드 풀도 정리한다."""
|
|
43
|
+
context = build_context(config or ServerConfig())
|
|
44
|
+
server = serve(
|
|
45
|
+
build_router(context),
|
|
46
|
+
context.stats,
|
|
47
|
+
context.config.host,
|
|
48
|
+
context.config.port,
|
|
49
|
+
context.config.is_verbose,
|
|
50
|
+
)
|
|
51
|
+
server.on_stop = context.jobs.shutdown
|
|
52
|
+
return server
|
localqpu/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""명령줄 진입점: localqpu start / localqpu backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from localqpu import __version__
|
|
13
|
+
from localqpu.app import start_server
|
|
14
|
+
from localqpu.constants import DEFAULT_BACKENDS, DEFAULT_HOST, DEFAULT_MAX_SIM_QUBITS, DEFAULT_PORT
|
|
15
|
+
from localqpu.context import ServerConfig
|
|
16
|
+
from localqpu.ibm.backends import BackendCatalogError, available_backend_names
|
|
17
|
+
from localqpu.scenario import Scenario, ScenarioError, parse_scenario
|
|
18
|
+
from localqpu.server import RunningServer
|
|
19
|
+
|
|
20
|
+
#: 모듈 로거.
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
"""명령줄 파서."""
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="localqpu", description="IBM Quantum Platform API 로컬 에뮬레이터"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"localqpu {__version__}")
|
|
30
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
start = commands.add_parser("start", help="에뮬레이터 서버를 실행한다")
|
|
32
|
+
start.add_argument(
|
|
33
|
+
"--host",
|
|
34
|
+
default=DEFAULT_HOST,
|
|
35
|
+
help=f"바인딩 주소(기본 {DEFAULT_HOST}). 외부 노출은 명시할 때만",
|
|
36
|
+
)
|
|
37
|
+
start.add_argument(
|
|
38
|
+
"--port",
|
|
39
|
+
type=_port_number,
|
|
40
|
+
default=DEFAULT_PORT,
|
|
41
|
+
help=f"포트(기본 {DEFAULT_PORT}, 0이면 빈 포트)",
|
|
42
|
+
)
|
|
43
|
+
start.add_argument(
|
|
44
|
+
"--backends", default=",".join(DEFAULT_BACKENDS), help="노출할 칩, 쉼표 구분"
|
|
45
|
+
)
|
|
46
|
+
start.add_argument(
|
|
47
|
+
"--max-sim-qubits",
|
|
48
|
+
type=_non_negative_int,
|
|
49
|
+
default=DEFAULT_MAX_SIM_QUBITS,
|
|
50
|
+
help="정확 시뮬레이션 최대 활성 큐비트",
|
|
51
|
+
)
|
|
52
|
+
start.add_argument("--scenario", type=Path, help="장애 시나리오 JSON 파일")
|
|
53
|
+
start.add_argument("--verbose", action="store_true", help="요청 본문 일부도 로그로 남긴다")
|
|
54
|
+
commands.add_parser("backends", help="사용 가능한 칩 스냅샷 이름을 출력한다")
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _port_number(raw: str) -> int:
|
|
59
|
+
"""0~65535 범위의 포트 번호. 범위를 벗어나면 bind 단계의 OverflowError traceback 대신 인자 오류로 알린다."""
|
|
60
|
+
value = _integer(raw)
|
|
61
|
+
if not 0 <= value <= 65535:
|
|
62
|
+
raise argparse.ArgumentTypeError(f"포트는 0~65535 범위여야 합니다(0이면 빈 포트): {value}")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _non_negative_int(raw: str) -> int:
|
|
67
|
+
"""0 이상의 정수."""
|
|
68
|
+
value = _integer(raw)
|
|
69
|
+
if value < 0:
|
|
70
|
+
raise argparse.ArgumentTypeError(f"0 이상 범위의 정수여야 합니다: {value}")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _integer(raw: str) -> int:
|
|
75
|
+
"""정수 문자열을 읽는다."""
|
|
76
|
+
try:
|
|
77
|
+
return int(raw)
|
|
78
|
+
except ValueError as error:
|
|
79
|
+
raise argparse.ArgumentTypeError(f"정수 범위의 값이어야 합니다: {raw!r}") from error
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def config_from_args(args: argparse.Namespace) -> ServerConfig:
|
|
83
|
+
"""start 옵션을 ServerConfig로 바꾼다.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ScenarioError: 시나리오 내용이 잘못됐을 때.
|
|
87
|
+
OSError: 시나리오 파일을 읽지 못했을 때.
|
|
88
|
+
"""
|
|
89
|
+
backends = tuple(name.strip() for name in args.backends.split(",") if name.strip())
|
|
90
|
+
return ServerConfig(
|
|
91
|
+
host=args.host,
|
|
92
|
+
port=args.port,
|
|
93
|
+
backends=backends,
|
|
94
|
+
max_sim_qubits=args.max_sim_qubits,
|
|
95
|
+
scenario=_load_scenario(args.scenario),
|
|
96
|
+
is_verbose=args.verbose,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
101
|
+
"""명령을 실행하고 종료 코드를 돌려준다(0 정상, 2 설정 오류)."""
|
|
102
|
+
args = build_parser().parse_args(argv)
|
|
103
|
+
if args.command == "backends":
|
|
104
|
+
print("\n".join(available_backend_names()))
|
|
105
|
+
return 0
|
|
106
|
+
logging.basicConfig(
|
|
107
|
+
level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(message)s"
|
|
108
|
+
)
|
|
109
|
+
server = _start_or_report(args)
|
|
110
|
+
if server is None:
|
|
111
|
+
return 2
|
|
112
|
+
print(_banner(server), flush=True) # 출력을 파일로 돌리고 SIGTERM으로 끝내도 배너가 남도록
|
|
113
|
+
_serve_until_interrupted(server)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_scenario(path: Path | None) -> Scenario:
|
|
118
|
+
"""시나리오 파일을 읽는다. 없으면 기본 시나리오."""
|
|
119
|
+
if path is None:
|
|
120
|
+
return Scenario()
|
|
121
|
+
try:
|
|
122
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
123
|
+
except json.JSONDecodeError as error:
|
|
124
|
+
raise ScenarioError(f"{path}가 올바른 JSON이 아닙니다: {error}") from error
|
|
125
|
+
return parse_scenario(raw)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _start_or_report(args: argparse.Namespace) -> RunningServer | None:
|
|
129
|
+
"""서버를 띄운다. 설정 오류는 원인과 해결 방향을 stderr에 쓰고 None을 돌려준다."""
|
|
130
|
+
try:
|
|
131
|
+
return start_server(config_from_args(args))
|
|
132
|
+
except (ScenarioError, BackendCatalogError) as error:
|
|
133
|
+
print(f"localqpu: {error}", file=sys.stderr)
|
|
134
|
+
except OSError as error:
|
|
135
|
+
print(
|
|
136
|
+
f"localqpu: 서버를 시작하지 못했습니다({error}). 시나리오 파일 경로를 확인하거나, 포트가 사용 중이면 --port로 다른 포트를 지정하세요.",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _serve_until_interrupted(server: RunningServer) -> None:
|
|
143
|
+
"""Ctrl+C까지 기다렸다가 서버를 멈춘다."""
|
|
144
|
+
try:
|
|
145
|
+
server.wait()
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
print("\nlocalqpu를 종료합니다.")
|
|
148
|
+
finally:
|
|
149
|
+
server.stop()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _banner(server: RunningServer) -> str:
|
|
153
|
+
"""시작 안내문."""
|
|
154
|
+
return (
|
|
155
|
+
f"localqpu {__version__} 실행 중: {server.url}\n"
|
|
156
|
+
f" Python에서 연결: import localqpu; service = localqpu.connect(port={server.port})\n"
|
|
157
|
+
" 종료: Ctrl+C"
|
|
158
|
+
)
|
localqpu/client.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""localqpu에 연결된 QiskitRuntimeService를 만든다(설계서 5.9절)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from qiskit_ibm_runtime import QiskitRuntimeService
|
|
8
|
+
|
|
9
|
+
from localqpu.constants import DEFAULT_HOST, DEFAULT_PORT, FAKE_CLOUD_HOST, LOCAL_INSTANCE_CRN
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def connect(port: int = DEFAULT_PORT, host: str = DEFAULT_HOST) -> QiskitRuntimeService:
|
|
13
|
+
"""localqpu를 가리키는 QiskitRuntimeService를 만든다.
|
|
14
|
+
|
|
15
|
+
주의: 런타임 인증 주소는 클라이언트 코드에 iam.cloud.ibm.com으로 고정돼 있고
|
|
16
|
+
IAM_URL 환경변수로만 바뀐다. 이 값을 설정하지 않으면 클라이언트가 실제 IBM으로
|
|
17
|
+
키를 보낸다. 그래서 이 함수는 프로세스 환경변수 IAM_URL과 NO_PROXY를 설정한다
|
|
18
|
+
(프로세스 전체에 영향). http·https 프록시를 모두 localqpu로 지정해, 설정이 새더라도
|
|
19
|
+
외부로 직접 나가지 않고 localqpu에서 CONNECT로 막히게 한다.
|
|
20
|
+
"""
|
|
21
|
+
proxy_url = f"http://{host}:{port}"
|
|
22
|
+
os.environ["IAM_URL"] = f"http://iam.{FAKE_CLOUD_HOST}"
|
|
23
|
+
_bypass_environment_proxies(FAKE_CLOUD_HOST)
|
|
24
|
+
return QiskitRuntimeService(
|
|
25
|
+
channel="ibm_quantum_platform",
|
|
26
|
+
token="localqpu",
|
|
27
|
+
instance=LOCAL_INSTANCE_CRN,
|
|
28
|
+
url=f"http://{FAKE_CLOUD_HOST}",
|
|
29
|
+
url_resolver=_resolve_runtime_url,
|
|
30
|
+
proxies={"urls": {"http": proxy_url, "https": proxy_url}},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bypass_environment_proxies(host: str) -> None:
|
|
35
|
+
"""NO_PROXY·no_proxy에 가짜 호스트를 더해, 환경 프록시가 localqpu 대신 쓰이지 않게 한다.
|
|
36
|
+
|
|
37
|
+
Qiskit은 프록시를 세션(session.proxies)에만 두는데, requests는 환경 프록시(HTTP_PROXY 등)를
|
|
38
|
+
세션 프록시보다 먼저 적용한다(Session.merge_environment_settings). 이것이 없으면 회사 프록시가
|
|
39
|
+
설정된 환경에서 요청이 localqpu가 아니라 외부 프록시로 나간다. requests는 NO_PROXY 항목을
|
|
40
|
+
접미사로 비교하므로 iam.localqpu.test 같은 하위 호스트도 함께 제외된다.
|
|
41
|
+
"""
|
|
42
|
+
for variable in ("NO_PROXY", "no_proxy"):
|
|
43
|
+
entries = [
|
|
44
|
+
entry.strip() for entry in os.environ.get(variable, "").split(",") if entry.strip()
|
|
45
|
+
]
|
|
46
|
+
if host not in entries:
|
|
47
|
+
os.environ[variable] = ",".join([*entries, host])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_runtime_url(
|
|
51
|
+
url: str,
|
|
52
|
+
instance: str,
|
|
53
|
+
private_endpoint: bool | None = None,
|
|
54
|
+
channel: str = "ibm_quantum_platform",
|
|
55
|
+
) -> str:
|
|
56
|
+
"""런타임 API 기본 URL. 클라이언트의 url_resolver 규약(인자 4개)을 따른다."""
|
|
57
|
+
return f"http://{FAKE_CLOUD_HOST}/api/v1"
|
localqpu/constants.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""localqpu 전역 상수.
|
|
2
|
+
|
|
3
|
+
클라이언트 설정(connect)과 서버 응답이 같은 값을 써야 하므로 한곳에 모은다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
#: localqpu가 흉내 내는 유일한 인스턴스의 CRN. 클라이언트의 is_crn 검사를 통과하는 형식이다.
|
|
9
|
+
LOCAL_INSTANCE_CRN: str = "crn:v1:bluemix:public:quantum-computing:us-east:a/localqpu::localqpu"
|
|
10
|
+
|
|
11
|
+
#: 글로벌 카탈로그 플랜 ID. 런타임 경로(/api/v1/backends 등)와 겹치지 않는 이름이라 경로만으로 라우팅할 수 있다.
|
|
12
|
+
LOCAL_PLAN_ID: str = "localqpu-plan"
|
|
13
|
+
|
|
14
|
+
#: 프록시 모드에서 클라이언트에 알려 주는 가짜 클라우드 호스트. 실제로 해석될 일이 없는 .test 도메인이다.
|
|
15
|
+
FAKE_CLOUD_HOST: str = "localqpu.test"
|
|
16
|
+
|
|
17
|
+
#: 기본 바인딩 주소. 외부 노출을 막기 위해 루프백만 쓴다.
|
|
18
|
+
DEFAULT_HOST: str = "127.0.0.1"
|
|
19
|
+
|
|
20
|
+
#: 기본 포트.
|
|
21
|
+
DEFAULT_PORT: int = 8787
|
|
22
|
+
|
|
23
|
+
#: 기본으로 노출하는 칩 스냅샷 이름.
|
|
24
|
+
DEFAULT_BACKENDS: tuple[str, ...] = ("ibm_brisbane",)
|
|
25
|
+
|
|
26
|
+
#: 정확 시뮬레이션을 허용하는 최대 활성 큐비트 수. 넘으면 sampler는 stub, executor는 실패한다.
|
|
27
|
+
DEFAULT_MAX_SIM_QUBITS: int = 24
|
|
28
|
+
|
|
29
|
+
#: localqpu 자체 사유(입력 해석 실패, 한도 초과 등)로 작업이 실패했을 때 쓰는 사유 코드.
|
|
30
|
+
LOCALQPU_ERROR_CODE: int = 9000
|
|
31
|
+
|
|
32
|
+
#: 사용자가 localqpu에서 작업을 취소했을 때 쓰는 사유 코드. 클라이언트가 시간 초과로 해석하는 1305와 겹치지 않게 한다.
|
|
33
|
+
LOCALQPU_CANCEL_CODE: int = 9001
|
|
34
|
+
|
|
35
|
+
#: 기존 SamplerV2에서 shots가 지정되지 않았을 때 쓰는 기본값. IBM SamplerV2 기본값과 같다.
|
|
36
|
+
DEFAULT_SAMPLER_SHOTS: int = 4096
|
|
37
|
+
|
|
38
|
+
#: 발급하는 가짜 IAM 토큰의 수명(초).
|
|
39
|
+
TOKEN_LIFETIME_SECONDS: int = 3600
|
localqpu/context.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""서버 설정과 구성 요소 묶음. 라우트 모듈이 app 모듈을 import하지 않도록 따로 둔다."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from localqpu.constants import DEFAULT_BACKENDS, DEFAULT_HOST, DEFAULT_MAX_SIM_QUBITS, DEFAULT_PORT
|
|
8
|
+
from localqpu.ibm.backends import BackendCatalog
|
|
9
|
+
from localqpu.jobs import JobManager
|
|
10
|
+
from localqpu.scenario import Scenario, ScenarioState
|
|
11
|
+
from localqpu.server import ServerStats
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ServerConfig:
|
|
16
|
+
"""서버 실행 설정. CLI 옵션과 1:1로 대응한다."""
|
|
17
|
+
|
|
18
|
+
host: str = DEFAULT_HOST
|
|
19
|
+
port: int = DEFAULT_PORT
|
|
20
|
+
backends: tuple[str, ...] = DEFAULT_BACKENDS
|
|
21
|
+
max_sim_qubits: int = DEFAULT_MAX_SIM_QUBITS
|
|
22
|
+
scenario: Scenario = field(default_factory=Scenario)
|
|
23
|
+
is_verbose: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AppContext:
|
|
28
|
+
"""라우트 핸들러가 공유하는 구성 요소."""
|
|
29
|
+
|
|
30
|
+
config: ServerConfig
|
|
31
|
+
catalog: BackendCatalog
|
|
32
|
+
scenario_state: ScenarioState
|
|
33
|
+
jobs: JobManager
|
|
34
|
+
stats: ServerStats
|
localqpu/control.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""/_localqpu 제어 API. 테스트 코드가 서버 상태를 바꾸고 검사하는 용도다(설계서 5.8절)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from localqpu import __version__
|
|
9
|
+
from localqpu.context import AppContext
|
|
10
|
+
from localqpu.jobs import JobRecord
|
|
11
|
+
from localqpu.scenario import (
|
|
12
|
+
ScenarioError,
|
|
13
|
+
ensure_known_backends,
|
|
14
|
+
parse_scenario,
|
|
15
|
+
scenario_to_json,
|
|
16
|
+
)
|
|
17
|
+
from localqpu.server import Request, Response, Router, error_response
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_control_routes(router: Router, context: AppContext) -> None:
|
|
21
|
+
"""제어 라우트를 등록한다."""
|
|
22
|
+
routes = [
|
|
23
|
+
("GET", "/_localqpu/health", get_health),
|
|
24
|
+
("GET", "/_localqpu/scenario", get_scenario),
|
|
25
|
+
("PUT", "/_localqpu/scenario", put_scenario),
|
|
26
|
+
("POST", "/_localqpu/reset", reset_state),
|
|
27
|
+
("GET", "/_localqpu/jobs", list_jobs),
|
|
28
|
+
]
|
|
29
|
+
for method, pattern, handler in routes:
|
|
30
|
+
router.add(method, pattern, partial(handler, context))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_health(context: AppContext, request: Request, params: dict[str, str]) -> Response:
|
|
34
|
+
"""상태 확인. 막힌 CONNECT 수로 외부 유출 시도를 확인할 수 있다."""
|
|
35
|
+
body = {
|
|
36
|
+
"status": "ok",
|
|
37
|
+
"version": __version__,
|
|
38
|
+
"backends": context.catalog.names,
|
|
39
|
+
"blocked_connect_requests": context.stats.blocked_connect_requests,
|
|
40
|
+
}
|
|
41
|
+
return Response(200, body)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_scenario(context: AppContext, request: Request, params: dict[str, str]) -> Response:
|
|
45
|
+
"""현재 시나리오(소비하지 않은 next_jobs 포함)."""
|
|
46
|
+
return Response(200, scenario_to_json(context.scenario_state.current()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def put_scenario(context: AppContext, request: Request, params: dict[str, str]) -> Response:
|
|
50
|
+
"""시나리오를 교체한다. 잘못된 시나리오는 400이고 기존 시나리오를 유지한다."""
|
|
51
|
+
try:
|
|
52
|
+
scenario = parse_scenario(request.json_body())
|
|
53
|
+
ensure_known_backends(scenario, context.catalog.names)
|
|
54
|
+
except ScenarioError as error:
|
|
55
|
+
return error_response(400, str(error))
|
|
56
|
+
context.scenario_state.set_scenario(scenario)
|
|
57
|
+
return Response(200, scenario_to_json(scenario))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reset_state(context: AppContext, request: Request, params: dict[str, str]) -> Response:
|
|
61
|
+
"""작업 기록, 시나리오(시작 시점으로), 통계를 초기화한다. 작업·시나리오는 한 잠금에서 함께 되돌린다."""
|
|
62
|
+
context.jobs.reset()
|
|
63
|
+
context.stats.reset()
|
|
64
|
+
return Response(204)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_jobs(context: AppContext, request: Request, params: dict[str, str]) -> Response:
|
|
68
|
+
"""제출된 작업 요약. 테스트 단언용이며 상태를 진행시키지 않는다."""
|
|
69
|
+
return Response(200, {"jobs": [_job_summary(job) for job in context.jobs.list_jobs()]})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _job_summary(job: JobRecord) -> dict[str, Any]:
|
|
73
|
+
"""작업 하나의 요약."""
|
|
74
|
+
return {
|
|
75
|
+
"id": job.job_id,
|
|
76
|
+
"program_id": job.program_id,
|
|
77
|
+
"backend": job.backend_name,
|
|
78
|
+
"status": job.status,
|
|
79
|
+
"reason": job.reason,
|
|
80
|
+
"reason_code": job.reason_code,
|
|
81
|
+
"is_stub": job.is_stub,
|
|
82
|
+
"active_qubits": job.active_qubits,
|
|
83
|
+
"polls": job.polls,
|
|
84
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""제어 API 클라이언트. 프록시를 거치지 않고 localqpu에 바로 요청한다."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from localqpu.http_util import send_direct
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LocalqpuControlError(RuntimeError):
|
|
12
|
+
"""제어 API가 기대와 다른 상태 코드를 돌려줬을 때. 메시지에 서버 사유가 들어 있다."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocalqpuControl:
|
|
16
|
+
"""테스트 코드에서 시나리오를 바꾸고 작업을 검사하는 얇은 클라이언트."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_url: str) -> None:
|
|
19
|
+
"""서버 기본 URL(예: http://127.0.0.1:8787)을 받는다."""
|
|
20
|
+
self._base_url = base_url.rstrip("/")
|
|
21
|
+
|
|
22
|
+
def health(self) -> dict[str, Any]:
|
|
23
|
+
"""서버 상태."""
|
|
24
|
+
return dict(self._call("GET", "/_localqpu/health"))
|
|
25
|
+
|
|
26
|
+
def scenario(self) -> dict[str, Any]:
|
|
27
|
+
"""현재 시나리오."""
|
|
28
|
+
return dict(self._call("GET", "/_localqpu/scenario"))
|
|
29
|
+
|
|
30
|
+
def set_scenario(self, scenario: Mapping[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""시나리오를 교체한다.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
LocalqpuControlError: 시나리오가 잘못됐을 때. 메시지에 문제 위치가 있다.
|
|
35
|
+
"""
|
|
36
|
+
return dict(self._call("PUT", "/_localqpu/scenario", dict(scenario)))
|
|
37
|
+
|
|
38
|
+
def reset(self) -> None:
|
|
39
|
+
"""작업·시나리오·통계를 초기화한다."""
|
|
40
|
+
self._call("POST", "/_localqpu/reset", expected_status=204)
|
|
41
|
+
|
|
42
|
+
def jobs(self) -> list[dict[str, Any]]:
|
|
43
|
+
"""제출된 작업 요약 목록."""
|
|
44
|
+
return list(self._call("GET", "/_localqpu/jobs")["jobs"])
|
|
45
|
+
|
|
46
|
+
def _call(self, method: str, path: str, payload: Any = None, expected_status: int = 200) -> Any:
|
|
47
|
+
"""요청을 보내고 상태 코드를 확인한다."""
|
|
48
|
+
status, body = send_direct(method, f"{self._base_url}{path}", payload)
|
|
49
|
+
if status != expected_status:
|
|
50
|
+
raise LocalqpuControlError(f"{method} {path} → {status}: {_error_text(body)}")
|
|
51
|
+
return body
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _error_text(body: Any) -> str:
|
|
55
|
+
"""오류 본문에서 사람이 읽을 메시지를 꺼낸다."""
|
|
56
|
+
if isinstance(body, dict) and body.get("errors"):
|
|
57
|
+
return "; ".join(str(error.get("message")) for error in body["errors"])
|
|
58
|
+
return str(body)
|
localqpu/http_util.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""프록시를 거치지 않는 직접 HTTP 호출.
|
|
2
|
+
|
|
3
|
+
제어 API와 테스트는 환경변수 프록시 설정(HTTP_PROXY 등)과 무관하게 localqpu에 바로 닿아야 한다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
#: 환경변수 프록시를 무시하는 opener.
|
|
14
|
+
_DIRECT_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def send_direct(
|
|
18
|
+
method: str, url: str, payload: Any = None, timeout: float = 10.0
|
|
19
|
+
) -> tuple[int, Any]:
|
|
20
|
+
"""JSON 요청을 보내고 (상태 코드, 본문)을 돌려준다. 본문은 JSON 객체·문자열·None. 4xx·5xx도 예외 없이 돌려준다."""
|
|
21
|
+
data = None if payload is None else json.dumps(payload).encode()
|
|
22
|
+
request = urllib.request.Request(
|
|
23
|
+
url, data=data, method=method, headers={"Content-Type": "application/json"}
|
|
24
|
+
)
|
|
25
|
+
try:
|
|
26
|
+
with _DIRECT_OPENER.open(request, timeout=timeout) as response:
|
|
27
|
+
return response.status, _decode_body(response.read())
|
|
28
|
+
except urllib.error.HTTPError as error:
|
|
29
|
+
return error.code, _decode_body(error.read())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _decode_body(raw: bytes) -> Any:
|
|
33
|
+
"""빈 본문은 None, JSON이면 객체, 아니면 문자열(실패 작업의 결과처럼 평문인 응답)."""
|
|
34
|
+
if not raw:
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(raw)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
return raw.decode(errors="replace")
|
localqpu/ibm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IBM Quantum Platform API 흉내."""
|