diff --git a/CHANGELOG.md b/CHANGELOG.md index a916ba82..767f4366 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.2 ### Fixes diff --git a/README.md b/README.md index 39f9dc3a..39c33cb1 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,115 @@ 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 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, +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. + +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 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 858f1076..26a83083 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1271,3 +1271,13 @@ Based on: - [python v0.46.2] . ### Releases - [PyPI v0.46.2] https://pypi.org/project/unstructured-client/0.46.2 - . + +## 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_skip_preflight_models.py b/_test_unstructured_client/unit/test_skip_preflight_models.py new file mode 100644 index 00000000..5c84d801 --- /dev/null +++ b/_test_unstructured_client/unit/test_skip_preflight_models.py @@ -0,0 +1,340 @@ +"""Behaviour of the hand-maintained `skip_preflight` and job-status model code. + +These assert what the models *do*, not how they are protected from regeneration: `.genignore` +was removed from this repo, so a test asserting entries in it would assert nothing. What is +left here is the real cover - if a regeneration (or a careless edit) drops `skip_preflight`, +the fold, or the enums' forward tolerance, these fail. +""" + +import inspect +import json +from pathlib import Path + +import pytest + +from unstructured_client.models import shared +from unstructured_client.types import Unset +from unstructured_client.utils.metadata import MultipartFormMetadata, find_field_metadata + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +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. Nothing regenerates this file, so this test 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_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} ', ' {"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 + + +@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. + + `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 + + +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 + ) + + +@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/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 85d5419c..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.2" +__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.2 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..e3b50450 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,10 +13,49 @@ OptionalNullable, UNSET, UNSET_SENTINEL, + Unset, ) 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 @@ -43,9 +83,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 +100,128 @@ 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, 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 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: + self.__dict__["request_data"] = _drop_skip_preflight( + self.request_data, payload + ) + 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. 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. + 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", ]