Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ before the PR is mergeable.

> **Running locally without exporting a token:** if the target project is already registered in a kbagent `config.json`, use config-dir mode -- `make test-e2e-local CONFIG_DIR=/path/to/.kbagent ALIAS=my-proj`. The harness reads the token from `config.json` at import time and promotes it into `E2E_API_TOKEN` / `E2E_URL`; an explicit `E2E_API_TOKEN` still wins.

- [ ] **API call-count test for hot read paths** -- a new or changed list/detail command that users and agents run often (the `project`/`config`/`job`/`storage`/`flow` read commands and their peers) adds or updates a case in `tests/test_api_call_counts.py`. It pins the exact `(METHOD, path)` calls via `helpers.assert_api_calls`; list commands also get a 1-vs-10-items case proving the count does not grow per item. The expected lists are a ratchet: raising one is a deliberate, reviewed change -- say why in the PR
- [ ] **API call-count test for hot read paths** -- a new or changed list/detail command that users and agents run often (the `project`/`config`/`job`/`storage`/`flow` read commands and their peers) adds or updates a case in `tests/test_api_call_counts.py`. It pins the exact calls via `helpers.assert_api_calls`, which compares the method and path and, where the test pins them, the parsed query (`include_query=True`) and the token (`include_token=True`, for multi-project cases). List commands also run with 1 and 10 items and expect the same calls at both sizes: this catches a call made once per item, but not a call made once per batch or page of more than 10 items. Fixtures must look like real API payloads (several component types, a transformation with rows and storage mappings, Queue jobs with `runId`, alias and shared tables), because a per-item call that depends on a field the fixture lacks is not caught. The expected lists are a ratchet: raising one is a deliberate, reviewed change -- say why in the PR

- [ ] **Run `make check`** before committing (lint + format + full test suite)
- [ ] **Run `make typecheck`** -- `ty` must pass clean (0 diagnostics; the backlog was cleared in 0.45.0, so the gate is blocking, not warning-only)
Expand Down
5 changes: 3 additions & 2 deletions src/keboola_agent_cli/data_science_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ def __exit__(self, *args: object) -> None:
def list_apps(self) -> list[dict[str, Any]]:
"""Return the thin index of ALL deployments in the project (no body filter).

The Data Science API scopes responses by the token's project; there
is no ``branchId`` query parameter on the list endpoint.
The Data Science API scopes responses by the token's project. The list
endpoint also accepts ``componentId``, ``type`` and ``branchId``
filters; this method sends none of them.

``GET /apps`` is paginated: without ``limit``/``offset`` it returns
only a default first page (100 items) that mixes workspace
Expand Down
56 changes: 45 additions & 11 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from urllib.parse import parse_qsl, urlencode

import httpx

Expand Down Expand Up @@ -136,19 +137,37 @@ def setup_two_projects(tmp_config_dir: Path) -> ConfigStore:
# API call-count recording (issue #802)
# ---------------------------------------------------------------------------

# One recorded HTTP call: (METHOD, path) -- host stripped, query optional.
ApiCall = tuple[str, str]
# One recorded HTTP call: (METHOD, path) -- host stripped, query optional --
# plus the X-StorageApi-Token value when the caller asks for it.
ApiCall = tuple[str, ...]

# Status returned for a request no route matches. Deliberately NOT a
# retryable status (429/5xx): an unmatched call must fail fast, never sleep
# through the client's backoff.
UNMATCHED_ROUTE_STATUS = 418


def _call_of(request: httpx.Request, *, include_query: bool) -> ApiCall:
def _sorted_query(target: str) -> str:
"""Rebuild ``path?query`` with the query parsed and its pairs sorted.

Parameter order and percent-encoding then do not matter: two targets are
equal only when they carry the same parameters with the same values.
"""
path, _, query = target.partition("?")
if not query:
return path
pairs = sorted(parse_qsl(query, keep_blank_values=True))
return f"{path}?{urlencode(pairs, safe='[],')}"


def _call_of(
request: httpx.Request, *, include_query: bool, include_token: bool = False
) -> ApiCall:
path = request.url.path
if include_query and request.url.query:
path = f"{path}?{request.url.query.decode()}"
path = _sorted_query(f"{path}?{request.url.query.decode()}")
if include_token:
return (request.method, path, request.headers.get("X-StorageApi-Token", ""))
return (request.method, path)


Expand Down Expand Up @@ -176,25 +195,40 @@ def _respond(request: httpx.Request) -> httpx.Response:
httpx_mock.add_callback(_respond, is_reusable=True, is_optional=True)


def recorded_api_calls(httpx_mock: Any, *, include_query: bool = False) -> list[ApiCall]:
"""Every HTTP request the mock saw, as ``(METHOD, path)`` in send order."""
return [_call_of(r, include_query=include_query) for r in httpx_mock.get_requests()]
def recorded_api_calls(
httpx_mock: Any, *, include_query: bool = False, include_token: bool = False
) -> list[ApiCall]:
"""Every HTTP request the mock saw, as ``(METHOD, path[, token])`` in send order."""
return [
_call_of(r, include_query=include_query, include_token=include_token)
for r in httpx_mock.get_requests()
]


def assert_api_calls(
httpx_mock: Any,
expected: Sequence[ApiCall],
*,
include_query: bool = False,
include_token: bool = False,
ordered: bool = True,
) -> None:
"""Assert the exact number AND sequence of HTTP calls a command made.

``ordered=False`` compares sorted multisets -- use it where calls are
issued from a thread pool (multi-project fan-out) and the send order is
not deterministic. The count is exact either way: a duplicate call fails.
``include_query=True`` compares the query too, parsed and sorted on both
sides, so the expected literal may list parameters in any order.
``include_token=True`` adds each call's ``X-StorageApi-Token`` as a third
element -- use it in multi-project tests, where the path alone cannot show
which project a call was for. ``ordered=False`` compares sorted multisets
-- use it where calls are issued from a thread pool (multi-project
fan-out) and the send order is not deterministic. The count is exact
either way: a duplicate call fails.
"""
actual = recorded_api_calls(httpx_mock, include_query=include_query)
actual = recorded_api_calls(
httpx_mock, include_query=include_query, include_token=include_token
)
if include_query:
expected = [(call[0], _sorted_query(call[1]), *call[2:]) for call in expected]
if not ordered:
actual, expected = sorted(actual), sorted(expected)
assert actual == list(expected), (
Expand Down
Loading
Loading