Skip to content
Open
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ coroutines. The [SDK guide](https://proofline.sh/docs/sdks/) and
Licensed under the Apache License, Version 2.0. The generated models under
`proofline/_generated` carry the MIT license of the generator templates.

## Pull request history

`list_pull_request_reviews` returns one page of the runs recorded for a specific
pull request. `iter_pull_request_reviews` follows the continuation cursor over
that history for you, while keeping the same page and item bounds as
`iter_reviews`. Unlike `iter_reviews`, which lists the latest run of each pull
request, it walks every run of one pull request.

```python
for review in client.iter_pull_request_reviews(
organization_id, "owner/name", 42, page_size=50,
filters={"status": "COMPLETED"},
):
print(review.id, review.status)
```

Use `await` for each item with `AsyncProoflineClient`. Filters, the initial
cursor and server ordering are preserved across pages. `page_size` accepts
1–100, `max_pages` 1–1,000 and `max_items` 1–100,000. A missing, repeated or
non-advancing continuation cursor raises `ProoflineError`; reaching either bound
raises it with the `pagination-limit` type.

## Contributing

This repository distributes the Proofline Python SDK. The public OpenAPI contract generates the client and models in `proofline/_generated/`. Open an issue for changes to the contract or generated code. Pull requests are welcome for the hand-written client in `proofline/client.py`, transport and helpers outside `_generated/`, and tests in `tests/`.
Expand Down
106 changes: 90 additions & 16 deletions proofline/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ def _page_limits(page_size: int, max_pages: int, max_items: int) -> None:
)


def _iteration_params(
filters: ReviewFilters | None, page_size: int
) -> tuple[ReviewFilters, set[str]]:
params: ReviewFilters = {}
if filters:
params.update(filters)
params["limit"] = page_size
seen: set[str] = set()
cursor = params.get("cursor")
if cursor:
seen.add(cursor)
return params, seen


