Skip to content

Commit 27553e2

Browse files
CyMuleclaude
andauthored
fix: stop failing on rejected jobs and add skip_preflight (0.47.0) (#356)
## What Jobs the new preflight check rejects no longer break the SDK. Workflows can also opt out of preflight from the SDK. **Before**, after a job is rejected: ``` client.jobs.list_jobs(...) → ValidationError: 'REJECTED' is not a valid JobStatus (whole page lost) client.jobs.get_job(...) → ValidationError ``` **After:** ``` job.status → JobStatus.REJECTED job.status → "SOME_FUTURE_STATUS" # a status this SDK version doesn't know comes back as a plain str instead of raising details.node_stats[0].failure_category → PreflightFailureCategory.AUTH_INVALID_CREDENTIAL details.node_stats[0].failure_reason → "The AWS Access Key Id you provided does not exist." ``` - `JobStatus` and `JobProcessingStatus` gain `REJECTED`. `JobInformation.status` and `JobDetails.processing_status` also accept unknown values. Known values are still enum members, so `job.status == JobStatus.COMPLETED` and `.value` behave as before. - `JobNodeDetails` gains `failure_category` (`Union[PreflightFailureCategory, str]`) and `failure_reason`, so a caller can see why a job was rejected. The server sets them only on the node that failed preflight; before this, pydantic dropped them. - `skip_preflight` is now on `CreateWorkflow`, `UpdateWorkflow` (unset = unchanged) and `WorkflowInformation`. For a one-off `create_job`, the API defines the flag inside the `request_data` JSON. The docs now say to set it there; the SDK does not rewrite that string. - Releases as **0.47.0**. ## Why The SDK models used to be regenerated from the API spec every night. That generator has been dead since January (removed in #349). Since then, new server values reach the SDK only when someone adds them by hand. `REJECTED` didn't get added, and a closed enum turns a new value into a hard failure. This PR also adds a way to catch that kind of drift before a customer does (DTPL-696 acceptance criterion 4): ``` weekly CI → fetch live OpenAPI specs → compare with SDK models → new missing field/enum value → open/update a GitHub issue (a failed download fails the run but opens no issue) ``` - `make check-spec-drift` runs the check locally. Gaps we already know about are recorded in `scripts/spec_drift_baseline.json` (20 schemas today, e.g. `JobInformation.processing_status`, the `teradata` connector type). The check fails only on new drift, or on a baseline entry that has since been covered. - `CONTRIBUTING.md` documents how to resolve a drift issue. Replaces #353, which got here through a custom `_missing_` enum hook and code that spliced bytes into `request_data`. This version uses a plain pydantic union and leaves `request_data` alone. ## Notes for consumers - Type checkers now see these two status fields as `Union[JobStatus, str]` / `Union[JobProcessingStatus, str]`. Code that reads `.value` or `.name` on them needs to handle the `str` case. Runtime behavior for known statuses is unchanged. - Follow-up, not in this PR: the SDK's other "open" enums (connector types, partition `Strategy`, etc.) use `OpenEnumMeta` / `validate_open_enum`. On Python 3.12+ those return a raw `str` even for known values, and they emit pydantic serializer warnings. The long-term fix is to move them to the same union pattern used here. That changes their runtime types, so it belongs in its own PR. ## Validation - `make test-unit`: 419 passed. `make test-contract`: 72 passed. The new job/workflow contract tests pass on Python 3.11, 3.12 and 3.13, and they fail without the model changes. - `make lint` is clean. `make check-spec-drift` passes against the live specs. - Two rounds of Codex review. Round 1 found that enums declared inline on a property escaped the drift check; that is fixed and has a test. Round 2 was clean. - cubic: three findings fixed (no drift issue on a fetch failure, no duplicate issues from overlapping runs, changelog wording). Re-review clean, and full CI is green, including integration on 3.11–3.13. ## Maintenance validation (2026-10-19, keepalive base refresh) - Synchronized main through its 0.46.5 keepalive release while retaining the authored 0.47.0 version and user-agent. Manual conflict resolution touched only the changelog and version file; main runtime and test changes merged automatically. - Local validation: 419 unit tests and 71 contract tests passed (one existing expected unit failure). Four localhost socket tests needed bind permission and passed without source changes. - Exact maintenance-head CI: https://github.com/Unstructured-IO/unstructured-python-client/actions/runs/39329146842. ## Review follow-up (2026-10-20) - Added `failure_category` / `failure_reason` to `JobNodeDetails` with a new `PreflightFailureCategory` enum, and dropped both from the drift baseline. A new contract test covers a known category, an unknown category (plain `str`) and a passing node (both null); it passes on 3.11, 3.12 and 3.13. - Removed the `JobStatus` / `JobProcessingStatus` entries from `overlay_client.yaml`. Nothing reads the overlay since the generator was removed, and `x-speakeasy-unknown-values` would describe the `OpenEnumMeta` style, not the union shipped here. - `make test-unit` 419 passed, `make test-contract` 72 passed, `make lint` clean, `make check-spec-drift` clean against the live specs. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 91410b0 commit 27553e2

30 files changed

Lines changed: 676 additions & 32 deletions

‎.github/workflows/spec_drift.yaml‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: API spec drift
2+
3+
on:
4+
schedule:
5+
- cron: "0 13 * * 1"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
concurrency:
13+
group: ${{ github.workflow }}
14+
cancel-in-progress: false
15+
16+
jobs:
17+
check:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v5
21+
- uses: astral-sh/setup-uv@v7
22+
- name: Set up Python 3.13
23+
uses: actions/setup-python@v6
24+
with:
25+
python-version: "3.13"
26+
- name: Install dependencies
27+
env:
28+
UV_LOCKED: "1"
29+
UV_PYTHON: "3.13"
30+
run: make install
31+
- name: Check spec drift
32+
id: drift
33+
env:
34+
UV_PYTHON: "3.13"
35+
run: |
36+
set -o pipefail
37+
make check-spec-drift | tee drift.txt
38+
- name: Report drift in an issue
39+
if: failure() && steps.drift.outcome == 'failure'
40+
env:
41+
GH_TOKEN: ${{ github.token }}
42+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
43+
run: |
44+
if ! grep -qE '^(New drift|Stale baseline):' drift.txt; then
45+
echo "The check failed without reporting drift; see the previous step."
46+
exit 0
47+
fi
48+
title="SDK models have drifted from the API spec"
49+
body="$(printf '%s\n\n```\n%s\n```\n\nSee CONTRIBUTING.md, \"Keeping models in sync with the API\".' "$RUN_URL" "$(cat drift.txt)")"
50+
number="$(gh issue list --state open --search "in:title \"$title\"" --json number --jq '.[0].number')"
51+
if [ -n "$number" ]; then
52+
gh issue comment "$number" --body "$body"
53+
else
54+
gh issue create --title "$title" --body "$body"
55+
fi

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 0.47.0
2+
3+
### Features
4+
* Add `skip_preflight` to `CreateWorkflow` and `UpdateWorkflow`. Set it to `True` to skip the job preflight check for a workflow's jobs; on update, leaving it unset keeps the current value. `WorkflowInformation.skip_preflight` reports the workflow's current setting. For a one-off job, set `"skip_preflight": true` in the `request_data` JSON passed to `jobs.create_job`.
5+
6+
### Fixes
7+
* Deserialize jobs the preflight check rejected. `JobStatus` and `JobProcessingStatus` gain `REJECTED`; previously `jobs.get_job` raised on a rejected job, and one rejected job failed the whole `jobs.list_jobs` page. `JobInformation.status` and `JobDetails.processing_status` now also accept a status this SDK version does not know, returning it as a plain `str` instead of raising. Known statuses are still returned as enum members, but the fields are typed `Union[JobStatus, str]` and `Union[JobProcessingStatus, str]`, so type-checked code that reads `.value` or `.name` from them needs to handle the `str` case.
8+
* Expose why a job was rejected. `JobNodeDetails` gains `failure_category` (a `PreflightFailureCategory`, or a plain `str` for a category this SDK version does not know) and `failure_reason`, set on the node that failed preflight in `jobs.get_job_details`. Previously both were dropped during deserialization.
9+
110
## 0.46.6
211

