From c728e9b0bbc9b8dbd869bed91d21149af2076bb5 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 05:13:07 +0100 Subject: [PATCH 1/4] Add `kognic-auth call` subcommand for authenticated HTTP requests Adds a `call` subcommand that resolves credentials from a config file (~/.config/kognic/config.json) based on URL domain matching, then makes authenticated API requests. Also adds PTH ruff lint rule and migrates existing code to use pathlib. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- README.md | 94 ++++++- pyproject.toml | 2 +- src/kognic/auth/cli.py | 72 ----- src/kognic/auth/cli/__init__.py | 38 +++ src/kognic/auth/cli/call.py | 109 +++++++ src/kognic/auth/cli/get_access_token.py | 72 +++++ src/kognic/auth/config.py | 92 ++++++ src/kognic/auth/credentials_parser.py | 4 +- tests/test_cli.py | 359 +++++++++++++++++++++++- tests/test_config.py | 144 ++++++++++ uv.lock | 170 +++++------ 12 files changed, 979 insertions(+), 180 deletions(-) delete mode 100644 src/kognic/auth/cli.py create mode 100644 src/kognic/auth/cli/__init__.py create mode 100644 src/kognic/auth/cli/call.py create mode 100644 src/kognic/auth/cli/get_access_token.py create mode 100644 src/kognic/auth/config.py create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore index ff363f3..e4bad8a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ **/_debug/** **/key.json -src/kognic/auth/_version.py \ No newline at end of file +src/kognic/auth/_version.py +.claude diff --git a/README.md b/README.md index 70823bd..969817b 100644 --- a/README.md +++ b/README.md @@ -31,23 +31,55 @@ sess.get("https://api.app.kognic.com") ## CLI -The package provides a command-line interface for generating access tokens, great for MCP integrations. +The package provides a command-line interface for generating access tokens and making authenticated API calls. +This is great for LLM use cases, the `kognic-auth call` is a lightweight curl, that hides any complexity of authentication and context management, +so you can just focus on the API call you want to make. This also avoids tokens being leaked to the shell history, +as you can use named contexts and config files to manage your credentials. + +### Configuration file + +The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named contexts for different environments, each with its own host, auth server, and credentials. + +```json +{ + "default_context": "production", + "contexts": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "~/.config/kognic/credentials-prod.json" + }, + "staging": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "~/.config/kognic/credentials-demo.json" + } + } +} +``` + +Each context has the following fields: +- `host` - The API hostname, used by `call` to automatically match a context based on the request URL. +- `auth_server` - The OAuth server URL used to fetch tokens. +- `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables. + +`default_context` specifies which context to use as a fallback when no `--context` flag is given and no URL match is found. + +### get-access-token + +Generate an access token for Kognic API authentication. ```bash -kognic-auth get-access-token [--server SERVER] [--credentials FILE] +kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--context NAME] [--config FILE] ``` **Options:** - `--server` - Authentication server URL (default: `https://auth.app.kognic.com`) - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. +- `--context` - Use a named context from the config file. +- `--config` - Config file path (default: `~/.config/kognic/config.json`) -The token endpoint is constructed by appending `/v1/auth/oauth/token` to the server URL. For example: -- Default: `https://auth.app.kognic.com/v1/auth/oauth/token` -- Custom: `https://custom.server/v1/auth/oauth/token` - -**Exit codes:** -- `0` - Success -- `1` - Error (missing credentials, invalid credentials file, authentication failure, etc.) +When `--context` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the context values. **Examples:** ```bash @@ -57,9 +89,49 @@ kognic-auth get-access-token # Using a credentials file kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json -# Using a custom authentication server -kognic-auth get-access-token --server https://auth..kognic.com +# Using a named context +kognic-auth get-access-token --context staging + +# Using a context but overriding the server +kognic-auth get-access-token --context staging --server https://custom.server +``` + +### call + +Make an authenticated HTTP request to a Kognic API. + +```bash +kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--context NAME] [--config FILE] ``` +**Options:** +- `URL` - Full URL to call +- `-X`, `--request` - HTTP method (default: `GET`) +- `-d`, `--data` - Request body (JSON string) +- `-H`, `--header` - Header in `Key: Value` format (repeatable) +- `--context` - Force a specific context (skip URL-based matching) +- `--config` - Config file path (default: `~/.config/kognic/config.json`) + +When `--context` is not provided, the context is automatically resolved by matching the request URL's hostname against the `host` field of each context in the config file. + +**Examples:** +```bash +# GET request (default method), context auto-resolved from URL hostname +kognic-auth call https://app.kognic.com/v1/projects + +# Explicit context +kognic-auth call https://staging.kognic.com/v1/projects --context staging + +# POST with JSON body +kognic-auth call https://app.kognic.com/v1/projects -X POST -d '{"name": "test"}' + +# Custom headers +kognic-auth call https://app.kognic.com/v1/projects -H "Accept: application/json" +``` + +**Exit codes:** +- `0` - Success (HTTP 2xx) +- `1` - Error (HTTP error, missing credentials, invalid input, etc.) + ## Changelog See Github releases from v3.1.0, historic changelog is available in CHANGELOG.md diff --git a/pyproject.toml b/pyproject.toml index 8032f77..8a8c4f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,4 +58,4 @@ line-length = 120 target-version = "py38" [tool.ruff.lint] -select = ["E", "F", "B", "W", "I001"] +select = ["E", "F", "B", "W", "I001", "PTH"] diff --git a/src/kognic/auth/cli.py b/src/kognic/auth/cli.py deleted file mode 100644 index 1671c0f..0000000 --- a/src/kognic/auth/cli.py +++ /dev/null @@ -1,72 +0,0 @@ -import argparse -import sys - -from kognic.auth import DEFAULT_HOST - - -def create_parser(): - parser = argparse.ArgumentParser( - prog="kognic-auth", - description="Kognic authentication CLI", - ) - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # get-access-token subcommand - token_parser = subparsers.add_parser( - "get-access-token", - help="Generate an access token for Kognic API authentication", - ) - token_parser.add_argument( - "--server", - default=DEFAULT_HOST, - help=f"Authentication server URL (default: {DEFAULT_HOST})", - ) - token_parser.add_argument( - "--credentials", - metavar="FILE", - help="Path to JSON credentials file. If not provided, credentials are read from environment variables.", - ) - - return parser - - -def get_access_token(parsed): - try: - from kognic.auth.requests.auth_session import RequestsAuthSession - except ImportError: - print("Error: requests library is required. Install with: pip install kognic-auth[requests]", file=sys.stderr) - return 1 - - try: - session = RequestsAuthSession( - auth=parsed.credentials, - host=parsed.server, - ) - # Access .session to trigger token fetch - _ = session.session - print(session.access_token) - return 0 - except FileNotFoundError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error fetching token: {e}", file=sys.stderr) - return 1 - - -def main(args=None): - parser = create_parser() - parsed = parser.parse_args(args) - - if parsed.command == "get-access-token": - return get_access_token(parsed) - else: - parser.print_help() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py new file mode 100644 index 0000000..2941bcc --- /dev/null +++ b/src/kognic/auth/cli/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse +import sys +from types import ModuleType + +from kognic.auth.cli import call, get_access_token + +_SUBCOMMANDS: list[ModuleType] = [get_access_token, call] + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="kognic-auth", + description="Kognic authentication CLI", + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + for subcommand in _SUBCOMMANDS: + subcommand.register_parser(subparsers) + + return parser + + +def main(args: list[str] | None = None) -> int: + parser = create_parser() + parsed = parser.parse_args(args) + + for subcommand in _SUBCOMMANDS: + if parsed.command == subcommand.COMMAND: + return subcommand.run(parsed) + + parser.print_help() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/call.py new file mode 100644 index 0000000..7afadd8 --- /dev/null +++ b/src/kognic/auth/cli/call.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from kognic.auth.config import DEFAULT_CONFIG_PATH, load_config, resolve_context +from kognic.auth.requests.auth_session import RequestsAuthSession + +COMMAND = "call" + + +def register_parser(subparsers: argparse._SubParsersAction) -> None: + call_parser = subparsers.add_parser( + COMMAND, + help="Make an authenticated HTTP request to a Kognic API", + ) + call_parser.add_argument("url", metavar="URL", help="Full URL to call") + call_parser.add_argument( + "-X", + "--request", + dest="method", + default="GET", + metavar="METHOD", + help="HTTP method (default: GET)", + ) + call_parser.add_argument("-d", "--data", help="Request body (JSON string)") + call_parser.add_argument( + "-H", + "--header", + action="append", + dest="headers", + metavar="HDR", + help="Header in 'Key: Value' format (repeatable)", + ) + call_parser.add_argument( + "--config", + default=DEFAULT_CONFIG_PATH, + help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", + ) + call_parser.add_argument( + "--context", dest="context_name", help="Force a specific context (skip URL-based matching)" + ) + + +def _parse_headers(raw: list[str] | None) -> dict[str, str] | None: + if not raw: + return None + headers: dict[str, str] = {} + for h in raw: + if ": " not in h: + raise ValueError(f"Invalid header format '{h}'. Expected 'Key: Value'.") + key, value = h.split(": ", 1) + headers[key] = value + return headers + + +def _parse_body(raw: str | None, headers: dict[str, str]) -> Any: + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON data: {e}") from e + headers.setdefault("Content-Type", "application/json") + return data + + +def _print_response(response: Any) -> None: + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type: + try: + print(json.dumps(response.json(), indent=2)) + except (json.JSONDecodeError, ValueError): + print(response.text) + else: + print(response.text) + + +def run(parsed: argparse.Namespace) -> int: + try: + config = load_config(parsed.config) + context = resolve_context(config, parsed.url, parsed.context_name) + + session = RequestsAuthSession( + auth=context.credentials, + host=context.auth_server, + ) + + headers = _parse_headers(parsed.headers) or {} + data = _parse_body(parsed.data, headers) + + response = session.session.request( + method=parsed.method.upper(), + url=parsed.url, + json=data if data is not None else None, + headers=headers if headers else None, + ) + + _print_response(response) + return 0 if response.ok else 1 + + except (FileNotFoundError, ValueError, json.JSONDecodeError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py new file mode 100644 index 0000000..355166a --- /dev/null +++ b/src/kognic/auth/cli/get_access_token.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import sys + +from kognic.auth import DEFAULT_HOST +from kognic.auth.config import DEFAULT_CONFIG_PATH, load_config +from kognic.auth.requests.auth_session import RequestsAuthSession + +COMMAND = "get-access-token" + + +def register_parser(subparsers: argparse._SubParsersAction) -> None: + token_parser = subparsers.add_parser( + COMMAND, + help="Generate an access token for Kognic API authentication", + ) + token_parser.add_argument( + "--server", + default=None, + help=f"Authentication server URL (default: {DEFAULT_HOST})", + ) + token_parser.add_argument( + "--credentials", + metavar="FILE", + help="Path to JSON credentials file. If not provided, credentials are read from environment variables.", + ) + token_parser.add_argument( + "--config", + default=DEFAULT_CONFIG_PATH, + help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", + ) + token_parser.add_argument( + "--context", + dest="context_name", + help="Use a specific context from the config file", + ) + + +def run(parsed: argparse.Namespace) -> int: + try: + host = parsed.server + credentials = parsed.credentials + + if parsed.context_name: + config = load_config(parsed.config) + if parsed.context_name not in config.contexts: + print(f"Error: Unknown context: {parsed.context_name}", file=sys.stderr) + return 1 + ctx = config.contexts[parsed.context_name] + if host is None: + host = ctx.auth_server + if credentials is None: + credentials = ctx.credentials + + session = RequestsAuthSession( + auth=credentials, + host=host or DEFAULT_HOST, + ) + # Access .session to trigger token fetch + _ = session.session + print(session.access_token) + return 0 + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error fetching token: {e}", file=sys.stderr) + return 1 diff --git a/src/kognic/auth/config.py b/src/kognic/auth/config.py new file mode 100644 index 0000000..2c80da1 --- /dev/null +++ b/src/kognic/auth/config.py @@ -0,0 +1,92 @@ +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from kognic.auth import DEFAULT_HOST + +DEFAULT_CONFIG_PATH = str(Path("~") / ".config" / "kognic" / "config.json") + + +@dataclass +class Context: + name: str + host: str + auth_server: str + credentials: Optional[str] = None + + +@dataclass +class Config: + contexts: dict = field(default_factory=dict) + default_context: Optional[str] = None + + +def load_config(path: str = DEFAULT_CONFIG_PATH) -> Config: + """Load config from JSON file. Returns empty Config if file doesn't exist.""" + expanded = Path(path).expanduser() + if not expanded.exists(): + return Config() + + data = json.loads(expanded.read_text()) + + contexts = {} + for name, ctx_data in data.get("contexts", {}).items(): + credentials = ctx_data.get("credentials") + if credentials: + credentials = str(Path(credentials).expanduser()) + contexts[name] = Context( + name=name, + host=ctx_data["host"], + auth_server=ctx_data["auth_server"], + credentials=credentials, + ) + + return Config( + contexts=contexts, + default_context=data.get("default_context"), + ) + + +def resolve_context(config: Config, url: str, context_name: Optional[str] = None) -> Context: + """Resolve which context to use for a given URL. + + Resolution order: + 1. Explicit context_name (--context flag) + 2. Exact host match from URL + 3. Subdomain suffix match from URL + 4. default_context from config + 5. Fallback to default auth server with no credentials file + """ + # Explicit context name + if context_name: + if context_name not in config.contexts: + raise ValueError(f"Unknown context: {context_name}") + return config.contexts[context_name] + + # Domain matching + parsed = urlparse(url) + hostname = parsed.hostname or "" + + # Exact match + for ctx in config.contexts.values(): + if hostname == ctx.host: + return ctx + + # Subdomain suffix match + for ctx in config.contexts.values(): + if hostname.endswith("." + ctx.host): + return ctx + + # Default context fallback + if config.default_context and config.default_context in config.contexts: + return config.contexts[config.default_context] + + # No config at all — use default auth server with env var credentials + return Context( + name="default", + host="app.kognic.com", + auth_server=DEFAULT_HOST, + credentials=None, + ) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index f888e0e..cb92a65 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -1,6 +1,7 @@ import json import os from dataclasses import dataclass +from pathlib import Path from typing import Optional, Union REQUIRED_CREDENTIALS_FILE_KEYS = [ @@ -26,8 +27,7 @@ def parse_credentials(path: Union[str, dict]): credentials = path else: try: - with open(path) as f: - credentials = json.load(f) + credentials = json.loads(Path(path).read_text()) except FileNotFoundError: raise FileNotFoundError(f"Could not find Api Credentials file at {path}") from None diff --git a/tests/test_cli.py b/tests/test_cli.py index f5d7cc0..f7bc8ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,16 +1,20 @@ +import json import unittest from unittest import mock from kognic.auth import DEFAULT_HOST from kognic.auth.cli import create_parser, main +from kognic.auth.cli.call import run as call_run +from kognic.auth.config import Context class CliParserTest(unittest.TestCase): def test_default_server(self): parser = create_parser() args = parser.parse_args(["get-access-token"]) - self.assertEqual(args.server, DEFAULT_HOST) + self.assertIsNone(args.server) self.assertIsNone(args.credentials) + self.assertIsNone(args.context_name) def test_custom_server(self): parser = create_parser() @@ -30,6 +34,11 @@ def test_all_options(self): self.assertEqual(args.server, "https://my.server") self.assertEqual(args.credentials, "my_creds.json") + def test_get_access_token_with_context(self): + parser = create_parser() + args = parser.parse_args(["get-access-token", "--context", "demo"]) + self.assertEqual(args.context_name, "demo") + def test_no_command_shows_help(self): with mock.patch("builtins.print"): result = main([]) @@ -37,7 +46,7 @@ def test_no_command_shows_help(self): class CliMainTest(unittest.TestCase): - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_prints_token(self, mock_session_class): mock_session = mock.MagicMock() mock_session.access_token = "test-access-token-123" @@ -50,7 +59,7 @@ def test_main_prints_token(self, mock_session_class): mock_print.assert_called_once_with("test-access-token-123") mock_session_class.assert_called_once_with(auth=None, host=DEFAULT_HOST) - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_with_credentials_file(self, mock_session_class): mock_session = mock.MagicMock() mock_session.access_token = "token-from-file" @@ -63,7 +72,7 @@ def test_main_with_credentials_file(self, mock_session_class): mock_print.assert_called_once_with("token-from-file") mock_session_class.assert_called_once_with(auth="/path/to/creds.json", host=DEFAULT_HOST) - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_with_custom_server(self, mock_session_class): mock_session = mock.MagicMock() mock_session.access_token = "custom-server-token" @@ -76,7 +85,7 @@ def test_main_with_custom_server(self, mock_session_class): mock_print.assert_called_once_with("custom-server-token") mock_session_class.assert_called_once_with(auth=None, host="https://custom.server") - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_with_all_options(self, mock_session_class): mock_session = mock.MagicMock() mock_session.access_token = "full-options-token" @@ -88,7 +97,7 @@ def test_main_with_all_options(self, mock_session_class): self.assertEqual(result, 0) mock_session_class.assert_called_once_with(auth="creds.json", host="https://my.server") - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_file_not_found(self, mock_session_class): mock_session_class.side_effect = FileNotFoundError("Could not find Api Credentials file at /bad/path.json") @@ -99,7 +108,7 @@ def test_main_file_not_found(self, mock_session_class): mock_print.assert_called_once() self.assertIn("Error:", mock_print.call_args[0][0]) - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_value_error(self, mock_session_class): mock_session_class.side_effect = ValueError("Bad auth credentials") @@ -110,7 +119,7 @@ def test_main_value_error(self, mock_session_class): mock_print.assert_called_once() self.assertIn("Error:", mock_print.call_args[0][0]) - @mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") def test_main_generic_exception(self, mock_session_class): mock_session_class.side_effect = Exception("Network error") @@ -121,6 +130,340 @@ def test_main_generic_exception(self, mock_session_class): mock_print.assert_called_once() self.assertIn("Error fetching token:", mock_print.call_args[0][0]) + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.load_config") + def test_main_with_context(self, mock_load_config, mock_session_class): + from kognic.auth.config import Config + + mock_load_config.return_value = Config( + contexts={ + "demo": Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", + ), + }, + ) + mock_session = mock.MagicMock() + mock_session.access_token = "demo-token" + mock_session_class.return_value = mock_session + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--context", "demo"]) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("demo-token") + mock_session_class.assert_called_once_with( + auth="/path/to/demo-creds.json", + host="https://auth.demo.kognic.com", + ) + + @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") + @mock.patch("kognic.auth.cli.get_access_token.load_config") + def test_main_with_context_server_override(self, mock_load_config, mock_session_class): + from kognic.auth.config import Config + + mock_load_config.return_value = Config( + contexts={ + "demo": Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", + ), + }, + ) + mock_session = mock.MagicMock() + mock_session.access_token = "override-token" + mock_session_class.return_value = mock_session + + with mock.patch("builtins.print"): + result = main(["get-access-token", "--context", "demo", "--server", "https://custom.server"]) + + self.assertEqual(result, 0) + mock_session_class.assert_called_once_with( + auth="/path/to/demo-creds.json", + host="https://custom.server", + ) + + def test_main_with_unknown_context(self): + with mock.patch("kognic.auth.cli.get_access_token.load_config") as mock_load_config: + from kognic.auth.config import Config + + mock_load_config.return_value = Config() + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--context", "nonexistent"]) + + self.assertEqual(result, 1) + mock_print.assert_called_once() + self.assertIn("Unknown context", mock_print.call_args[0][0]) + + +class CallParserTest(unittest.TestCase): + def test_call_basic(self): + parser = create_parser() + args = parser.parse_args(["call", "https://app.kognic.com/v1/projects"]) + self.assertEqual(args.command, "call") + self.assertEqual(args.method, "GET") + self.assertEqual(args.url, "https://app.kognic.com/v1/projects") + self.assertIsNone(args.data) + self.assertIsNone(args.headers) + self.assertIsNone(args.context_name) + + def test_call_with_method(self): + parser = create_parser() + args = parser.parse_args(["call", "-X", "POST", "https://app.kognic.com/v1/projects"]) + self.assertEqual(args.method, "POST") + + def test_call_with_data(self): + parser = create_parser() + args = parser.parse_args(["call", "https://app.kognic.com/v1/projects", "-X", "POST", "-d", '{"name": "test"}']) + self.assertEqual(args.method, "POST") + self.assertEqual(args.data, '{"name": "test"}') + + def test_call_with_headers(self): + parser = create_parser() + args = parser.parse_args( + [ + "call", + "https://app.kognic.com/v1/projects", + "-H", + "Accept: application/json", + "-H", + "X-Custom: value", + ] + ) + self.assertEqual(args.headers, ["Accept: application/json", "X-Custom: value"]) + + def test_call_with_context(self): + parser = create_parser() + args = parser.parse_args(["call", "https://demo.kognic.com/v1/projects", "--context", "demo"]) + self.assertEqual(args.context_name, "demo") + + def test_call_with_config(self): + parser = create_parser() + args = parser.parse_args(["call", "https://app.kognic.com/v1/projects", "--config", "/custom/config.json"]) + self.assertEqual(args.config, "/custom/config.json") + + +class CallApiTest(unittest.TestCase): + def _make_parsed( + self, + method="GET", + url="https://app.kognic.com/v1/projects", + data=None, + headers=None, + config="/nonexistent/config.json", + context_name=None, + ): + parser = create_parser() + args = ["call", url] + if method != "GET": + args.extend(["-X", method]) + if data: + args.extend(["-d", data]) + if headers: + for h in headers: + args.extend(["-H", h]) + args.extend(["--config", config]) + if context_name: + args.extend(["--context", context_name]) + return parser.parse_args(args) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"projects": []} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_session.session.request.assert_called_once_with( + method="GET", + url="https://app.kognic.com/v1/projects", + json=None, + headers=None, + ) + mock_print.assert_called_once_with(json.dumps({"projects": []}, indent=2)) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_post_with_data(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"id": 1} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed(method="POST", data='{"name": "test"}') + with mock.patch("builtins.print"): + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_session.session.request.assert_called_once_with( + method="POST", + url="https://app.kognic.com/v1/projects", + json={"name": "test"}, + headers={"Content-Type": "application/json"}, + ) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "text/plain"} + mock_response.text = "OK" + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed(headers=["Accept: text/plain", "X-Custom: value"]) + with mock.patch("builtins.print"): + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_session.session.request.assert_called_once_with( + method="GET", + url="https://app.kognic.com/v1/projects", + json=None, + headers={"Accept": "text/plain", "X-Custom": "value"}, + ) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = False + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"error": "not found"} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + with mock.patch("builtins.print"): + result = call_run(parsed) + + self.assertEqual(result, 1) + + def test_call_api_invalid_json_data(self): + parsed = self._make_parsed(data="not json") + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 1) + error_output = mock_print.call_args[0][0] + self.assertIn("Invalid JSON data", error_output) + + def test_call_api_invalid_header_format(self): + parsed = self._make_parsed(headers=["BadHeader"]) + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 1) + error_output = mock_print.call_args[0][0] + self.assertIn("Invalid header format", error_output) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_plain_text_response(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "text/plain"} + mock_response.text = "Hello World" + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with("Hello World") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", + ) + + with mock.patch("kognic.auth.cli.call.RequestsAuthSession") as mock_session_class: + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "text/plain"} + mock_response.text = "ok" + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed(url="https://demo.kognic.com/v1/projects") + with mock.patch("builtins.print"): + call_run(parsed) + + mock_session_class.assert_called_once_with( + auth="/path/to/demo-creds.json", + host="https://auth.demo.kognic.com", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b3e9539 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,144 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from kognic.auth import DEFAULT_HOST +from kognic.auth.config import Config, Context, load_config, resolve_context + + +class LoadConfigTest(unittest.TestCase): + def test_missing_file_returns_empty_config(self): + config = load_config("/nonexistent/path/config.json") + self.assertEqual(config.contexts, {}) + self.assertIsNone(config.default_context) + + def test_valid_config(self): + data = { + "contexts": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "~/creds.json", + }, + "staging": { + "host": "staging.kognic.com", + "auth_server": "https://auth.staging.kognic.com", + }, + }, + "default_context": "production", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + config = load_config(f.name) + + Path(f.name).unlink() + + self.assertEqual(len(config.contexts), 2) + self.assertEqual(config.default_context, "production") + + prod = config.contexts["production"] + self.assertEqual(prod.name, "production") + self.assertEqual(prod.host, "app.kognic.com") + self.assertEqual(prod.auth_server, "https://auth.app.kognic.com") + self.assertTrue(prod.credentials.endswith("creds.json")) + self.assertNotIn("~", prod.credentials) + + staging = config.contexts["staging"] + self.assertIsNone(staging.credentials) + + def test_invalid_json_raises(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not valid json{") + f.flush() + with self.assertRaises(json.JSONDecodeError): + load_config(f.name) + Path(f.name).unlink() + + def test_empty_contexts(self): + data = {"contexts": {}} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + config = load_config(f.name) + Path(f.name).unlink() + + self.assertEqual(config.contexts, {}) + self.assertIsNone(config.default_context) + + +class ResolveContextTest(unittest.TestCase): + def setUp(self): + self.config = Config( + contexts={ + "production": Context( + name="production", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials="/path/to/prod-creds.json", + ), + "staging": Context( + name="staging", + host="staging.kognic.com", + auth_server="https://auth.staging.kognic.com", + credentials="/path/to/staging-creds.json", + ), + }, + default_context="production", + ) + + def test_explicit_context(self): + ctx = resolve_context(self.config, "https://anything.com/v1/foo", "staging") + self.assertEqual(ctx.name, "staging") + + def test_explicit_context_unknown_raises(self): + with self.assertRaises(ValueError) as cm: + resolve_context(self.config, "https://anything.com", "nonexistent") + self.assertIn("Unknown context", str(cm.exception)) + + def test_exact_host_match(self): + ctx = resolve_context(self.config, "https://app.kognic.com/v1/projects") + self.assertEqual(ctx.name, "production") + + def test_subdomain_match(self): + ctx = resolve_context(self.config, "https://api.app.kognic.com/v1/projects") + self.assertEqual(ctx.name, "production") + + def test_staging_exact_match(self): + ctx = resolve_context(self.config, "https://staging.kognic.com/v1/projects") + self.assertEqual(ctx.name, "staging") + + def test_staging_subdomain_match(self): + ctx = resolve_context(self.config, "https://api.staging.kognic.com/v1/projects") + self.assertEqual(ctx.name, "staging") + + def test_default_context_fallback(self): + ctx = resolve_context(self.config, "https://unknown.example.com/v1/foo") + self.assertEqual(ctx.name, "production") + + def test_no_config_fallback(self): + empty_config = Config() + ctx = resolve_context(empty_config, "https://app.kognic.com/v1/projects") + self.assertEqual(ctx.name, "default") + self.assertEqual(ctx.auth_server, DEFAULT_HOST) + self.assertIsNone(ctx.credentials) + + def test_no_default_no_match_falls_back_to_default_auth(self): + config = Config( + contexts={ + "staging": Context( + name="staging", + host="staging.kognic.com", + auth_server="https://auth.staging.kognic.com", + ), + }, + default_context=None, + ) + ctx = resolve_context(config, "https://unknown.example.com/v1/foo") + self.assertEqual(ctx.name, "default") + self.assertEqual(ctx.auth_server, DEFAULT_HOST) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 3164d7e..7e7f828 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.8" +requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", "python_full_version < '3.10'", @@ -9,7 +9,7 @@ resolution-markers = [ [[package]] name = "anyio" version = "4.12.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, @@ -22,20 +22,20 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" -source = { registry = "https://pypi.org/simple" } +version = "1.6.7" +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, ] [[package]] name = "certifi" version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, @@ -44,10 +44,10 @@ wheels = [ [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -139,7 +139,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, @@ -244,7 +244,7 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -252,68 +252,68 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.4" -source = { registry = "https://pypi.org/simple" } +version = "46.0.5" +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -325,7 +325,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -334,7 +334,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -347,7 +347,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "idna" version = "3.11" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, @@ -371,7 +371,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version < '3.10'", ] @@ -383,7 +383,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] @@ -414,8 +414,8 @@ requests = [ [package.dev-dependencies] dev = [ { name = "httpx" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10'" }, { name = "requests" }, ] @@ -439,7 +439,7 @@ dev = [ [[package]] name = "packaging" version = "26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, @@ -448,7 +448,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -457,7 +457,7 @@ wheels = [ [[package]] name = "pycparser" version = "2.23" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version < '3.10'", ] @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] @@ -481,7 +481,7 @@ wheels = [ [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, @@ -490,14 +490,14 @@ wheels = [ [[package]] name = "pytest" version = "8.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10'" }, { name = "packaging", marker = "python_full_version < '3.10'" }, { name = "pluggy", marker = "python_full_version < '3.10'" }, { name = "pygments", marker = "python_full_version < '3.10'" }, @@ -511,14 +511,14 @@ wheels = [ [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging", marker = "python_full_version >= '3.10'" }, { name = "pluggy", marker = "python_full_version >= '3.10'" }, { name = "pygments", marker = "python_full_version >= '3.10'" }, @@ -532,7 +532,7 @@ wheels = [ [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -547,7 +547,7 @@ wheels = [ [[package]] name = "tomli" version = "2.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, @@ -601,7 +601,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -610,7 +610,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.kognic.io/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, From e055ea08cd2623dc651f305b4f6cae0cba20568f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 11:51:06 +0100 Subject: [PATCH 2/4] Add --format option to `kognic-auth call` with jsonl, csv, tsv, and table output Supports automatic flattening of JSON responses that are top-level arrays or single-key objects wrapping an array (e.g. {"data": [...]}). Nested values are JSON-serialized in tabular formats. Co-Authored-By: Claude Opus 4.6 --- README.md | 35 ++- src/kognic/auth/cli/call.py | 90 +++++++- tests/test_cli.py | 448 ++++++++++++++++++++++++++++++++++++ tests/test_config.py | 44 ++-- 4 files changed, 589 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 969817b..c3a11a9 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. Th "auth_server": "https://auth.app.kognic.com", "credentials": "~/.config/kognic/credentials-prod.json" }, - "staging": { + "demo": { "host": "demo.kognic.com", "auth_server": "https://auth.demo.kognic.com", "credentials": "~/.config/kognic/credentials-demo.json" @@ -101,7 +101,7 @@ kognic-auth get-access-token --context staging --server https://custom.server Make an authenticated HTTP request to a Kognic API. ```bash -kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--context NAME] [--config FILE] +kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--context NAME] [--config FILE] ``` **Options:** @@ -109,6 +109,7 @@ kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--context NAME] [--confi - `-X`, `--request` - HTTP method (default: `GET`) - `-d`, `--data` - Request body (JSON string) - `-H`, `--header` - Header in `Key: Value` format (repeatable) +- `--format` - Output format (default: `json`). See [Output formats](#output-formats) below. - `--context` - Force a specific context (skip URL-based matching) - `--config` - Config file path (default: `~/.config/kognic/config.json`) @@ -120,7 +121,7 @@ When `--context` is not provided, the context is automatically resolved by match kognic-auth call https://app.kognic.com/v1/projects # Explicit context -kognic-auth call https://staging.kognic.com/v1/projects --context staging +kognic-auth call https://demo.kognic.com/v1/projects --context demo # POST with JSON body kognic-auth call https://app.kognic.com/v1/projects -X POST -d '{"name": "test"}' @@ -129,6 +130,34 @@ kognic-auth call https://app.kognic.com/v1/projects -X POST -d '{"name": "test"} kognic-auth call https://app.kognic.com/v1/projects -H "Accept: application/json" ``` +#### Output formats + +The `--format` option controls how JSON responses are printed. For `jsonl`, `csv`, `tsv`, and `table`, the command automatically extracts the list from responses that are either a top-level JSON array or a JSON object with a single key holding an array (e.g. `{"data": [...]}`). If the response doesn't match this shape, it falls back to pretty-printed JSON. + +| Format | Description | +|---------|-------------| +| `json` | Pretty-printed JSON (default) | +| `jsonl` | One JSON object per line ([JSON Lines](https://jsonlines.org/)) | +| `csv` | Comma-separated values with a header row | +| `tsv` | Tab-separated values with a header row | +| `table` | Markdown table with aligned columns | + +Nested values (dicts and lists) are JSON-serialized in `csv`, `tsv`, and `table` output. + +```bash +# One JSON object per line, useful for piping to jq or grep +kognic-auth call https://app.kognic.com/v1/projects --format=jsonl + +# CSV output +kognic-auth call https://app.kognic.com/v1/projects --format=csv + +# TSV output, easy to paste into spreadsheets +kognic-auth call https://app.kognic.com/v1/projects --format=tsv + +# Markdown table +kognic-auth call https://app.kognic.com/v1/projects --format=table +``` + **Exit codes:** - `0` - Success (HTTP 2xx) - `1` - Error (HTTP error, missing credentials, invalid input, etc.) diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/call.py index 7afadd8..a3fc944 100644 --- a/src/kognic/auth/cli/call.py +++ b/src/kognic/auth/cli/call.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +import csv +import io import json import sys from typing import Any @@ -42,6 +44,13 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: call_parser.add_argument( "--context", dest="context_name", help="Force a specific context (skip URL-based matching)" ) + call_parser.add_argument( + "--format", + dest="output_format", + choices=["json", "jsonl", "csv", "tsv", "table"], + default="json", + help="Output format: json (default), jsonl (one JSON object per line), csv, tsv, table (markdown)", + ) def _parse_headers(raw: list[str] | None) -> dict[str, str] | None: @@ -67,13 +76,88 @@ def _parse_body(raw: str | None, headers: dict[str, str]) -> Any: return data -def _print_response(response: Any) -> None: +def _extract_items(body: Any) -> list[Any] | None: + if isinstance(body, list): + return body + if isinstance(body, dict): + values = list(body.values()) + if len(values) == 1 and isinstance(values[0], list): + return values[0] + return None + + +def _stringify_value(value: Any) -> str: + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + + +def _print_delimited(items: list[Any], *, delimiter: str = ",") -> None: + if not items: + return + fieldnames = _collect_fieldnames(items) + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, delimiter=delimiter) + writer.writeheader() + for item in items: + if isinstance(item, dict): + writer.writerow({k: _stringify_value(v) for k, v in item.items()}) + else: + writer.writerow({"value": _stringify_value(item)}) + print(buf.getvalue(), end="") + + +def _collect_fieldnames(items: list[Any]) -> list[str]: + fieldnames: list[str] = [] + for item in items: + if isinstance(item, dict): + for key in item: + if key not in fieldnames: + fieldnames.append(key) + return fieldnames + + +def _print_table(items: list[Any]) -> None: + if not items: + return + fieldnames = _collect_fieldnames(items) + col_widths = [len(f) for f in fieldnames] + rows: list[list[str]] = [] + for item in items: + row = [ + _stringify_value(item.get(f, "")) if isinstance(item, dict) else _stringify_value(item) for f in fieldnames + ] + rows.append(row) + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], len(cell)) + header = "| " + " | ".join(f.ljust(col_widths[i]) for i, f in enumerate(fieldnames)) + " |" + separator = "|-" + "-|-".join("-" * w for w in col_widths) + "-|" + print(header) + print(separator) + for row in rows: + print("| " + " | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row)) + " |") + + +def _print_response(response: Any, *, output_format: str = "json") -> None: content_type = response.headers.get("Content-Type", "") if "application/json" in content_type: try: - print(json.dumps(response.json(), indent=2)) + body = response.json() except (json.JSONDecodeError, ValueError): print(response.text) + return + if output_format in ("jsonl", "csv", "tsv", "table"): + items = _extract_items(body) + if items is not None: + if output_format in ("csv", "tsv"): + _print_delimited(items, delimiter="\t" if output_format == "tsv" else ",") + elif output_format == "table": + _print_table(items) + else: + for item in items: + print(json.dumps(item)) + return + print(json.dumps(body, indent=2)) else: print(response.text) @@ -98,7 +182,7 @@ def run(parsed: argparse.Namespace) -> int: headers=headers if headers else None, ) - _print_response(response) + _print_response(response, output_format=parsed.output_format) return 0 if response.ok else 1 except (FileNotFoundError, ValueError, json.JSONDecodeError) as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index f7bc8ab..6e09ef3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -435,6 +435,454 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with("Hello World") + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "jsonl" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + self.assertEqual(mock_print.call_count, 2) + mock_print.assert_any_call(json.dumps({"id": 1, "name": "a"})) + mock_print.assert_any_call(json.dumps({"id": 2, "name": "b"})) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_config, mock_resolve_context): + """When --format=jsonl is used and response has a single key holding a list, flatten it.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"projects": [{"id": 1}, {"id": 2}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "jsonl" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + self.assertEqual(mock_print.call_count, 2) + mock_print.assert_any_call(json.dumps({"id": 1})) + mock_print.assert_any_call(json.dumps({"id": 2})) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config, mock_resolve_context): + """When --format=jsonl is used but response has multiple keys, pretty-print as usual.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1}], "total": 1} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "jsonl" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with(json.dumps({"data": [{"id": 1}], "total": 1}, indent=2)) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_context): + """When --format=jsonl is used and response body is a list, flatten it.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = [{"id": 1}, {"id": 2}, {"id": 3}] + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "jsonl" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + self.assertEqual(mock_print.call_count, 3) + mock_print.assert_any_call(json.dumps({"id": 1})) + mock_print.assert_any_call(json.dumps({"id": 2})) + mock_print.assert_any_call(json.dumps({"id": 3})) + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + """When --format=jsonl is used and data is an empty list, nothing is printed.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": []} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "jsonl" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_not_called() + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + output = mock_print.call_args[0][0] + lines = output.strip().split("\r\n") + self.assertEqual(lines[0], "id,name") + self.assertEqual(lines[1], "1,a") + self.assertEqual(lines[2], "2,b") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "tsv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + output = mock_print.call_args[0][0] + lines = output.strip().split("\r\n") + self.assertEqual(lines[0], "id\tname") + self.assertEqual(lines[1], "1\ta") + self.assertEqual(lines[2], "2\tb") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_table_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "name": "alice"}, {"id": 2, "name": "b"}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "table" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + lines = [call[0][0] for call in mock_print.call_args_list] + self.assertEqual(lines[0], "| id | name |") + self.assertEqual(lines[1], "|----|-------|") + self.assertEqual(lines[2], "| 1 | alice |") + self.assertEqual(lines[3], "| 2 | b |") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + """Table with empty list prints nothing.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": []} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "table" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_not_called() + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_resolve_context): + """Nested dicts and lists are JSON-serialized in CSV output.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"], "meta": {"key": "val"}}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + output = mock_print.call_args[0][0] + lines = output.strip().split("\r\n") + self.assertEqual(lines[0], "id,tags,meta") + self.assertEqual(lines[1], '1,"[""a"", ""b""]","{""key"": ""val""}"') + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_table_nested_values(self, mock_session_class, mock_load_config, mock_resolve_context): + """Nested dicts and lists are JSON-serialized in table output.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"]}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "table" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + lines = [call[0][0] for call in mock_print.call_args_list] + self.assertEqual(lines[0], "| id | tags |") + self.assertEqual(lines[1], "|----|------------|") + self.assertEqual(lines[2], '| 1 | ["a", "b"] |') + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_context): + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = [{"x": 10, "y": 20}, {"x": 30, "y": 40}] + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + output = mock_print.call_args[0][0] + lines = output.strip().split("\r\n") + self.assertEqual(lines[0], "x,y") + self.assertEqual(lines[1], "10,20") + self.assertEqual(lines[2], "30,40") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mock_resolve_context): + """CSV output includes all keys across all rows, with blanks for missing values.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "extra": "z"}]} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + output = mock_print.call_args[0][0] + lines = output.strip().split("\r\n") + self.assertEqual(lines[0], "id,name,extra") + self.assertEqual(lines[1], "1,a,") + self.assertEqual(lines[2], "2,,z") + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + """CSV with empty list prints nothing.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"data": []} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_not_called() + + @mock.patch("kognic.auth.cli.call.resolve_context") + @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config, mock_resolve_context): + """CSV with non-flattenable response falls back to pretty JSON.""" + mock_load_config.return_value = mock.MagicMock() + mock_resolve_context.return_value = Context( + name="default", + host="app.kognic.com", + auth_server="https://auth.app.kognic.com", + credentials=None, + ) + mock_session = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"a": 1, "b": 2} + mock_session.session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + parsed = self._make_parsed() + parsed.output_format = "csv" + with mock.patch("builtins.print") as mock_print: + result = call_run(parsed) + + self.assertEqual(result, 0) + mock_print.assert_called_once_with(json.dumps({"a": 1, "b": 2}, indent=2)) + @mock.patch("kognic.auth.cli.call.resolve_context") @mock.patch("kognic.auth.cli.call.load_config") def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_context): diff --git a/tests/test_config.py b/tests/test_config.py index b3e9539..b988586 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,9 +21,9 @@ def test_valid_config(self): "auth_server": "https://auth.app.kognic.com", "credentials": "~/creds.json", }, - "staging": { - "host": "staging.kognic.com", - "auth_server": "https://auth.staging.kognic.com", + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", }, }, "default_context": "production", @@ -45,8 +45,8 @@ def test_valid_config(self): self.assertTrue(prod.credentials.endswith("creds.json")) self.assertNotIn("~", prod.credentials) - staging = config.contexts["staging"] - self.assertIsNone(staging.credentials) + demo = config.contexts["demo"] + self.assertIsNone(demo.credentials) def test_invalid_json_raises(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: @@ -78,19 +78,19 @@ def setUp(self): auth_server="https://auth.app.kognic.com", credentials="/path/to/prod-creds.json", ), - "staging": Context( - name="staging", - host="staging.kognic.com", - auth_server="https://auth.staging.kognic.com", - credentials="/path/to/staging-creds.json", + "demo": Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", + credentials="/path/to/demo-creds.json", ), }, default_context="production", ) def test_explicit_context(self): - ctx = resolve_context(self.config, "https://anything.com/v1/foo", "staging") - self.assertEqual(ctx.name, "staging") + ctx = resolve_context(self.config, "https://anything.com/v1/foo", "demo") + self.assertEqual(ctx.name, "demo") def test_explicit_context_unknown_raises(self): with self.assertRaises(ValueError) as cm: @@ -105,13 +105,13 @@ def test_subdomain_match(self): ctx = resolve_context(self.config, "https://api.app.kognic.com/v1/projects") self.assertEqual(ctx.name, "production") - def test_staging_exact_match(self): - ctx = resolve_context(self.config, "https://staging.kognic.com/v1/projects") - self.assertEqual(ctx.name, "staging") + def test_demo_exact_match(self): + ctx = resolve_context(self.config, "https://demo.kognic.com/v1/projects") + self.assertEqual(ctx.name, "demo") - def test_staging_subdomain_match(self): - ctx = resolve_context(self.config, "https://api.staging.kognic.com/v1/projects") - self.assertEqual(ctx.name, "staging") + def test_demo_subdomain_match(self): + ctx = resolve_context(self.config, "https://api.demo.kognic.com/v1/projects") + self.assertEqual(ctx.name, "demo") def test_default_context_fallback(self): ctx = resolve_context(self.config, "https://unknown.example.com/v1/foo") @@ -127,10 +127,10 @@ def test_no_config_fallback(self): def test_no_default_no_match_falls_back_to_default_auth(self): config = Config( contexts={ - "staging": Context( - name="staging", - host="staging.kognic.com", - auth_server="https://auth.staging.kognic.com", + "demo": Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.kognic.com", ), }, default_context=None, From bc180cf004482d506f297658eb9ba9489b4ad51d Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 12:46:40 +0100 Subject: [PATCH 3/4] mark experimental --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c3a11a9..e205009 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,15 @@ sess = RequestsAuthSession() sess.get("https://api.app.kognic.com") ``` -## CLI +## CLI (Experimental) The package provides a command-line interface for generating access tokens and making authenticated API calls. This is great for LLM use cases, the `kognic-auth call` is a lightweight curl, that hides any complexity of authentication and context management, so you can just focus on the API call you want to make. This also avoids tokens being leaked to the shell history, as you can use named contexts and config files to manage your credentials. +The interface is currently marked experimental, and breaking changes may be made without a major version bump. Feedback is welcome to help stabilize the design. + ### Configuration file The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named contexts for different environments, each with its own host, auth server, and credentials. From f5492b5d359a0495415690cf3e199531db4ced90 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 12:49:58 +0100 Subject: [PATCH 4/4] polish README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e205009..1e1767a 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ kognic-auth get-access-token kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json # Using a named context -kognic-auth get-access-token --context staging +kognic-auth get-access-token --context demo # Using a context but overriding the server -kognic-auth get-access-token --context staging --server https://custom.server +kognic-auth get-access-token --context demo --server https://custom.server ``` ### call