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
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ Each `*API` class holds a `client: BildClient` and only issues HTTP via
| Suite | Role |
| --- | --- |
| `tests/test_auth.py` | token required, bearer header, no Content-Type, 401/403 |
| `tests/test_client_routes.py` | every resource method hits the expected path/method |
| `tests/test_transport.py` | 4xx/5xx mapping, empty/204 bodies, timeout, base URL, `.env` |
| `tests/test_resolvers.py` | branch/version auto-resolve fallbacks and ValueErrors |
| `tests/test_client_routes.py` | every resource method hits the expected path/method/body |
| `tests/test_import.py` | package import smoke |
| `tests/test_live_api.py` | read-only calls against the real API when a token is present |
| `tests/test_architecture.py` | layer and public-surface invariants |
Expand Down
8 changes: 7 additions & 1 deletion bild/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,13 @@ def _pick_list(payload: Any):


def _safe_json(response: requests.Response):
content = getattr(response, "content", None)
if content in (b"", ""):
return None
try:
return response.json()
except Exception:
return {"raw": response.text}
text = getattr(response, "text", "")
if not text or not str(text).strip():
return None
return {"raw": text}
1 change: 1 addition & 0 deletions docs/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ delete files, search). Do not "fix" them to `POST`.

- Unit tests use a fake session. They must not need a network or token.
- Route tests assert path suffix, method, and important JSON/query fields.
`tests/test_client_routes.py` must call every public `*API` method.
- Live tests (`tests/test_live_api.py`) are read-only and skip without
`BILD_API_KEY`.
- Prefer `unittest` for class-scoped live setup; pytest collects both.
Expand Down
11 changes: 5 additions & 6 deletions docs/QUALITY_SCORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ C = usable but thin; D = missing or stale.
| Area | Grade | Notes |
| --- | --- | --- |
| Auth / token loading | B | Env + `.env` + constructor. No expiry awareness. |
| HTTP transport | B | Header contract tested. No retries or pagination helpers. |
| HTTP transport | B | Header contract, errors, empty bodies tested. No retries or pagination helpers. |
| Resource coverage | A | Route tests cover the documented groups. |
| Types | C | Runtime hints only; mypy is not strict on untyped defs. |
| Unit tests | B | Auth + full route table. Helpers are duplicated. |
| Live tests | B | Read-only, skip without key. Account-data dependent. |
| Unit tests | A | Auth, transport, resolvers, and per-group route table with method/body asserts. |
| Live tests | B | Read-only, skip without key. Account-data dependent; `s3Url` lists skipped. |
| Docs / agent map | A | `AGENTS.md` + `docs/` catalog, linted. |
| Packaging | B | setuptools, source install. Not on PyPI. |
| Lint / format | A | ruff + custom harness linters in CI. |
Expand All @@ -20,8 +20,7 @@ C = usable but thin; D = missing or stale.
## Gaps to close next

1. Split `bild/client.py` into transport + `bild/resources/` (tech debt).
2. Deduplicate fake session helpers in tests.
3. Tighten mypy (`check_untyped_defs`) after the split.
4. Publish to PyPI when the public surface is stable.
2. Tighten mypy (`check_untyped_defs`) after the split.
3. Publish to PyPI when the public surface is stable.

See [exec-plans/tech-debt-tracker.md](exec-plans/tech-debt-tracker.md).
5 changes: 3 additions & 2 deletions docs/RELIABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
- Timeout: 30 seconds per request (`timeout=` on `BildClient`).
- Live tests use 60 seconds.
- No automatic retries. Callers retry if they need to.
- JSON parse failures become `{"raw": response.text}` rather than raising
from the transport layer.
- Empty success bodies (HTTP 204 / no bytes) return `None`.
- Non-empty JSON parse failures become `{"raw": response.text}` rather
than raising from the transport layer.

## Error mapping

Expand Down
16 changes: 15 additions & 1 deletion docs/design-docs/http-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,21 @@ Resource methods that accept `branch_id: str | None` should call

## Response helper

`_safe_json` returns parsed JSON or `{"raw": response.text}`.
`_safe_json` returns parsed JSON. An empty success body (HTTP 204, or no
bytes) returns `None` so DELETE helpers are not confused with
`{"raw": ""}`. Non-empty non-JSON bodies become `{"raw": response.text}`.

`_pick_list` / `_pick_from_response` tolerate `{data: ...}` and `{items: ...}`
envelopes. Keep that tolerance at the helper layer, not copied into every
resource method.

## List envelopes that point at S3

Some list endpoints (notably `files.list`) may return `{s3Url: ...}`
instead of an inline JSON array when the result is large. The client
returns that envelope as-is and does **not** fetch the URL.