312
### Fixes

‎CONTRIBUTING.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ If you encounter any bugs or have suggestions for improvements, please open an i
1717

1818
We will review and triage issues as quickly as possible. Our goal is to address bugs and incorporate improvements in the next release.
1919

20+
## Keeping models in sync with the API
21+
22+
The models under `src/unstructured_client/models/` are maintained by hand. They are no longer regenerated from the API's OpenAPI spec, so a field or enum value added on the server does not reach this SDK until someone adds it.
23+
24+
`make check-spec-drift` compares the public specs (`https://platform.unstructuredapp.io/openapi.json` and `https://api.unstructuredapp.io/general/openapi.json`) with the models in `unstructured_client.models.shared`. It reports every property and enum value the spec has that the matching model lacks. Gaps the SDK does not cover yet are recorded in `scripts/spec_drift_baseline.json`, so the check fails only on new drift, or on a baseline entry that no longer applies.
25+
26+
The `API spec drift` workflow runs the check every Monday and opens or comments on an issue when it fails. To resolve it:
27+
28+
1. Add new fields and enum values to the models, and update the matching page under `docs/models/`.
29+
* A new enum value on a response model is the case that breaks callers. A closed enum raises on a value it does not know, and one bad item fails a whole list response. Response fields whose enum is expected to grow should accept unknown values. See `JobInformation.status` for the pattern: `Union[<Enum>, UnrecognizedStr]` with `union_mode="left_to_right"`.
30+
2. Acknowledge any gap you are deliberately not covering, and clear entries the SDK now covers, with `uv run python scripts/check_spec_drift.py --update-baseline`.
31+
3. Add a `CHANGELOG.md` entry and bump `src/unstructured_client/_version.py`.
32+
2033
## Contact
2134

