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..1e1767a 100644 --- a/README.md +++ b/README.md @@ -29,25 +29,59 @@ 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. + +```json +{ + "default_context": "production", + "contexts": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "~/.config/kognic/credentials-prod.json" + }, + "demo": { + "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. -The package provides a command-line interface for generating access tokens, great for MCP integrations. +### 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 +91,78 @@ 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 demo + +# Using a context but overriding the server +kognic-auth get-access-token --context demo --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] [--format FORMAT] [--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) +- `--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`) + +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://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"}' + +# Custom headers +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.) + ## 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..a3fc944 --- /dev/null +++ b/src/kognic/auth/cli/call.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import argparse +import csv +import io +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)" + ) + 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: + 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 _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: + 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) + + +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, output_format=parsed.output_format) + 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..6e09ef3 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,788 @@ 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") + @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): + 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..b988586 --- /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", + }, + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.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) + + 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: + 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", + ), + "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", "demo") + self.assertEqual(ctx.name, "demo") + + 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_demo_exact_match(self): + ctx = resolve_context(self.config, "https://demo.kognic.com/v1/projects") + self.assertEqual(ctx.name, "demo") + + 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") + 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={ + "demo": Context( + name="demo", + host="demo.kognic.com", + auth_server="https://auth.demo.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" },