Following `s3Url` would be a second HTTP hop, usually to a signed URL
that must not receive the Bild `Authorization` header. Callers that need
the file list should GET the URL themselves (or use `client.get` only
against Bild paths). Do not add an automatic follow without a new spec.
1 change: 0 additions & 1 deletion docs/exec-plans/tech-debt-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
| ID | Item | Why it exists | Suggested fix | Severity |
| --- | --- | --- | --- | --- |
| TD-1 | `bild/client.py` holds transport and every `*API` class | Fast first implementation | Split transport vs `bild/resources/*.py`; keep public imports stable | Medium |
| TD-2 | `FakeResponse` / fake session duplicated in tests | Tests grew independently | Shared `tests/fakes.py` | Low |
| TD-3 | mypy does not use `check_untyped_defs` | Current helpers are loosely typed | Annotate helpers after TD-1, then tighten | Low |
| TD-4 | Package not published to PyPI | Still source-install | Release process + version policy | Low |
| TD-5 | No retries / pagination helpers | API wrappers stay thin | Add only with a design doc and tests | Low |
Expand Down
103 changes: 103 additions & 0 deletions tests/fakes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse


@dataclass
class FakeResponse:
status_code: int = 200
payload: Any = None
raw_text: str | None = None

@property
def ok(self) -> bool:
return 200 <= self.status_code < 300

@property
def content(self) -> bytes:
if self.raw_text is not None:
return self.raw_text.encode("utf-8")
if self.payload is None:
return b""
return b"{}"

@property
def text(self) -> str:
if self.raw_text is not None:
return self.raw_text
if self.payload is None:
return ""
return str(self.payload)

def json(self) -> Any:
if self.payload is None:
raise ValueError("No JSON body")
return self.payload


class RecordingSession:
def __init__(
self,
status_code: int = 200,
payload: Any = None,
*,
empty: bool = False,
):
self.headers: dict[str, str] = {}
self.calls: list[dict[str, Any]] = []
self.status_code = status_code
if empty:
self.payload = None
else:
self.payload = {"ok": True} if payload is None else payload

def request(self, method, url, params=None, json=None, timeout=None, **kwargs):
self.calls.append(
{
"method": method.upper(),
"url": url,
"path": urlparse(url).path,
"params": params,
"json": json,
"json_passed": "json" in kwargs or json is not None,
"headers": dict(self.headers),
"timeout": timeout,
}
)
return FakeResponse(self.status_code, payload=self.payload)


class ScriptedSession:
def __init__(self, by_suffix: dict[str, FakeResponse] | None = None):
self.headers: dict[str, str] = {}
self.calls: list[dict[str, Any]] = []
self.by_suffix = by_suffix or {}

def request(self, method, url, params=None, json=None, timeout=None, **kwargs):
path = urlparse(url).path
self.calls.append(
{
"method": method.upper(),
"url": url,
"path": path,
"json": json,
"params": params,
"timeout": timeout,
}
)
for suffix, response in self.by_suffix.items():
if path.endswith(suffix):
return response
return FakeResponse(200, {"ok": True, "path": path})


class RouteSession(ScriptedSession):
def __init__(self):
super().__init__(
{
"/branches": FakeResponse(200, {"data": [{"id": "branch-main", "isMain": True}]}),
"/latest": FakeResponse(200, {"data": {"fileVersion": "v-latest"}}),
}
)
43 changes: 1 addition & 42 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import sys
import types
import unittest
from dataclasses import dataclass
from urllib.parse import urlparse

try:
import requests # noqa: F401
Expand All @@ -17,46 +15,7 @@

from bild import BildAuthError, BildClient
from bild.client import DEFAULT_BASE_URL


@dataclass
class FakeResponse:
status_code: int
payload: dict

@property
def ok(self):
return 200 <= self.status_code < 300

def json(self):
return self.payload

@property
def text(self):
return str(self.payload)


class RecordingSession:
def __init__(self, status_code: int = 200, payload: dict | None = None):
self.headers = {}
self.calls = []
self.status_code = status_code
self.payload = payload or {"ok": True}

def request(self, method, url, params=None, json=None, timeout=None, **kwargs):
self.calls.append(
{
"method": method.upper(),
"url": url,
"path": urlparse(url).path,
"params": params,
"json": json,
"json_passed": "json" in kwargs or json is not None,
"headers": dict(self.headers),
"timeout": timeout,
}
)
return FakeResponse(self.status_code, self.payload)
from tests.fakes import RecordingSession


class TestBildAuth(unittest.TestCase):
Expand Down
Loading
Loading