2235
If you have any questions or need further assistance, please feel free to reach out by opening an issue.

‎Makefile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ lint:
6868
uv run pylint --rcfile=pylintrc src
6969
uv run mypy src
7070

71+
.PHONY: check-spec-drift
72+
check-spec-drift:
73+
uv run python scripts/check_spec_drift.py
74+
7175
.PHONY: publish
7276
publish:
7377
./scripts/publish.sh

‎_test_contract/platform_api/test_jobs.py‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,118 @@ def test_create_job(httpx_mock, platform_client: UnstructuredClient, platform_ap
222222
assert job.status == "SCHEDULED"
223223
assert job.job_type == "template"
224224
assert job.created_at == datetime.fromisoformat("2025-06-22T11:37:21.648+00:00")
225+
226+
227+
def test_list_jobs_tolerates_rejected_and_unrecognized_statuses(
228+
httpx_mock, platform_client: UnstructuredClient, platform_api_url: str
229+
):
230+
url = f"{platform_api_url}/api/v1/jobs/"
231+
232+
def job(job_id: str, status: str) -> dict:
233+
return {
234+
"created_at": "2025-06-22T11:37:21.648Z",
235+
"id": job_id,
236+
"status": status,
237+
"workflow_id": "16b80fee-64dc-472d-8f26-1d7729b6423d",
238+
"workflow_name": "test_workflow",
239+
}
240+
241+
httpx_mock.add_response(
242+
method="GET",
243+
headers={"Content-Type": "application/json"},
244+
json=[
245+
job("fcdc4994-eea5-425c-91fa-e03f2bd8030d", "REJECTED"),
246+
job("a1b2c3d4-eea5-425c-91fa-e03f2bd8030d", "SOME_FUTURE_STATUS"),
247+
job("b2c3d4e5-eea5-425c-91fa-e03f2bd8030d", "COMPLETED"),
248+
],
249+
url=url,
250+
)
251+
252+
jobs = platform_client.jobs.list_jobs(request=operations.ListJobsRequest()).response_list_jobs
253+
254+
assert jobs[0].status is shared.JobStatus.REJECTED
255+
assert type(jobs[1].status) is str
256+
assert jobs[1].status == "SOME_FUTURE_STATUS"
257+
assert jobs[2].status is shared.JobStatus.COMPLETED
258+
259+
260+
@pytest.mark.parametrize(
261+
("processing_status", "expected"),
262+
[
263+
("REJECTED", shared.JobProcessingStatus.REJECTED),
264+
("SOME_FUTURE_STATUS", "SOME_FUTURE_STATUS"),
265+
],
266+
)
267+
def test_get_job_details_tolerates_rejected_and_unrecognized_statuses(
268+
httpx_mock,
269+
platform_client: UnstructuredClient,
270+
platform_api_url: str,
271+
processing_status: str,
272+
expected,
273+
):
274+
url = f"{platform_api_url}/api/v1/jobs/fcdc4994-eea5-425c-91fa-e03f2bd8030d/details"
275+
276+
httpx_mock.add_response(
277+
method="GET",
278+
headers={"Content-Type": "application/json"},
279+
json={
280+
"id": "fcdc4994-eea5-425c-91fa-e03f2bd8030d",
281+
"node_stats": [],
282+
"processing_status": processing_status,
283+
},
284+
url=url,
285+
)
286+
287+
details = platform_client.jobs.get_job_details(
288+
request=operations.GetJobDetailsRequest(job_id="fcdc4994-eea5-425c-91fa-e03f2bd8030d")
289+
).job_details
290+
291+
assert details.processing_status == expected
292+
assert type(details.processing_status) is type(expected)
293+
294+
295+
def test_get_job_details_exposes_preflight_failure(
296+
httpx_mock, platform_client: UnstructuredClient, platform_api_url: str
297+
):
298+
url = f"{platform_api_url}/api/v1/jobs/fcdc4994-eea5-425c-91fa-e03f2bd8030d/details"
299+
300+
def node(name: str, **failure) -> dict:
301+
return {
302+
"node_name": name,
303+
"ready": 0,
304+
"in_progress": 0,
305+
"success": 0,
306+
"failure": 0,
307+
**failure,
308+
}
309+
310+
httpx_mock.add_response(
311+
method="GET",
312+
headers={"Content-Type": "application/json"},
313+
json={
314+
"id": "fcdc4994-eea5-425c-91fa-e03f2bd8030d",
315+
"processing_status": "REJECTED",
316+
"node_stats": [
317+
node(
318+
"s3-source",
319+
failure_category="AUTH_INVALID_CREDENTIAL",
320+
failure_reason="The AWS Access Key Id you provided does not exist.",
321+
),
322+
node("future-destination", failure_category="SOME_FUTURE_CATEGORY"),
323+
node("partitioner", failure_category=None, failure_reason=None),
324+
],
325+
},
326+
url=url,
327+
)
328+
329+
details = platform_client.jobs.get_job_details(
330+
request=operations.GetJobDetailsRequest(job_id="fcdc4994-eea5-425c-91fa-e03f2bd8030d")
331+
).job_details
332+
333+
source, destination, partitioner = details.node_stats
334+
assert source.failure_category is shared.PreflightFailureCategory.AUTH_INVALID_CREDENTIAL
335+
assert source.failure_reason == "The AWS Access Key Id you provided does not exist."
336+
assert type(destination.failure_category) is str
337+
assert destination.failure_category == "SOME_FUTURE_CATEGORY"
338+
assert partitioner.failure_category is None
339+
assert partitioner.failure_reason is None

‎_test_contract/platform_api/test_workflows.py‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from datetime import datetime
23

34
import pytest
@@ -256,4 +257,66 @@ def test_run_workflow(httpx_mock, platform_client: UnstructuredClient, platform_
256257
new_job = run_workflow_response.job_information
257258
assert new_job.id == "fcdc4994-eea5-425c-91fa-e03f2bd8030d"
258259
assert new_job.workflow_name == "test_workflow"
259-
assert new_job.status == "IN_PROGRESS"
260+
assert new_job.status == "IN_PROGRESS"
261+
262+
WORKFLOW_RESPONSE = {
263+
"created_at": "2025-06-22T11:37:21.648Z",
264+
"destinations": [],
265+
"id": "16b80fee-64dc-472d-8f26-1d7729b6423d",
266+
"name": "test_workflow",
267+
"sources": [],
268+
"workflow_nodes": [],
269+
"status": "active",
270+
}
271+
272+
273+
def test_create_workflow_sends_skip_preflight(
274+
httpx_mock, platform_client: UnstructuredClient, platform_api_url: str
275+
):
276+
httpx_mock.add_response(
277+
method="POST",
278+
url=f"{platform_api_url}/api/v1/workflows/",
279+
json={**WORKFLOW_RESPONSE, "skip_preflight": True},
280+
)
281+
282+
response = platform_client.workflows.create_workflow(
283+
request=operations.CreateWorkflowRequest(
284+
create_workflow=shared.CreateWorkflow(
285+
name="test_workflow",
286+
workflow_type="custom",
287+
skip_preflight=True,
288+
)
289+
)
290+
)
291+
292+
assert json.loads(httpx_mock.get_requests()[0].content)["skip_preflight"] is True
293+
assert response.workflow_information.skip_preflight is True
294+
295+
296+
@pytest.mark.parametrize(
297+
("update_workflow", "expected_body"),
298+
[
299+
(shared.UpdateWorkflow(name="renamed"), {"name": "renamed"}),
300+
(shared.UpdateWorkflow(skip_preflight=False), {"skip_preflight": False}),
301+
(shared.UpdateWorkflow(skip_preflight=True), {"skip_preflight": True}),
302+
],
303+
)
304+
def test_update_workflow_sends_skip_preflight_only_when_set(
305+
httpx_mock,
306+
platform_client: UnstructuredClient,
307+
platform_api_url: str,
308+
update_workflow: shared.UpdateWorkflow,
309+
expected_body: dict,
310+
):
311+
url = f"{platform_api_url}/api/v1/workflows/16b80fee-64dc-472d-8f26-1d7729b6423d"
312+
httpx_mock.add_response(method="PUT", url=url, json=WORKFLOW_RESPONSE)
313+
314+
response = platform_client.workflows.update_workflow(
315+
request=operations.UpdateWorkflowRequest(
316+
workflow_id="16b80fee-64dc-472d-8f26-1d7729b6423d",
317+
update_workflow=update_workflow,
318+
)
319+
)
320+
321+
assert json.loads(httpx_mock.get_requests()[0].content) == expected_body
322+
assert response.workflow_information.skip_preflight is False
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from scripts.check_spec_drift import find_drift
2+
3+
4+
def _spec(schemas: dict) -> dict:
5+
return {"components": {"schemas": schemas}}
6+
7+
8+
def test_matching_spec_reports_no_drift():
9+
spec = _spec(
10+
{
11+
"JobStatus": {"type": "string", "enum": ["SCHEDULED", "REJECTED"]},
12+
"CreateWorkflow": {
13+
"properties": {
14+
"name": {"type": "string"},
15+
"schedule": {"anyOf": [{"type": "string", "enum": ["daily"]}, {"type": "null"}]},
16+
}
17+
},
18+
}
19+
)
20+
assert find_drift([spec]) == {}
21+
22+
23+
def test_reports_missing_enum_values_and_properties():
24+
spec = _spec(
25+
{
26+
"JobStatus": {"type": "string", "enum": ["SCHEDULED", "PAUSED"]},
27+
"CreateWorkflow": {"properties": {"name": {}, "brand_new_field": {}}},
28+
"SomeSchemaTheSdkDoesNotModel": {"properties": {"x": {}}},
29+
}
30+
)
31+
assert find_drift([spec]) == {
32+
"CreateWorkflow": ["brand_new_field"],
33+
"JobStatus": ["PAUSED"],
34+
}
35+
36+
37+
def test_reports_missing_values_of_an_inline_property_enum():
38+
spec = _spec(
39+
{
40+
"CreateWorkflow": {
41+
"properties": {
42+
"schedule": {
43+
"anyOf": [
44+
{"type": "string", "enum": ["daily", "every 30 minutes"]},
45+
{"type": "null"},
46+
]
47+
}
48+
}
49+
}
50+
}
51+
)
52+
assert find_drift([spec]) == {"CreateWorkflow.schedule": ["every 30 minutes"]}

‎docs/models/shared/bodycreatejob.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
| Field | Type | Required | Description |
77
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
88
| `input_files` | List[[shared.InputFiles](../../models/shared/inputfiles.md)] | :heavy_minus_sign: | N/A |
9-
| `request_data` | *str* | :heavy_check_mark: | N/A |
9+
| `request_data` | *str* | :heavy_check_mark: | JSON-encoded object. Provide either `template_id` or `job_nodes`, not both. Set `"skip_preflight": true` to skip the job preflight check for this job. |

‎docs/models/shared/createworkflow.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
| `name` | *str* | :heavy_check_mark: | N/A |
1010
| `reprocess_all` | *OptionalNullable[bool]* | :heavy_minus_sign: | N/A |
1111
| `schedule` | [OptionalNullable[shared.Schedule]](../../models/shared/schedule.md) | :heavy_minus_sign: | N/A |
12+
| `skip_preflight` | *OptionalNullable[bool]* | :heavy_minus_sign: | Skip the job preflight check for this workflow. |
1213
| `source_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A |
1314
| `template_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A |
1415
| `workflow_nodes` | List[[shared.WorkflowNode](../../models/shared/workflownode.md)] | :heavy_minus_sign: | N/A |

‎docs/models/shared/jobdetails.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
| `id` | *str* | :heavy_check_mark: | N/A |
99
| `message` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A |
1010
| `node_stats` | List[[shared.JobNodeDetails](../../models/shared/jobnodedetails.md)] | :heavy_check_mark: | N/A |
11-
| `processing_status` | [shared.JobProcessingStatus](../../models/shared/jobprocessingstatus.md) | :heavy_check_mark: | N/A |
11+
| `processing_status` | Union[[shared.JobProcessingStatus](../../models/shared/jobprocessingstatus.md), *str*] | :heavy_check_mark: | N/A |

0 commit comments

Comments
 (0)