diff --git a/dimos/cli/cloud.py b/dimos/cli/cloud.py new file mode 100644 index 0000000000..20065efeff --- /dev/null +++ b/dimos/cli/cloud.py @@ -0,0 +1,158 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dimensional cloud auth: `dimos login` / `dimos logout` / `dimos whoami`. + +Device-code flow (RFC 8628 shaped) against login.dimensional.org — built for robots: +no browser or clipboard needed on this machine. The CLI prints an 8-character code, +you approve it from any signed-in browser (laptop, phone), and the minted API key is +stored in the system keyring, falling back to a 0600 file (`CREDENTIALS_PATH`) on +headless machines with no keyring backend. `DIMOS_API_KEY` (via GlobalConfig) +overrides any stored login. +""" + +import json +import os +import socket +import time +from types import ModuleType +from typing import Any, cast +import urllib.error +import urllib.parse +import urllib.request + +import typer + +from dimos.constants import CREDENTIALS_PATH +from dimos.core.global_config import global_config + +_KEYRING_SERVICE = "dimos-cloud" +_KEYRING_USER = "default" + + +def _base() -> str: + return global_config.dimos_cloud_url.rstrip("/") + + +def _post(path: str, **params: str | int) -> dict[str, Any]: + url = f"{_base()}{path}?" + urllib.parse.urlencode(params) + with urllib.request.urlopen(urllib.request.Request(url, method="POST")) as r: + return cast("dict[str, Any]", json.load(r)) + + +def _keyring() -> ModuleType | None: + """The OS keyring, or None on machines without a usable backend (headless robots).""" + try: + import keyring + + keyring.get_password(_KEYRING_SERVICE, "probe") + return keyring + except Exception: + return None + + +def _store(creds: dict[str, str]) -> str: + """Persist credentials; returns a human-readable location for the login message.""" + blob = json.dumps(creds) + if kr := _keyring(): + kr.set_password(_KEYRING_SERVICE, _KEYRING_USER, blob) + return "system keyring" + CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True) + # No keyring backend (typical on robots): owner-only file, the same convention + # gh / aws / kubectl use for exactly this situation. + fd = os.open(CREDENTIALS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(blob) + return str(CREDENTIALS_PATH) + + +def _load() -> dict[str, str] | None: + if kr := _keyring(): + if blob := kr.get_password(_KEYRING_SERVICE, _KEYRING_USER): + return cast("dict[str, str]", json.loads(blob)) + try: + return cast("dict[str, str]", json.loads(CREDENTIALS_PATH.read_text())) + except (OSError, ValueError): + return None + + +def _forget() -> bool: + found = False + if kr := _keyring(): + if kr.get_password(_KEYRING_SERVICE, _KEYRING_USER): + kr.delete_password(_KEYRING_SERVICE, _KEYRING_USER) + found = True + if CREDENTIALS_PATH.exists(): + CREDENTIALS_PATH.unlink() + found = True + return found + + +def api_key() -> str | None: + """The credential for cloud calls: DIMOS_API_KEY first, then the stored login.""" + if global_config.dimos_api_key: + return global_config.dimos_api_key + creds = _load() + return creds.get("api_key") if creds else None + + +def login() -> None: + """Sign this machine in to Dimensional cloud.""" + d = _post("/auth/device", label=socket.gethostname()) + typer.echo(f"\n Open {d['verification_uri']}") + typer.echo(f" Enter code {d['user_code']}\n") + deadline = time.time() + d["expires_in"] + while time.time() < deadline: + time.sleep(d["interval"]) + r = _post("/auth/token", device_code=d["device_code"]) + if r["status"] == "ok": + where = _store({"api_key": r["api_key"], "email": r["email"]}) + typer.echo(f"Logged in as {r['email']} (key {r['key_id']}…, stored in {where})") + return + if r["status"] in ("denied", "expired"): + typer.echo(f"Login {r['status']}.", err=True) + raise typer.Exit(1) + typer.echo("Login timed out.", err=True) + raise typer.Exit(1) + + +def logout() -> None: + """Forget the stored key. Revoke it fully at login.dimensional.org/keys.""" + if _forget(): + typer.echo(f"Logged out. Revoke the key at {_base()}/keys.") + else: + typer.echo("Not logged in.") + + +def whoami() -> None: + """Show which account this machine's key belongs to.""" + key = api_key() + if not key: + typer.echo("Not logged in — run `dimos login`.", err=True) + raise typer.Exit(1) + req = urllib.request.Request( + f"{_base()}/auth/whoami", headers={"Authorization": f"Bearer {key}"} + ) + try: + with urllib.request.urlopen(req) as r: + who = json.load(r) + except urllib.error.HTTPError as e: + typer.echo( + "Key invalid or revoked — run `dimos login`." + if e.code == 401 + else f"Cloud error: {e.code}", + err=True, + ) + raise typer.Exit(1) from e + typer.echo(f"{who['email']} (scopes: {who['scopes']})") diff --git a/dimos/cli/dimos.py b/dimos/cli/dimos.py index 3d956be54f..4dd72d3110 100644 --- a/dimos/cli/dimos.py +++ b/dimos/cli/dimos.py @@ -50,6 +50,7 @@ from dimos.agents.mcp.mcp_adapter import McpAdapter, McpError from dimos.cli.cache import app as cache_app +from dimos.cli.cloud import login as cloud_login, logout as cloud_logout, whoami as cloud_whoami from dimos.cli.hardware_cli import app as hardware_app from dimos.cli.shell import shell from dimos.constants import CONFIG_DIR, LOG_DIR @@ -178,6 +179,9 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.add_typer(piper_app, name="piper") main.command()(shell) main.add_typer(cache_app, name="cache") +main.command("login")(cloud_login) +main.command("logout")(cloud_logout) +main.command("whoami")(cloud_whoami) def _with_relay_bridge(blueprint: Blueprint) -> Blueprint: diff --git a/dimos/cli/test_cloud.py b/dimos/cli/test_cloud.py new file mode 100644 index 0000000000..6591c5d63e --- /dev/null +++ b/dimos/cli/test_cloud.py @@ -0,0 +1,160 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import json +from pathlib import Path +import time +from typing import Any +import urllib.error +import urllib.request + +import pytest +import typer + +from dimos.cli import cloud +from dimos.core.global_config import global_config + + +class FakeKeyring: + """Dict-backed stand-in for the OS keyring.""" + + def __init__(self) -> None: + self.store: dict[tuple[str, str], str] = {} + + def get_password(self, service: str, user: str) -> str | None: + return self.store.get((service, user)) + + def set_password(self, service: str, user: str, value: str) -> None: + self.store[(service, user)] = value + + def delete_password(self, service: str, user: str) -> None: + del self.store[(service, user)] + + +@pytest.fixture +def filestore(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Force the headless path: no keyring backend, credentials in a temp file.""" + monkeypatch.setattr(cloud, "_keyring", lambda: None) + cred = tmp_path / "credentials.json" + monkeypatch.setattr(cloud, "CREDENTIALS_PATH", cred) + monkeypatch.setattr(global_config, "dimos_api_key", None) + return cred + + +def _responses(*rs: dict[str, Any]) -> Any: + it = iter(rs) + return lambda path, **kw: next(it) + + +def test_login_stores_key_owner_only(monkeypatch: pytest.MonkeyPatch, filestore: Path) -> None: + monkeypatch.setattr(time, "sleep", lambda s: None) + monkeypatch.setattr( + cloud, + "_post", + _responses( + { + "device_code": "dc", + "user_code": "AAAA-BBBB", + "verification_uri": "u", + "interval": 5, + "expires_in": 900, + }, + {"status": "authorization_pending"}, + {"status": "ok", "api_key": "dimos_sk_x", "key_id": "dimos_sk_x", "email": "e@x"}, + ), + ) + + cloud.login() + + assert json.loads(filestore.read_text())["api_key"] == "dimos_sk_x" + assert oct(filestore.stat().st_mode)[-3:] == "600" + assert cloud.api_key() == "dimos_sk_x" + + cloud.logout() + assert not filestore.exists() and cloud.api_key() is None + + +def test_login_denied_exits(monkeypatch: pytest.MonkeyPatch, filestore: Path) -> None: + monkeypatch.setattr(time, "sleep", lambda s: None) + monkeypatch.setattr( + cloud, + "_post", + _responses( + { + "device_code": "dc", + "user_code": "AAAA-BBBB", + "verification_uri": "u", + "interval": 5, + "expires_in": 900, + }, + {"status": "denied"}, + ), + ) + with pytest.raises(typer.Exit): + cloud.login() + assert not filestore.exists() + + +def test_global_config_key_overrides_stored( + monkeypatch: pytest.MonkeyPatch, filestore: Path +) -> None: + filestore.write_text(json.dumps({"api_key": "dimos_sk_stored"})) + monkeypatch.setattr(global_config, "dimos_api_key", "dimos_sk_env") + assert cloud.api_key() == "dimos_sk_env" + + +def test_keyring_store_load_forget(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + kr = FakeKeyring() + monkeypatch.setattr(cloud, "_keyring", lambda: kr) + monkeypatch.setattr(cloud, "CREDENTIALS_PATH", tmp_path / "never-written.json") + monkeypatch.setattr(global_config, "dimos_api_key", None) + + assert cloud._store({"api_key": "dimos_sk_kr", "email": "e@x"}) == "system keyring" + assert not (tmp_path / "never-written.json").exists() # keyring won; no file + assert cloud.api_key() == "dimos_sk_kr" + assert cloud._forget() is True + assert cloud.api_key() is None and cloud._forget() is False + + +def test_whoami_displays_account( + monkeypatch: pytest.MonkeyPatch, filestore: Path, capsys: pytest.CaptureFixture[str] +) -> None: + filestore.write_text(json.dumps({"api_key": "dimos_sk_w"})) + seen: dict[str, str] = {} + + def fake_urlopen(req: Any) -> io.BytesIO: + seen["auth"] = req.get_header("Authorization") + return io.BytesIO(json.dumps({"email": "e@x", "scopes": "data"}).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + cloud.whoami() + assert seen["auth"] == "Bearer dimos_sk_w" + assert "e@x (scopes: data)" in capsys.readouterr().out + + +def test_whoami_revoked_key_exits(monkeypatch: pytest.MonkeyPatch, filestore: Path) -> None: + filestore.write_text(json.dumps({"api_key": "dimos_sk_dead"})) + + def fake_urlopen(req: Any) -> io.BytesIO: + raise urllib.error.HTTPError(req.full_url, 401, "unauthorized", {}, None) # type: ignore[arg-type] + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + with pytest.raises(typer.Exit): + cloud.whoami() + + +def test_whoami_not_logged_in_exits(filestore: Path) -> None: + with pytest.raises(typer.Exit): + cloud.whoami() diff --git a/dimos/constants.py b/dimos/constants.py index 3b0fc385c8..96208932e0 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -38,6 +38,8 @@ LOG_DIR = STATE_DIR / "logs" RECORDINGS_DIR = STATE_DIR / "recordings" +CREDENTIALS_PATH = CONFIG_DIR / "dimos" / "credentials.json" + """ Constants for shared memory Usually, auto-detection for size would be preferred. Sadly, though, channels are made diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index f5cce34f11..85d6e8c9d9 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -102,6 +102,8 @@ class GlobalConfig(BaseSettings): dimsim_headless: bool = True local_relay: bool = False relay_url: str | None = None + dimos_cloud_url: str = "https://login.dimensional.org" + dimos_api_key: str | None = None model_config = SettingsConfigDict( env_file=".env", diff --git a/pyproject.toml b/pyproject.toml index 7c4faf5afa..5336ca2a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ dependencies = [ "textual-serve>=1.1.1,<2", "terminaltexteffects==0.12.2", "typer>=0.19.2,<1", + "keyring>=25,<26", "ipython", "plotext==5.3.2", # Used for calculating the occupancy map. diff --git a/uv.lock b/uv.lock index 399a4a909b..77c8ec56d4 100644 --- a/uv.lock +++ b/uv.lock @@ -29,7 +29,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-03T19:08:40.466827Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -418,6 +418,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -1661,6 +1670,7 @@ dependencies = [ { name = "imagecodecs", version = "2026.6.26", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "keyring" }, { name = "lazy-loader" }, { name = "llvmlite" }, { name = "lz4" }, @@ -2222,6 +2232,7 @@ requires-dist = [ { name = "ipykernel", marker = "extra == 'misc'" }, { name = "ipython" }, { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, + { name = "keyring", specifier = ">=25,<26" }, { name = "langchain", marker = "extra == 'agents'", specifier = ">=1.2.3,<2" }, { name = "langchain-core", marker = "extra == 'agents'", specifier = ">=1.2.22,<2" }, { name = "langchain-huggingface", marker = "extra == 'agents'", specifier = ">=1,<2" }, @@ -3951,6 +3962,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + [[package]] name = "jax" version = "0.6.2" @@ -4126,6 +4173,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -4278,6 +4334,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -8011,6 +8085,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -8703,6 +8786,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "(platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32') or sys_platform == 'linux'" }, + { name = "jeepney", marker = "(platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32') or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "sentencepiece" version = "0.2.2"