lqcloud 0.4.2__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.
lqcloud/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """LQCloud SDK — Cloud Quantum Computing toolkit.
2
+
3
+ All commonly-used classes and functions are available at the top level::
4
+
5
+ from lqcloud import LQCloudProvider, QuantumCircuit, save_account, plot_histogram
6
+ """
7
+
8
+ from .provider import LQCloudProvider
9
+ from .circuit.circuit import QuantumCircuit
10
+ from .circuit.quantum_register import QuantumRegister
11
+ from .circuit.classical_register import ClassicalRegister
12
+ from .circuit.pauli_op import PauliOp
13
+ from .circuit.parameter import Parameter
14
+ from .auth.config import save_account, load_account
15
+ from .visualization import plot_histogram
16
+ from .job.status import JobStatus
17
+ from .job.result import BatchResult, Result
18
+ from .exceptions import (
19
+ LQCloudError,
20
+ AuthenticationError,
21
+ BackendNotFoundError,
22
+ CircuitError,
23
+ JobError,
24
+ JobTimeoutError,
25
+ )
26
+
27
+ # 版本号统一从已安装包的元数据读取,单一来源在 ``pyproject.toml``,
28
+ # 避免出现 wheel 已升到新版本但这里硬编码字符串还停留在旧版本的情况。
29
+ try: # pragma: no cover - 标准库导入兜底
30
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
31
+ except ImportError: # Python <3.8 理论上不会走到,仅作保险
32
+ from importlib_metadata import ( # type: ignore[no-redef]
33
+ PackageNotFoundError,
34
+ version as _pkg_version,
35
+ )
36
+
37
+ try:
38
+ __version__ = _pkg_version("lqcloud")
39
+ except PackageNotFoundError:
40
+ # 直接从源码目录运行(未通过 pip 安装)时的兜底值,保证 ``import lqcloud``
41
+ # 不会因为读不到元数据而失败。
42
+ __version__ = "0.0.0+local"
43
+
44
+ del _pkg_version, PackageNotFoundError
45
+
46
+
47
+
48
+ __all__ = [
49
+ # Provider / Backend
50
+ "LQCloudProvider",
51
+ # Circuit construction
52
+ "QuantumCircuit",
53
+ "QuantumRegister",
54
+ "ClassicalRegister",
55
+ "PauliOp",
56
+ "Parameter",
57
+ # Authentication
58
+ "save_account",
59
+ "load_account",
60
+ # Visualization
61
+ "plot_histogram",
62
+ # Job
63
+ "JobStatus",
64
+ "Result",
65
+ "BatchResult",
66
+ # Exceptions
67
+ "LQCloudError",
68
+ "AuthenticationError",
69
+ "BackendNotFoundError",
70
+ "CircuitError",
71
+ "JobError",
72
+ "JobTimeoutError",
73
+ # Meta
74
+ "__version__",
75
+ ]
@@ -0,0 +1,5 @@
1
+ """Authentication configuration module."""
2
+
3
+ from .config import load_account, save_account, interactive_setup
4
+
5
+ __all__ = ['load_account', 'save_account', 'interactive_setup']
lqcloud/auth/config.py ADDED
@@ -0,0 +1,189 @@
1
+ """Authentication configuration — save, load, and interactive setup.
2
+
3
+ Configuration is stored in ``~/.lqcloud/config.json``. The SDK checks
4
+ environment variables first, then the config file.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import stat
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ logger = logging.getLogger("lqcloud")
16
+
17
+ # Default server URL (public endpoint, no internal IPs)
18
+ DEFAULT_URL = "https://cloud.logicalqubit.com"
19
+
20
+
21
+ def _get_config_dir() -> Path:
22
+ """Return the configuration directory, with fallback for sandboxed envs."""
23
+ try:
24
+ return Path.home() / ".lqcloud"
25
+ except RuntimeError:
26
+ fallback = Path(os.environ.get("TEMP", os.environ.get("TMP", "."))) / ".lqcloud"
27
+ return fallback
28
+
29
+
30
+ DEFAULT_CONFIG_DIR = _get_config_dir()
31
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
32
+
33
+
34
+ def save_account(
35
+ api_key: Optional[str] = None,
36
+ token: Optional[str] = None,
37
+ url: str = DEFAULT_URL,
38
+ ) -> None:
39
+ """Persist authentication credentials to the local config file.
40
+
41
+ Args:
42
+ api_key: API Key issued by the LQCloud platform.
43
+ token: Alternative bearer token.
44
+ url: Backend URL (default: ``https://cloud.logicalqubit.com``).
45
+
46
+ Raises:
47
+ ValueError: If neither ``api_key`` nor ``token`` is provided, or if
48
+ ``url`` is empty / does not use the ``http://`` or ``https://``
49
+ scheme.
50
+ """
51
+ api_key = api_key.strip() if isinstance(api_key, str) else api_key
52
+ token = token.strip() if isinstance(token, str) else token
53
+ url = url.strip() if isinstance(url, str) else url
54
+
55
+ if not api_key and not token:
56
+ raise ValueError(
57
+ "At least one of api_key / token must be provided and non-empty."
58
+ )
59
+ if not url:
60
+ raise ValueError("url must be a non-empty string.")
61
+ if not (url.startswith("http://") or url.startswith("https://")):
62
+ raise ValueError(
63
+ "url must start with 'http://' or 'https://' "
64
+ f"(got: {url!r})."
65
+ )
66
+
67
+ DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
68
+ # 限制配置目录权限,防止其他用户枚举/读取凭证文件
69
+ try:
70
+ if DEFAULT_CONFIG_DIR.stat().st_mode != (stat.S_IFDIR | 0o700):
71
+ os.chmod(DEFAULT_CONFIG_DIR, 0o700)
72
+ except OSError:
73
+ logger.warning("Unable to set permissions on %s", DEFAULT_CONFIG_DIR)
74
+
75
+ data = {"api_key": api_key, "token": token, "url": url}
76
+
77
+ with open(DEFAULT_CONFIG_FILE, "w") as f:
78
+ json.dump(data, f, indent=4)
79
+
80
+ # 限制配置文件權限,防止其他用戶讀取 API Key / Token
81
+ try:
82
+ os.chmod(DEFAULT_CONFIG_FILE, 0o600)
83
+ except OSError:
84
+ logger.warning("Unable to set permissions on %s", DEFAULT_CONFIG_FILE)
85
+
86
+ logger.info("Account saved to %s", DEFAULT_CONFIG_FILE)
87
+
88
+
89
+ def load_account(interactive: bool = True) -> Optional[dict]:
90
+ """Load authentication configuration.
91
+
92
+ Priority: **environment variables** > **config file** > **interactive setup**.
93
+
94
+ Args:
95
+ interactive: When ``True`` (default) and no credentials are found
96
+ in the environment or config file, fall back to
97
+ :func:`interactive_setup`. When ``False``, return ``None``
98
+ instead — useful in non-interactive environments (CI, Docker,
99
+ Jupyter headless, scheduled tasks).
100
+
101
+ Returns:
102
+ A dict with keys ``api_key``, ``token``, ``url``, or ``None`` if
103
+ nothing is available and interactive setup is disabled or cancelled.
104
+ """
105
+ # 1. Environment variables
106
+ env_api_key = os.getenv("LQCLOUD_API_KEY")
107
+ env_token = os.getenv("LQCLOUD_TOKEN")
108
+ env_url = os.getenv("LQCLOUD_URL")
109
+
110
+ if env_api_key or env_token:
111
+ env_dict: dict = {}
112
+ if env_api_key:
113
+ env_dict["api_key"] = env_api_key
114
+ if env_token:
115
+ env_dict["token"] = env_token
116
+ env_dict["url"] = env_url or DEFAULT_URL
117
+ return env_dict
118
+
119
+ # 2. Config file
120
+ if DEFAULT_CONFIG_FILE.exists():
121
+ try:
122
+ with open(DEFAULT_CONFIG_FILE, "r") as f:
123
+ return json.load(f)
124
+ except json.JSONDecodeError:
125
+ pass # corrupted file — fall through
126
+
127
+ # 3. Interactive setup (optional)
128
+ if not interactive:
129
+ return None
130
+ return interactive_setup()
131
+
132
+ import getpass
133
+
134
+
135
+ def _stdin_is_interactive() -> bool:
136
+ """Return ``True`` only when stdin can actually be prompted.
137
+
138
+ In non-interactive environments — CI, Windows services, Docker, Jupyter
139
+ headless, scheduled tasks, or under pytest's output capture — stdin is not
140
+ a TTY and reading it raises ``OSError`` (or hangs). Detect that up front so
141
+ :func:`interactive_setup` can skip prompting instead of crashing.
142
+ """
143
+ try:
144
+ return bool(sys.stdin) and sys.stdin.isatty()
145
+ except (ValueError, OSError, AttributeError):
146
+ return False
147
+
148
+
149
+ def interactive_setup() -> Optional[dict]:
150
+ """Prompt the user for API key and server URL, then save.
151
+
152
+ Returns:
153
+ A dict with ``api_key`` and ``url``, or ``None`` if the user
154
+ cancels via ``Ctrl-C`` / ``Ctrl-D``, or if stdin is not interactive.
155
+ """
156
+ # Never read stdin in a non-interactive environment (CI / service / headless
157
+ # / pytest capture); doing so raises OSError or blocks forever.
158
+ if not _stdin_is_interactive():
159
+ logger.info(
160
+ "Non-interactive stdin detected; skipping credential prompt. "
161
+ "Set LQCLOUD_API_KEY, call save_account(...), or pass api_key/token."
162
+ )
163
+ return None
164
+
165
+ try:
166
+ print("=" * 70)
167
+ print("🔐 LQCloud Account Setup")
168
+ print("=" * 70)
169
+ print("First-time configuration required.")
170
+ print(f"Credentials will be saved to {DEFAULT_CONFIG_FILE}")
171
+ print("-" * 70)
172
+
173
+ api_key = getpass.getpass("Enter API Key: ").strip()
174
+ while not api_key:
175
+ print("❌ API Key cannot be empty.")
176
+ api_key = getpass.getpass("Enter API Key: ").strip()
177
+
178
+ url_input = input(f"Enter server URL [default: {DEFAULT_URL}]: ").strip()
179
+ url = url_input if url_input else DEFAULT_URL
180
+
181
+ print("-" * 70)
182
+ save_account(api_key=api_key, url=url)
183
+ print("✅ Configuration complete!")
184
+ print("=" * 70)
185
+ except (KeyboardInterrupt, EOFError):
186
+ print("\nSetup cancelled.")
187
+ return None
188
+
189
+ return {"api_key": api_key, "url": url}
@@ -0,0 +1 @@
1
+ # lqcloud backend module