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 .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
- name: Run E2E tests
env:
REDUCTO_API_KEY: ${{ secrets.REDUCTO_API_KEY }}
run: uv run pytest tests/e2e/ -v -x -o "addopts="
run: uv run pytest tests/e2e/ -v -x -o "addopts=" -n auto --dist loadscope
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ $ pip install ./path-to-wheel-file.whl
$ ./scripts/test
```

End-to-end tests in `tests/e2e/` call the live Reducto API and are excluded by default. They need `REDUCTO_API_KEY`. Run them in parallel, one worker per test class:

```sh
$ REDUCTO_API_KEY=... uv run pytest tests/e2e/ -o "addopts=" -n auto --dist loadscope
```

## Linting and formatting

This repository uses [ruff](https://github.com/astral-sh/ruff) to format the code.
Expand Down
11 changes: 9 additions & 2 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ from reducto.types import (
AsyncPipelineResponse,
AsyncSplitResponse,
BaseProcessingOptions,
ChartResponse,
Chunking,
ChunkingConfig,
ClassifyResponse,
DirectWebhookConfig,
DocumentProperties,
EditResponse,
EnrichConfig,
ErrorCode,
ErrorDetail,
ExperimentalProcessingOptions,
ExtractResponse,
FigureAgentic,
Expand All @@ -31,6 +35,7 @@ from reducto.types import (
TableAgentic,
TableSummaryConfig,
TextAgentic,
URLResult,
Upload,
WebhookConfigNew,
)
Expand All @@ -41,12 +46,13 @@ from reducto.types import (
Types:

```python
from reducto.types import APIVersionResponse
from reducto.types import APIVersionResponse, ClientDeleteUploadResponse
```

Methods:

- <code title="get /version">client.<a href="./src/reducto/_client.py">api_version</a>() -> str</code>
- <code title="delete /upload/{file_id}">client.<a href="./src/reducto/_client.py">delete_upload</a>(file_id) -> <a href="./src/reducto/types/client_delete_upload_response.py">ClientDeleteUploadResponse</a></code>
- <code title="post /upload">client.<a href="./src/reducto/_client.py">upload</a>(\*\*<a href="src/reducto/types/client_upload_params.py">params</a>) -> <a href="./src/reducto/types/shared/upload.py">Upload</a></code>

# Parse
Expand Down Expand Up @@ -154,11 +160,12 @@ Methods:
Types:

```python
from reducto.types import JobGetResponse, JobGetAllResponse
from reducto.types import JobDeleteResponse, JobGetResponse, JobGetAllResponse
```

Methods:

- <code title="post /cancel/{job_id}">client.job.<a href="./src/reducto/resources/job.py">cancel</a>(job_id) -> object</code>
- <code title="delete /job/{job_id}">client.job.<a href="./src/reducto/resources/job.py">delete</a>(job_id, \*\*<a href="src/reducto/types/job_delete_params.py">params</a>) -> <a href="./src/reducto/types/job_delete_response.py">JobDeleteResponse</a></code>
- <code title="get /job/{job_id}">client.job.<a href="./src/reducto/resources/job.py">get</a>(job_id) -> <a href="./src/reducto/types/job_get_response.py">JobGetResponse</a></code>
- <code title="get /jobs">client.job.<a href="./src/reducto/resources/job.py">get_all</a>(\*\*<a href="src/reducto/types/job_get_all_params.py">params</a>) -> <a href="./src/reducto/types/job_get_all_response.py">JobGetAllResponse</a></code>
84 changes: 78 additions & 6 deletions scripts/spec_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
and walked in parallel, comparing JSON property names, types, enum values, and
required-ness.

`spec/drift-allowlist.json` lists known, intentional deviations with a reason.
Matching items are reported as allowed and do not fail the check. Entries that
match nothing are flagged as stale.

The check runs against the committed snapshot `spec/openapi.json` by default, so
it is reproducible and needs no network. `--live` checks against the public URL.
`--update-snapshot` fetches the live spec, rewrites the snapshot, then checks.
Expand All @@ -31,7 +35,7 @@
import typing_extensions
from typing import Any, Dict, List, Tuple, Union, Literal, Optional
from pathlib import Path
from dataclasses import field, asdict, dataclass
from dataclasses import field, asdict, replace, dataclass
from typing_extensions import Required, Annotated, NotRequired, get_args, get_origin, is_typeddict

REPO = Path(__file__).resolve().parent.parent
Expand All @@ -43,6 +47,7 @@

LIVE_SPEC = "https://reducto.ai/openapi.json"
SNAPSHOT = REPO / "spec" / "openapi.json"
ALLOWLIST = REPO / "spec" / "drift-allowlist.json"
HTTP_VERBS = ("get", "post", "put", "patch", "delete")
HTTP_METHODS = {name: verb for verb in HTTP_VERBS for name in (verb, "_" + verb)}
SUCCESS_CODES = ("200", "201", "202")
Expand All @@ -64,6 +69,7 @@ class Shape:
items: Optional[Shape] = None
members: List[Shape] = field(default_factory=lambda: [])
label: str = ""
has_default: bool = False


ANY = Shape("any")
Expand Down Expand Up @@ -117,6 +123,12 @@ def resolve(self, node: JsonDict) -> JsonDict:
return node

def shape(self, node: JsonDict, seen: typing.FrozenSet[str] = frozenset()) -> Shape:
shape = self.shape_of(node, seen)
if "default" in node and not shape.has_default:
shape = replace(shape, has_default=True)
return shape

def shape_of(self, node: JsonDict, seen: typing.FrozenSet[str]) -> Shape:
label = ""
if "$ref" in node:
label = node["$ref"].rsplit("/", 1)[-1]
Expand Down Expand Up @@ -485,9 +497,13 @@ def compare_required(self, spec: Shape, sdk: Shape, name: str, loc: str) -> None
if spec_req == sdk_req:
return
# Response models express nullable fields as Optional[...] = None, so
# "required but nullable" in the spec is not drift there.
# "required but nullable" in the spec is not drift there. A response
# field with a server default is always present, so the sdk may
# require it.
if self.ignore_null and spec_req and is_nullable(spec.props[name]):
return
if self.ignore_null and sdk_req and spec.props[name].has_default:
return
self.report(loc, "required", f"spec {req_word(spec_req)}, sdk {req_word(sdk_req)}")

def compare_union(self, spec: Shape, sdk: Shape, loc: str) -> None:
Expand Down Expand Up @@ -682,9 +698,59 @@ def display(source: str) -> str:
return source


def print_report(drifts: List[Drift], endpoints: List[Endpoint], spec: Spec, source: str) -> None:
@dataclass
class Allowed:
endpoint: str
location: str
kind: str
reason: str
detail: Optional[str] = None

def matches(self, d: Drift) -> bool:
if (self.endpoint, self.location, self.kind) != (d.endpoint, d.location, d.kind):
return False
return self.detail is None or self.detail == d.detail


def load_allowlist(path: Path) -> List[Allowed]:
if not path.exists():
return []
entries = typing.cast(List[JsonDict], json.loads(path.read_text()))
return [Allowed(**e) for e in entries]


def split_allowed(drifts: List[Drift], allowlist: List[Allowed]) -> Tuple[List[Drift], List[Drift], List[Allowed]]:
active: List[Drift] = []
allowed: List[Drift] = []
used: typing.Set[int] = set()
for d in drifts:
hit = next((i for i, a in enumerate(allowlist) if a.matches(d)), None)
if hit is None:
active.append(d)
else:
allowed.append(d)
used.add(hit)
stale = [a for i, a in enumerate(allowlist) if i not in used]
return active, allowed, stale


def print_report(
drifts: List[Drift],
allowed: List[Drift],
stale: List[Allowed],
endpoints: List[Endpoint],
spec: Spec,
source: str,
) -> None:
print(f"Spec: {display(source)} (version {spec.version})")
print(f"Checked {len(endpoints)} sdk endpoints.")
if allowed:
print(f"{len(allowed)} allowed drift item(s), see {ALLOWLIST.relative_to(REPO)}:")
for d in allowed:
loc = f" {d.location}" if d.location else ""
print(f" {d.endpoint} [{d.kind}]{loc}: {d.detail}")
for a in stale:
print(f"Stale allowlist entry matches nothing: {a.endpoint} [{a.kind}] {a.location}", file=sys.stderr)
if not drifts:
print("No drift found.")
return
Expand Down Expand Up @@ -735,13 +801,19 @@ def main() -> int:

spec = Spec(load_doc(source))
endpoints = discover_endpoints()
drifts = [d for d in run(spec, endpoints) if d.kind not in args.ignore]
found = [d for d in run(spec, endpoints) if d.kind not in args.ignore]
drifts, allowed, stale = split_allowed(found, load_allowlist(ALLOWLIST))

if args.json:
payload = {"spec": source, "spec_version": spec.version, "drifts": [asdict(d) for d in drifts]}
payload = {
"spec": source,
"spec_version": spec.version,
"drifts": [asdict(d) for d in drifts],
"allowed": [asdict(d) for d in allowed],
}
print(json.dumps(payload, indent=2))
else:
print_report(drifts, endpoints, spec, source)
print_report(drifts, allowed, stale, endpoints, spec, source)
return 0 if not drifts or args.warn_only else 1


Expand Down
9 changes: 9 additions & 0 deletions spec/drift-allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"endpoint": "POST /extract",
"location": "response",
"kind": "extra",
"detail": "sdk union member object(7 fields) has no spec counterpart",
"reason": "The API returns ExtractResponse (response_type 'extract') for accounts not on V3 extract. The spec lists only V3ExtractResponse and AsyncExtractResponse for this endpoint. ExtractRunResponse includes ExtractResponse so those responses parse. Remove once the spec adds ExtractResponse to POST /extract."
}
]
80 changes: 80 additions & 0 deletions src/reducto/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from ._utils import (
is_given,
extract_files,
path_template,
maybe_transform,
deepcopy_minimal,
get_async_library,
Expand All @@ -49,6 +50,7 @@
make_request_options,
)
from .types.shared.upload import Upload
from .types.client_delete_upload_response import ClientDeleteUploadResponse

if TYPE_CHECKING:
from .resources import job, edit, parse, split, extract, webhook, classify, pipeline
Expand Down Expand Up @@ -312,6 +314,39 @@ def api_version(
cast_to=str,
)

def delete_upload(
self,
file_id: str,
*,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ClientDeleteUploadResponse:
"""
Delete Uploaded File

Args:
extra_headers: Send extra headers

extra_query: Add additional query parameters to the request

extra_body: Add additional JSON properties to the request

timeout: Override the client-level default timeout for this request, in seconds
"""
if not file_id:
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
return self.delete(
path_template("/upload/{file_id}", file_id=file_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
cast_to=ClientDeleteUploadResponse,
)

def upload(
self,
*,
Expand Down Expand Up @@ -623,6 +658,39 @@ async def api_version(
cast_to=str,
)

async def delete_upload(
self,
file_id: str,
*,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ClientDeleteUploadResponse:
"""
Delete Uploaded File

Args:
extra_headers: Send extra headers

extra_query: Add additional query parameters to the request

extra_body: Add additional JSON properties to the request

timeout: Override the client-level default timeout for this request, in seconds
"""
if not file_id:
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
return await self.delete(
path_template("/upload/{file_id}", file_id=file_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
cast_to=ClientDeleteUploadResponse,
)

async def upload(
self,
*,
Expand Down Expand Up @@ -711,6 +779,9 @@ def __init__(self, client: Reducto) -> None:
self.api_version = to_raw_response_wrapper(
client.api_version,
)
self.delete_upload = to_raw_response_wrapper(
client.delete_upload,
)
self.upload = to_raw_response_wrapper(
client.upload,
)
Expand Down Expand Up @@ -773,6 +844,9 @@ def __init__(self, client: AsyncReducto) -> None:
self.api_version = async_to_raw_response_wrapper(
client.api_version,
)
self.delete_upload = async_to_raw_response_wrapper(
client.delete_upload,
)
self.upload = async_to_raw_response_wrapper(
client.upload,
)
Expand Down Expand Up @@ -835,6 +909,9 @@ def __init__(self, client: Reducto) -> None:
self.api_version = to_streamed_response_wrapper(
client.api_version,
)
self.delete_upload = to_streamed_response_wrapper(
client.delete_upload,
)
self.upload = to_streamed_response_wrapper(
client.upload,
)
Expand Down Expand Up @@ -897,6 +974,9 @@ def __init__(self, client: AsyncReducto) -> None:
self.api_version = async_to_streamed_response_wrapper(
client.api_version,
)
self.delete_upload = async_to_streamed_response_wrapper(
client.delete_upload,
)
self.upload = async_to_streamed_response_wrapper(
client.upload,
)
Expand Down
Loading