From f659069cfb2029ede501e9f4246220d1e38f07f2 Mon Sep 17 00:00:00 2001 From: Gavin Chen Date: Thu, 27 Aug 2026 15:27:40 -0700 Subject: [PATCH 1/3] feat: add skip_preflight and REJECTED job status (0.47.0) Two gaps kept the client behind the server on the job pre-flight check. REJECTED job status ------------------- JobStatus and JobProcessingStatus were closed enums without REJECTED, so get_job on a rejected job raised, and one rejected job in a page failed the whole list_jobs call, since the page is validated as a single list. Both enums now carry REJECTED plus a _missing_ hook that preserves an unrecognised server-side value verbatim, so the next status the server adds is not a client break. The hook cannot come from the spec - a generated enum is closed - so both files stay in .genignore with a guard test. skip_preflight -------------- Added to CreateWorkflow and UpdateWorkflow to opt a workflow out of the check, and to WorkflowInformation to read the setting back. Modelled exactly on reprocess_all: OptionalNullable[bool] = UNSET on the request models, so an unset value is omitted rather than sent as false, which is what makes the server's "omit means unchanged" contract work on update. Sending false opts back in. BodyCreateJob takes it as a plain argument. The spec types request_data as a string and describes the payload only in contentSchema, so there is no model to generate; the field folds into that JSON instead, and carries no MultipartFormMetadata so it never becomes a form part of its own. An explicit argument wins over a value already in the string. The fold splices rather than re-encoding, so every other byte of the caller's payload survives verbatim - a json.loads/json.dumps round trip preserves values but not their representation. It runs on assignment and through model_copy as well as construction: the field is invisible on the wire, so a dropped fold would reach the server as nothing at all and raise nothing, running the job with preflight enabled while the caller believes otherwise. Re-running it on an already-correct payload is a no-op. Also exports Unset from unstructured_client.types. UNSET was exported but not its type, leaving no public way to test for unset-ness - pydantic deep-copies the default per instance, so an identity check against the singleton silently fails open. Co-Authored-By: Claude Opus 5 (1M context) --- .genignore | 24 ++ CHANGELOG.md | 8 + README.md | 96 +++++ RELEASES.md | 10 + _test_contract/platform_api/test_jobs.py | 390 ++++++++++++++++++ _test_contract/platform_api/test_workflows.py | 192 ++++++++- .../unit/test_regeneration_guards.py | 235 +++++++++++ docs/models/shared/bodycreatejob.md | 3 +- docs/models/shared/createworkflow.md | 1 + docs/models/shared/jobprocessingstatus.md | 6 +- docs/models/shared/jobstatus.md | 6 +- docs/models/shared/updateworkflow.md | 1 + docs/models/shared/workflowinformation.md | 1 + src/unstructured_client/_version.py | 4 +- .../models/shared/body_create_job.py | 105 ++++- .../models/shared/createworkflow.py | 6 + .../models/shared/jobprocessingstatus.py | 17 + .../models/shared/jobstatus.py | 17 + .../models/shared/updateworkflow.py | 9 + .../models/shared/workflowinformation.py | 12 +- src/unstructured_client/types/__init__.py | 2 + 21 files changed, 1135 insertions(+), 10 deletions(-) diff --git a/.genignore b/.genignore index cb92e874..3d742e94 100644 --- a/.genignore +++ b/.genignore @@ -39,3 +39,27 @@ src/unstructured_client/models/operations/partition.py # moved since 2026-01. These entries are insurance for when that is restored, not a # defence against an imminent run. docs/models/operations/partitionresponse.md + +# Hand-written REJECTED member and the _missing_ forward-tolerance hook on the job status +# enums. A regenerated closed enum would drop both. +src/unstructured_client/models/shared/jobstatus.py +src/unstructured_client/models/shared/jobprocessingstatus.py + +# Docs for those same enums - generated from the spec, so a regeneration drops the rows. +docs/models/shared/jobstatus.md +docs/models/shared/jobprocessingstatus.md + +# Client-side `skip_preflight` convenience field + the validator that folds it into the +# `request_data` JSON. The spec types `request_data` as a plain string and describes the payload +# only in `contentSchema`, so `Body_create_job` has no `skip_preflight` property at all - a +# regenerated model would drop the field and the fold, silently sending callers back to +# hand-editing that string. Both paths are claimed by .speakeasy/gen.lock. +# See test_regeneration_guards.py::test_body_create_job_folds_skip_preflight_into_request_data. +src/unstructured_client/models/shared/body_create_job.py +docs/models/shared/bodycreatejob.md + +# Exports `Unset` alongside `UNSET`. Speakeasy exports the singleton but not its type, leaving no +# public way to ask "is this value unset" - which `body_create_job.py` needs, and which otherwise +# forces a private import of `utils.values._is_set` from a models module. A regeneration would drop +# the export and break that import. +src/unstructured_client/types/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b62d9527..508e31ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.47.0 + +### Features +* Add `skip_preflight` to `shared.CreateWorkflow` and `shared.UpdateWorkflow` to opt a workflow out of the job pre-flight check, and to `shared.WorkflowInformation` to read the current setting back. It behaves like `reprocess_all`: omitting it on update leaves the stored value unchanged, while sending `false` opts back in. `shared.BodyCreateJob` accepts it as a plain argument and folds it into the `request_data` JSON where the server reads it, so callers no longer have to hand-edit that string; an explicit argument wins over a value already present in it. + +### Fixes +* `JobStatus` and `JobProcessingStatus` gained `REJECTED`, so `jobs.get_job` and `jobs.list_jobs` no longer raise on a preflight-rejected job. Previously a single rejected job in a page failed the entire `list_jobs` call, since the page is validated as one list. Both enums now also preserve an unrecognised server-side status verbatim instead of raising, so the next status the server adds is not a client break. + ## 0.46.1 ### Fixes diff --git a/README.md b/README.md index 39f9dc3a..e2b30e1c 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,102 @@ s = UnstructuredClient(debug_logger=logging.getLogger("unstructured_client")) +### Skipping the job preflight check + +Jobs run a preflight check by default, which validates connector credentials and configuration +before any documents are processed. Set `skip_preflight` to opt out. + +On a workflow, the setting is stored and applies to every run of that workflow — including +scheduled runs and `run_workflow`: + +```python +from unstructured_client import UnstructuredClient +from unstructured_client.models import operations, shared + +uc_client = UnstructuredClient(api_key_auth="YOUR_API_KEY") + +created = uc_client.workflows.create_workflow( + request=operations.CreateWorkflowRequest( + create_workflow=shared.CreateWorkflow( + name="my-workflow", + workflow_type=shared.WorkflowType.ADVANCED, + source_id="...", + destination_id="...", + skip_preflight=True, + ) + ) +) +print(created.workflow_information.skip_preflight) # True +``` + +On update, **omitting** `skip_preflight` leaves the stored value alone; send `False` to opt back in: + +```python +workflow_id = created.workflow_information.id + +# Renames the workflow; leaves skip_preflight as it was +uc_client.workflows.update_workflow( + request=operations.UpdateWorkflowRequest( + workflow_id=workflow_id, + update_workflow=shared.UpdateWorkflow(name="renamed"), + ) +) + +# Turns preflight back on +uc_client.workflows.update_workflow( + request=operations.UpdateWorkflowRequest( + workflow_id=workflow_id, + update_workflow=shared.UpdateWorkflow(skip_preflight=False), + ) +) +``` + +For a one-off job, pass it to `create_job`. The API carries this field inside the `request_data` +JSON rather than as its own form field, and the SDK writes it there for you — so you do not need to +edit that string yourself. An explicit `skip_preflight` argument wins over any value already in it: + +```python +import json + +uc_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob( + request_data=json.dumps({"job_nodes": [...]}), + skip_preflight=True, + ) + ) +) +``` + +One exception, and the reason the example above uses `job_nodes` rather than `template_id`: a job +created from a `template_id` may not run a preflight check in the first place, because it has no +customer-supplied connector configuration to validate. Setting `skip_preflight` on such a job is +accepted but has no effect — it is a no-op, not an error. + +`skip_preflight=None` means "no preference". Note that it is *sent* — the workflow models put an +explicit `null` on the wire rather than omitting the field — but what the server does with that +`null` differs per call: + +| Call | On the wire | Effect | +| --- | --- | --- | +| `create_workflow` | `"skip_preflight": null` | treated as not set — preflight runs | +| `update_workflow` | `"skip_preflight": null` | **leaves the stored value unchanged** — if the workflow already had preflight skipped, it stays skipped | +| `create_job` | field absent (`request_data` untouched) | treated as not set — preflight runs | + +So `None` is not a way to turn preflight back on for an existing workflow. Pass +`skip_preflight=False` explicitly for that. + +Note on job statuses: `JobStatus` and `JobProcessingStatus` preserve a value this client version +does not know rather than raising, so a status added server-side is not a client break. The +trade-off is that a polling loop written as `while job.status not in (COMPLETED, FAILED, STOPPED, +REJECTED)` would not recognise a *new* terminal status and would spin until its own timeout. If you +poll, give the loop a deadline and treat an unrecognised status as a reason to stop and inspect, +rather than assuming it is non-terminal. + +For the same reason these enums are not input validators: `some_string in shared.JobStatus` is +true for *any* string, since the lookup that backs `in` is the same one that tolerates unknown +values. Validate against `shared.JobStatus.__members__` if you need to reject unknown input. + ### Maturity This SDK is in beta, and there may be breaking changes between versions without a major version update. Therefore, we recommend pinning usage diff --git a/RELEASES.md b/RELEASES.md index d30e16cc..de49d539 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1261,3 +1261,13 @@ Based on: - [python v0.46.1] . ### Releases - [PyPI v0.46.1] https://pypi.org/project/unstructured-client/0.46.1 - . + +## 2026-08-27 00:00:00 +### Changes +Based on: +- OpenAPI Doc +- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy +### Generated +- [python v0.47.0] . +### Releases +- [PyPI v0.47.0] https://pypi.org/project/unstructured-client/0.47.0 - . diff --git a/_test_contract/platform_api/test_jobs.py b/_test_contract/platform_api/test_jobs.py index bfd39508..1ee19566 100644 --- a/_test_contract/platform_api/test_jobs.py +++ b/_test_contract/platform_api/test_jobs.py @@ -1,6 +1,10 @@ +import json +import re from datetime import datetime +from urllib.parse import parse_qs import pytest +from pydantic import ValidationError from unstructured_client import UnstructuredClient from unstructured_client.models import shared, operations @@ -222,3 +226,389 @@ def test_create_job(httpx_mock, platform_client: UnstructuredClient, platform_ap assert job.status == "SCHEDULED" assert job.job_type == "template" assert job.created_at == datetime.fromisoformat("2025-06-22T11:37:21.648+00:00") + + +def _job(job_id: str, status: str) -> dict: + return { + "created_at": "2025-06-22T11:37:21.648Z", + "id": job_id, + "status": status, + "runtime": None, + "workflow_id": "16b80fee-64dc-472d-8f26-1d7729b6423d", + "workflow_name": "test_workflow", + } + + +def test_get_job_rejected(httpx_mock, platform_client: UnstructuredClient, platform_api_url: str): + """A preflight-rejected job deserializes instead of raising.""" + job_id = "fcdc4994-eea5-425c-91fa-e03f2bd8030d" + url = f"{platform_api_url}/api/v1/jobs/{job_id}" + + httpx_mock.add_response( + method="GET", + headers={"Content-Type": "application/json"}, + json=_job(job_id, "REJECTED"), + url=url, + ) + + job_response = platform_client.jobs.get_job( + request=operations.GetJobRequest(job_id=job_id) + ) + assert job_response.status_code == 200 + assert job_response.job_information.status == shared.JobStatus.REJECTED + + +def test_list_jobs_mixed_page_with_rejected( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """One rejected job must not fail the whole page - the page is validated as one list.""" + url = f"{platform_api_url}/api/v1/jobs/" + + httpx_mock.add_response( + method="GET", + headers={"Content-Type": "application/json"}, + json=[ + _job("fcdc4994-eea5-425c-91fa-e03f2bd8030d", "IN_PROGRESS"), + _job("2b1e7f10-0d9a-4f4d-9d1e-6c9f0a1b2c3d", "REJECTED"), + _job("3c2f8a21-1e0b-5a5e-8e2f-7d0a1b2c3d4e", "COMPLETED"), + ], + url=url, + ) + + jobs_response = platform_client.jobs.list_jobs(request=operations.ListJobsRequest()) + assert jobs_response.status_code == 200 + + statuses = [job.status for job in jobs_response.response_list_jobs] + assert statuses == [ + shared.JobStatus.IN_PROGRESS, + shared.JobStatus.REJECTED, + shared.JobStatus.COMPLETED, + ] + + +def test_get_job_details_rejected( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """processing_status is a second closed enum that REJECTED must not break.""" + job_id = "fcdc4994-eea5-425c-91fa-e03f2bd8030d" + url = f"{platform_api_url}/api/v1/jobs/{job_id}/details" + + httpx_mock.add_response( + method="GET", + headers={"Content-Type": "application/json"}, + json={"id": job_id, "processing_status": "REJECTED", "node_stats": []}, + url=url, + ) + + details_response = platform_client.jobs.get_job_details( + request=operations.GetJobDetailsRequest(job_id=job_id) + ) + assert details_response.status_code == 200 + assert ( + details_response.job_details.processing_status + == shared.JobProcessingStatus.REJECTED + ) + + +@pytest.mark.parametrize("unknown_status", ["PAUSED", "SOME_FUTURE_STATUS"]) +def test_list_jobs_tolerates_unknown_status( + httpx_mock, + platform_client: UnstructuredClient, + platform_api_url: str, + unknown_status: str, +): + """A status added server-side after this release must not break the client. + + The raw value is preserved, so callers can log exactly what the server sent. + """ + url = f"{platform_api_url}/api/v1/jobs/" + + httpx_mock.add_response( + method="GET", + headers={"Content-Type": "application/json"}, + json=[ + _job("fcdc4994-eea5-425c-91fa-e03f2bd8030d", "IN_PROGRESS"), + _job("2b1e7f10-0d9a-4f4d-9d1e-6c9f0a1b2c3d", unknown_status), + ], + url=url, + ) + + jobs_response = platform_client.jobs.list_jobs(request=operations.ListJobsRequest()) + assert jobs_response.status_code == 200 + + unknown = jobs_response.response_list_jobs[1].status + assert unknown.value == unknown_status + assert isinstance(unknown, shared.JobStatus) + # The unknown value must not leak into the declared member set. + assert unknown_status not in shared.JobStatus.__members__ + + +def _sent_field_names(httpx_mock) -> set[str]: + """Every form field name the SDK sent, for either encoding.""" + request = httpx_mock.get_requests()[0] + body = request.read().decode() + if "multipart/form-data" in request.headers.get("content-type", ""): + # (? str: + """Pull the `request_data` form field out of the body the SDK actually sent. + + Handles both encodings: httpx sends `multipart/form-data` once there is a file part and + falls back to `application/x-www-form-urlencoded` when the body is fields only. + """ + requests = httpx_mock.get_requests() + assert len(requests) == 1 + request = requests[0] + body = request.read().decode() + + if "multipart/form-data" in request.headers.get("content-type", ""): + match = re.search(r'name="request_data"\r\n\r\n(.*?)\r\n--', body, re.DOTALL) + assert match, f"no request_data part in multipart body: {body!r}" + return match.group(1) + + fields = parse_qs(body) + assert "request_data" in fields, f"no request_data field in body: {body!r}" + return fields["request_data"][0] + + +def _mock_create_job(httpx_mock, platform_api_url: str) -> None: + httpx_mock.add_response( + method="POST", + headers={"Content-Type": "application/json"}, + json=_job("fcdc4994-eea5-425c-91fa-e03f2bd8030d", "IN_PROGRESS"), + url=f"{platform_api_url}/api/v1/jobs/", + ) + + +@pytest.mark.parametrize("skip_preflight", [True, False]) +def test_create_job_folds_skip_preflight_into_request_data( + httpx_mock, + platform_client: UnstructuredClient, + platform_api_url: str, + skip_preflight: bool, +): + """`skip_preflight` rides inside the `request_data` JSON, not as its own form field. + + The spec types the multipart field as a plain string and describes the payload only in + `contentSchema`, so this is the only place the server reads it. + """ + _mock_create_job(httpx_mock, platform_api_url) + + platform_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob( + request_data=json.dumps({"template_id": "some-template"}), + skip_preflight=skip_preflight, + ) + ) + ) + + assert json.loads(_sent_request_data(httpx_mock)) == { + "template_id": "some-template", + "skip_preflight": skip_preflight, + } + # It must not also appear as a form field of its own. Assert on the parsed field names + # rather than a substring of the encoded body, which would pass by coincidence. + assert _sent_field_names(httpx_mock) == {"request_data"} + + +def test_create_job_leaves_request_data_untouched_when_unset( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """Unset means the caller's string is passed through byte for byte.""" + _mock_create_job(httpx_mock, platform_api_url) + request_data = '{"template_id":"some-template"}' + + platform_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob(request_data=request_data) + ) + ) + + assert _sent_request_data(httpx_mock) == request_data + + +def test_create_job_skip_preflight_param_overrides_json( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """The explicit parameter wins over a value already in the JSON string.""" + _mock_create_job(httpx_mock, platform_api_url) + + platform_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob( + request_data='{"template_id":"some-template","skip_preflight":true}', + skip_preflight=False, + ) + ) + ) + + assert json.loads(_sent_request_data(httpx_mock))["skip_preflight"] is False + + +def test_create_job_skip_preflight_requires_json_object_request_data(): + """A non-JSON `request_data` cannot carry the flag, so say so at construction time.""" + with pytest.raises(ValidationError, match="must be a JSON object"): + shared.BodyCreateJob(request_data="not json at all", skip_preflight=True) + + with pytest.raises(ValidationError, match="must be a JSON object"): + shared.BodyCreateJob(request_data="[1, 2, 3]", skip_preflight=True) + + +def test_create_job_typed_dict_path_folds_skip_preflight( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """The TypedDict request shape routes through `utils.unmarshal`, so the validator still runs.""" + _mock_create_job(httpx_mock, platform_api_url) + + platform_client.jobs.create_job( + request={ + "body_create_job": { + "request_data": '{"template_id":"some-template"}', + "skip_preflight": True, + } + } + ) + + assert json.loads(_sent_request_data(httpx_mock))["skip_preflight"] is True + + +def test_create_job_folds_skip_preflight_with_input_files( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """The real production shape: a file upload, which switches the body to multipart. + + Without a file part httpx sends urlencoded, so this is the only test that exercises the + multipart branch - and it is the branch where "not a form field of its own" matters most, + since a stray part would be indistinguishable from a real one. + """ + _mock_create_job(httpx_mock, platform_api_url) + + platform_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob( + request_data=json.dumps({"job_nodes": []}), + input_files=[ + shared.InputFiles( + content=b"hello", + file_name="a.pdf", + content_type="application/pdf", + ) + ], + skip_preflight=True, + ) + ) + ) + + request = httpx_mock.get_requests()[0] + assert "multipart/form-data" in request.headers["content-type"] + assert json.loads(_sent_request_data(httpx_mock)) == { + "job_nodes": [], + "skip_preflight": True, + } + assert _sent_field_names(httpx_mock) == {"request_data", "input_files[]"} + + +def test_create_job_preserves_high_precision_numbers_on_the_wire( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """Byte preservation has to survive form encoding, not just the model. + + The model-level fidelity tests assert on `body.request_data`. This one reads the value back + out of the encoded request body, which is what the server actually parses. + """ + _mock_create_job(httpx_mock, platform_api_url) + # More precision than a float64 holds, plus an exponent form and a significant trailing zero. + request_data = '{"job_nodes":[{"threshold":0.123456789012345678901,"scale":1e5,"pad":1.50}]}' + + platform_client.jobs.create_job( + request=operations.CreateJobRequest( + body_create_job=shared.BodyCreateJob( + request_data=request_data, skip_preflight=True + ) + ) + ) + + sent = _sent_request_data(httpx_mock) + for literal in ("0.123456789012345678901", "1e5", "1.50"): + assert literal in sent, f"{literal} was rewritten on the wire: {sent!r}" + assert json.loads(sent)["skip_preflight"] is True + + +def test_create_job_with_files_and_flag_survives_reassignment( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """Multipart + fold + idempotency together. + + Each is covered alone; this is the combination a real caller hits when they build a body, + attach files, then adjust the flag before sending. + """ + _mock_create_job(httpx_mock, platform_api_url) + + body = shared.BodyCreateJob( + request_data='{"job_nodes":[],"scale":1e5}', + input_files=[ + shared.InputFiles( + content=b"hello", file_name="a.pdf", content_type="application/pdf" + ) + ], + skip_preflight=True, + ) + # Attaching more files must not disturb the already-folded payload. + body.input_files = list(body.input_files) + [ + shared.InputFiles( + content=b"world", file_name="b.pdf", content_type="application/pdf" + ) + ] + assert "1e5" in body.request_data, "an unrelated assignment reformatted request_data" + + # Flipping the flag must land the new value. + body.skip_preflight = False + + platform_client.jobs.create_job( + request=operations.CreateJobRequest(body_create_job=body) + ) + + request = httpx_mock.get_requests()[0] + assert "multipart/form-data" in request.headers["content-type"] + assert json.loads(_sent_request_data(httpx_mock))["skip_preflight"] is False + assert _sent_field_names(httpx_mock) == {"request_data", "input_files[]"} + + +def test_create_job_reusing_one_body_sends_the_value_set_at_call_time( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """A reused body is mutable, so each call must carry whatever was set before it. + + Deterministic stand-in for the shared-mutable-state hazard: the model folds on assignment, + so a body shared across calls is only safe if callers mutate it between them, never during. + """ + url = f"{platform_api_url}/api/v1/jobs/" + for _ in range(2): + httpx_mock.add_response( + method="POST", + headers={"Content-Type": "application/json"}, + json=_job("fcdc4994-eea5-425c-91fa-e03f2bd8030d", "IN_PROGRESS"), + url=url, + ) + + body = shared.BodyCreateJob(request_data='{"job_nodes":[]}', skip_preflight=True) + platform_client.jobs.create_job( + request=operations.CreateJobRequest(body_create_job=body) + ) + body.skip_preflight = False + platform_client.jobs.create_job( + request=operations.CreateJobRequest(body_create_job=body) + ) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + sent = [ + json.loads(parse_qs(r.read().decode())["request_data"][0])["skip_preflight"] + for r in requests + ] + assert sent == [True, False] diff --git a/_test_contract/platform_api/test_workflows.py b/_test_contract/platform_api/test_workflows.py index f6abe1a9..7c0c2b61 100644 --- a/_test_contract/platform_api/test_workflows.py +++ b/_test_contract/platform_api/test_workflows.py @@ -1,3 +1,4 @@ +import json from datetime import datetime import pytest @@ -256,4 +257,193 @@ def test_run_workflow(httpx_mock, platform_client: UnstructuredClient, platform_ new_job = run_workflow_response.job_information assert new_job.id == "fcdc4994-eea5-425c-91fa-e03f2bd8030d" assert new_job.workflow_name == "test_workflow" - assert new_job.status == "IN_PROGRESS" \ No newline at end of file + assert new_job.status == "IN_PROGRESS" + +WORKFLOW_ID = "16b80fee-64dc-472d-8f26-1d7729b6423d" + + +def _workflow_json(**overrides) -> dict: + payload = { + "created_at": "2025-06-22T11:37:21.648Z", + "destinations": ["aeebecc7-9d8e-4625-bf1d-815c2f084869"], + "id": WORKFLOW_ID, + "name": "test_workflow", + "sources": ["f1f7b1b2-8e4b-4a2b-8f1d-3e3c7c9e5a3c"], + "workflow_nodes": [], + "status": "active", + "workflow_type": "advanced", + } + payload.update(overrides) + return payload + + +def _sent_body(httpx_mock) -> dict: + requests = httpx_mock.get_requests() + assert len(requests) == 1 + return json.loads(requests[0].read()) + + +@pytest.mark.parametrize( + ("skip_preflight", "expected"), + [(True, True), (False, False)], +) +def test_create_workflow_sends_skip_preflight( + httpx_mock, + platform_client: UnstructuredClient, + platform_api_url: str, + skip_preflight: bool, + expected: bool, +): + """An explicit value reaches the wire, `False` included.""" + httpx_mock.add_response( + method="POST", + url=f"{platform_api_url}/api/v1/workflows/", + status_code=200, + json=_workflow_json(skip_preflight=skip_preflight), + ) + + platform_client.workflows.create_workflow( + request=operations.CreateWorkflowRequest( + create_workflow=shared.CreateWorkflow( + name="test_workflow", + workflow_type="advanced", + skip_preflight=skip_preflight, + ) + ) + ) + + assert _sent_body(httpx_mock)["skip_preflight"] is expected + + +def test_create_workflow_omits_skip_preflight_when_unset( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """Unset must be *absent*, not `false`. + + Regression guard for the `optional_fields` list in `CreateWorkflow.serialize_model`: leave + `skip_preflight` out of it and the serializer emits the field on every create call. + """ + httpx_mock.add_response( + method="POST", + url=f"{platform_api_url}/api/v1/workflows/", + status_code=200, + json=_workflow_json(), + ) + + platform_client.workflows.create_workflow( + request=operations.CreateWorkflowRequest( + create_workflow=shared.CreateWorkflow( + name="test_workflow", workflow_type="advanced" + ) + ) + ) + + assert "skip_preflight" not in _sent_body(httpx_mock) + + +@pytest.mark.parametrize("skip_preflight", [True, False]) +def test_update_workflow_sends_skip_preflight( + httpx_mock, + platform_client: UnstructuredClient, + platform_api_url: str, + skip_preflight: bool, +): + """`False` must be sent, not dropped - it is how a caller opts back in to preflight.""" + httpx_mock.add_response( + method="PUT", + url=f"{platform_api_url}/api/v1/workflows/{WORKFLOW_ID}", + status_code=200, + json=_workflow_json(skip_preflight=skip_preflight), + ) + + platform_client.workflows.update_workflow( + request=operations.UpdateWorkflowRequest( + workflow_id=WORKFLOW_ID, + update_workflow=shared.UpdateWorkflow(skip_preflight=skip_preflight), + ) + ) + + assert _sent_body(httpx_mock)["skip_preflight"] is skip_preflight + + +def test_update_workflow_omits_skip_preflight_when_unset( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """Omitted means "leave unchanged" server-side, so an unrelated update must not send it.""" + httpx_mock.add_response( + method="PUT", + url=f"{platform_api_url}/api/v1/workflows/{WORKFLOW_ID}", + status_code=200, + json=_workflow_json(skip_preflight=True), + ) + + platform_client.workflows.update_workflow( + request=operations.UpdateWorkflowRequest( + workflow_id=WORKFLOW_ID, + update_workflow=shared.UpdateWorkflow(name="renamed"), + ) + ) + + body = _sent_body(httpx_mock) + assert body == {"name": "renamed"} + + +@pytest.mark.parametrize( + ("response_json", "expected"), + [ + (_workflow_json(skip_preflight=True), True), + (_workflow_json(skip_preflight=False), False), + # Absent in the response - defaults to False rather than None. + (_workflow_json(), False), + ], +) +def test_workflow_information_reads_skip_preflight( + httpx_mock, + platform_client: UnstructuredClient, + platform_api_url: str, + response_json: dict, + expected: bool, +): + """The response echo must be readable. + + Without the field on the model, `extra="ignore"` silently drops what the server sent and a + caller cannot tell whether preflight is skipped. + """ + httpx_mock.add_response( + method="GET", + url=f"{platform_api_url}/api/v1/workflows/{WORKFLOW_ID}", + status_code=200, + json=response_json, + ) + + response = platform_client.workflows.get_workflow( + request=operations.GetWorkflowRequest(workflow_id=WORKFLOW_ID) + ) + + assert response.workflow_information.skip_preflight is expected + + +def test_update_workflow_skip_preflight_only_sends_nothing_else( + httpx_mock, platform_client: UnstructuredClient, platform_api_url: str +): + """A partial update must carry *only* what the caller set. + + The other tests assert the key is present. This one asserts nothing else leaked in, which is + what makes "omit means unchanged" safe: any extra key here would overwrite server state the + caller never mentioned. + """ + httpx_mock.add_response( + method="PUT", + url=f"{platform_api_url}/api/v1/workflows/{WORKFLOW_ID}", + status_code=200, + json=_workflow_json(skip_preflight=True), + ) + + platform_client.workflows.update_workflow( + request=operations.UpdateWorkflowRequest( + workflow_id=WORKFLOW_ID, + update_workflow=shared.UpdateWorkflow(skip_preflight=True), + ) + ) + + assert _sent_body(httpx_mock) == {"skip_preflight": True} diff --git a/_test_unstructured_client/unit/test_regeneration_guards.py b/_test_unstructured_client/unit/test_regeneration_guards.py index 62dc76a1..15d28817 100644 --- a/_test_unstructured_client/unit/test_regeneration_guards.py +++ b/_test_unstructured_client/unit/test_regeneration_guards.py @@ -1,9 +1,15 @@ +import inspect +import json import re + +import pytest from pathlib import Path import tomllib from unstructured_client.models import shared +from unstructured_client.types import Unset from unstructured_client.utils.forms import serialize_multipart_form +from unstructured_client.utils.metadata import MultipartFormMetadata, find_field_metadata REPO_ROOT = Path(__file__).resolve().parents[2] @@ -147,3 +153,232 @@ def test_body_run_workflow_input_files_are_serialized_as_multipart_files(): assert media_type == "multipart/form-data" assert form == {} assert files == [("input_files[]", ("hello.pdf", b"hello", "application/pdf"))] + + +def test_job_status_enums_stay_forward_tolerant(): + """REJECTED and the `_missing_` hook are hand-written; nothing regenerates them. + + REJECTED is in the live spec, but the `_missing_` hook cannot be: a generated enum is + closed. Speakeasy generation is decommissioned, so this test - not `.genignore` - is what + protects both. + """ + for enum in (shared.JobStatus, shared.JobProcessingStatus): + assert enum.REJECTED.value == "REJECTED" + + # An unrecognised value is preserved verbatim rather than raising or + # collapsing to a sentinel. + unknown = enum("SOME_FUTURE_STATUS") + assert isinstance(unknown, enum) + assert unknown.value == "SOME_FUTURE_STATUS" + # Repeated lookups must return the same object, not grow a new one each time. + assert unknown is enum("SOME_FUTURE_STATUS") + # ...and must not leak into the declared member set. + assert "SOME_FUTURE_STATUS" not in enum.__members__ + + for name in ("jobstatus", "jobprocessingstatus"): + docs = (REPO_ROOT / f"docs/models/shared/{name}.md").read_text() + assert "REJECTED" in docs + + +def test_genignore_covers_code_the_spec_cannot_produce(): + """`.genignore` must list every file carrying code no spec can generate. + + The rule is narrow on purpose. `skip_preflight` on the three workflow models IS in the live + spec, so a regeneration would reproduce it and listing those files would only freeze them + against future spec-driven fields. These four cannot come from the spec at all: + - the `_missing_` hook: a generated enum is closed; + - `BodyCreateJob.skip_preflight` and its fold: `Body_create_job` has no such property, + because the spec types `request_data` as a plain string; + - the `Unset` export in `types/__init__.py`: the generator exports the singleton but not + its type. + """ + genignore = (REPO_ROOT / ".genignore").read_text() + for path in ( + "src/unstructured_client/models/shared/jobstatus.py", + "src/unstructured_client/models/shared/jobprocessingstatus.py", + "docs/models/shared/jobstatus.md", + "docs/models/shared/jobprocessingstatus.md", + "src/unstructured_client/models/shared/body_create_job.py", + "docs/models/shared/bodycreatejob.md", + "src/unstructured_client/types/__init__.py", + ): + assert path in genignore, f"{path} carries custom code and must stay in .genignore" + + # The converse: files whose custom content IS spec-derivable must NOT be frozen. + for path in ( + "src/unstructured_client/models/shared/createworkflow.py", + "src/unstructured_client/models/shared/updateworkflow.py", + "src/unstructured_client/models/shared/workflowinformation.py", + ): + assert path not in genignore, ( + f"{path} should not be in .genignore: skip_preflight is in the live spec, so " + "freezing the file would only block future spec-driven fields" + ) + + +def test_workflow_models_expose_skip_preflight(): + """`skip_preflight` is the preflight opt-out; it must stay on all four models. + + It mirrors `reprocess_all` exactly: `OptionalNullable[bool] = UNSET` on the two request + models so an unset value is omitted rather than sent, and `Optional[bool] = False` on the + response model so the server's echo is readable. + """ + for model in (shared.CreateWorkflow, shared.UpdateWorkflow): + field = model.model_fields["skip_preflight"] + assert isinstance(field.default, Unset), ( + f"{model.__name__}.skip_preflight must default to UNSET so an unset value is " + "omitted from the request body, not sent as false" + ) + # Both lists in serialize_model matter: missing from optional_fields, the serializer's + # `elif` branch emits the field on every call. + source = inspect.getsource(model.serialize_model) + assert source.count('"skip_preflight"') == 2, ( + f"{model.__name__}.serialize_model must list skip_preflight in BOTH " + "optional_fields and nullable_fields" + ) + + info_field = shared.WorkflowInformation.model_fields["skip_preflight"] + assert info_field.default is False + + job_field = shared.BodyCreateJob.model_fields["skip_preflight"] + assert isinstance(job_field.default, Unset) + # Deliberately carries no multipart metadata: the validator folds it into request_data, and + # serialize_multipart_form skips any field without MultipartFormMetadata. + assert find_field_metadata(job_field, MultipartFormMetadata) is None + + +def test_body_create_job_folds_skip_preflight_into_request_data(): + """Unset must be tested with `isinstance(..., Unset)`, never `is UNSET`. + + Pydantic deep-copies the default per instance, so an identity check against the singleton + silently fails open and rewrites `request_data` on every call. + """ + untouched = '{"template_id":"t"}' + assert shared.BodyCreateJob(request_data=untouched).request_data == untouched + + for value in (True, False): + body = shared.BodyCreateJob(request_data=untouched, skip_preflight=value) + assert json.loads(body.request_data) == {"template_id": "t", "skip_preflight": value} + + +def test_unset_is_publicly_importable(): + """`Unset` must stay exported, or `body_create_job` cannot import it. + + `UNSET` alone is not enough: pydantic deep-copies the default per model instance, so callers + need the *type* to test for unset-ness rather than an identity check against the singleton. + Without this export the only route is a private import of `utils.values._is_set` from a + models module, which crosses a package boundary for an underscore-prefixed name. + """ + from unstructured_client import types + + assert "Unset" in types.__all__ + assert isinstance(types.UNSET, types.Unset) + + # The property that makes the type necessary in the first place. + body = shared.BodyCreateJob(request_data="{}") + assert body.skip_preflight is not types.UNSET + assert isinstance(body.skip_preflight, types.Unset) + + +def test_body_create_job_folds_on_assignment_not_only_construction(): + """`skip_preflight` must survive being set after construction. + + The field carries no multipart metadata, so a dropped fold reaches the wire as nothing at + all and raises nothing - the job would run *with* preflight while the caller believes it is + off. `validate_assignment=True` on the model is what closes that; the fold writes through + `__dict__` to avoid re-entering itself. + """ + body = shared.BodyCreateJob(request_data='{"a":1}') + assert body.request_data == '{"a":1}', "unset must not rewrite the payload" + + body.skip_preflight = True + assert json.loads(body.request_data) == {"a": 1, "skip_preflight": True} + + # Toggling back must write false, not leave the previous true in place. + body.skip_preflight = False + assert json.loads(body.request_data) == {"a": 1, "skip_preflight": False} + + # Replacing the payload must re-apply the flag rather than lose it. + body.request_data = '{"z":9}' + assert json.loads(body.request_data) == {"z": 9, "skip_preflight": False} + + # The base model config must be merged, not replaced. + assert shared.BodyCreateJob.model_config["populate_by_name"] is True + assert shared.BodyCreateJob.model_config["validate_assignment"] is True + + +def test_body_create_job_folds_skip_preflight_through_model_copy(): + """`model_copy(update=...)` bypasses validation, so the fold has to be re-applied. + + The dangerous direction is the second case: a body that already folded `true`, copied with + `skip_preflight=False`, would keep sending `true` and run the job with preflight disabled + while the caller believes they just re-enabled it. + """ + lost = shared.BodyCreateJob(request_data='{"a":1}').model_copy( + update={"skip_preflight": True} + ) + assert json.loads(lost.request_data)["skip_preflight"] is True + + reenabled = shared.BodyCreateJob( + request_data='{"a":1}', skip_preflight=True + ).model_copy(update={"skip_preflight": False}) + assert json.loads(reenabled.request_data)["skip_preflight"] is False + + # A copy with no update must not change the payload. + body = shared.BodyCreateJob(request_data='{"a":1}', skip_preflight=True) + for copied in (body.model_copy(), body.model_copy(deep=True)): + assert copied.request_data == body.request_data + + +@pytest.mark.parametrize( + "request_data", + [ + '{"x": 0.123456789012345678901}', # more precision than a float64 can hold + '{"x": 1e5}', # exponent form survives + '{"x": 1E+2}', + '{"x": 1.50}', # trailing zero is significant to some consumers + '{"n": "café"}', # non-ASCII stays as written + '{"a":1}', + ], +) +def test_body_create_job_fold_preserves_the_callers_bytes(request_data): + """Adding the flag must not re-encode the rest of the payload. + + A json.loads/json.dumps round trip preserves *values* but not their representation, and + `request_data` carries caller configuration that may reach precision-sensitive consumers. + The fold splices instead, so every other byte survives verbatim. + """ + folded = shared.BodyCreateJob( + request_data=request_data, skip_preflight=True + ).request_data + + original_body = request_data.strip()[1:-1].strip() + assert original_body in folded, f"{original_body!r} was rewritten: {folded!r}" + assert json.loads(folded)["skip_preflight"] is True + + +@pytest.mark.parametrize("request_data", ["{}", "{ }", '{"a":1} ']) +def test_body_create_job_fold_handles_empty_and_padded_objects(request_data): + """The splice has to cope with an empty object and with surrounding whitespace.""" + folded = shared.BodyCreateJob( + request_data=request_data, skip_preflight=True + ).request_data + assert json.loads(folded)["skip_preflight"] is True + + +def test_body_create_job_fold_is_a_no_op_once_correct(): + """Re-running the fold must not touch an already-correct payload. + + `validate_assignment=True` re-runs every model validator on *any* field assignment, so + without an early return, touching an unrelated field would drive the payload down the + re-encode branch and silently reformat it - undoing the byte-preservation above. + """ + body = shared.BodyCreateJob(request_data='{"x": 1e5}', skip_preflight=True) + folded = body.request_data + assert "1e5" in folded + + body.input_files = None + assert body.request_data == folded, "an unrelated assignment reformatted request_data" + + unchanged = body.model_copy(update={"input_files": None}) + assert unchanged.request_data == folded diff --git a/docs/models/shared/bodycreatejob.md b/docs/models/shared/bodycreatejob.md index 1632b184..6600a64b 100644 --- a/docs/models/shared/bodycreatejob.md +++ b/docs/models/shared/bodycreatejob.md @@ -6,4 +6,5 @@ | Field | Type | Required | Description | | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | `input_files` | List[[shared.InputFiles](../../models/shared/inputfiles.md)] | :heavy_minus_sign: | N/A | -| `request_data` | *str* | :heavy_check_mark: | N/A | \ No newline at end of file +| `request_data` | *str* | :heavy_check_mark: | N/A | +| `skip_preflight` | *OptionalNullable[bool]* | :heavy_minus_sign: | Skip the job preflight check for this job. Preflight runs by default. Folded into the `request_data` JSON on serialization rather than sent as its own form field. | \ No newline at end of file diff --git a/docs/models/shared/createworkflow.md b/docs/models/shared/createworkflow.md index a2f5f669..99aafb19 100644 --- a/docs/models/shared/createworkflow.md +++ b/docs/models/shared/createworkflow.md @@ -9,6 +9,7 @@ | `name` | *str* | :heavy_check_mark: | N/A | | `reprocess_all` | *OptionalNullable[bool]* | :heavy_minus_sign: | N/A | | `schedule` | [OptionalNullable[shared.Schedule]](../../models/shared/schedule.md) | :heavy_minus_sign: | N/A | +| `skip_preflight` | *OptionalNullable[bool]* | :heavy_minus_sign: | Skip the job preflight check for this workflow. Preflight runs by default. | | `source_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `template_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `workflow_nodes` | List[[shared.WorkflowNode](../../models/shared/workflownode.md)] | :heavy_minus_sign: | N/A | diff --git a/docs/models/shared/jobprocessingstatus.md b/docs/models/shared/jobprocessingstatus.md index 0e666dff..17da7ee8 100644 --- a/docs/models/shared/jobprocessingstatus.md +++ b/docs/models/shared/jobprocessingstatus.md @@ -10,4 +10,8 @@ | `SUCCESS` | SUCCESS | | `COMPLETED_WITH_ERRORS` | COMPLETED_WITH_ERRORS | | `STOPPED` | STOPPED | -| `FAILED` | FAILED | \ No newline at end of file +| `FAILED` | FAILED | +| `REJECTED` | REJECTED | + +An unrecognised value returned by the server is preserved verbatim as a pseudo-member +rather than raising, so a new server-side status is not a client break. diff --git a/docs/models/shared/jobstatus.md b/docs/models/shared/jobstatus.md index 8846b157..9cab30dc 100644 --- a/docs/models/shared/jobstatus.md +++ b/docs/models/shared/jobstatus.md @@ -9,4 +9,8 @@ | `IN_PROGRESS` | IN_PROGRESS | | `COMPLETED` | COMPLETED | | `STOPPED` | STOPPED | -| `FAILED` | FAILED | \ No newline at end of file +| `FAILED` | FAILED | +| `REJECTED` | REJECTED | + +An unrecognised value returned by the server is preserved verbatim as a pseudo-member +rather than raising, so a new server-side status is not a client break. diff --git a/docs/models/shared/updateworkflow.md b/docs/models/shared/updateworkflow.md index 5f3b2014..3ba9d491 100644 --- a/docs/models/shared/updateworkflow.md +++ b/docs/models/shared/updateworkflow.md @@ -9,6 +9,7 @@ | `name` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `reprocess_all` | *OptionalNullable[bool]* | :heavy_minus_sign: | N/A | | `schedule` | [OptionalNullable[shared.UpdateWorkflowSchedule]](../../models/shared/updateworkflowschedule.md) | :heavy_minus_sign: | N/A | +| `skip_preflight` | *OptionalNullable[bool]* | :heavy_minus_sign: | Skip the job preflight check for this workflow. Omit to leave the current value unchanged; send `false` to opt back in. | | `source_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `template_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `workflow_nodes` | List[[shared.WorkflowNode](../../models/shared/workflownode.md)] | :heavy_minus_sign: | N/A | diff --git a/docs/models/shared/workflowinformation.md b/docs/models/shared/workflowinformation.md index f8c4b867..e121f749 100644 --- a/docs/models/shared/workflowinformation.md +++ b/docs/models/shared/workflowinformation.md @@ -11,6 +11,7 @@ | `name` | *str* | :heavy_check_mark: | N/A | | | `reprocess_all` | *Optional[bool]* | :heavy_minus_sign: | N/A | | | `schedule` | [OptionalNullable[shared.WorkflowSchedule]](../../models/shared/workflowschedule.md) | :heavy_minus_sign: | N/A | {
"crontab_entries": [
{
"cron_expression": "0 0 * * *"
}
]
} | +| `skip_preflight` | *Optional[bool]* | :heavy_minus_sign: | Whether the job preflight check is skipped for this workflow. | | | `sources` | List[*str*] | :heavy_check_mark: | N/A | | | `status` | [shared.WorkflowState](../../models/shared/workflowstate.md) | :heavy_check_mark: | N/A | | | `updated_at` | [date](https://docs.python.org/3/library/datetime.html#date-objects) | :heavy_minus_sign: | N/A | | diff --git a/src/unstructured_client/_version.py b/src/unstructured_client/_version.py index 02e1257b..51a85b03 100644 --- a/src/unstructured_client/_version.py +++ b/src/unstructured_client/_version.py @@ -3,10 +3,10 @@ import importlib.metadata __title__: str = "unstructured-client" -__version__: str = "0.46.1" +__version__: str = "0.47.0" __openapi_doc_version__: str = "1.2.31" __gen_version__: str = "2.680.0" -__user_agent__: str = "speakeasy-sdk/python 0.46.1 2.680.0 1.2.31 unstructured-client" +__user_agent__: str = "speakeasy-sdk/python 0.47.0 2.680.0 1.2.31 unstructured-client" try: if __package__ is not None: diff --git a/src/unstructured_client/models/shared/body_create_job.py b/src/unstructured_client/models/shared/body_create_job.py index 129293ff..320636dc 100644 --- a/src/unstructured_client/models/shared/body_create_job.py +++ b/src/unstructured_client/models/shared/body_create_job.py @@ -2,8 +2,9 @@ from __future__ import annotations import io +import json import pydantic -from pydantic import model_serializer +from pydantic import ConfigDict, model_serializer, model_validator from typing import IO, List, Optional, Union from typing_extensions import Annotated, NotRequired, TypedDict from unstructured_client.types import ( @@ -12,6 +13,7 @@ OptionalNullable, UNSET, UNSET_SENTINEL, + Unset, ) from unstructured_client.utils import FieldMetadata, MultipartFormMetadata @@ -43,9 +45,16 @@ class InputFiles(BaseModel): class BodyCreateJobTypedDict(TypedDict): request_data: str input_files: NotRequired[Nullable[List[InputFilesTypedDict]]] + skip_preflight: NotRequired[Nullable[bool]] class BodyCreateJob(BaseModel): + # The only model here with a derived invariant across two fields: `skip_preflight` has to + # end up inside `request_data`. Without this, `body.skip_preflight = True` after + # construction would be silently dropped - the field carries no multipart metadata, so + # nothing reaches the wire and nothing raises. Merged with the base config, not replacing it. + model_config = ConfigDict(validate_assignment=True) + request_data: Annotated[str, FieldMetadata(multipart=True)] input_files: Annotated[ @@ -53,10 +62,100 @@ class BodyCreateJob(BaseModel): FieldMetadata(multipart=MultipartFormMetadata(file=True)), ] = UNSET + skip_preflight: OptionalNullable[bool] = UNSET + r"""Skip the job preflight check for this job. Preflight runs by default. + + A convenience parameter, not a wire field: multipart has no ``skip_preflight`` part, so the + server reads it out of the ``request_data`` JSON. The validator below writes it there, which + is why this field deliberately carries no ``MultipartFormMetadata`` -- ``serialize_multipart_form`` + skips any field without it, so it never becomes a form field of its own. + """ + + @model_validator(mode="after") + def fold_skip_preflight_into_request_data(self) -> "BodyCreateJob": + """Validator entry point. The work is in ``_fold_skip_preflight`` so that + ``model_copy`` can reuse it - a decorated validator is a descriptor, not a callable.""" + return self._fold_skip_preflight() + + def _fold_skip_preflight(self) -> "BodyCreateJob": + """Write ``skip_preflight`` into ``request_data``, where the server reads it. + + Set explicitly, it wins over any value already in the JSON string: the parameter is the + more specific expression of intent. Left unset, ``request_data`` is passed through byte + for byte. + """ + # Tested with `isinstance`, not `is UNSET`: pydantic deep-copies the default per + # instance, so an identity check against the UNSET singleton always fails and the + # fold would run on every call. + if self.skip_preflight is None or isinstance(self.skip_preflight, Unset): + return self + + raw = self.request_data + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError( + "request_data must be a JSON object to use skip_preflight; " + f"could not parse it: {exc}" + ) from exc + if not isinstance(payload, dict): + raise ValueError( + "request_data must be a JSON object to use skip_preflight, " + f"got {type(payload).__name__}" + ) + + literal = "true" if self.skip_preflight else "false" + if payload.get("skip_preflight") is bool(self.skip_preflight): + # Already folded and already correct. Returning early keeps this a genuine no-op: + # `validate_assignment=True` re-runs the whole validator on *any* field assignment, + # so without this, touching an unrelated field would push the payload down the + # re-encode branch below and silently reformat it (1e5 -> 100000.0). + return self + + if "skip_preflight" in payload: + # The caller already put the key in the string. Replacing it in place would mean + # locating its span in the raw text, so rewrite the document instead - and accept + # that a re-encode is not lexically identical (0.1000 -> 0.1, 1e5 -> 100000.0). + payload["skip_preflight"] = bool(self.skip_preflight) + folded = json.dumps(payload, ensure_ascii=False) + else: + # The common case, and deliberately a text splice rather than a re-encode: a + # json.loads/json.dumps round trip is lossy for number *representation* even + # though it preserves value, and request_data carries caller configuration that + # may be read by precision-sensitive consumers. Splicing leaves every other byte + # exactly as handed to us. + # + # json.loads succeeded and produced a dict, so the last non-space character is + # guaranteed to be the closing brace. + body = raw.rstrip()[:-1].rstrip() + separator = "" if body.endswith("{") else "," + folded = f'{body}{separator}"skip_preflight": {literal}}}' + + # Written through __dict__ on purpose: a normal assignment would re-enter this + # validator under validate_assignment=True and recurse until the stack blows. + self.__dict__["request_data"] = folded + return self + + def model_copy(self, *, update=None, deep=False) -> "BodyCreateJob": + """Re-apply the fold after a copy. + + `model_copy` bypasses validation by design, so without this an updated + `skip_preflight` never reaches `request_data`. The dangerous direction is + `update={"skip_preflight": False}` on a body that already had it folded as `true`: + the copy would keep sending `true` and run the job with preflight disabled while the + caller believes they just re-enabled it. + """ + copied = super().model_copy(update=update, deep=deep) + if update and not {"skip_preflight", "request_data"}.isdisjoint(update): + return copied._fold_skip_preflight() # pylint: disable=protected-access + # A copy that touches neither field is left strictly alone. Re-folding here would push + # an already-folded payload down the re-encode branch and reformat it for no reason. + return copied + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = ["input_files"] - nullable_fields = ["input_files"] + optional_fields = ["input_files", "skip_preflight"] + nullable_fields = ["input_files", "skip_preflight"] null_default_fields = [] serialized = handler(self) diff --git a/src/unstructured_client/models/shared/createworkflow.py b/src/unstructured_client/models/shared/createworkflow.py index 741f838d..feecc6e5 100644 --- a/src/unstructured_client/models/shared/createworkflow.py +++ b/src/unstructured_client/models/shared/createworkflow.py @@ -36,6 +36,7 @@ class CreateWorkflowTypedDict(TypedDict): destination_id: NotRequired[Nullable[str]] reprocess_all: NotRequired[Nullable[bool]] schedule: NotRequired[Nullable[Schedule]] + skip_preflight: NotRequired[Nullable[bool]] source_id: NotRequired[Nullable[str]] template_id: NotRequired[Nullable[str]] workflow_nodes: NotRequired[Nullable[List[WorkflowNodeTypedDict]]] @@ -52,6 +53,9 @@ class CreateWorkflow(BaseModel): schedule: OptionalNullable[Schedule] = UNSET + skip_preflight: OptionalNullable[bool] = UNSET + r"""Skip the job preflight check for this workflow. Preflight runs by default.""" + source_id: OptionalNullable[str] = UNSET template_id: OptionalNullable[str] = UNSET @@ -64,6 +68,7 @@ def serialize_model(self, handler): "destination_id", "reprocess_all", "schedule", + "skip_preflight", "source_id", "template_id", "workflow_nodes", @@ -72,6 +77,7 @@ def serialize_model(self, handler): "destination_id", "reprocess_all", "schedule", + "skip_preflight", "source_id", "template_id", "workflow_nodes", diff --git a/src/unstructured_client/models/shared/jobprocessingstatus.py b/src/unstructured_client/models/shared/jobprocessingstatus.py index 5e68d14c..393b20b2 100644 --- a/src/unstructured_client/models/shared/jobprocessingstatus.py +++ b/src/unstructured_client/models/shared/jobprocessingstatus.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import Enum +from typing import Optional, cast class JobProcessingStatus(str, Enum): @@ -11,3 +12,19 @@ class JobProcessingStatus(str, Enum): COMPLETED_WITH_ERRORS = "COMPLETED_WITH_ERRORS" STOPPED = "STOPPED" FAILED = "FAILED" + REJECTED = "REJECTED" + + @classmethod + def _missing_(cls, value: object) -> Optional["JobProcessingStatus"]: + """Preserve a status this client does not know yet, instead of raising. + + The server adds values on its own schedule; a closed enum breaks clients. + """ + if not isinstance(value, str): + return None + pseudo = str.__new__(cls, value) + pseudo._name_ = value + pseudo._value_ = value + return cast( + "JobProcessingStatus", cls._value2member_map_.setdefault(value, pseudo) + ) diff --git a/src/unstructured_client/models/shared/jobstatus.py b/src/unstructured_client/models/shared/jobstatus.py index bf847dbb..b9077dc9 100644 --- a/src/unstructured_client/models/shared/jobstatus.py +++ b/src/unstructured_client/models/shared/jobstatus.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import Enum +from typing import Optional, cast class JobStatus(str, Enum): @@ -10,3 +11,19 @@ class JobStatus(str, Enum): COMPLETED = "COMPLETED" STOPPED = "STOPPED" FAILED = "FAILED" + REJECTED = "REJECTED" + + @classmethod + def _missing_(cls, value: object) -> Optional["JobStatus"]: + """Preserve a status this client does not know yet, instead of raising. + + The server adds values on its own schedule; a closed enum breaks clients. + """ + if not isinstance(value, str): + return None + pseudo = str.__new__(cls, value) + pseudo._name_ = value + pseudo._value_ = value + return cast( + "JobStatus", cls._value2member_map_.setdefault(value, pseudo) + ) diff --git a/src/unstructured_client/models/shared/updateworkflow.py b/src/unstructured_client/models/shared/updateworkflow.py index 3f474988..e1bebec4 100644 --- a/src/unstructured_client/models/shared/updateworkflow.py +++ b/src/unstructured_client/models/shared/updateworkflow.py @@ -35,6 +35,7 @@ class UpdateWorkflowTypedDict(TypedDict): name: NotRequired[Nullable[str]] reprocess_all: NotRequired[Nullable[bool]] schedule: NotRequired[Nullable[UpdateWorkflowSchedule]] + skip_preflight: NotRequired[Nullable[bool]] source_id: NotRequired[Nullable[str]] template_id: NotRequired[Nullable[str]] workflow_nodes: NotRequired[Nullable[List[WorkflowNodeTypedDict]]] @@ -50,6 +51,12 @@ class UpdateWorkflow(BaseModel): schedule: OptionalNullable[UpdateWorkflowSchedule] = UNSET + skip_preflight: OptionalNullable[bool] = UNSET + r"""Skip the job preflight check for this workflow. + + Omit to leave the current value unchanged; send ``False`` to opt back in. + """ + source_id: OptionalNullable[str] = UNSET template_id: OptionalNullable[str] = UNSET @@ -65,6 +72,7 @@ def serialize_model(self, handler): "name", "reprocess_all", "schedule", + "skip_preflight", "source_id", "template_id", "workflow_nodes", @@ -75,6 +83,7 @@ def serialize_model(self, handler): "name", "reprocess_all", "schedule", + "skip_preflight", "source_id", "template_id", "workflow_nodes", diff --git a/src/unstructured_client/models/shared/workflowinformation.py b/src/unstructured_client/models/shared/workflowinformation.py index 2025fa84..7b3f1263 100644 --- a/src/unstructured_client/models/shared/workflowinformation.py +++ b/src/unstructured_client/models/shared/workflowinformation.py @@ -28,6 +28,7 @@ class WorkflowInformationTypedDict(TypedDict): workflow_nodes: List[WorkflowNodeTypedDict] reprocess_all: NotRequired[bool] schedule: NotRequired[Nullable[WorkflowScheduleTypedDict]] + skip_preflight: NotRequired[bool] updated_at: NotRequired[Nullable[datetime]] workflow_type: NotRequired[Nullable[WorkflowType]] @@ -51,13 +52,22 @@ class WorkflowInformation(BaseModel): schedule: OptionalNullable[WorkflowSchedule] = UNSET + skip_preflight: Optional[bool] = False + r"""Whether the job preflight check is skipped for this workflow.""" + updated_at: OptionalNullable[datetime] = UNSET workflow_type: OptionalNullable[WorkflowType] = UNSET @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = ["reprocess_all", "schedule", "updated_at", "workflow_type"] + optional_fields = [ + "reprocess_all", + "schedule", + "skip_preflight", + "updated_at", + "workflow_type", + ] nullable_fields = ["schedule", "updated_at", "workflow_type"] null_default_fields = [] diff --git a/src/unstructured_client/types/__init__.py b/src/unstructured_client/types/__init__.py index fc76fe0c..9385267f 100644 --- a/src/unstructured_client/types/__init__.py +++ b/src/unstructured_client/types/__init__.py @@ -8,6 +8,7 @@ UnrecognizedStr, UNSET, UNSET_SENTINEL, + Unset, ) __all__ = [ @@ -18,4 +19,5 @@ "UnrecognizedStr", "UNSET", "UNSET_SENTINEL", + "Unset", ] From 25b1cd25d6ce693bfc669b43d8e02a9af4f651d3 Mon Sep 17 00:00:00 2001 From: Gavin Chen Date: Thu, 27 Aug 2026 16:32:43 -0700 Subject: [PATCH 2/3] fix: clear a folded skip_preflight when the argument is set to None Setting skip_preflight back to None -- by assignment or model_copy -- left the previously folded "skip_preflight": true inside request_data. The object then said "no preference" while the wire still carried true, so the job ran with preflight off after the caller had cleared the flag. Silent, and in the permissive direction. The early return lumped Unset and None together. That is right when request_data is untouched caller bytes, but after a fold it no longer is. The two are different statements and now behave differently: Unset never mentioned -> passthrough, byte for byte, including a skip_preflight the caller wrote into the JSON themselves None explicitly no preference -> clears a folded key; the explicit argument wins, as it already does for True and False Widening the existing branch to cover both would have introduced the opposite bug: stripping a key the caller wrote and never asked us to touch. None on a non-JSON payload also stays silent rather than raising -- nothing was folded in, so there is nothing to undo. Also makes the splice preserve surrounding whitespace. The comment claimed it "leaves every other byte exactly as handed to us" while rstrip() discarded the indentation before the closing brace and any trailing newline. The splice now keeps content, the gap before the brace, and anything after it as separate pieces and reassembles them, so the claim is true. README: correct the enum containment note. It said `some_string in shared.JobStatus` is true for any string; that holds only on 3.12. CPython changed __contains__ twice, so across the supported versions the same expression raises TypeError on 3.11, returns True on 3.12 and returns False on 3.13. Verified on all three. __members__ is stable everywhere and stays the recommendation. Also documents the None-clears-versus-unset-passthrough split above. No behaviour change to the enums themselves: forward tolerance on deserialization works identically on 3.11, 3.12 and 3.13. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 21 ++++- .../unit/test_skip_preflight_models.py | 79 ++++++++++++++++++- .../models/shared/body_create_job.py | 44 +++++++++-- 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e2b30e1c..39c33cb1 100644 --- a/README.md +++ b/README.md @@ -613,11 +613,16 @@ explicit `null` on the wire rather than omitting the field — but what the serv | --- | --- | --- | | `create_workflow` | `"skip_preflight": null` | treated as not set — preflight runs | | `update_workflow` | `"skip_preflight": null` | **leaves the stored value unchanged** — if the workflow already had preflight skipped, it stays skipped | -| `create_job` | field absent (`request_data` untouched) | treated as not set — preflight runs | +| `create_job` | field absent from `request_data` | treated as not set — preflight runs | So `None` is not a way to turn preflight back on for an existing workflow. Pass `skip_preflight=False` explicitly for that. +On `create_job`, passing `None` also *clears* a value an earlier call had folded in, so a body you +built with `skip_preflight=True` and then reset to `None` sends no flag at all. Leaving the argument +**unset** is different: `request_data` is then passed through byte for byte, including a +`skip_preflight` you wrote into the JSON yourself. + Note on job statuses: `JobStatus` and `JobProcessingStatus` preserve a value this client version does not know rather than raising, so a status added server-side is not a client break. The trade-off is that a polling loop written as `while job.status not in (COMPLETED, FAILED, STOPPED, @@ -625,9 +630,17 @@ REJECTED)` would not recognise a *new* terminal status and would spin until its poll, give the loop a deadline and treat an unrecognised status as a reason to stop and inspect, rather than assuming it is non-terminal. -For the same reason these enums are not input validators: `some_string in shared.JobStatus` is -true for *any* string, since the lookup that backs `in` is the same one that tolerates unknown -values. Validate against `shared.JobStatus.__members__` if you need to reject unknown input. +These enums are also not input validators. Do not use `some_string in shared.JobStatus` to +decide whether a status is one this client knows: `in` on an enum changed twice in CPython, so +across the versions this SDK supports the same expression raises `TypeError` on 3.11, returns +`True` on 3.12 (where `in` consults the same lookup that tolerates unknown values), and returns +`False` on 3.13. Validate against `shared.JobStatus.__members__` instead, which is a plain +mapping of the declared members and behaves the same everywhere: + +```python +if status not in shared.JobStatus.__members__: + ... # a status this client version does not declare +``` ### Maturity diff --git a/_test_unstructured_client/unit/test_skip_preflight_models.py b/_test_unstructured_client/unit/test_skip_preflight_models.py index 181cb6ac..ac7014fd 100644 --- a/_test_unstructured_client/unit/test_skip_preflight_models.py +++ b/_test_unstructured_client/unit/test_skip_preflight_models.py @@ -184,7 +184,7 @@ def test_body_create_job_fold_preserves_the_callers_bytes(request_data): assert json.loads(folded)["skip_preflight"] is True -@pytest.mark.parametrize("request_data", ["{}", "{ }", '{"a":1} ']) +@pytest.mark.parametrize("request_data", ["{}", "{ }", '{"a":1} ', ' {"a":1}']) def test_body_create_job_fold_handles_empty_and_padded_objects(request_data): """The splice has to cope with an empty object and with surrounding whitespace.""" folded = shared.BodyCreateJob( @@ -193,6 +193,36 @@ def test_body_create_job_fold_handles_empty_and_padded_objects(request_data): assert json.loads(folded)["skip_preflight"] is True +@pytest.mark.parametrize( + "request_data", + [ + '{\n "a": 1\n}\n', # indentation and a trailing newline + '{\n "x": 0.123456789012345678901\n}\n', # ...alongside a number that must not move + '{"a":1} ', # trailing spaces + ' {"a":1}', # leading spaces + "{ }", # the gap inside an otherwise empty object + ], +) +def test_body_create_job_fold_preserves_surrounding_whitespace(request_data): + """Whitespace is insignificant to a parser, but the fold claims to preserve every byte. + + Splicing the flag in must put the layout back: the indentation before the closing brace, + the newline after it, and anything ahead of the opening brace. Otherwise the payload is + quietly reformatted, which is the exact thing the splice exists to avoid. + """ + folded = shared.BodyCreateJob( + request_data=request_data, skip_preflight=True + ).request_data + + # Removing exactly what was inserted must give back the original, byte for byte. + inserted = '"skip_preflight": true' + restored = folded.replace(f",{inserted}", "", 1) if f",{inserted}" in folded else folded.replace(inserted, "", 1) + assert restored == request_data, ( + f"fold did not preserve the payload: {request_data!r} -> {folded!r}" + ) + assert json.loads(folded)["skip_preflight"] is True + + def test_body_create_job_fold_is_a_no_op_once_correct(): """Re-running the fold must not touch an already-correct payload. @@ -209,3 +239,50 @@ def test_body_create_job_fold_is_a_no_op_once_correct(): unchanged = body.model_copy(update={"input_files": None}) assert unchanged.request_data == folded + + +def test_body_create_job_none_clears_a_previously_folded_flag(): + """Clearing the flag must clear it from the payload too. + + `None` is "no preference", which the server reads as not set. Leaving a stale + `"skip_preflight": true` behind would run the job with preflight off *after* the caller + cleared the flag - silent, and in the permissive direction. + """ + body = shared.BodyCreateJob(request_data='{"a":1}', skip_preflight=True) + assert json.loads(body.request_data)["skip_preflight"] is True + + body.skip_preflight = None + assert "skip_preflight" not in json.loads(body.request_data) + + copied = shared.BodyCreateJob( + request_data='{"a":1}', skip_preflight=True + ).model_copy(update={"skip_preflight": None}) + assert "skip_preflight" not in json.loads(copied.request_data) + + +def test_body_create_job_unset_still_passes_a_caller_written_flag_through(): + """`Unset` and `None` are not the same, and only `None` may rewrite the payload. + + A caller who put `skip_preflight` in the JSON themselves and never touched the argument + has said nothing for the SDK to override, so those bytes must survive verbatim. Clearing + them here would be the SDK silently reversing the caller's own instruction. + """ + written = '{"a":1,"skip_preflight":true}' + assert shared.BodyCreateJob(request_data=written).request_data == written + + +@pytest.mark.parametrize( + "request_data", ["not json at all", '{"x": 1e5}', "{}"] +) +def test_body_create_job_none_is_harmless_when_there_is_nothing_to_clear(request_data): + """"No preference" has nothing to reject and nothing to rewrite. + + Unlike the True/False paths it must not raise on a non-JSON payload: nothing was folded + into it, so there is nothing to undo. + """ + assert ( + shared.BodyCreateJob( + request_data=request_data, skip_preflight=None + ).request_data + == request_data + ) diff --git a/src/unstructured_client/models/shared/body_create_job.py b/src/unstructured_client/models/shared/body_create_job.py index 320636dc..40701b33 100644 --- a/src/unstructured_client/models/shared/body_create_job.py +++ b/src/unstructured_client/models/shared/body_create_job.py @@ -81,13 +81,35 @@ def _fold_skip_preflight(self) -> "BodyCreateJob": """Write ``skip_preflight`` into ``request_data``, where the server reads it. Set explicitly, it wins over any value already in the JSON string: the parameter is the - more specific expression of intent. Left unset, ``request_data`` is passed through byte - for byte. + more specific expression of intent, and that includes ``None`` - "no preference" clears + a value an earlier fold left behind. Left *unset*, ``request_data`` is passed through + byte for byte, including a ``skip_preflight`` the caller wrote themselves. """ # Tested with `isinstance`, not `is UNSET`: pydantic deep-copies the default per # instance, so an identity check against the UNSET singleton always fails and the # fold would run on every call. - if self.skip_preflight is None or isinstance(self.skip_preflight, Unset): + if isinstance(self.skip_preflight, Unset): + # Never mentioned. `request_data` is the caller's bytes, including a + # `skip_preflight` they wrote themselves; pass it through untouched. + return self + + if self.skip_preflight is None: + # Explicitly "no preference", which the server reads as "not set". If an earlier + # fold left a value behind, drop it: the explicit argument wins over whatever is + # in the string, and a stale `true` here would run the job with preflight off + # after the caller cleared the flag. + try: + payload = json.loads(self.request_data) + except json.JSONDecodeError: + # Nothing was ever folded into a non-JSON payload, so there is nothing to + # undo. Unlike the True/False paths, "no preference" has no reason to reject. + return self + if isinstance(payload, dict) and "skip_preflight" in payload: + del payload["skip_preflight"] + # Removing a key cannot be spliced the way adding one can - it needs the + # key's span in the raw text - so this path re-encodes, with the same + # representation caveat as the branch below. + self.__dict__["request_data"] = json.dumps(payload, ensure_ascii=False) return self raw = self.request_data @@ -126,10 +148,18 @@ def _fold_skip_preflight(self) -> "BodyCreateJob": # exactly as handed to us. # # json.loads succeeded and produced a dict, so the last non-space character is - # guaranteed to be the closing brace. - body = raw.rstrip()[:-1].rstrip() - separator = "" if body.endswith("{") else "," - folded = f'{body}{separator}"skip_preflight": {literal}}}' + # guaranteed to be the closing brace. Split the text around it and put every piece + # back, so indentation and trailing newlines survive too - insignificant to a JSON + # parser, but "every other byte" has to mean every other byte. + stripped = raw.rstrip() + trailing = raw[len(stripped) :] # whitespace after the closing brace + head = stripped[:-1] # everything before the closing brace + inner = head.rstrip() # ...without the whitespace that preceded it + gap = head[len(inner) :] # ...and that whitespace, kept aside + separator = "" if inner.endswith("{") else "," + folded = ( + f'{inner}{separator}"skip_preflight": {literal}{gap}}}{trailing}' + ) # Written through __dict__ on purpose: a normal assignment would re-enter this # validator under validate_assignment=True and recurse until the stack blows. From 46518b6014f381dbd44b5eb69abfaf7398110698 Mon Sep 17 00:00:00 2001 From: Gavin Chen Date: Thu, 27 Aug 2026 16:57:56 -0700 Subject: [PATCH 3/3] fix: clear skip_preflight without re-encoding the caller's payload The None path removed the folded key by re-encoding the whole document, so clearing the flag rewrote unrelated configuration: 1e5 became 100000.0, 1.50 became 1.5, and precision past a double was lost. The insert path goes out of its way to preserve the caller's bytes, so having the clear path discard them contradicted the code's own contract. The fold writes a fixed fragment, so clearing can be its exact inverse rather than a re-encode. `_drop_skip_preflight` removes that fragment from the raw text, searching from the right because the fold always appends after existing content. The candidate is parsed and compared against the expected payload before being accepted, so a fragment that happens to match a *nested* `skip_preflight` is rejected instead of silently corrupting the document. A caller who wrote the key with their own spacing matches no fragment and falls back to a re-encode, which is correct but not byte-preserving. Fold-then-clear is now a true round trip: '{\n "x": 1e5\n}\n' comes back identical, as do a 21-digit decimal, non-ASCII, an empty object and '{ }'. Narrow in practice - it needs a hand-written request_data, since a json.dumps payload is already normalized before the SDK sees it, plus a non-canonical number and a fold-then-clear transition. Fixed for the consistency reason above rather than the precision one. Co-Authored-By: Claude Opus 5 (1M context) --- .../unit/test_skip_preflight_models.py | 52 +++++++++++++++++++ .../models/shared/body_create_job.py | 46 ++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/_test_unstructured_client/unit/test_skip_preflight_models.py b/_test_unstructured_client/unit/test_skip_preflight_models.py index ac7014fd..5c84d801 100644 --- a/_test_unstructured_client/unit/test_skip_preflight_models.py +++ b/_test_unstructured_client/unit/test_skip_preflight_models.py @@ -286,3 +286,55 @@ def test_body_create_job_none_is_harmless_when_there_is_nothing_to_clear(request ).request_data == request_data ) + + +@pytest.mark.parametrize( + "request_data", + [ + '{"a":1}', + "{}", + "{ }", + '{\n "x": 1e5\n}\n', + '{"x": 0.123456789012345678901}', + '{"n": "café"}', + '{"a": {"b": 1}}', + ], +) +def test_body_create_job_fold_then_clear_is_a_round_trip(request_data): + """Clearing the flag must restore the caller's bytes, not re-encode them. + + The fold writes a fixed fragment, so clearing can be its exact inverse. Re-encoding here + would preserve values but not their representation (`1e5` -> `100000.0`), which would be + inconsistent: protecting the payload when adding the flag and discarding it when removing + the flag. + """ + body = shared.BodyCreateJob(request_data=request_data, skip_preflight=True) + assert json.loads(body.request_data)["skip_preflight"] is True + + body.skip_preflight = None + assert body.request_data == request_data + + +def test_body_create_job_clear_does_not_touch_a_nested_skip_preflight(): + """The removal must find *our* fragment, not one nested inside the payload. + + A naive first-match removal would strip the nested key and corrupt the caller's data. The + candidate is parsed and compared before being accepted, so a wrong match is rejected. + """ + nested = '{"x":{"skip_preflight": true},"a":1}' + body = shared.BodyCreateJob(request_data=nested, skip_preflight=False) + body.skip_preflight = None + + assert body.request_data == nested + assert json.loads(body.request_data) == {"x": {"skip_preflight": True}, "a": 1} + + +def test_body_create_job_clear_falls_back_when_the_key_was_written_by_hand(): + """A caller's own spacing will not match the fold's fragment, so this re-encodes. + + Correct but not byte-preserving, and the flag must still be gone. + """ + body = shared.BodyCreateJob( + request_data='{"a":1, "skip_preflight" : true}', skip_preflight=None + ) + assert json.loads(body.request_data) == {"a": 1} diff --git a/src/unstructured_client/models/shared/body_create_job.py b/src/unstructured_client/models/shared/body_create_job.py index 40701b33..e3b50450 100644 --- a/src/unstructured_client/models/shared/body_create_job.py +++ b/src/unstructured_client/models/shared/body_create_job.py @@ -18,6 +18,44 @@ from unstructured_client.utils import FieldMetadata, MultipartFormMetadata +def _drop_skip_preflight(raw: str, payload: dict) -> str: + """Remove `skip_preflight` from `raw`, keeping every other byte where possible. + + The fold writes a known, fixed fragment, so clearing it can be its exact inverse rather + than a re-encode. That matters: a re-encode preserves values but not their representation + (``1e5`` becomes ``100000.0``), and it would be inconsistent to protect the caller's bytes + when adding the flag and discard them when removing it. + + The candidate is parsed and compared before being accepted, so a fragment that happens to + match a *nested* `skip_preflight` is rejected rather than silently corrupting the payload. + A caller who wrote the key with their own spacing will not match the fragment at all; that + falls back to a re-encode, which is correct but not byte-preserving. + """ + expected = {k: v for k, v in payload.items() if k != "skip_preflight"} + + current = payload["skip_preflight"] + if current is True or current is False: + literal = "true" if current else "false" + # Longest first: the comma form is what a non-empty object gets. + for fragment in ( + f',"skip_preflight": {literal}', + f'"skip_preflight": {literal}', + ): + # From the right: the fold always appends after existing content, so when the + # payload also carries a nested `skip_preflight`, ours is the later one. + index = raw.rfind(fragment) + if index == -1: + continue + candidate = raw[:index] + raw[index + len(fragment) :] + try: + if json.loads(candidate) == expected: + return candidate + except json.JSONDecodeError: + continue + + return json.dumps(expected, ensure_ascii=False) + + class InputFilesTypedDict(TypedDict): content: Union[bytes, IO[bytes], io.BufferedReader] file_name: str @@ -105,11 +143,9 @@ def _fold_skip_preflight(self) -> "BodyCreateJob": # undo. Unlike the True/False paths, "no preference" has no reason to reject. return self if isinstance(payload, dict) and "skip_preflight" in payload: - del payload["skip_preflight"] - # Removing a key cannot be spliced the way adding one can - it needs the - # key's span in the raw text - so this path re-encodes, with the same - # representation caveat as the branch below. - self.__dict__["request_data"] = json.dumps(payload, ensure_ascii=False) + self.__dict__["request_data"] = _drop_skip_preflight( + self.request_data, payload + ) return self raw = self.request_data