diff --git a/README.md b/README.md index 9755de3..ac736d4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Python 3.10 or newer is required. Get an API key at [platform.hcompany.ai/settin export HAI_API_KEY=hk-... ``` +With the `cli` extra, `hai login` stores a key once instead, and `Client()` picks it up. + ## Quickstart Launch the built-in `h/web-surfer-pro` agent, which ships with its own browser, and describe the task in plain language. `run_session` polls until the agent finishes and returns the final answer. @@ -63,7 +65,7 @@ print(result.status) print(result.answer) ``` -`Client()` reads `HAI_API_KEY` from the environment. +`Client()` reads `HAI_API_KEY` from the environment, else the key `hai login` stored in `~/.config/hai/.env`. `result` is a `SessionRunResult`: `id`, `status`, `answer`, the accumulated `events`, and `final_changes`. @@ -335,7 +337,7 @@ hai sessions watch hai mcp install ``` -`hai login` signs in through the browser with Google and stores a key in `~/.config/hai/.env`. Without a Google account or a browser, create a key at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys) and run `hai login --key`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then a local `.env`, then `~/.config/hai/.env`. Run `hai --help` for the full command set. +`hai login` signs in with Google in the browser, or takes a key you paste from [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys), and stores it in `~/.config/hai/.env`. Any other command does the same on first use in a terminal, so `hai login` is optional. Scripts and `--json` runs never prompt: pipe a key into `hai login --key` or set `HAI_API_KEY`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then `~/.config/hai/.env`. Run `hai --help` for the full command set. ## Documentation diff --git a/pyproject.toml b/pyproject.toml index e9accfb..ec19cef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hai-agents" -version = "1.0.16" +version = "1.0.17" description = "Python SDK for H Company's Agents API: autonomous agents powered by Holo." requires-python = ">=3.10" readme = "README.md" diff --git a/src/hai_agents/client.py b/src/hai_agents/client.py index 09b497c..d53c090 100644 --- a/src/hai_agents/client.py +++ b/src/hai_agents/client.py @@ -2,17 +2,22 @@ Fern emits the API surface as ``BaseClient``/``AsyncBaseClient``; these thin subclasses add the object-oriented sugar (``run_session``, ``start_session``, -``session``) that delegates to the hand-written polling helpers. +``session``) that delegates to the hand-written polling helpers, and default +``api_key`` to the key ``hai login`` stored. """ from __future__ import annotations import asyncio +import functools +import os import typing +from pathlib import Path import typing_extensions from .base_client import AsyncBaseClient, BaseClient +from .core.api_error import ApiError from .polling import ( AnswerT, AsyncSessionHandle, @@ -31,8 +36,46 @@ if typing.TYPE_CHECKING: from hai_agents_local.runtime import Inference, LocalRuntime +API_KEY_VAR = "HAI_API_KEY" + +_P = typing_extensions.ParamSpec("_P") + + +def credentials_path() -> Path: + """The global `.env` that `hai login` writes: `$XDG_CONFIG_HOME/hai/.env`, else `~/.config/hai/.env`.""" + return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "hai" / ".env" + + +def _stored_api_key() -> typing.Optional[str]: + """The `HAI_API_KEY` that `hai login` stored, if any.""" + try: + lines = credentials_path().read_text(encoding="utf-8").splitlines() + except (OSError, RuntimeError, UnicodeDecodeError): + return None + for line in lines: + name, sep, value = line.strip().removeprefix("export ").partition("=") + if sep and name.strip() == API_KEY_VAR: + return value.strip().strip("'\"") or None + return None + + +def _default_api_key(init: typing.Callable[_P, None]) -> typing.Callable[_P, None]: + """Resolve `api_key` as: argument, then `HAI_API_KEY`, then the key stored by `hai login`.""" + + @functools.wraps(init) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: + if kwargs.get("api_key") is None: + api_key = os.getenv(API_KEY_VAR) or _stored_api_key() + if api_key is None: + raise ApiError(body=f"No API key found. Pass api_key, set {API_KEY_VAR}, or run `hai login`.") + kwargs["api_key"] = api_key + init(*args, **kwargs) + + return wrapper + class Client(BaseClient): + __init__ = _default_api_key(BaseClient.__init__) local_runtime: typing.Optional[LocalRuntime] = None _owns_runtime = False _auto_bridges = True @@ -138,6 +181,7 @@ def sessions(self) -> SessionsClient: class AsyncClient(AsyncBaseClient): + __init__ = _default_api_key(AsyncBaseClient.__init__) local_runtime: typing.Optional[LocalRuntime] = None _owns_runtime = False _auto_bridges = True diff --git a/src/hai_agents/core/client_wrapper.py b/src/hai_agents/core/client_wrapper.py index 0354d1b..61f709b 100644 --- a/src/hai_agents/core/client_wrapper.py +++ b/src/hai_agents/core/client_wrapper.py @@ -29,9 +29,9 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "hai_agents/1.0.16", + "User-Agent": "hai_agents/1.0.17", "X-HCompany-Client-Name": "hai_agents", - "X-HCompany-Client-Version": "1.0.16", + "X-HCompany-Client-Version": "1.0.17", "X-HCompany-Client-Type": "sdk", "X-HCompany-Language": "Python", "X-HCompany-Runtime": f"python/{platform.python_version()}", diff --git a/src/hai_agents_cli/app.py b/src/hai_agents_cli/app.py index 7c42d15..88303ef 100644 --- a/src/hai_agents_cli/app.py +++ b/src/hai_agents_cli/app.py @@ -93,7 +93,7 @@ def login( "Reads it from a hidden prompt, or from stdin when piped.", ), ) -> None: - """Sign in through the browser and store an API key in ~/.config/hai/.env.""" + """Sign in through the browser or with a pasted key, and store the key in ~/.config/hai/.env.""" state = _state(ctx) if credentials.current_api_key() and not force: console.print("Already signed in. Pass --force to rotate the key.") @@ -101,13 +101,43 @@ def login( if key: _store_pasted_key(state.base_url) return - if not sys.stdin.isatty(): - _raise_cli_error(RuntimeError(f"login needs an interactive terminal and a browser. {auth.KEY_FALLBACK}")) + if not _interactive(): + _raise_cli_error(RuntimeError(f"login needs an interactive terminal. {auth.KEY_FALLBACK}")) + _sign_in(state.base_url) + +def _interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _sign_in_if_needed(state: AppState) -> None: + """Commands sign in on first use in a terminal, so a fresh install needs no `hai login` step.""" + if credentials.current_api_key(state.api_key) or state.json_output or not _interactive(): + return + console.print("No API key found, so let's sign you in first.") + _sign_in(state.base_url) + + +def _sign_in(base_url: str | None) -> None: + console.print( + "How do you want to sign in?\n" + " 1. Google account, in your browser\n" + f" 2. Paste an API key from {credentials.API_KEYS_PAGE}" + ) + choice = typer.prompt("Choice", default="1").strip() + if choice == "1": + _browser_sign_in(base_url) + elif choice == "2": + _store_pasted_key(base_url) + else: + _raise_cli_error(RuntimeError(f"Choice must be 1 or 2, got {choice!r}.")) + + +def _browser_sign_in(base_url: str | None) -> None: label = f"hai CLI ({socket.gethostname()})" try: minted = auth.login_and_mint( - credentials.portal_base(state.base_url), + credentials.portal_base(base_url), label, lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"), ) @@ -559,6 +589,7 @@ def mcp_install( else: _raise_cli_error(RuntimeError(f"Unknown client {client!r}. Run `hai mcp install list` to see supported ids.")) + _sign_in_if_needed(state) try: resolved = credentials.resolve_api_key(state.api_key) key = resolved() if callable(resolved) else resolved @@ -700,6 +731,7 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + _sign_in_if_needed(state) try: bridge = bridge_type( api_key=credentials.resolve_api_key(state.api_key), @@ -784,6 +816,7 @@ def _state(ctx: typer.Context) -> AppState: def _client(state: AppState) -> Client: + _sign_in_if_needed(state) try: return make_client(api_key=state.api_key, base_url=state.base_url) except RuntimeError as exc: diff --git a/src/hai_agents_common/credentials.py b/src/hai_agents_common/credentials.py index 2a89bb0..f26e8fa 100644 --- a/src/hai_agents_common/credentials.py +++ b/src/hai_agents_common/credentials.py @@ -11,11 +11,11 @@ from dotenv import dotenv_values, set_key, unset_key from hai_agents import AsyncClient, Client +from hai_agents.client import API_KEY_VAR, credentials_path from hai_agents.environment import HaiAgentsEnvironment ApiKey = str | Callable[[], str] -API_KEY_VAR = "HAI_API_KEY" BASE_URL_VAR = "HAI_API_BASE_URL" PORTAL_URL_VAR = "HAI_PORTAL_URL" @@ -25,8 +25,7 @@ } API_KEYS_PAGE = "https://platform.hcompany.ai/settings/api-keys" -LOCAL_ENV_PATH = Path(".env") -GLOBAL_ENV_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "hai" / ".env" +GLOBAL_ENV_PATH = credentials_path() def portal_base(base_url: str | None = None) -> str: @@ -105,9 +104,8 @@ def source(explicit: ApiKey | None = None) -> str | None: return "argument" if os.environ.get(API_KEY_VAR): return "environment" - for path in _env_paths(): - if path.exists() and dotenv_values(path).get(API_KEY_VAR): - return str(path) + if _stored(API_KEY_VAR): + return str(GLOBAL_ENV_PATH) return None @@ -119,18 +117,11 @@ def _client_kwargs(api_key: ApiKey | None, base_url: str | None) -> dict[str, Ap return kwargs -def _env_paths() -> tuple[Path, ...]: - # CWD `.env` overrides the global config `.env`. - return (LOCAL_ENV_PATH, GLOBAL_ENV_PATH) +def _lookup(name: str) -> str | None: + return os.environ.get(name) or _stored(name) -def _lookup(name: str) -> str | None: - if os.environ.get(name): - return os.environ[name] - for path in _env_paths(): - if not path.exists(): - continue - value = dotenv_values(path).get(name) - if value: - return value - return None +def _stored(name: str) -> str | None: + if not GLOBAL_ENV_PATH.exists(): + return None + return dotenv_values(GLOBAL_ENV_PATH).get(name) or None diff --git a/tests/test_cli.py b/tests/test_cli.py index 248d451..82eca18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,7 +22,6 @@ def test_help_renders_h_glyph() -> None: def test_missing_api_key_is_clear(monkeypatch, tmp_path) -> None: monkeypatch.delenv("HAI_API_KEY", raising=False) - monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env") monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env") result = runner.invoke(app, ["sessions", "get", "sess_1"], env={}) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index dff6c05..cecfb64 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -2,12 +2,15 @@ import stat import sys +from types import SimpleNamespace import httpx import pytest from typer.testing import CliRunner +import hai_agents.client as sdk_client import hai_agents_cli.app as app_module +from hai_agents import AsyncClient, Client from hai_agents.core.api_error import ApiError from hai_agents_cli import auth from hai_agents_cli.app import app @@ -21,8 +24,9 @@ def isolated_env(tmp_path, monkeypatch): """Point credential resolution at empty temp files and a clean environment.""" for var in (credentials.API_KEY_VAR, credentials.BASE_URL_VAR, credentials.PORTAL_URL_VAR): monkeypatch.delenv(var, raising=False) - monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env") - monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", sdk_client.credentials_path()) + credentials.GLOBAL_ENV_PATH.parent.mkdir(parents=True) def test_env_var_beats_dotenv(monkeypatch): @@ -33,12 +37,11 @@ def test_env_var_beats_dotenv(monkeypatch): assert credentials.source() == "environment" -def test_local_dotenv_overrides_global(): +def test_stored_key_is_found_and_reported(): credentials.GLOBAL_ENV_PATH.write_text("HAI_API_KEY=hk-global\n") - credentials.LOCAL_ENV_PATH.write_text("HAI_API_KEY=hk-local\n") - assert credentials.resolve_api_key() == "hk-local" - assert credentials.source() == str(credentials.LOCAL_ENV_PATH) + assert credentials.resolve_api_key() == "hk-global" + assert credentials.source() == str(credentials.GLOBAL_ENV_PATH) def test_missing_key_raises_with_guidance(): @@ -146,6 +149,48 @@ def get_session_quota(self): assert "rejected this key" in _error_text(result) +@pytest.mark.parametrize("client_type", [Client, AsyncClient]) +def test_sdk_client_falls_back_to_the_key_hai_login_stored(monkeypatch, client_type): + with pytest.raises(ApiError, match="hai login"): + client_type() + + credentials.save_api_key("hk-stored") + monkeypatch.delenv(credentials.API_KEY_VAR) + assert client_type()._client_wrapper._get_api_key() == "hk-stored" + + monkeypatch.setenv(credentials.API_KEY_VAR, "hk-env") + assert client_type()._client_wrapper._get_api_key() == "hk-env" + assert client_type(api_key="hk-arg")._client_wrapper._get_api_key() == "hk-arg" + + +def test_commands_sign_in_on_first_use_then_run(monkeypatch): + listed = [] + fake = SimpleNamespace( + sessions=SimpleNamespace(get_session_quota=lambda: None), + agents=SimpleNamespace(list_agents=lambda **_: listed.append(True) or SimpleNamespace(items=[])), + ) + monkeypatch.setattr(app_module, "make_client", lambda **_: fake) + monkeypatch.setattr(app_module, "_interactive", lambda: True) + + result = runner.invoke(app, ["agents", "list"], input="2\nhk-pasted\n") + monkeypatch.delenv(credentials.API_KEY_VAR, raising=False) + + assert result.exit_code == 0, _error_text(result) + assert "hk-pasted" in credentials.GLOBAL_ENV_PATH.read_text() + assert listed == [True] + + +@pytest.mark.parametrize(("args", "interactive"), [(["agents", "list"], False), (["--json", "agents", "list"], True)]) +def test_scripts_never_get_a_sign_in_prompt(monkeypatch, args, interactive): + monkeypatch.setattr(app_module, "_interactive", lambda: interactive) + + result = runner.invoke(app, args, input="2\nhk-pasted\n") + + assert result.exit_code == 1 + assert "No API key found" in _error_text(result) + assert not credentials.GLOBAL_ENV_PATH.exists() + + def _error_text(result) -> str: return "\n".join(part for part in (result.output, result.stderr, str(result.exception)) if part) diff --git a/tests/test_mcp_install.py b/tests/test_mcp_install.py index d3cc65b..042d3ec 100644 --- a/tests/test_mcp_install.py +++ b/tests/test_mcp_install.py @@ -95,7 +95,6 @@ def test_install_requires_api_key(monkeypatch, tmp_path) -> None: from hai_agents_common import credentials monkeypatch.delenv("HAI_API_KEY", raising=False) - monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env") monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env") result = runner.invoke(app, ["mcp", "install", "cursor"], env={}) diff --git a/uv.lock b/uv.lock index 02200f0..c05cdb0 100644 --- a/uv.lock +++ b/uv.lock @@ -541,7 +541,7 @@ wheels = [ [[package]] name = "hai-agents" -version = "1.0.16" +version = "1.0.17" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -602,7 +602,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.4.0,<2" }, { name = "python-dotenv", specifier = ">=1.2.3,<2" }, { name = "rich", specifier = ">=15.0.0,<16" }, - { name = "ruff", specifier = "==0.16.7" }, + { name = "ruff", specifier = "==0.16.9" }, { name = "typer", specifier = ">=0.27.2,<1" }, ] @@ -1646,27 +1646,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, - { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, - { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] [[package]]