def _next_cursor(page: ReviewPage, seen: set[str]) -> str | None:
if not isinstance(page.truncated, bool):
raise _malformed()
Expand Down Expand Up @@ -469,14 +483,7 @@ def iter_reviews(
filters: ReviewFilters | None = None,
) -> Iterator[ReviewSummary]:
_page_limits(page_size, max_pages, max_items)
params: ReviewFilters = {}
if filters:
params.update(filters)
params["limit"] = page_size
seen: set[str] = set()
cursor = params.get("cursor")
if cursor:
seen.add(cursor)
params, seen = _iteration_params(filters, page_size)
count = 0
for _ in range(max_pages):
page = self.list_reviews(organization_id, **params)
Expand All @@ -491,6 +498,43 @@ def iter_reviews(
params["cursor"] = cursor
raise _pagination_limit()

def iter_pull_request_reviews(
self,
organization_id: UUID,
repository: str,
pull_request_number: int,
*,
page_size: int = 100,
max_pages: int = 100,
max_items: int = 10_000,
filters: ReviewFilters | None = None,
) -> Iterator[ReviewSummary]:
"""Iterate every recorded run of one pull request.

Unlike :meth:`iter_reviews`, which lists the latest run of each pull
request, this walks the full history at the pull-request endpoint. Pages
are fetched lazily in server order. A missing, repeated or non-advancing
continuation cursor raises ``ProoflineError``; reaching ``max_pages`` or
``max_items`` raises the same type with ``pagination-limit``.
"""
_page_limits(page_size, max_pages, max_items)
params, seen = _iteration_params(filters, page_size)
count = 0
for _ in range(max_pages):
page = self.list_pull_request_reviews(
organization_id, repository, pull_request_number, filters=params
)
for item in page.items:
if count == max_items:
raise _pagination_limit()
yield item
count += 1
cursor = _next_cursor(page, seen)
if cursor is None:
return
params["cursor"] = cursor
raise _pagination_limit()

def wait_for_review(
self, organization_id: UUID, review_id: UUID, *, timeout: float = 300
) -> ReviewOutcome:
Expand Down Expand Up @@ -828,14 +872,7 @@ async def iter_reviews(
filters: ReviewFilters | None = None,
) -> AsyncIterator[ReviewSummary]:
_page_limits(page_size, max_pages, max_items)
params: ReviewFilters = {}
if filters:
params.update(filters)
params["limit"] = page_size
seen: set[str] = set()
cursor = params.get("cursor")
if cursor:
seen.add(cursor)
params, seen = _iteration_params(filters, page_size)
count = 0
for _ in range(max_pages):
page = await self.list_reviews(organization_id, **params)
Expand All @@ -850,6 +887,43 @@ async def iter_reviews(
params["cursor"] = cursor
raise _pagination_limit()

async def iter_pull_request_reviews(
self,
organization_id: UUID,
repository: str,
pull_request_number: int,
*,
page_size: int = 100,
max_pages: int = 100,
max_items: int = 10_000,
filters: ReviewFilters | None = None,
) -> AsyncIterator[ReviewSummary]:
"""Iterate every recorded run of one pull request.

Unlike :meth:`iter_reviews`, which lists the latest run of each pull
request, this walks the full history at the pull-request endpoint. Pages
are fetched lazily in server order. A missing, repeated or non-advancing
continuation cursor raises ``ProoflineError``; reaching ``max_pages`` or
``max_items`` raises the same type with ``pagination-limit``.
"""
_page_limits(page_size, max_pages, max_items)
params, seen = _iteration_params(filters, page_size)
count = 0
for _ in range(max_pages):
page = await self.list_pull_request_reviews(
organization_id, repository, pull_request_number, filters=params
)
for item in page.items:
if count == max_items:
raise _pagination_limit()
yield item
count += 1
cursor = _next_cursor(page, seen)
if cursor is None:
return
params["cursor"] = cursor
raise _pagination_limit()

async def wait_for_review(
self, organization_id: UUID, review_id: UUID, *, timeout: float = 300
) -> ReviewOutcome:
Expand Down
8 changes: 7 additions & 1 deletion scripts/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ def main() -> None:
"The generated JSON operations must match the twenty-one public JSON token routes."
)
tree = ast.parse((ROOT / "proofline/client.py").read_text())
helpers = {"close", "aclose", "iter_reviews", "wait_for_review"}
helpers = {
"close",
"aclose",
"iter_reviews",
"iter_pull_request_reviews",
"wait_for_review",
}
clients = {
node.name: {
member.name
Expand Down
86 changes: 86 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ async def collect(client: Client, **kwargs: Any) -> list[Any]:
return list(iterator)


async def collect_pull_request(client: Client, **kwargs: Any) -> list[Any]:
iterator = client.iter_pull_request_reviews(ORG, "repo/name", 42, **kwargs)
if hasattr(iterator, "__aiter__"):
return [item async for item in iterator]
return list(iterator)


class Clock:
def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
self.now = 100.0
Expand Down Expand Up @@ -1333,6 +1340,85 @@ def respond(request: Request, index: int) -> Reply:
assert raised.value.type == "urn:proofline:sdk:pagination-limit"


@MODES
async def test_pull_request_review_iteration_preserves_path_filters_and_bounds(mode: str) -> None:
def respond(request: Request, index: int) -> Reply:
split = urlsplit(request.path)
assert split.path == f"/api/v1/organizations/{ORG}/reviews/pr/repo%2Fname/42"
query = parse_qs(split.query)
assert query["status"] == ["COMPLETED"]
assert query["limit"] == ["2"]
if index == 0:
assert query["cursor"] == ["initial"]
elif index == 1:
assert query["cursor"] == ["opaque+/cursor"]
else:
assert query["cursor"] == ["final"]
return Reply(
body=page(
cursor="opaque+/cursor" if index == 0 else ("final" if index == 1 else None),
truncated=index < 2,
)
)

with Server(respond) as server:
async with session(mode, server.url) as client:
items = await collect_pull_request(
client,
page_size=2,
filters={"status": "COMPLETED", "cursor": "initial"},
)
assert [item.id for item in items] == [REVIEW, REVIEW, REVIEW]
assert len(server.requests) == 3


@MODES
async def test_pull_request_review_iteration_refuses_cycles_and_bounds(mode: str) -> None:
for cursor in ["repeated", None]:
with Server(
lambda request, index: Reply(body=page(cursor=cursor, truncated=True))
) as server:
async with session(mode, server.url) as client:
with pytest.raises(ProoflineError) as raised:
await collect_pull_request(client)
assert raised.value.type == "urn:proofline:sdk:invalid-pagination"
assert len(server.requests) <= 2
with Server(
lambda request, index: Reply(body=page(cursor=str(index), truncated=True))
) as server:
async with session(mode, server.url) as client:
with pytest.raises(ProoflineError) as raised:
await collect_pull_request(client, max_pages=2)
assert raised.value.type == "urn:proofline:sdk:pagination-limit"
assert len(server.requests) == 2
two_items = page()
two_items["items"].append(dict(two_items["items"][0]))
with Server(lambda request, index: Reply(body=two_items)) as server:
async with session(mode, server.url) as client:
with pytest.raises(ProoflineError) as raised:
await collect_pull_request(client, max_items=1)
assert raised.value.type == "urn:proofline:sdk:pagination-limit"
with Server(lambda request, index: Reply(body=page())) as server:
async with session(mode, server.url) as client:
with pytest.raises(ValueError):
await collect_pull_request(client, page_size=0)
assert not server.requests


@MODES
async def test_pull_request_review_iteration_fetches_pages_lazily(mode: str) -> None:
with Server(lambda request, index: Reply(body=page(cursor="next", truncated=True))) as server:
async with session(mode, server.url) as client:
iterator = client.iter_pull_request_reviews(ORG, "repo/name", 42)
if hasattr(iterator, "__aiter__"):
assert (await anext(iterator)).id == REVIEW
await iterator.aclose()
else:
assert next(iterator).id == REVIEW
iterator.close()
assert len(server.requests) == 1


def test_token_environment_and_https_origin_rejection(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PROOFLINE_TOKEN", TOKEN)
with ProoflineClient() as client:
Expand Down