pico-store-lab 0.1.2__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.
@@ -0,0 +1,19 @@
1
+ node_modules/
2
+ apps/website/.local/
3
+ apps/website/.wrangler/
4
+ apps/android/.gradle/
5
+ apps/android/build/
6
+ apps/android/app/build/
7
+ packages/ts/dist/
8
+ packages/python/dist/
9
+ __pycache__/
10
+ *.pyc
11
+ target/
12
+ packages/kotlin/build/
13
+ build/
14
+ dist/
15
+ *.apk
16
+ *.part
17
+ *.auth.json
18
+ *mirror-config.json
19
+ .DS_Store
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: pico-store-lab
3
+ Version: 0.1.2
4
+ Summary: PICO app search, sign-in, and downloads for Python and the command line
5
+ Project-URL: Repository, https://github.com/nkanf-dev/pico-store-lab
6
+ Project-URL: Issues, https://github.com/nkanf-dev/pico-store-lab/issues
7
+ Author: PICO Store Lab contributors
8
+ License-Expression: MIT
9
+ Keywords: pico,sdk,store,vr
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: keyring<26,>=25.6
14
+ Description-Content-Type: text/markdown
15
+
16
+ # PICO Store Lab — Python SDK
17
+
18
+ PICO app search, sign-in, and downloads for Python, with a `pico-store-py` command-line tool for Windows, macOS, and Linux. This package is part of [PICO Store Lab](https://github.com/nkanf-dev/pico-store-lab).
19
+
20
+ Install with `python -m pip install pico-store-lab`.
21
+
22
+ ```python
23
+ from pathlib import Path
24
+ from pico_store_lab import PicoStoreClient, StoreTarget
25
+
26
+ client = PicoStoreClient()
27
+ found = client.search("YouTube VR").items[0]
28
+ target = StoreTarget(found.item_id, found.package_name)
29
+ client.item(target)
30
+ client.send_code(email)
31
+ auth = client.login(email, code_from_user)
32
+ client.download(target, auth, Path("selected-app.apk"))
33
+ ```
34
+
35
+ Configure the request identity and region with `StoreConfig`:
36
+
37
+ ```python
38
+ from pico_store_lab import StoreConfig
39
+
40
+ client = PicoStoreClient(
41
+ config=StoreConfig(
42
+ device_name="YOUR_DEVICE", language="en", zone="Europe/London", web_region="uk"
43
+ )
44
+ )
45
+ ```
46
+
47
+ Other fields include `store_host`, `account_host`, `web_store_host`, `manifest_version_code`, `app_id`, `client_type`, `passport_aid`, and `device_platform`. The default profile reflects the observed A9210/Japanese-language overseas-store requests. Select any exact `StoreTarget` from search. `download()` confirms ownership, acquires an available free offer if necessary, then gets the APK metadata. Paid offers are purchased on the official store.
48
+
49
+ `email` and `code_from_user` come from your app's UI. `PicoStoreClient` performs the requests and verified download; pass a custom transport if you need different HTTP behavior. Low-level builders and parsers remain public. Use `pico-store-py --help` for the smaller CLI surface.
50
+
51
+ The [player guide](https://github.com/nkanf-dev/pico-store-lab#player-guide) gives the complete `search` → `status` → `send-code` → `login` → `download` command sequence, including device installation.
@@ -0,0 +1,36 @@
1
+ # PICO Store Lab — Python SDK
2
+
3
+ PICO app search, sign-in, and downloads for Python, with a `pico-store-py` command-line tool for Windows, macOS, and Linux. This package is part of [PICO Store Lab](https://github.com/nkanf-dev/pico-store-lab).
4
+
5
+ Install with `python -m pip install pico-store-lab`.
6
+
7
+ ```python
8
+ from pathlib import Path
9
+ from pico_store_lab import PicoStoreClient, StoreTarget
10
+
11
+ client = PicoStoreClient()
12
+ found = client.search("YouTube VR").items[0]
13
+ target = StoreTarget(found.item_id, found.package_name)
14
+ client.item(target)
15
+ client.send_code(email)
16
+ auth = client.login(email, code_from_user)
17
+ client.download(target, auth, Path("selected-app.apk"))
18
+ ```
19
+
20
+ Configure the request identity and region with `StoreConfig`:
21
+
22
+ ```python
23
+ from pico_store_lab import StoreConfig
24
+
25
+ client = PicoStoreClient(
26
+ config=StoreConfig(
27
+ device_name="YOUR_DEVICE", language="en", zone="Europe/London", web_region="uk"
28
+ )
29
+ )
30
+ ```
31
+
32
+ Other fields include `store_host`, `account_host`, `web_store_host`, `manifest_version_code`, `app_id`, `client_type`, `passport_aid`, and `device_platform`. The default profile reflects the observed A9210/Japanese-language overseas-store requests. Select any exact `StoreTarget` from search. `download()` confirms ownership, acquires an available free offer if necessary, then gets the APK metadata. Paid offers are purchased on the official store.
33
+
34
+ `email` and `code_from_user` come from your app's UI. `PicoStoreClient` performs the requests and verified download; pass a custom transport if you need different HTTP behavior. Low-level builders and parsers remain public. Use `pico-store-py --help` for the smaller CLI surface.
35
+
36
+ The [player guide](https://github.com/nkanf-dev/pico-store-lab#player-guide) gives the complete `search` → `status` → `send-code` → `login` → `download` command sequence, including device installation.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pico-store-lab"
7
+ version = "0.1.2"
8
+ description = "PICO app search, sign-in, and downloads for Python and the command line"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["keyring>=25.6,<26"]
12
+ license = "MIT"
13
+ authors = [{ name = "PICO Store Lab contributors" }]
14
+ keywords = ["pico", "vr", "sdk", "store"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/nkanf-dev/pico-store-lab"
19
+ Issues = "https://github.com/nkanf-dev/pico-store-lab/issues"
20
+
21
+ [project.scripts]
22
+ pico-store-py = "pico_store_lab.cli:main"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/pico_store_lab"]
26
+
27
+ [tool.ruff]
28
+ line-length = 100
29
+ target-version = "py311"
30
+ src = ["src"]
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "D", "UP", "B", "ANN"]
34
+
35
+ [tool.ruff.lint.pydocstyle]
36
+ convention = "google"
@@ -0,0 +1,79 @@
1
+ """Typed PICO store protocol and mirror policy SDK."""
2
+
3
+ from pico_store_lab.client import (
4
+ PicoStoreClient,
5
+ StoreResponse,
6
+ download_verified_apk,
7
+ send_request,
8
+ )
9
+ from pico_store_lab.mirror import (
10
+ DEFAULT_MIRROR_POLICY,
11
+ MirrorDecision,
12
+ MirrorPolicy,
13
+ mirror_decision,
14
+ )
15
+ from pico_store_lab.protocol import (
16
+ ACCOUNT_HOST,
17
+ DEFAULT_TARGET,
18
+ OFFICIAL_STORE_URL,
19
+ PICO_ITEM_ID,
20
+ PICO_PACKAGE,
21
+ STORE_HOST,
22
+ DownloadInfo,
23
+ PicoAuth,
24
+ PublicItem,
25
+ RequestSpec,
26
+ SearchItem,
27
+ SearchResults,
28
+ StoreConfig,
29
+ StoreTarget,
30
+ encode_account_field,
31
+ make_account_item_request,
32
+ make_account_request,
33
+ make_download_info_request,
34
+ make_free_acquisition_request,
35
+ make_public_item_request,
36
+ make_search_request,
37
+ parse_download_info,
38
+ parse_free_acquisition,
39
+ parse_official_json,
40
+ parse_public_item,
41
+ parse_search_results,
42
+ )
43
+
44
+ __all__ = [
45
+ "ACCOUNT_HOST",
46
+ "DEFAULT_MIRROR_POLICY",
47
+ "OFFICIAL_STORE_URL",
48
+ "PICO_ITEM_ID",
49
+ "PICO_PACKAGE",
50
+ "STORE_HOST",
51
+ "DEFAULT_TARGET",
52
+ "DownloadInfo",
53
+ "MirrorDecision",
54
+ "MirrorPolicy",
55
+ "PicoAuth",
56
+ "PicoStoreClient",
57
+ "PublicItem",
58
+ "RequestSpec",
59
+ "StoreTarget",
60
+ "StoreConfig",
61
+ "make_account_item_request",
62
+ "make_free_acquisition_request",
63
+ "parse_free_acquisition",
64
+ "StoreResponse",
65
+ "SearchItem",
66
+ "SearchResults",
67
+ "download_verified_apk",
68
+ "encode_account_field",
69
+ "make_account_request",
70
+ "make_download_info_request",
71
+ "make_public_item_request",
72
+ "make_search_request",
73
+ "mirror_decision",
74
+ "parse_download_info",
75
+ "parse_official_json",
76
+ "parse_public_item",
77
+ "parse_search_results",
78
+ "send_request",
79
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow `python -m pico_store_lab` as a zero-install CLI."""
2
+
3
+ from pico_store_lab.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """Small bilingual command-line client built on the Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from pico_store_lab import credentials
12
+ from pico_store_lab.client import PicoStoreClient
13
+ from pico_store_lab.protocol import StoreTarget
14
+
15
+ MESSAGES = {
16
+ "en": {
17
+ "sent": "PICO verification email sent.",
18
+ "saved": "Signed in.",
19
+ "downloaded": "Downloaded: {path}",
20
+ "signed_out": "Signed out.",
21
+ "error": "Error: {message}",
22
+ },
23
+ "zh-CN": {
24
+ "sent": "PICO 验证码邮件已发送。",
25
+ "saved": "已登录。",
26
+ "downloaded": "已下载:{path}",
27
+ "signed_out": "已退出登录。",
28
+ "error": "错误:{message}",
29
+ },
30
+ }
31
+
32
+
33
+ def _message(locale: str, key: str, **values: str) -> str:
34
+ return MESSAGES[locale][key].format(**values)
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ """Create the stable CLI argument parser."""
39
+ parser = argparse.ArgumentParser(prog="pico-store-py", description="PICO Store Lab Python CLI")
40
+ parser.add_argument("--locale", choices=("en", "zh-CN"), default="en")
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+ search = sub.add_parser("search", help="Search official PICO apps")
43
+ search.add_argument("word")
44
+ status = sub.add_parser("status", help="Show an official PICO item")
45
+ status.add_argument("--item-id", required=True)
46
+ status.add_argument("--package", required=True)
47
+ send = sub.add_parser("send-code", help="Send a PICO email code")
48
+ send.add_argument("--email", required=True)
49
+ login = sub.add_parser("login", help="Sign in to your PICO international account")
50
+ login.add_argument("--email", required=True)
51
+ sub.add_parser("logout", help="Sign out")
52
+ download = sub.add_parser("download", help="Download an app")
53
+ download.add_argument("--output", required=True, type=Path)
54
+ download.add_argument("--item-id", required=True)
55
+ download.add_argument("--package", required=True)
56
+ return parser
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ """Run one command; return a process exit status without leaking credentials."""
61
+ args = build_parser().parse_args(argv)
62
+ client = PicoStoreClient()
63
+ try:
64
+ if args.command == "search":
65
+ result = client.search(args.word)
66
+ print(
67
+ json.dumps(
68
+ [
69
+ {
70
+ "name": item.name,
71
+ "itemId": item.item_id,
72
+ "packageName": item.package_name,
73
+ "versionCode": item.version_code,
74
+ }
75
+ for item in result.items
76
+ ],
77
+ indent=2,
78
+ )
79
+ )
80
+ elif args.command == "status":
81
+ target = StoreTarget(args.item_id, args.package)
82
+ item = client.item(target)
83
+ print(
84
+ json.dumps(
85
+ {
86
+ "name": item.name,
87
+ "versionCode": item.version_code,
88
+ "price": item.price,
89
+ "officialUrl": item.official_url,
90
+ },
91
+ indent=2,
92
+ )
93
+ )
94
+ elif args.command == "send-code":
95
+ client.send_code(args.email)
96
+ print(_message(args.locale, "sent"))
97
+ elif args.command == "login":
98
+ code = getpass.getpass("PICO email code: ")
99
+ auth = client.login(args.email, code)
100
+ credentials.save(auth)
101
+ print(_message(args.locale, "saved"))
102
+ elif args.command == "logout":
103
+ credentials.clear()
104
+ print(_message(args.locale, "signed_out"))
105
+ elif args.command == "download":
106
+ target = StoreTarget(args.item_id, args.package)
107
+ client.download(target, credentials.load(), args.output)
108
+ print(_message(args.locale, "downloaded", path=str(args.output)))
109
+ except (OSError, ValueError, RuntimeError) as error:
110
+ print(_message(args.locale, "error", message=str(error)), file=sys.stderr)
111
+ return 1
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
@@ -0,0 +1,227 @@
1
+ """Composable network client for the complete PICO store acquisition flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from email.message import Message
12
+ from pathlib import Path
13
+ from urllib.error import HTTPError, URLError
14
+ from urllib.request import Request, urlopen
15
+
16
+ from pico_store_lab.protocol import (
17
+ DEFAULT_CONFIG,
18
+ DownloadInfo,
19
+ PicoAuth,
20
+ PublicItem,
21
+ RequestSpec,
22
+ SearchResults,
23
+ StoreConfig,
24
+ StoreTarget,
25
+ make_account_item_request,
26
+ make_account_request,
27
+ make_download_info_request,
28
+ make_free_acquisition_request,
29
+ make_public_item_request,
30
+ make_search_request,
31
+ parse_download_info,
32
+ parse_free_acquisition,
33
+ parse_official_json,
34
+ parse_public_item,
35
+ parse_search_results,
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class StoreResponse:
41
+ """Decoded API payload and raw response headers."""
42
+
43
+ data: object
44
+ headers: Message
45
+
46
+
47
+ Transport = Callable[[RequestSpec, int], StoreResponse]
48
+
49
+
50
+ def send_request(spec: RequestSpec, retries: int = 3) -> StoreResponse:
51
+ """Send a protocol request with bounded retries for transient failures."""
52
+ if retries < 1:
53
+ raise ValueError("at least one request attempt required")
54
+ last_error: Exception | None = None
55
+ for attempt in range(retries):
56
+ request = Request(
57
+ spec.url, data=spec.body.encode("utf-8"), headers=spec.headers, method=spec.method
58
+ )
59
+ try:
60
+ with urlopen(request, timeout=25) as response: # noqa: S310 - SDK selects official URLs
61
+ body = response.read(4 * 1024 * 1024 + 1)
62
+ if len(body) > 4 * 1024 * 1024:
63
+ raise RuntimeError("PICO response exceeds 4 MiB")
64
+ return StoreResponse(parse_official_json(body.decode()), response.headers)
65
+ except HTTPError as error:
66
+ if error.code < 500 and error.code != 429:
67
+ raise RuntimeError(f"PICO HTTP {error.code}") from error
68
+ last_error = error
69
+ except URLError as error:
70
+ last_error = error
71
+ if attempt + 1 < retries:
72
+ time.sleep(min(attempt + 1, 5))
73
+ raise RuntimeError(f"PICO request failed: {last_error}")
74
+
75
+
76
+ def _account_data(response: StoreResponse) -> dict[str, object]:
77
+ data = response.data
78
+ if not isinstance(data, dict) or data.get("message") != "success":
79
+ raise RuntimeError("PICO account request rejected")
80
+ body = data.get("data")
81
+ return body if isinstance(body, dict) else {}
82
+
83
+
84
+ def download_verified_apk(info: DownloadInfo, output: Path, retries: int = 8) -> Path:
85
+ """Download official APK bytes and verify the provided MD5 before publishing a file."""
86
+ if retries < 1:
87
+ raise ValueError("at least one download attempt required")
88
+ if output.suffix.lower() != ".apk" or output.exists():
89
+ raise ValueError("new .apk output path required")
90
+ temporary = output.with_name(f"{output.stem}.part.apk")
91
+ last_error: Exception | None = None
92
+ for attempt in range(retries):
93
+ digest = hashlib.md5() # noqa: S324 - official APK metadata supplies MD5
94
+ try:
95
+ with urlopen(info.url, timeout=60) as response: # noqa: S310 - validated HTTPS URL
96
+ with temporary.open("wb") as stream:
97
+ while chunk := response.read(65536):
98
+ digest.update(chunk)
99
+ stream.write(chunk)
100
+ if digest.hexdigest() != info.md5:
101
+ temporary.unlink(missing_ok=True)
102
+ raise ValueError("APK digest mismatch")
103
+ os.link(temporary, output)
104
+ temporary.unlink()
105
+ return output
106
+ except (OSError, URLError) as error:
107
+ last_error = error
108
+ if attempt + 1 < retries:
109
+ time.sleep(min(attempt + 1, 5))
110
+ raise RuntimeError(f"APK download failed: {last_error}")
111
+
112
+
113
+ class PicoStoreClient:
114
+ """High-level SDK interface; callers may replace transport at any point."""
115
+
116
+ def __init__(
117
+ self, transport: Transport = send_request, config: StoreConfig = DEFAULT_CONFIG
118
+ ) -> None:
119
+ """Use the standard HTTP transport unless one is injected."""
120
+ self.transport = transport
121
+ self.config = config
122
+
123
+ def search(self, word: str, next_id: int = 1) -> SearchResults:
124
+ """Search the official public app catalog."""
125
+ response = self.transport(
126
+ make_search_request(
127
+ word, next_id=next_id, language=self.config.language, config=self.config
128
+ ),
129
+ 3,
130
+ )
131
+ return parse_search_results(response.data)
132
+
133
+ def item(self, target: StoreTarget, auth: PicoAuth | None = None) -> PublicItem:
134
+ """Read and validate public metadata for the selected app."""
135
+ request = (
136
+ make_account_item_request(auth, target, self.config)
137
+ if auth
138
+ else make_public_item_request(
139
+ target=target,
140
+ language=self.config.language,
141
+ zone=self.config.zone,
142
+ config=self.config,
143
+ )
144
+ )
145
+ response = self.transport(request, 3)
146
+ return parse_public_item(response.data, target, self.config)
147
+
148
+ def acquire_free(self, item: PublicItem, auth: PicoAuth) -> str:
149
+ """Request a free app for the signed-in account."""
150
+ return parse_free_acquisition(
151
+ self.transport(make_free_acquisition_request(auth, item, self.config), 1).data
152
+ )
153
+
154
+ def ensure_entitlement(self, target: StoreTarget, auth: PicoAuth) -> PublicItem:
155
+ """Confirm ownership or acquire a free offer before downloading."""
156
+ current = self.item(target, auth)
157
+ if current.entitlement_status == 1:
158
+ return current
159
+ if current.offer_exists is not True:
160
+ raise RuntimeError("PICO has no offer for this account region")
161
+ if not re.fullmatch(r"0(?:\.0+)?", current.price):
162
+ raise RuntimeError("PICO app is not free or already owned")
163
+ acquisition_error: Exception | None = None
164
+ try:
165
+ self.acquire_free(current, auth)
166
+ except Exception as error:
167
+ acquisition_error = error
168
+ for attempt in range(3):
169
+ try:
170
+ updated = self.item(target, auth)
171
+ except Exception as error:
172
+ if attempt == 2:
173
+ if acquisition_error is not None:
174
+ raise acquisition_error from error
175
+ raise
176
+ time.sleep(0.4)
177
+ continue
178
+ if updated.entitlement_status == 1:
179
+ return updated
180
+ if attempt < 2:
181
+ time.sleep(0.4)
182
+ if acquisition_error is not None:
183
+ raise acquisition_error
184
+ raise RuntimeError("PICO entitlement was not confirmed after free acquisition")
185
+
186
+ def send_code(self, email: str) -> None:
187
+ """Send one PICO email verification code."""
188
+ _account_data(
189
+ self.transport(make_account_request("send-code", email, config=self.config), 1)
190
+ )
191
+
192
+ def login(self, email: str, code: str) -> PicoAuth:
193
+ """Exchange the email code for the caller's PICO account session."""
194
+ response = self.transport(make_account_request("login", email, code, self.config), 1)
195
+ data = _account_data(response)
196
+ cookies: dict[str, str] = {}
197
+ for line in response.headers.get_all("Set-Cookie", []):
198
+ key, separator, value = line.split(";", 1)[0].partition("=")
199
+ if separator:
200
+ cookies[key] = value
201
+ auth = PicoAuth(
202
+ str(data.get("user_id_str") or data.get("user_id") or "0"),
203
+ str(response.headers.get("x-tt-token", "")),
204
+ cookies,
205
+ )
206
+ if not auth.x_tt_token and not auth.cookies:
207
+ raise RuntimeError("PICO login returned no usable session")
208
+ return auth
209
+
210
+ def download_info(self, target: StoreTarget, auth: PicoAuth) -> DownloadInfo:
211
+ """Read download metadata authorized for the caller's own account."""
212
+ response = self.transport(
213
+ make_download_info_request(
214
+ auth,
215
+ target=target,
216
+ language=self.config.language,
217
+ zone=self.config.zone,
218
+ config=self.config,
219
+ ),
220
+ 3,
221
+ )
222
+ return parse_download_info(response.data, target)
223
+
224
+ def download(self, target: StoreTarget, auth: PicoAuth, output: Path) -> Path:
225
+ """Get and verify an official APK in one call."""
226
+ self.ensure_entitlement(target, auth)
227
+ return download_verified_apk(self.download_info(target, auth), output)
@@ -0,0 +1,81 @@
1
+ """Account persistence through the platform credential store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from keyring.backend import KeyringBackend
9
+
10
+ from pico_store_lab.protocol import PicoAuth
11
+
12
+ SERVICE = "dev.nkanf.picostore.python"
13
+ ACCOUNT = "current"
14
+
15
+
16
+ def _backend() -> KeyringBackend:
17
+ # Select native stores directly: never load a configured plaintext/null backend.
18
+ if sys.platform == "darwin":
19
+ from keyring.backends.macOS import Keyring
20
+
21
+ return Keyring()
22
+ if sys.platform == "win32":
23
+ from keyring.backends.Windows import WinVaultKeyring
24
+
25
+ return WinVaultKeyring()
26
+ if sys.platform.startswith("linux"):
27
+ from keyring.backends.SecretService import Keyring
28
+
29
+ return Keyring()
30
+ raise RuntimeError("Sign-in requires Windows, macOS, or Linux.")
31
+
32
+
33
+ def save(auth: PicoAuth) -> None:
34
+ """Save a usable account session."""
35
+ if not auth.x_tt_token and not auth.cookies:
36
+ raise ValueError("Sign-in returned no usable session. Please sign in again.")
37
+ value = json.dumps({"uid": auth.uid, "x_tt_token": auth.x_tt_token, "cookies": auth.cookies})
38
+ try:
39
+ _backend().set_password(SERVICE, ACCOUNT, value)
40
+ except Exception:
41
+ raise RuntimeError(
42
+ "Unable to save sign-in. Unlock the system credential store and try again."
43
+ ) from None
44
+
45
+
46
+ def load() -> PicoAuth:
47
+ """Read the current account session."""
48
+ try:
49
+ raw = _backend().get_password(SERVICE, ACCOUNT)
50
+ except Exception:
51
+ raise RuntimeError(
52
+ "Unable to read sign-in. Unlock the system credential store and try again."
53
+ ) from None
54
+ if raw is None:
55
+ raise ValueError("Please run pico-store-py login first.")
56
+ try:
57
+ data = json.loads(raw)
58
+ if not isinstance(data, dict) or not isinstance(data.get("cookies", {}), dict):
59
+ raise ValueError
60
+ auth = PicoAuth(
61
+ str(data.get("uid", "0")),
62
+ str(data.get("x_tt_token", "")),
63
+ {str(key): str(value) for key, value in data.get("cookies", {}).items()},
64
+ )
65
+ if not auth.x_tt_token and not auth.cookies:
66
+ raise ValueError
67
+ except (ValueError, TypeError):
68
+ raise ValueError("Sign-in could not be read. Please sign in again.") from None
69
+ return auth
70
+
71
+
72
+ def clear() -> None:
73
+ """Remove the current account session."""
74
+ try:
75
+ backend = _backend()
76
+ if backend.get_password(SERVICE, ACCOUNT) is not None:
77
+ backend.delete_password(SERVICE, ACCOUNT)
78
+ except Exception:
79
+ raise RuntimeError(
80
+ "Could not sign out. Unlock the system credential store and try again."
81
+ ) from None