From 052947f97e2f72ae8c3d7b4c5adbfce5a2bacc41 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:02:28 +0000 Subject: [PATCH 1/7] [fern-generated] Update SDK Generated by Fern CLI Version: unknown Generators: - fernapi/fern-python-sdk: 4.53.0 --- .fern/metadata.json | 4 +- poetry.lock | 6 +- pyproject.toml | 2 +- reference.md | 24 ++- src/extend_ai/core/client_wrapper.py | 4 +- src/extend_ai/requests/__init__.py | 50 +++++- src/extend_ai/requests/block_metadata.py | 8 - .../requests/classification_metric.py | 46 ++++++ src/extend_ai/requests/classifier_version.py | 2 + .../requests/classifier_version_summary.py | 2 + .../classify_evaluation_set_run_metrics.py | 28 ++++ src/extend_ai/requests/created_by.py | 20 +++ src/extend_ai/requests/created_by_api_key.py | 14 ++ src/extend_ai/requests/created_by_user.py | 14 ++ src/extend_ai/requests/evaluation_set.py | 2 + src/extend_ai/requests/evaluation_set_item.py | 3 + .../requests/evaluation_set_item_summary.py | 4 + src/extend_ai/requests/evaluation_set_run.py | 4 + .../evaluation_set_run_entity_version.py | 4 + .../evaluation_set_run_field_metric.py | 41 +++++ .../requests/evaluation_set_run_metrics.py | 120 ++++++++++++++- .../evaluation_set_run_metrics_base.py | 69 +++++++++ .../extract_evaluation_set_run_metrics.py | 28 ++++ .../requests/extract_output_metadata_value.py | 4 +- src/extend_ai/requests/extractor_version.py | 2 + .../requests/extractor_version_summary.py | 2 + .../splitter_evaluation_set_run_metrics.py | 47 ++++++ src/extend_ai/requests/splitter_version.py | 2 + .../requests/splitter_version_summary.py | 2 + src/extend_ai/requests/webhook_endpoint.py | 3 + .../requests/webhook_endpoint_create.py | 3 + src/extend_ai/requests/workflow.py | 2 + .../requests/workflow_run_package.py | 21 +++ .../workflow_run_package_files_item.py | 8 + src/extend_ai/requests/workflow_summary.py | 2 + src/extend_ai/requests/workflow_version.py | 2 + .../requests/workflow_version_summary.py | 2 + src/extend_ai/types/__init__.py | 53 ++++++- src/extend_ai/types/block_metadata.py | 8 - .../types/block_metadata_text_direction.py | 38 ----- src/extend_ai/types/classification_metric.py | 60 ++++++++ src/extend_ai/types/classifier_version.py | 4 + .../types/classifier_version_summary.py | 4 + .../classify_evaluation_set_run_metrics.py | 37 +++++ src/extend_ai/types/created_by.py | 61 ++++++++ src/extend_ai/types/created_by_api_key.py | 27 ++++ src/extend_ai/types/created_by_user.py | 27 ++++ src/extend_ai/types/evaluation_set.py | 4 + src/extend_ai/types/evaluation_set_item.py | 5 + .../types/evaluation_set_item_summary.py | 7 + src/extend_ai/types/evaluation_set_run.py | 6 +- .../evaluation_set_run_entity_version.py | 10 ++ .../types/evaluation_set_run_field_metric.py | 57 +++++++ .../types/evaluation_set_run_metrics.py | 145 +++++++++++++++++- .../types/evaluation_set_run_metrics_base.py | 87 +++++++++++ .../extract_evaluation_set_run_metrics.py | 37 +++++ src/extend_ai/types/extractor_version.py | 4 + .../types/extractor_version_summary.py | 4 + .../splitter_evaluation_set_run_metrics.py | 60 ++++++++ src/extend_ai/types/splitter_version.py | 4 + .../types/splitter_version_summary.py | 4 + src/extend_ai/types/webhook_endpoint.py | 5 + .../types/webhook_endpoint_create.py | 5 + src/extend_ai/types/workflow.py | 4 + src/extend_ai/types/workflow_run_package.py | 32 ++++ .../types/workflow_run_package_files_item.py | 8 + src/extend_ai/types/workflow_summary.py | 4 + src/extend_ai/types/workflow_version.py | 4 + .../types/workflow_version_summary.py | 4 + src/extend_ai/workflow_runs/client.py | 35 +++-- src/extend_ai/workflow_runs/raw_client.py | 39 +++-- 71 files changed, 1390 insertions(+), 104 deletions(-) create mode 100644 src/extend_ai/requests/classification_metric.py create mode 100644 src/extend_ai/requests/classify_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/requests/created_by.py create mode 100644 src/extend_ai/requests/created_by_api_key.py create mode 100644 src/extend_ai/requests/created_by_user.py create mode 100644 src/extend_ai/requests/evaluation_set_run_field_metric.py create mode 100644 src/extend_ai/requests/evaluation_set_run_metrics_base.py create mode 100644 src/extend_ai/requests/extract_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/requests/splitter_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/requests/workflow_run_package.py create mode 100644 src/extend_ai/requests/workflow_run_package_files_item.py delete mode 100644 src/extend_ai/types/block_metadata_text_direction.py create mode 100644 src/extend_ai/types/classification_metric.py create mode 100644 src/extend_ai/types/classify_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/types/created_by.py create mode 100644 src/extend_ai/types/created_by_api_key.py create mode 100644 src/extend_ai/types/created_by_user.py create mode 100644 src/extend_ai/types/evaluation_set_run_field_metric.py create mode 100644 src/extend_ai/types/evaluation_set_run_metrics_base.py create mode 100644 src/extend_ai/types/extract_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/types/splitter_evaluation_set_run_metrics.py create mode 100644 src/extend_ai/types/workflow_run_package.py create mode 100644 src/extend_ai/types/workflow_run_package_files_item.py diff --git a/.fern/metadata.json b/.fern/metadata.json index a952fd7..ef027d7 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -12,6 +12,6 @@ "enum_type": "forward_compatible_python_enums" } }, - "originGitCommit": "094c36681badc82afeb39a2c91a716461a5a0bd2", - "sdkVersion": "1.16.0" + "originGitCommit": "a5c1143a7b724689db7c5e4cee897834882c9d69", + "sdkVersion": "1.17.0" } \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 4bc6ae7..9d0c35a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,13 +38,13 @@ trio = ["trio (>=0.26.1)"] [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, - {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 3b3b12a..8db04ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "extend_ai" -version = "1.16.0" +version = "1.17.0" description = "" readme = "README.md" authors = [] diff --git a/reference.md b/reference.md index befc552..0df7437 100644 --- a/reference.md +++ b/reference.md @@ -7148,7 +7148,9 @@ Example: `"invoice"`
-Run a workflow with a file. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. +Run a workflow. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + +Pass `file` for a single document, or `package` to process 2-50 files together as one package in a single run. Exactly one of `file` or `package` must be provided. The request returns immediately with a `PROCESSING` status. Use webhooks or poll the Get Workflow Run endpoint for results.
@@ -7172,7 +7174,13 @@ client = Extend( ) client.workflow_runs.create( workflow={"id": "wf_1234567890"}, - file={"url": "https://example.com/invoice.pdf"}, + package={ + "files": [ + {"url": "https://example.com/invoice.pdf"}, + {"url": "https://example.com/bill-of-lading.pdf"}, + {"id": "file_xK9mLPqRtN3vS8wF5hB2cQ"}, + ] + }, ) ``` @@ -7197,7 +7205,15 @@ client.workflow_runs.create(
-**file:** `WorkflowRunsCreateRequestFileParams` — The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. If you wish to process more at a time, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. +**file:** `typing.Optional[WorkflowRunsCreateRequestFileParams]` — The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. Mutually exclusive with `package` — provide one or the other. If you wish to process many files as independent runs, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + +
+
+ +
+
+ +**package:** `typing.Optional[WorkflowRunPackageParams]` — A set of 2–50 files to process together in a single workflow run. Mutually exclusive with `file` — provide one or the other.
@@ -7205,7 +7221,7 @@ client.workflow_runs.create(
-**outputs:** `typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]]` — Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. +**outputs:** `typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]]` — Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. Not supported on package runs — a package run produces a single merged result across all files and cannot accept pre-supplied per-processor outputs.
diff --git a/src/extend_ai/core/client_wrapper.py b/src/extend_ai/core/client_wrapper.py index fe9705e..a6f1950 100644 --- a/src/extend_ai/core/client_wrapper.py +++ b/src/extend_ai/core/client_wrapper.py @@ -26,12 +26,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "extend_ai/1.16.0", + "User-Agent": "extend_ai/1.17.0", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "extend_ai", - "X-Fern-SDK-Version": "1.16.0", + "X-Fern-SDK-Version": "1.17.0", **(self.get_custom_headers() or {}), } headers["Authorization"] = f"Bearer {self._get_token()}" diff --git a/src/extend_ai/requests/__init__.py b/src/extend_ai/requests/__init__.py index b806367..f97b45d 100644 --- a/src/extend_ai/requests/__init__.py +++ b/src/extend_ai/requests/__init__.py @@ -28,6 +28,7 @@ from .citation import CitationParams from .citation_page import CitationPageParams from .classification import ClassificationParams + from .classification_metric import ClassificationMetricParams from .classification_next_entry import ClassificationNextEntryParams from .classifications import ClassificationsParams from .classifier import ClassifierParams @@ -42,6 +43,7 @@ from .classifier_version_summary import ClassifierVersionSummaryParams from .classify_advanced_options import ClassifyAdvancedOptionsParams from .classify_config import ClassifyConfigParams + from .classify_evaluation_set_run_metrics import ClassifyEvaluationSetRunMetricsParams from .classify_output import ClassifyOutputParams from .classify_override_config import ClassifyOverrideConfigParams from .classify_request_classifier import ClassifyRequestClassifierParams @@ -68,6 +70,9 @@ from .conditional_step_definition import ConditionalStepDefinitionParams from .conditional_step_definition_config import ConditionalStepDefinitionConfigParams from .conditional_step_definition_config_conditions_item import ConditionalStepDefinitionConfigConditionsItemParams + from .created_by import CreatedByParams, CreatedBy_ApiKeyParams, CreatedBy_UserParams + from .created_by_api_key import CreatedByApiKeyParams + from .created_by_user import CreatedByUserParams from .data_retention import DataRetentionParams from .detect_form_request_file import DetectFormRequestFileParams from .edit_bounding_box import EditBoundingBoxParams @@ -118,7 +123,14 @@ EvaluationSetRunEntityVersion_ExtractorVersionParams, EvaluationSetRunEntityVersion_SplitterVersionParams, ) - from .evaluation_set_run_metrics import EvaluationSetRunMetricsParams + from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetricParams + from .evaluation_set_run_metrics import ( + EvaluationSetRunMetricsParams, + EvaluationSetRunMetrics_ClassifyParams, + EvaluationSetRunMetrics_ExtractParams, + EvaluationSetRunMetrics_SplitterParams, + ) + from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBaseParams from .evaluation_set_run_options import EvaluationSetRunOptionsParams from .excel_sheet_range import ExcelSheetRangeParams from .external_data_validation_result import ExternalDataValidationResultParams @@ -136,6 +148,7 @@ from .extract_config import ExtractConfigParams from .extract_config_json import ExtractConfigJsonParams from .extract_config_legacy import ExtractConfigLegacyParams + from .extract_evaluation_set_run_metrics import ExtractEvaluationSetRunMetricsParams from .extract_output import ExtractOutputParams from .extract_output_edits import ExtractOutputEditsParams from .extract_output_json import ExtractOutputJsonParams @@ -334,6 +347,7 @@ from .splitter_created_webhook_event import SplitterCreatedWebhookEventParams from .splitter_deleted_webhook_event import SplitterDeletedWebhookEventParams from .splitter_draft_updated_webhook_event import SplitterDraftUpdatedWebhookEventParams + from .splitter_evaluation_set_run_metrics import SplitterEvaluationSetRunMetricsParams from .splitter_ref import SplitterRefParams from .splitter_summary import SplitterSummaryParams from .splitter_updated_webhook_event import SplitterUpdatedWebhookEventParams @@ -415,6 +429,8 @@ from .workflow_run_completed_webhook_event import WorkflowRunCompletedWebhookEventParams from .workflow_run_failed_webhook_event import WorkflowRunFailedWebhookEventParams from .workflow_run_needs_review_webhook_event import WorkflowRunNeedsReviewWebhookEventParams + from .workflow_run_package import WorkflowRunPackageParams + from .workflow_run_package_files_item import WorkflowRunPackageFilesItemParams from .workflow_run_rejected_webhook_event import WorkflowRunRejectedWebhookEventParams from .workflow_run_step_run_processed_webhook_event import WorkflowRunStepRunProcessedWebhookEventParams from .workflow_run_summary import WorkflowRunSummaryParams @@ -461,6 +477,7 @@ "ChunkParams": ".chunk", "CitationPageParams": ".citation_page", "CitationParams": ".citation", + "ClassificationMetricParams": ".classification_metric", "ClassificationNextEntryParams": ".classification_next_entry", "ClassificationParams": ".classification", "ClassificationsParams": ".classifications", @@ -476,6 +493,7 @@ "ClassifierVersionSummaryParams": ".classifier_version_summary", "ClassifyAdvancedOptionsParams": ".classify_advanced_options", "ClassifyConfigParams": ".classify_config", + "ClassifyEvaluationSetRunMetricsParams": ".classify_evaluation_set_run_metrics", "ClassifyOutputParams": ".classify_output", "ClassifyOverrideConfigParams": ".classify_override_config", "ClassifyRequestClassifierParams": ".classify_request_classifier", @@ -500,6 +518,11 @@ "ConditionalStepDefinitionConfigConditionsItemParams": ".conditional_step_definition_config_conditions_item", "ConditionalStepDefinitionConfigParams": ".conditional_step_definition_config", "ConditionalStepDefinitionParams": ".conditional_step_definition", + "CreatedByApiKeyParams": ".created_by_api_key", + "CreatedByParams": ".created_by", + "CreatedByUserParams": ".created_by_user", + "CreatedBy_ApiKeyParams": ".created_by", + "CreatedBy_UserParams": ".created_by", "DataRetentionParams": ".data_retention", "DetectFormRequestFileParams": ".detect_form_request_file", "EditBoundingBoxParams": ".edit_bounding_box", @@ -543,7 +566,12 @@ "EvaluationSetRunEntity_ClassifierParams": ".evaluation_set_run_entity", "EvaluationSetRunEntity_ExtractorParams": ".evaluation_set_run_entity", "EvaluationSetRunEntity_SplitterParams": ".evaluation_set_run_entity", + "EvaluationSetRunFieldMetricParams": ".evaluation_set_run_field_metric", + "EvaluationSetRunMetricsBaseParams": ".evaluation_set_run_metrics_base", "EvaluationSetRunMetricsParams": ".evaluation_set_run_metrics", + "EvaluationSetRunMetrics_ClassifyParams": ".evaluation_set_run_metrics", + "EvaluationSetRunMetrics_ExtractParams": ".evaluation_set_run_metrics", + "EvaluationSetRunMetrics_SplitterParams": ".evaluation_set_run_metrics", "EvaluationSetRunOptionsParams": ".evaluation_set_run_options", "EvaluationSetRunParams": ".evaluation_set_run", "ExcelSheetRangeParams": ".excel_sheet_range", @@ -560,6 +588,7 @@ "ExtractConfigJsonParams": ".extract_config_json", "ExtractConfigLegacyParams": ".extract_config_legacy", "ExtractConfigParams": ".extract_config", + "ExtractEvaluationSetRunMetricsParams": ".extract_evaluation_set_run_metrics", "ExtractOutputEditsParams": ".extract_output_edits", "ExtractOutputJsonParams": ".extract_output_json", "ExtractOutputLegacyParams": ".extract_output_legacy", @@ -747,6 +776,7 @@ "SplitterCreatedWebhookEventParams": ".splitter_created_webhook_event", "SplitterDeletedWebhookEventParams": ".splitter_deleted_webhook_event", "SplitterDraftUpdatedWebhookEventParams": ".splitter_draft_updated_webhook_event", + "SplitterEvaluationSetRunMetricsParams": ".splitter_evaluation_set_run_metrics", "SplitterParams": ".splitter", "SplitterRefParams": ".splitter_ref", "SplitterSummaryParams": ".splitter_summary", @@ -824,6 +854,8 @@ "WorkflowRunCompletedWebhookEventParams": ".workflow_run_completed_webhook_event", "WorkflowRunFailedWebhookEventParams": ".workflow_run_failed_webhook_event", "WorkflowRunNeedsReviewWebhookEventParams": ".workflow_run_needs_review_webhook_event", + "WorkflowRunPackageFilesItemParams": ".workflow_run_package_files_item", + "WorkflowRunPackageParams": ".workflow_run_package", "WorkflowRunParams": ".workflow_run", "WorkflowRunRejectedWebhookEventParams": ".workflow_run_rejected_webhook_event", "WorkflowRunStepRunProcessedWebhookEventParams": ".workflow_run_step_run_processed_webhook_event", @@ -893,6 +925,7 @@ def __dir__(): "ChunkParams", "CitationPageParams", "CitationParams", + "ClassificationMetricParams", "ClassificationNextEntryParams", "ClassificationParams", "ClassificationsParams", @@ -908,6 +941,7 @@ def __dir__(): "ClassifierVersionSummaryParams", "ClassifyAdvancedOptionsParams", "ClassifyConfigParams", + "ClassifyEvaluationSetRunMetricsParams", "ClassifyOutputParams", "ClassifyOverrideConfigParams", "ClassifyRequestClassifierParams", @@ -932,6 +966,11 @@ def __dir__(): "ConditionalStepDefinitionConfigConditionsItemParams", "ConditionalStepDefinitionConfigParams", "ConditionalStepDefinitionParams", + "CreatedByApiKeyParams", + "CreatedByParams", + "CreatedByUserParams", + "CreatedBy_ApiKeyParams", + "CreatedBy_UserParams", "DataRetentionParams", "DetectFormRequestFileParams", "EditBoundingBoxParams", @@ -975,7 +1014,12 @@ def __dir__(): "EvaluationSetRunEntity_ClassifierParams", "EvaluationSetRunEntity_ExtractorParams", "EvaluationSetRunEntity_SplitterParams", + "EvaluationSetRunFieldMetricParams", + "EvaluationSetRunMetricsBaseParams", "EvaluationSetRunMetricsParams", + "EvaluationSetRunMetrics_ClassifyParams", + "EvaluationSetRunMetrics_ExtractParams", + "EvaluationSetRunMetrics_SplitterParams", "EvaluationSetRunOptionsParams", "EvaluationSetRunParams", "ExcelSheetRangeParams", @@ -992,6 +1036,7 @@ def __dir__(): "ExtractConfigJsonParams", "ExtractConfigLegacyParams", "ExtractConfigParams", + "ExtractEvaluationSetRunMetricsParams", "ExtractOutputEditsParams", "ExtractOutputJsonParams", "ExtractOutputLegacyParams", @@ -1179,6 +1224,7 @@ def __dir__(): "SplitterCreatedWebhookEventParams", "SplitterDeletedWebhookEventParams", "SplitterDraftUpdatedWebhookEventParams", + "SplitterEvaluationSetRunMetricsParams", "SplitterParams", "SplitterRefParams", "SplitterSummaryParams", @@ -1256,6 +1302,8 @@ def __dir__(): "WorkflowRunCompletedWebhookEventParams", "WorkflowRunFailedWebhookEventParams", "WorkflowRunNeedsReviewWebhookEventParams", + "WorkflowRunPackageFilesItemParams", + "WorkflowRunPackageParams", "WorkflowRunParams", "WorkflowRunRejectedWebhookEventParams", "WorkflowRunStepRunProcessedWebhookEventParams", diff --git a/src/extend_ai/requests/block_metadata.py b/src/extend_ai/requests/block_metadata.py index ade3891..7ee4079 100644 --- a/src/extend_ai/requests/block_metadata.py +++ b/src/extend_ai/requests/block_metadata.py @@ -4,7 +4,6 @@ import typing_extensions from ..core.serialization import FieldMetadata -from ..types.block_metadata_text_direction import BlockMetadataTextDirection from .block_metadata_page import BlockMetadataPageParams from .block_metadata_sheet import BlockMetadataSheetParams @@ -24,13 +23,6 @@ class BlockMetadataParams(typing_extensions.TypedDict): Spreadsheet sheet metadata. Present for blocks parsed from spreadsheet files, such as Excel workbooks. """ - text_direction: typing_extensions.NotRequired[ - typing_extensions.Annotated[BlockMetadataTextDirection, FieldMetadata(alias="textDirection")] - ] - """ - Text direction for this block's content ("ltr" for left-to-right, "rtl" for right-to-left). - """ - min_ocr_confidence: typing_extensions.NotRequired[ typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="minOcrConfidence")] ] diff --git a/src/extend_ai/requests/classification_metric.py b/src/extend_ai/requests/classification_metric.py new file mode 100644 index 0000000..fd89acd --- /dev/null +++ b/src/extend_ai/requests/classification_metric.py @@ -0,0 +1,46 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from ..core.serialization import FieldMetadata + + +class ClassificationMetricParams(typing_extensions.TypedDict): + """ + Per-class precision, recall, and F1 for a classify evaluation run. + """ + + count_expected: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countExpected")] + ] + """ + The number of files whose expected classification was this class. + """ + + count_predicted: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countPredicted")] + ] + """ + The number of files the model classified as this class. + """ + + count_correct: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countCorrect")] + ] + """ + The number of files correctly classified as this class. + """ + + precision: typing_extensions.NotRequired[float] + """ + `countCorrect / countPredicted` for this class. + """ + + recall: typing_extensions.NotRequired[float] + """ + `countCorrect / countExpected` for this class. + """ + + f1: typing_extensions.NotRequired[float] + """ + Harmonic mean of `precision` and `recall`. + """ diff --git a/src/extend_ai/requests/classifier_version.py b/src/extend_ai/requests/classifier_version.py index 634e08e..c95c285 100644 --- a/src/extend_ai/requests/classifier_version.py +++ b/src/extend_ai/requests/classifier_version.py @@ -6,6 +6,7 @@ from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt from .classify_config import ClassifyConfigParams +from .created_by import CreatedByParams class ClassifierVersionParams(typing_extensions.TypedDict): @@ -48,3 +49,4 @@ class ClassifierVersionParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/classifier_version_summary.py b/src/extend_ai/requests/classifier_version_summary.py index 65cf71d..971a64c 100644 --- a/src/extend_ai/requests/classifier_version_summary.py +++ b/src/extend_ai/requests/classifier_version_summary.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams class ClassifierVersionSummaryParams(typing_extensions.TypedDict): @@ -42,3 +43,4 @@ class ClassifierVersionSummaryParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/classify_evaluation_set_run_metrics.py b/src/extend_ai/requests/classify_evaluation_set_run_metrics.py new file mode 100644 index 0000000..5c91759 --- /dev/null +++ b/src/extend_ai/requests/classify_evaluation_set_run_metrics.py @@ -0,0 +1,28 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import typing_extensions +from ..core.serialization import FieldMetadata +from .classification_metric import ClassificationMetricParams +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBaseParams + + +class ClassifyEvaluationSetRunMetricsParams(EvaluationSetRunMetricsBaseParams): + """ + Metrics for an evaluation set run against a classifier. + """ + + accuracy: typing_extensions.NotRequired[float] + """ + Overall accuracy across the evaluation set, computed as the share of files whose predicted class matched the expected class. + """ + + classification_metrics: typing_extensions.NotRequired[ + typing_extensions.Annotated[ + typing.Dict[str, ClassificationMetricParams], FieldMetadata(alias="classificationMetrics") + ] + ] + """ + Per-class precision, recall, and F1. Keys are the classifier's classification types as defined in the classifier configuration. + """ diff --git a/src/extend_ai/requests/created_by.py b/src/extend_ai/requests/created_by.py new file mode 100644 index 0000000..a6bd4a8 --- /dev/null +++ b/src/extend_ai/requests/created_by.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import typing_extensions + + +class CreatedBy_UserParams(typing_extensions.TypedDict): + type: typing.Literal["user"] + email: str + + +class CreatedBy_ApiKeyParams(typing_extensions.TypedDict): + type: typing.Literal["api_key"] + name: str + + +CreatedByParams = typing.Union[CreatedBy_UserParams, CreatedBy_ApiKeyParams] diff --git a/src/extend_ai/requests/created_by_api_key.py b/src/extend_ai/requests/created_by_api_key.py new file mode 100644 index 0000000..43b24c3 --- /dev/null +++ b/src/extend_ai/requests/created_by_api_key.py @@ -0,0 +1,14 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions + + +class CreatedByApiKeyParams(typing_extensions.TypedDict): + """ + Attribution for a resource created via the API using an API key. + """ + + name: str + """ + The name of the API key that created the resource. + """ diff --git a/src/extend_ai/requests/created_by_user.py b/src/extend_ai/requests/created_by_user.py new file mode 100644 index 0000000..8874216 --- /dev/null +++ b/src/extend_ai/requests/created_by_user.py @@ -0,0 +1,14 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions + + +class CreatedByUserParams(typing_extensions.TypedDict): + """ + Attribution for a resource created via the studio by a logged-in user. + """ + + email: str + """ + The email address of the user who created the resource. + """ diff --git a/src/extend_ai/requests/evaluation_set.py b/src/extend_ai/requests/evaluation_set.py index 481ebf0..b6063da 100644 --- a/src/extend_ai/requests/evaluation_set.py +++ b/src/extend_ai/requests/evaluation_set.py @@ -6,6 +6,7 @@ from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt from ..types.updated_at import UpdatedAt +from .created_by import CreatedByParams from .evaluation_set_entity import EvaluationSetEntityParams @@ -43,3 +44,4 @@ class EvaluationSetParams(typing_extensions.TypedDict): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] updated_at: typing_extensions.Annotated[UpdatedAt, FieldMetadata(alias="updatedAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/evaluation_set_item.py b/src/extend_ai/requests/evaluation_set_item.py index 95c64bd..2f655c2 100644 --- a/src/extend_ai/requests/evaluation_set_item.py +++ b/src/extend_ai/requests/evaluation_set_item.py @@ -4,6 +4,7 @@ import typing_extensions from ..core.serialization import FieldMetadata +from .created_by import CreatedByParams from .file_summary import FileSummaryParams from .provided_processor_output import ProvidedProcessorOutputParams @@ -37,3 +38,5 @@ class EvaluationSetItemParams(typing_extensions.TypedDict): """ The expected output that will be used to evaluate the performance of the extractor, classifier, or splitter associated with the evaluation set. This must conform to the output schema of the entity associated with the evaluation set. """ + + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/evaluation_set_item_summary.py b/src/extend_ai/requests/evaluation_set_item_summary.py index df9fdbb..c35b81d 100644 --- a/src/extend_ai/requests/evaluation_set_item_summary.py +++ b/src/extend_ai/requests/evaluation_set_item_summary.py @@ -3,6 +3,8 @@ import typing import typing_extensions +from ..core.serialization import FieldMetadata +from .created_by import CreatedByParams from .file_summary import FileSummaryParams @@ -23,3 +25,5 @@ class EvaluationSetItemSummaryParams(typing_extensions.TypedDict): """ A summary of the file associated with the evaluation set item. """ + + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/evaluation_set_run.py b/src/extend_ai/requests/evaluation_set_run.py index fa757dc..77baa65 100644 --- a/src/extend_ai/requests/evaluation_set_run.py +++ b/src/extend_ai/requests/evaluation_set_run.py @@ -46,6 +46,10 @@ class EvaluationSetRunParams(typing_extensions.TypedDict): """ metrics: EvaluationSetRunMetricsParams + """ + Metrics for the evaluation set run. The shape depends on the entity type. + """ + status: BatchRunStatus options: EvaluationSetRunOptionsParams """ diff --git a/src/extend_ai/requests/evaluation_set_run_entity_version.py b/src/extend_ai/requests/evaluation_set_run_entity_version.py index 727db9a..9a494f0 100644 --- a/src/extend_ai/requests/evaluation_set_run_entity_version.py +++ b/src/extend_ai/requests/evaluation_set_run_entity_version.py @@ -7,6 +7,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams class EvaluationSetRunEntityVersion_ExtractorVersionParams(typing_extensions.TypedDict): @@ -16,6 +17,7 @@ class EvaluationSetRunEntityVersion_ExtractorVersionParams(typing_extensions.Typ version: str extractor_id: typing_extensions.Annotated[str, FieldMetadata(alias="extractorId")] created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] class EvaluationSetRunEntityVersion_ClassifierVersionParams(typing_extensions.TypedDict): @@ -25,6 +27,7 @@ class EvaluationSetRunEntityVersion_ClassifierVersionParams(typing_extensions.Ty version: str classifier_id: typing_extensions.Annotated[str, FieldMetadata(alias="classifierId")] created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] class EvaluationSetRunEntityVersion_SplitterVersionParams(typing_extensions.TypedDict): @@ -34,6 +37,7 @@ class EvaluationSetRunEntityVersion_SplitterVersionParams(typing_extensions.Type version: str splitter_id: typing_extensions.Annotated[str, FieldMetadata(alias="splitterId")] created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] EvaluationSetRunEntityVersionParams = typing.Union[ diff --git a/src/extend_ai/requests/evaluation_set_run_field_metric.py b/src/extend_ai/requests/evaluation_set_run_field_metric.py new file mode 100644 index 0000000..4fdd192 --- /dev/null +++ b/src/extend_ai/requests/evaluation_set_run_field_metric.py @@ -0,0 +1,41 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from ..core.serialization import FieldMetadata + + +class EvaluationSetRunFieldMetricParams(typing_extensions.TypedDict): + """ + Per-field accuracy statistics for an extract evaluation run. + """ + + count_total: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="countTotal")]] + """ + The number of runs that included this field in the schema. + """ + + count_present: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countPresent")] + ] + """ + The number of runs where the model produced a non-null value for this field. + """ + + count_expected: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countExpected")] + ] + """ + The number of runs where an expected value was provided. + """ + + count_accurate: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="countAccurate")] + ] + """ + The number of runs where the extracted value matched the expected value. + """ + + accuracy: typing_extensions.NotRequired[float] + """ + Accuracy for this field, computed as `countAccurate / countExpected`. Omitted when `countExpected` is `0`. + """ diff --git a/src/extend_ai/requests/evaluation_set_run_metrics.py b/src/extend_ai/requests/evaluation_set_run_metrics.py index ffa635e..1e8f035 100644 --- a/src/extend_ai/requests/evaluation_set_run_metrics.py +++ b/src/extend_ai/requests/evaluation_set_run_metrics.py @@ -1,16 +1,122 @@ # This file was auto-generated by Fern from our API Definition. +from __future__ import annotations + +import typing + import typing_extensions from ..core.serialization import FieldMetadata +from .classification_metric import ClassificationMetricParams +from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetricParams -class EvaluationSetRunMetricsParams(typing_extensions.TypedDict): +class EvaluationSetRunMetrics_ExtractParams(typing_extensions.TypedDict): + type: typing.Literal["EXTRACT"] + accuracy: typing_extensions.NotRequired[float] + field_metrics: typing_extensions.NotRequired[ + typing_extensions.Annotated[ + typing.Dict[str, EvaluationSetRunFieldMetricParams], FieldMetadata(alias="fieldMetrics") + ] + ] + num_files_total: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesTotal")] + ] + num_files_processed: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesProcessed")] + ] num_files: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numFiles")]] - """ - The total number of files processed in this evaluation set run - """ + num_pages: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numPages")]] + mean_latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="meanLatencyMs")] + ] + p50latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p50LatencyMs")] + ] + p90latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p90LatencyMs")] + ] + p95latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p95LatencyMs")] + ] + p99latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p99LatencyMs")] + ] + +class EvaluationSetRunMetrics_ClassifyParams(typing_extensions.TypedDict): + type: typing.Literal["CLASSIFY"] + accuracy: typing_extensions.NotRequired[float] + classification_metrics: typing_extensions.NotRequired[ + typing_extensions.Annotated[ + typing.Dict[str, ClassificationMetricParams], FieldMetadata(alias="classificationMetrics") + ] + ] + num_files_total: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesTotal")] + ] + num_files_processed: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesProcessed")] + ] + num_files: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numFiles")]] num_pages: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numPages")]] - """ - The total number of pages processed in this evaluation set run - """ + mean_latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="meanLatencyMs")] + ] + p50latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p50LatencyMs")] + ] + p90latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p90LatencyMs")] + ] + p95latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p95LatencyMs")] + ] + p99latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p99LatencyMs")] + ] + + +class EvaluationSetRunMetrics_SplitterParams(typing_extensions.TypedDict): + type: typing.Literal["SPLITTER"] + precision: typing_extensions.NotRequired[float] + recall: typing_extensions.NotRequired[float] + f1: typing_extensions.NotRequired[float] + num_splits_expected: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsExpected")] + ] + num_splits_predicted: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsPredicted")] + ] + num_splits_correct: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsCorrect")] + ] + num_files_total: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesTotal")] + ] + num_files_processed: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesProcessed")] + ] + num_files: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numFiles")]] + num_pages: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numPages")]] + mean_latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="meanLatencyMs")] + ] + p50latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p50LatencyMs")] + ] + p90latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p90LatencyMs")] + ] + p95latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p95LatencyMs")] + ] + p99latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p99LatencyMs")] + ] + + +EvaluationSetRunMetricsParams = typing.Union[ + EvaluationSetRunMetrics_ExtractParams, + EvaluationSetRunMetrics_ClassifyParams, + EvaluationSetRunMetrics_SplitterParams, +] diff --git a/src/extend_ai/requests/evaluation_set_run_metrics_base.py b/src/extend_ai/requests/evaluation_set_run_metrics_base.py new file mode 100644 index 0000000..27a2841 --- /dev/null +++ b/src/extend_ai/requests/evaluation_set_run_metrics_base.py @@ -0,0 +1,69 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from ..core.serialization import FieldMetadata + + +class EvaluationSetRunMetricsBaseParams(typing_extensions.TypedDict): + """ + Fields common to every evaluation set run metrics variant. + """ + + num_files_total: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesTotal")] + ] + """ + The total number of files that will be processed in this evaluation set run. + """ + + num_files_processed: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numFilesProcessed")] + ] + """ + The number of files that have finished processing. + """ + + num_files: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numFiles")]] + """ + Deprecated. Use `numFilesProcessed` instead. + """ + + num_pages: typing_extensions.NotRequired[typing_extensions.Annotated[float, FieldMetadata(alias="numPages")]] + """ + The total number of pages processed across the completed files. + """ + + mean_latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="meanLatencyMs")] + ] + """ + Mean per-file latency in milliseconds. + """ + + p50latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p50LatencyMs")] + ] + """ + 50th percentile per-file latency in milliseconds. + """ + + p90latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p90LatencyMs")] + ] + """ + 90th percentile per-file latency in milliseconds. + """ + + p95latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p95LatencyMs")] + ] + """ + 95th percentile per-file latency in milliseconds. + """ + + p99latency_ms: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="p99LatencyMs")] + ] + """ + 99th percentile per-file latency in milliseconds. + """ diff --git a/src/extend_ai/requests/extract_evaluation_set_run_metrics.py b/src/extend_ai/requests/extract_evaluation_set_run_metrics.py new file mode 100644 index 0000000..f1f600f --- /dev/null +++ b/src/extend_ai/requests/extract_evaluation_set_run_metrics.py @@ -0,0 +1,28 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import typing_extensions +from ..core.serialization import FieldMetadata +from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetricParams +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBaseParams + + +class ExtractEvaluationSetRunMetricsParams(EvaluationSetRunMetricsBaseParams): + """ + Metrics for an evaluation set run against an extractor. + """ + + accuracy: typing_extensions.NotRequired[float] + """ + Aggregate accuracy across every reviewed field in `fieldMetrics`. + """ + + field_metrics: typing_extensions.NotRequired[ + typing_extensions.Annotated[ + typing.Dict[str, EvaluationSetRunFieldMetricParams], FieldMetadata(alias="fieldMetrics") + ] + ] + """ + Per-field accuracy statistics. Keys are the extractor schema's field names — nested fields are flattened to dot-joined paths. See [Extraction response format](https://docs.extend.ai/2026-02-09/extraction/response-format#output-value-and-metadata) for more on how extractor field paths are structured. + """ diff --git a/src/extend_ai/requests/extract_output_metadata_value.py b/src/extend_ai/requests/extract_output_metadata_value.py index 32afe2d..f083e4c 100644 --- a/src/extend_ai/requests/extract_output_metadata_value.py +++ b/src/extend_ai/requests/extract_output_metadata_value.py @@ -16,7 +16,9 @@ class ExtractOutputMetadataValueParams(typing_extensions.TypedDict): Confidence score from OCR processing, if applicable """ - logprobs_confidence: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="logprobsConfidence")] + logprobs_confidence: typing_extensions.NotRequired[ + typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="logprobsConfidence")] + ] """ Confidence score based on model logprobs """ diff --git a/src/extend_ai/requests/extractor_version.py b/src/extend_ai/requests/extractor_version.py index a9f5e86..e0ed432 100644 --- a/src/extend_ai/requests/extractor_version.py +++ b/src/extend_ai/requests/extractor_version.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams from .extract_config import ExtractConfigParams @@ -51,3 +52,4 @@ class ExtractorVersionParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/extractor_version_summary.py b/src/extend_ai/requests/extractor_version_summary.py index b57e91d..381e648 100644 --- a/src/extend_ai/requests/extractor_version_summary.py +++ b/src/extend_ai/requests/extractor_version_summary.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams class ExtractorVersionSummaryParams(typing_extensions.TypedDict): @@ -42,3 +43,4 @@ class ExtractorVersionSummaryParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/splitter_evaluation_set_run_metrics.py b/src/extend_ai/requests/splitter_evaluation_set_run_metrics.py new file mode 100644 index 0000000..a5f8429 --- /dev/null +++ b/src/extend_ai/requests/splitter_evaluation_set_run_metrics.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from ..core.serialization import FieldMetadata +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBaseParams + + +class SplitterEvaluationSetRunMetricsParams(EvaluationSetRunMetricsBaseParams): + """ + Metrics for an evaluation set run against a splitter. + """ + + precision: typing_extensions.NotRequired[float] + """ + `numSplitsCorrect / numSplitsPredicted`. + """ + + recall: typing_extensions.NotRequired[float] + """ + `numSplitsCorrect / numSplitsExpected`. + """ + + f1: typing_extensions.NotRequired[float] + """ + Harmonic mean of `precision` and `recall`. + """ + + num_splits_expected: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsExpected")] + ] + """ + The total number of expected split documents across the evaluation set. + """ + + num_splits_predicted: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsPredicted")] + ] + """ + The total number of split documents the model produced. + """ + + num_splits_correct: typing_extensions.NotRequired[ + typing_extensions.Annotated[float, FieldMetadata(alias="numSplitsCorrect")] + ] + """ + The number of predicted splits that exactly matched an expected split. + """ diff --git a/src/extend_ai/requests/splitter_version.py b/src/extend_ai/requests/splitter_version.py index c2768be..d1e98d3 100644 --- a/src/extend_ai/requests/splitter_version.py +++ b/src/extend_ai/requests/splitter_version.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams from .split_config import SplitConfigParams @@ -48,3 +49,4 @@ class SplitterVersionParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/splitter_version_summary.py b/src/extend_ai/requests/splitter_version_summary.py index e671aa8..554dbb7 100644 --- a/src/extend_ai/requests/splitter_version_summary.py +++ b/src/extend_ai/requests/splitter_version_summary.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams class SplitterVersionSummaryParams(typing_extensions.TypedDict): @@ -42,3 +43,4 @@ class SplitterVersionSummaryParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/webhook_endpoint.py b/src/extend_ai/requests/webhook_endpoint.py index f3d1095..bdf0547 100644 --- a/src/extend_ai/requests/webhook_endpoint.py +++ b/src/extend_ai/requests/webhook_endpoint.py @@ -8,6 +8,7 @@ from ..types.api_version_enum import ApiVersionEnum from ..types.webhook_endpoint_event_type import WebhookEndpointEventType from ..types.webhook_endpoint_status import WebhookEndpointStatus +from .created_by import CreatedByParams from .webhook_advanced_options import WebhookAdvancedOptionsParams @@ -48,3 +49,5 @@ class WebhookEndpointParams(typing_extensions.TypedDict): """ The date and time the webhook endpoint was created. """ + + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/webhook_endpoint_create.py b/src/extend_ai/requests/webhook_endpoint_create.py index ee7b6f1..efc44d5 100644 --- a/src/extend_ai/requests/webhook_endpoint_create.py +++ b/src/extend_ai/requests/webhook_endpoint_create.py @@ -8,6 +8,7 @@ from ..types.api_version_enum import ApiVersionEnum from ..types.webhook_endpoint_event_type import WebhookEndpointEventType from ..types.webhook_endpoint_status import WebhookEndpointStatus +from .created_by import CreatedByParams from .webhook_advanced_options import WebhookAdvancedOptionsParams @@ -59,3 +60,5 @@ class WebhookEndpointCreateParams(typing_extensions.TypedDict): """ The date and time the webhook endpoint was created. """ + + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/workflow.py b/src/extend_ai/requests/workflow.py index 762709e..24c5bcb 100644 --- a/src/extend_ai/requests/workflow.py +++ b/src/extend_ai/requests/workflow.py @@ -6,6 +6,7 @@ from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt from ..types.updated_at import UpdatedAt +from .created_by import CreatedByParams from .workflow_version import WorkflowVersionParams @@ -32,3 +33,4 @@ class WorkflowParams(typing_extensions.TypedDict): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] updated_at: typing_extensions.Annotated[UpdatedAt, FieldMetadata(alias="updatedAt")] draft_version: typing_extensions.Annotated[WorkflowVersionParams, FieldMetadata(alias="draftVersion")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/workflow_run_package.py b/src/extend_ai/requests/workflow_run_package.py new file mode 100644 index 0000000..30ec016 --- /dev/null +++ b/src/extend_ai/requests/workflow_run_package.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import typing_extensions +from .workflow_run_package_files_item import WorkflowRunPackageFilesItemParams + + +class WorkflowRunPackageParams(typing_extensions.TypedDict): + """ + An ordered collection of files to process together in a single workflow run. Use this instead of `file` when a set of documents should be treated as one package — every file is ingested up front, and the workflow's steps reason over the full set, producing a single `WorkflowRun`. + + Exactly one of `file` or `package` must be provided on a request — they are mutually exclusive. + """ + + files: typing.Sequence[WorkflowRunPackageFilesItemParams] + """ + The files to process, in submission order. Each entry can be a URL or an existing Extend file ID. Raw text and base64 inputs are not supported for package runs. + + Duplicate file IDs and duplicate URLs are rejected — each file may appear only once. A URL and a file ID are never treated as duplicates of each other, even if they resolve to the same document. + """ diff --git a/src/extend_ai/requests/workflow_run_package_files_item.py b/src/extend_ai/requests/workflow_run_package_files_item.py new file mode 100644 index 0000000..59d5cc6 --- /dev/null +++ b/src/extend_ai/requests/workflow_run_package_files_item.py @@ -0,0 +1,8 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .file_from_id import FileFromIdParams +from .file_from_url import FileFromUrlParams + +WorkflowRunPackageFilesItemParams = typing.Union[FileFromUrlParams, FileFromIdParams] diff --git a/src/extend_ai/requests/workflow_summary.py b/src/extend_ai/requests/workflow_summary.py index 3e5cbfc..73f43f6 100644 --- a/src/extend_ai/requests/workflow_summary.py +++ b/src/extend_ai/requests/workflow_summary.py @@ -6,6 +6,7 @@ from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt from ..types.updated_at import UpdatedAt +from .created_by import CreatedByParams class WorkflowSummaryParams(typing_extensions.TypedDict): @@ -30,3 +31,4 @@ class WorkflowSummaryParams(typing_extensions.TypedDict): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] updated_at: typing_extensions.Annotated[UpdatedAt, FieldMetadata(alias="updatedAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/workflow_version.py b/src/extend_ai/requests/workflow_version.py index 2c92cf8..deb4516 100644 --- a/src/extend_ai/requests/workflow_version.py +++ b/src/extend_ai/requests/workflow_version.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams from .workflow_step_definition import WorkflowStepDefinitionParams @@ -39,3 +40,4 @@ class WorkflowVersionParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/requests/workflow_version_summary.py b/src/extend_ai/requests/workflow_version_summary.py index 103e930..d92fa37 100644 --- a/src/extend_ai/requests/workflow_version_summary.py +++ b/src/extend_ai/requests/workflow_version_summary.py @@ -5,6 +5,7 @@ import typing_extensions from ..core.serialization import FieldMetadata from ..types.created_at import CreatedAt +from .created_by import CreatedByParams class WorkflowVersionSummaryParams(typing_extensions.TypedDict): @@ -33,3 +34,4 @@ class WorkflowVersionSummaryParams(typing_extensions.TypedDict): """ created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] + created_by: typing_extensions.Annotated[typing.Optional[CreatedByParams], FieldMetadata(alias="createdBy")] diff --git a/src/extend_ai/types/__init__.py b/src/extend_ai/types/__init__.py index ecbb561..07135c3 100644 --- a/src/extend_ai/types/__init__.py +++ b/src/extend_ai/types/__init__.py @@ -22,7 +22,6 @@ from .block_metadata import BlockMetadata from .block_metadata_page import BlockMetadataPage from .block_metadata_sheet import BlockMetadataSheet - from .block_metadata_text_direction import BlockMetadataTextDirection from .block_polygon_item import BlockPolygonItem from .block_type import BlockType from .bounding_box import BoundingBox @@ -34,6 +33,7 @@ from .citation import Citation from .citation_page import CitationPage from .classification import Classification + from .classification_metric import ClassificationMetric from .classification_next_entry import ClassificationNextEntry from .classifications import Classifications from .classifier import Classifier @@ -50,6 +50,7 @@ from .classify_advanced_options_context import ClassifyAdvancedOptionsContext from .classify_base_processor import ClassifyBaseProcessor from .classify_config import ClassifyConfig + from .classify_evaluation_set_run_metrics import ClassifyEvaluationSetRunMetrics from .classify_output import ClassifyOutput from .classify_override_config import ClassifyOverrideConfig from .classify_request_classifier import ClassifyRequestClassifier @@ -81,6 +82,9 @@ ConditionalStepDefinitionConfigConditionsItemType, ) from .created_at import CreatedAt + from .created_by import CreatedBy, CreatedBy_ApiKey, CreatedBy_User + from .created_by_api_key import CreatedByApiKey + from .created_by_user import CreatedByUser from .data_retention import DataRetention from .data_retention_mode import DataRetentionMode from .detect_form_request_file import DetectFormRequestFile @@ -136,7 +140,14 @@ EvaluationSetRunEntityVersion_ExtractorVersion, EvaluationSetRunEntityVersion_SplitterVersion, ) - from .evaluation_set_run_metrics import EvaluationSetRunMetrics + from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetric + from .evaluation_set_run_metrics import ( + EvaluationSetRunMetrics, + EvaluationSetRunMetrics_Classify, + EvaluationSetRunMetrics_Extract, + EvaluationSetRunMetrics_Splitter, + ) + from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBase from .evaluation_set_run_options import EvaluationSetRunOptions from .excel_sheet_range import ExcelSheetRange from .external_data_validation_result import ExternalDataValidationResult @@ -172,6 +183,7 @@ from .extract_config_json import ExtractConfigJson from .extract_config_legacy import ExtractConfigLegacy from .extract_config_legacy_base_processor import ExtractConfigLegacyBaseProcessor + from .extract_evaluation_set_run_metrics import ExtractEvaluationSetRunMetrics from .extract_output import ExtractOutput from .extract_output_edits import ExtractOutputEdits from .extract_output_json import ExtractOutputJson @@ -437,6 +449,7 @@ from .splitter_created_webhook_event import SplitterCreatedWebhookEvent from .splitter_deleted_webhook_event import SplitterDeletedWebhookEvent from .splitter_draft_updated_webhook_event import SplitterDraftUpdatedWebhookEvent + from .splitter_evaluation_set_run_metrics import SplitterEvaluationSetRunMetrics from .splitter_ref import SplitterRef from .splitter_summary import SplitterSummary from .splitter_updated_webhook_event import SplitterUpdatedWebhookEvent @@ -526,6 +539,8 @@ from .workflow_run_completed_webhook_event import WorkflowRunCompletedWebhookEvent from .workflow_run_failed_webhook_event import WorkflowRunFailedWebhookEvent from .workflow_run_needs_review_webhook_event import WorkflowRunNeedsReviewWebhookEvent + from .workflow_run_package import WorkflowRunPackage + from .workflow_run_package_files_item import WorkflowRunPackageFilesItem from .workflow_run_rejected_webhook_event import WorkflowRunRejectedWebhookEvent from .workflow_run_status import WorkflowRunStatus from .workflow_run_step_run_processed_webhook_event import WorkflowRunStepRunProcessedWebhookEvent @@ -568,7 +583,6 @@ "BlockMetadata": ".block_metadata", "BlockMetadataPage": ".block_metadata_page", "BlockMetadataSheet": ".block_metadata_sheet", - "BlockMetadataTextDirection": ".block_metadata_text_direction", "BlockPolygonItem": ".block_polygon_item", "BlockType": ".block_type", "BoundingBox": ".bounding_box", @@ -580,6 +594,7 @@ "Citation": ".citation", "CitationPage": ".citation_page", "Classification": ".classification", + "ClassificationMetric": ".classification_metric", "ClassificationNextEntry": ".classification_next_entry", "Classifications": ".classifications", "Classifier": ".classifier", @@ -596,6 +611,7 @@ "ClassifyAdvancedOptionsContext": ".classify_advanced_options_context", "ClassifyBaseProcessor": ".classify_base_processor", "ClassifyConfig": ".classify_config", + "ClassifyEvaluationSetRunMetrics": ".classify_evaluation_set_run_metrics", "ClassifyOutput": ".classify_output", "ClassifyOverrideConfig": ".classify_override_config", "ClassifyRequestClassifier": ".classify_request_classifier", @@ -623,6 +639,11 @@ "ConditionalStepDefinitionConfigConditionsItemOperation": ".conditional_step_definition_config_conditions_item_operation", "ConditionalStepDefinitionConfigConditionsItemType": ".conditional_step_definition_config_conditions_item_type", "CreatedAt": ".created_at", + "CreatedBy": ".created_by", + "CreatedByApiKey": ".created_by_api_key", + "CreatedByUser": ".created_by_user", + "CreatedBy_ApiKey": ".created_by", + "CreatedBy_User": ".created_by", "DataRetention": ".data_retention", "DataRetentionMode": ".data_retention_mode", "DetectFormRequestFile": ".detect_form_request_file", @@ -672,7 +693,12 @@ "EvaluationSetRunEntity_Classifier": ".evaluation_set_run_entity", "EvaluationSetRunEntity_Extractor": ".evaluation_set_run_entity", "EvaluationSetRunEntity_Splitter": ".evaluation_set_run_entity", + "EvaluationSetRunFieldMetric": ".evaluation_set_run_field_metric", "EvaluationSetRunMetrics": ".evaluation_set_run_metrics", + "EvaluationSetRunMetricsBase": ".evaluation_set_run_metrics_base", + "EvaluationSetRunMetrics_Classify": ".evaluation_set_run_metrics", + "EvaluationSetRunMetrics_Extract": ".evaluation_set_run_metrics", + "EvaluationSetRunMetrics_Splitter": ".evaluation_set_run_metrics", "EvaluationSetRunOptions": ".evaluation_set_run_options", "ExcelSheetRange": ".excel_sheet_range", "ExternalDataValidationResult": ".external_data_validation_result", @@ -698,6 +724,7 @@ "ExtractConfigJson": ".extract_config_json", "ExtractConfigLegacy": ".extract_config_legacy", "ExtractConfigLegacyBaseProcessor": ".extract_config_legacy_base_processor", + "ExtractEvaluationSetRunMetrics": ".extract_evaluation_set_run_metrics", "ExtractOutput": ".extract_output", "ExtractOutputEdits": ".extract_output_edits", "ExtractOutputJson": ".extract_output_json", @@ -947,6 +974,7 @@ "SplitterCreatedWebhookEvent": ".splitter_created_webhook_event", "SplitterDeletedWebhookEvent": ".splitter_deleted_webhook_event", "SplitterDraftUpdatedWebhookEvent": ".splitter_draft_updated_webhook_event", + "SplitterEvaluationSetRunMetrics": ".splitter_evaluation_set_run_metrics", "SplitterRef": ".splitter_ref", "SplitterSummary": ".splitter_summary", "SplitterUpdatedWebhookEvent": ".splitter_updated_webhook_event", @@ -1032,6 +1060,8 @@ "WorkflowRunCompletedWebhookEvent": ".workflow_run_completed_webhook_event", "WorkflowRunFailedWebhookEvent": ".workflow_run_failed_webhook_event", "WorkflowRunNeedsReviewWebhookEvent": ".workflow_run_needs_review_webhook_event", + "WorkflowRunPackage": ".workflow_run_package", + "WorkflowRunPackageFilesItem": ".workflow_run_package_files_item", "WorkflowRunRejectedWebhookEvent": ".workflow_run_rejected_webhook_event", "WorkflowRunStatus": ".workflow_run_status", "WorkflowRunStepRunProcessedWebhookEvent": ".workflow_run_step_run_processed_webhook_event", @@ -1096,7 +1126,6 @@ def __dir__(): "BlockMetadata", "BlockMetadataPage", "BlockMetadataSheet", - "BlockMetadataTextDirection", "BlockPolygonItem", "BlockType", "BoundingBox", @@ -1108,6 +1137,7 @@ def __dir__(): "Citation", "CitationPage", "Classification", + "ClassificationMetric", "ClassificationNextEntry", "Classifications", "Classifier", @@ -1124,6 +1154,7 @@ def __dir__(): "ClassifyAdvancedOptionsContext", "ClassifyBaseProcessor", "ClassifyConfig", + "ClassifyEvaluationSetRunMetrics", "ClassifyOutput", "ClassifyOverrideConfig", "ClassifyRequestClassifier", @@ -1151,6 +1182,11 @@ def __dir__(): "ConditionalStepDefinitionConfigConditionsItemOperation", "ConditionalStepDefinitionConfigConditionsItemType", "CreatedAt", + "CreatedBy", + "CreatedByApiKey", + "CreatedByUser", + "CreatedBy_ApiKey", + "CreatedBy_User", "DataRetention", "DataRetentionMode", "DetectFormRequestFile", @@ -1200,7 +1236,12 @@ def __dir__(): "EvaluationSetRunEntity_Classifier", "EvaluationSetRunEntity_Extractor", "EvaluationSetRunEntity_Splitter", + "EvaluationSetRunFieldMetric", "EvaluationSetRunMetrics", + "EvaluationSetRunMetricsBase", + "EvaluationSetRunMetrics_Classify", + "EvaluationSetRunMetrics_Extract", + "EvaluationSetRunMetrics_Splitter", "EvaluationSetRunOptions", "ExcelSheetRange", "ExternalDataValidationResult", @@ -1226,6 +1267,7 @@ def __dir__(): "ExtractConfigJson", "ExtractConfigLegacy", "ExtractConfigLegacyBaseProcessor", + "ExtractEvaluationSetRunMetrics", "ExtractOutput", "ExtractOutputEdits", "ExtractOutputJson", @@ -1475,6 +1517,7 @@ def __dir__(): "SplitterCreatedWebhookEvent", "SplitterDeletedWebhookEvent", "SplitterDraftUpdatedWebhookEvent", + "SplitterEvaluationSetRunMetrics", "SplitterRef", "SplitterSummary", "SplitterUpdatedWebhookEvent", @@ -1560,6 +1603,8 @@ def __dir__(): "WorkflowRunCompletedWebhookEvent", "WorkflowRunFailedWebhookEvent", "WorkflowRunNeedsReviewWebhookEvent", + "WorkflowRunPackage", + "WorkflowRunPackageFilesItem", "WorkflowRunRejectedWebhookEvent", "WorkflowRunStatus", "WorkflowRunStepRunProcessedWebhookEvent", diff --git a/src/extend_ai/types/block_metadata.py b/src/extend_ai/types/block_metadata.py index 5428b57..9f907f2 100644 --- a/src/extend_ai/types/block_metadata.py +++ b/src/extend_ai/types/block_metadata.py @@ -9,7 +9,6 @@ from ..core.unchecked_base_model import UncheckedBaseModel from .block_metadata_page import BlockMetadataPage from .block_metadata_sheet import BlockMetadataSheet -from .block_metadata_text_direction import BlockMetadataTextDirection class BlockMetadata(UncheckedBaseModel): @@ -27,13 +26,6 @@ class BlockMetadata(UncheckedBaseModel): Spreadsheet sheet metadata. Present for blocks parsed from spreadsheet files, such as Excel workbooks. """ - text_direction: typing_extensions.Annotated[ - typing.Optional[BlockMetadataTextDirection], FieldMetadata(alias="textDirection") - ] = pydantic.Field(alias="textDirection", default=None) - """ - Text direction for this block's content ("ltr" for left-to-right, "rtl" for right-to-left). - """ - min_ocr_confidence: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="minOcrConfidence")] = ( pydantic.Field(alias="minOcrConfidence", default=None) ) diff --git a/src/extend_ai/types/block_metadata_text_direction.py b/src/extend_ai/types/block_metadata_text_direction.py deleted file mode 100644 index 29080c7..0000000 --- a/src/extend_ai/types/block_metadata_text_direction.py +++ /dev/null @@ -1,38 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from ..core import enum - -T_Result = typing.TypeVar("T_Result") - - -class BlockMetadataTextDirection(enum.StrEnum): - """ - Text direction for this block's content ("ltr" for left-to-right, "rtl" for right-to-left). - """ - - LTR = "ltr" - RTL = "rtl" - _UNKNOWN = "__BLOCKMETADATATEXTDIRECTION_UNKNOWN__" - """ - This member is used for forward compatibility. If the value is not recognized by the enum, it will be stored here, and the raw value is accessible through `.value`. - """ - - @classmethod - def _missing_(cls, value: typing.Any) -> "BlockMetadataTextDirection": - unknown = cls._UNKNOWN - unknown._value_ = value - return unknown - - def visit( - self, - ltr: typing.Callable[[], T_Result], - rtl: typing.Callable[[], T_Result], - _unknown_member: typing.Callable[[str], T_Result], - ) -> T_Result: - if self is BlockMetadataTextDirection.LTR: - return ltr() - if self is BlockMetadataTextDirection.RTL: - return rtl() - return _unknown_member(self._value_) diff --git a/src/extend_ai/types/classification_metric.py b/src/extend_ai/types/classification_metric.py new file mode 100644 index 0000000..e846054 --- /dev/null +++ b/src/extend_ai/types/classification_metric.py @@ -0,0 +1,60 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from ..core.unchecked_base_model import UncheckedBaseModel + + +class ClassificationMetric(UncheckedBaseModel): + """ + Per-class precision, recall, and F1 for a classify evaluation run. + """ + + count_expected: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countExpected")] = ( + pydantic.Field(alias="countExpected", default=None) + ) + """ + The number of files whose expected classification was this class. + """ + + count_predicted: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countPredicted")] = ( + pydantic.Field(alias="countPredicted", default=None) + ) + """ + The number of files the model classified as this class. + """ + + count_correct: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countCorrect")] = ( + pydantic.Field(alias="countCorrect", default=None) + ) + """ + The number of files correctly classified as this class. + """ + + precision: typing.Optional[float] = pydantic.Field(default=None) + """ + `countCorrect / countPredicted` for this class. + """ + + recall: typing.Optional[float] = pydantic.Field(default=None) + """ + `countCorrect / countExpected` for this class. + """ + + f1: typing.Optional[float] = pydantic.Field(default=None) + """ + Harmonic mean of `precision` and `recall`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/classifier_version.py b/src/extend_ai/types/classifier_version.py index 47ea787..7c96102 100644 --- a/src/extend_ai/types/classifier_version.py +++ b/src/extend_ai/types/classifier_version.py @@ -9,6 +9,7 @@ from ..core.unchecked_base_model import UncheckedBaseModel from .classify_config import ClassifyConfig from .created_at import CreatedAt +from .created_by import CreatedBy class ClassifierVersion(UncheckedBaseModel): @@ -55,6 +56,9 @@ class ClassifierVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/classifier_version_summary.py b/src/extend_ai/types/classifier_version_summary.py index c747a59..6c6802e 100644 --- a/src/extend_ai/types/classifier_version_summary.py +++ b/src/extend_ai/types/classifier_version_summary.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy class ClassifierVersionSummary(UncheckedBaseModel): @@ -49,6 +50,9 @@ class ClassifierVersionSummary(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/classify_evaluation_set_run_metrics.py b/src/extend_ai/types/classify_evaluation_set_run_metrics.py new file mode 100644 index 0000000..3f50a9c --- /dev/null +++ b/src/extend_ai/types/classify_evaluation_set_run_metrics.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .classification_metric import ClassificationMetric +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBase + + +class ClassifyEvaluationSetRunMetrics(EvaluationSetRunMetricsBase): + """ + Metrics for an evaluation set run against a classifier. + """ + + accuracy: typing.Optional[float] = pydantic.Field(default=None) + """ + Overall accuracy across the evaluation set, computed as the share of files whose predicted class matched the expected class. + """ + + classification_metrics: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, ClassificationMetric]], FieldMetadata(alias="classificationMetrics") + ] = pydantic.Field(alias="classificationMetrics", default=None) + """ + Per-class precision, recall, and F1. Keys are the classifier's classification types as defined in the classifier configuration. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/created_by.py b/src/extend_ai/types/created_by.py new file mode 100644 index 0000000..94ee1ad --- /dev/null +++ b/src/extend_ai/types/created_by.py @@ -0,0 +1,61 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel, UnionMetadata + + +class CreatedBy_User(UncheckedBaseModel): + """ + The actor that created this resource. + + * `user` — the resource was created via the studio by a logged-in user. The user's `email` is exposed. + * `api_key` — the resource was created via the API using an API key. The key's `name` is exposed. + + May be `null` for resources created before attribution. + """ + + type: typing.Literal["user"] = "user" + email: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow + + +class CreatedBy_ApiKey(UncheckedBaseModel): + """ + The actor that created this resource. + + * `user` — the resource was created via the studio by a logged-in user. The user's `email` is exposed. + * `api_key` — the resource was created via the API using an API key. The key's `name` is exposed. + + May be `null` for resources created before attribution. + """ + + type: typing.Literal["api_key"] = "api_key" + name: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow + + +CreatedBy = typing_extensions.Annotated[ + typing.Union[CreatedBy_User, CreatedBy_ApiKey], UnionMetadata(discriminant="type") +] diff --git a/src/extend_ai/types/created_by_api_key.py b/src/extend_ai/types/created_by_api_key.py new file mode 100644 index 0000000..922335e --- /dev/null +++ b/src/extend_ai/types/created_by_api_key.py @@ -0,0 +1,27 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel + + +class CreatedByApiKey(UncheckedBaseModel): + """ + Attribution for a resource created via the API using an API key. + """ + + name: str = pydantic.Field() + """ + The name of the API key that created the resource. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/created_by_user.py b/src/extend_ai/types/created_by_user.py new file mode 100644 index 0000000..3577da1 --- /dev/null +++ b/src/extend_ai/types/created_by_user.py @@ -0,0 +1,27 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel + + +class CreatedByUser(UncheckedBaseModel): + """ + Attribution for a resource created via the studio by a logged-in user. + """ + + email: str = pydantic.Field() + """ + The email address of the user who created the resource. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/evaluation_set.py b/src/extend_ai/types/evaluation_set.py index a5d64a4..fdbb0f4 100644 --- a/src/extend_ai/types/evaluation_set.py +++ b/src/extend_ai/types/evaluation_set.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .evaluation_set_entity import EvaluationSetEntity from .updated_at import UpdatedAt @@ -50,6 +51,9 @@ class EvaluationSet(UncheckedBaseModel): updated_at: typing_extensions.Annotated[UpdatedAt, FieldMetadata(alias="updatedAt")] = pydantic.Field( alias="updatedAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/evaluation_set_item.py b/src/extend_ai/types/evaluation_set_item.py index c1c6af1..90e944d 100644 --- a/src/extend_ai/types/evaluation_set_item.py +++ b/src/extend_ai/types/evaluation_set_item.py @@ -7,6 +7,7 @@ from ..core.pydantic_utilities import IS_PYDANTIC_V2 from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel +from .created_by import CreatedBy from .file_summary import FileSummary from .provided_processor_output import ProvidedProcessorOutput @@ -45,6 +46,10 @@ class EvaluationSetItem(UncheckedBaseModel): The expected output that will be used to evaluate the performance of the extractor, classifier, or splitter associated with the evaluation set. This must conform to the output schema of the entity associated with the evaluation set. """ + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: diff --git a/src/extend_ai/types/evaluation_set_item_summary.py b/src/extend_ai/types/evaluation_set_item_summary.py index 2300006..48be1d3 100644 --- a/src/extend_ai/types/evaluation_set_item_summary.py +++ b/src/extend_ai/types/evaluation_set_item_summary.py @@ -3,8 +3,11 @@ import typing import pydantic +import typing_extensions from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel +from .created_by import CreatedBy from .file_summary import FileSummary @@ -26,6 +29,10 @@ class EvaluationSetItemSummary(UncheckedBaseModel): A summary of the file associated with the evaluation set item. """ + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: diff --git a/src/extend_ai/types/evaluation_set_run.py b/src/extend_ai/types/evaluation_set_run.py index 20f163c..98f3af6 100644 --- a/src/extend_ai/types/evaluation_set_run.py +++ b/src/extend_ai/types/evaluation_set_run.py @@ -50,7 +50,11 @@ class EvaluationSetRun(UncheckedBaseModel): The version of the extractor, classifier, or splitter that was run. """ - metrics: EvaluationSetRunMetrics + metrics: EvaluationSetRunMetrics = pydantic.Field() + """ + Metrics for the evaluation set run. The shape depends on the entity type. + """ + status: BatchRunStatus options: EvaluationSetRunOptions = pydantic.Field() """ diff --git a/src/extend_ai/types/evaluation_set_run_entity_version.py b/src/extend_ai/types/evaluation_set_run_entity_version.py index 0aeb723..0c52eb6 100644 --- a/src/extend_ai/types/evaluation_set_run_entity_version.py +++ b/src/extend_ai/types/evaluation_set_run_entity_version.py @@ -10,6 +10,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel, UnionMetadata from .created_at import CreatedAt +from .created_by import CreatedBy class EvaluationSetRunEntityVersion_ExtractorVersion(UncheckedBaseModel): @@ -27,6 +28,9 @@ class EvaluationSetRunEntityVersion_ExtractorVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 @@ -53,6 +57,9 @@ class EvaluationSetRunEntityVersion_ClassifierVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 @@ -79,6 +86,9 @@ class EvaluationSetRunEntityVersion_SplitterVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/evaluation_set_run_field_metric.py b/src/extend_ai/types/evaluation_set_run_field_metric.py new file mode 100644 index 0000000..c0715d8 --- /dev/null +++ b/src/extend_ai/types/evaluation_set_run_field_metric.py @@ -0,0 +1,57 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from ..core.unchecked_base_model import UncheckedBaseModel + + +class EvaluationSetRunFieldMetric(UncheckedBaseModel): + """ + Per-field accuracy statistics for an extract evaluation run. + """ + + count_total: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countTotal")] = ( + pydantic.Field(alias="countTotal", default=None) + ) + """ + The number of runs that included this field in the schema. + """ + + count_present: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countPresent")] = ( + pydantic.Field(alias="countPresent", default=None) + ) + """ + The number of runs where the model produced a non-null value for this field. + """ + + count_expected: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countExpected")] = ( + pydantic.Field(alias="countExpected", default=None) + ) + """ + The number of runs where an expected value was provided. + """ + + count_accurate: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="countAccurate")] = ( + pydantic.Field(alias="countAccurate", default=None) + ) + """ + The number of runs where the extracted value matched the expected value. + """ + + accuracy: typing.Optional[float] = pydantic.Field(default=None) + """ + Accuracy for this field, computed as `countAccurate / countExpected`. Omitted when `countExpected` is `0`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/evaluation_set_run_metrics.py b/src/extend_ai/types/evaluation_set_run_metrics.py index 42a4089..afb7ac8 100644 --- a/src/extend_ai/types/evaluation_set_run_metrics.py +++ b/src/extend_ai/types/evaluation_set_run_metrics.py @@ -1,29 +1,160 @@ # This file was auto-generated by Fern from our API Definition. +from __future__ import annotations + import typing import pydantic import typing_extensions from ..core.pydantic_utilities import IS_PYDANTIC_V2 from ..core.serialization import FieldMetadata -from ..core.unchecked_base_model import UncheckedBaseModel +from ..core.unchecked_base_model import UncheckedBaseModel, UnionMetadata +from .classification_metric import ClassificationMetric +from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetric + +class EvaluationSetRunMetrics_Extract(UncheckedBaseModel): + """ + Metrics for the evaluation set run. The shape depends on the entity type. + """ -class EvaluationSetRunMetrics(UncheckedBaseModel): + type: typing.Literal["EXTRACT"] = "EXTRACT" + accuracy: typing.Optional[float] = None + field_metrics: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, EvaluationSetRunFieldMetric]], FieldMetadata(alias="fieldMetrics") + ] = pydantic.Field(alias="fieldMetrics", default=None) + num_files_total: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFilesTotal")] = ( + pydantic.Field(alias="numFilesTotal", default=None) + ) + num_files_processed: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numFilesProcessed") + ] = pydantic.Field(alias="numFilesProcessed", default=None) num_files: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFiles")] = pydantic.Field( alias="numFiles", default=None ) + num_pages: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numPages")] = pydantic.Field( + alias="numPages", default=None + ) + mean_latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="meanLatencyMs")] = ( + pydantic.Field(alias="meanLatencyMs", default=None) + ) + p50latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p50LatencyMs")] = ( + pydantic.Field(alias="p50LatencyMs", default=None) + ) + p90latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p90LatencyMs")] = ( + pydantic.Field(alias="p90LatencyMs", default=None) + ) + p95latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p95LatencyMs")] = ( + pydantic.Field(alias="p95LatencyMs", default=None) + ) + p99latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p99LatencyMs")] = ( + pydantic.Field(alias="p99LatencyMs", default=None) + ) + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow + + +class EvaluationSetRunMetrics_Classify(UncheckedBaseModel): """ - The total number of files processed in this evaluation set run + Metrics for the evaluation set run. The shape depends on the entity type. """ + type: typing.Literal["CLASSIFY"] = "CLASSIFY" + accuracy: typing.Optional[float] = None + classification_metrics: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, ClassificationMetric]], FieldMetadata(alias="classificationMetrics") + ] = pydantic.Field(alias="classificationMetrics", default=None) + num_files_total: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFilesTotal")] = ( + pydantic.Field(alias="numFilesTotal", default=None) + ) + num_files_processed: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numFilesProcessed") + ] = pydantic.Field(alias="numFilesProcessed", default=None) + num_files: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFiles")] = pydantic.Field( + alias="numFiles", default=None + ) num_pages: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numPages")] = pydantic.Field( alias="numPages", default=None ) + mean_latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="meanLatencyMs")] = ( + pydantic.Field(alias="meanLatencyMs", default=None) + ) + p50latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p50LatencyMs")] = ( + pydantic.Field(alias="p50LatencyMs", default=None) + ) + p90latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p90LatencyMs")] = ( + pydantic.Field(alias="p90LatencyMs", default=None) + ) + p95latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p95LatencyMs")] = ( + pydantic.Field(alias="p95LatencyMs", default=None) + ) + p99latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p99LatencyMs")] = ( + pydantic.Field(alias="p99LatencyMs", default=None) + ) + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow + + +class EvaluationSetRunMetrics_Splitter(UncheckedBaseModel): """ - The total number of pages processed in this evaluation set run + Metrics for the evaluation set run. The shape depends on the entity type. """ + type: typing.Literal["SPLITTER"] = "SPLITTER" + precision: typing.Optional[float] = None + recall: typing.Optional[float] = None + f1: typing.Optional[float] = None + num_splits_expected: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numSplitsExpected") + ] = pydantic.Field(alias="numSplitsExpected", default=None) + num_splits_predicted: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numSplitsPredicted") + ] = pydantic.Field(alias="numSplitsPredicted", default=None) + num_splits_correct: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numSplitsCorrect")] = ( + pydantic.Field(alias="numSplitsCorrect", default=None) + ) + num_files_total: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFilesTotal")] = ( + pydantic.Field(alias="numFilesTotal", default=None) + ) + num_files_processed: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numFilesProcessed") + ] = pydantic.Field(alias="numFilesProcessed", default=None) + num_files: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFiles")] = pydantic.Field( + alias="numFiles", default=None + ) + num_pages: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numPages")] = pydantic.Field( + alias="numPages", default=None + ) + mean_latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="meanLatencyMs")] = ( + pydantic.Field(alias="meanLatencyMs", default=None) + ) + p50latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p50LatencyMs")] = ( + pydantic.Field(alias="p50LatencyMs", default=None) + ) + p90latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p90LatencyMs")] = ( + pydantic.Field(alias="p90LatencyMs", default=None) + ) + p95latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p95LatencyMs")] = ( + pydantic.Field(alias="p95LatencyMs", default=None) + ) + p99latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p99LatencyMs")] = ( + pydantic.Field(alias="p99LatencyMs", default=None) + ) + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: @@ -32,3 +163,9 @@ class Config: frozen = True smart_union = True extra = pydantic.Extra.allow + + +EvaluationSetRunMetrics = typing_extensions.Annotated[ + typing.Union[EvaluationSetRunMetrics_Extract, EvaluationSetRunMetrics_Classify, EvaluationSetRunMetrics_Splitter], + UnionMetadata(discriminant="type"), +] diff --git a/src/extend_ai/types/evaluation_set_run_metrics_base.py b/src/extend_ai/types/evaluation_set_run_metrics_base.py new file mode 100644 index 0000000..c1b5d0a --- /dev/null +++ b/src/extend_ai/types/evaluation_set_run_metrics_base.py @@ -0,0 +1,87 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from ..core.unchecked_base_model import UncheckedBaseModel + + +class EvaluationSetRunMetricsBase(UncheckedBaseModel): + """ + Fields common to every evaluation set run metrics variant. + """ + + num_files_total: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFilesTotal")] = ( + pydantic.Field(alias="numFilesTotal", default=None) + ) + """ + The total number of files that will be processed in this evaluation set run. + """ + + num_files_processed: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numFilesProcessed") + ] = pydantic.Field(alias="numFilesProcessed", default=None) + """ + The number of files that have finished processing. + """ + + num_files: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numFiles")] = pydantic.Field( + alias="numFiles", default=None + ) + """ + Deprecated. Use `numFilesProcessed` instead. + """ + + num_pages: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numPages")] = pydantic.Field( + alias="numPages", default=None + ) + """ + The total number of pages processed across the completed files. + """ + + mean_latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="meanLatencyMs")] = ( + pydantic.Field(alias="meanLatencyMs", default=None) + ) + """ + Mean per-file latency in milliseconds. + """ + + p50latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p50LatencyMs")] = ( + pydantic.Field(alias="p50LatencyMs", default=None) + ) + """ + 50th percentile per-file latency in milliseconds. + """ + + p90latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p90LatencyMs")] = ( + pydantic.Field(alias="p90LatencyMs", default=None) + ) + """ + 90th percentile per-file latency in milliseconds. + """ + + p95latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p95LatencyMs")] = ( + pydantic.Field(alias="p95LatencyMs", default=None) + ) + """ + 95th percentile per-file latency in milliseconds. + """ + + p99latency_ms: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="p99LatencyMs")] = ( + pydantic.Field(alias="p99LatencyMs", default=None) + ) + """ + 99th percentile per-file latency in milliseconds. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/extract_evaluation_set_run_metrics.py b/src/extend_ai/types/extract_evaluation_set_run_metrics.py new file mode 100644 index 0000000..06fa26c --- /dev/null +++ b/src/extend_ai/types/extract_evaluation_set_run_metrics.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .evaluation_set_run_field_metric import EvaluationSetRunFieldMetric +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBase + + +class ExtractEvaluationSetRunMetrics(EvaluationSetRunMetricsBase): + """ + Metrics for an evaluation set run against an extractor. + """ + + accuracy: typing.Optional[float] = pydantic.Field(default=None) + """ + Aggregate accuracy across every reviewed field in `fieldMetrics`. + """ + + field_metrics: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, EvaluationSetRunFieldMetric]], FieldMetadata(alias="fieldMetrics") + ] = pydantic.Field(alias="fieldMetrics", default=None) + """ + Per-field accuracy statistics. Keys are the extractor schema's field names — nested fields are flattened to dot-joined paths. See [Extraction response format](https://docs.extend.ai/2026-02-09/extraction/response-format#output-value-and-metadata) for more on how extractor field paths are structured. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/extractor_version.py b/src/extend_ai/types/extractor_version.py index 5bcf971..0c61c57 100644 --- a/src/extend_ai/types/extractor_version.py +++ b/src/extend_ai/types/extractor_version.py @@ -10,6 +10,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .extract_config import ExtractConfig @@ -60,6 +61,9 @@ class ExtractorVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/extractor_version_summary.py b/src/extend_ai/types/extractor_version_summary.py index 53cc6c9..7d02da1 100644 --- a/src/extend_ai/types/extractor_version_summary.py +++ b/src/extend_ai/types/extractor_version_summary.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy class ExtractorVersionSummary(UncheckedBaseModel): @@ -49,6 +50,9 @@ class ExtractorVersionSummary(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/splitter_evaluation_set_run_metrics.py b/src/extend_ai/types/splitter_evaluation_set_run_metrics.py new file mode 100644 index 0000000..ae4029f --- /dev/null +++ b/src/extend_ai/types/splitter_evaluation_set_run_metrics.py @@ -0,0 +1,60 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .evaluation_set_run_metrics_base import EvaluationSetRunMetricsBase + + +class SplitterEvaluationSetRunMetrics(EvaluationSetRunMetricsBase): + """ + Metrics for an evaluation set run against a splitter. + """ + + precision: typing.Optional[float] = pydantic.Field(default=None) + """ + `numSplitsCorrect / numSplitsPredicted`. + """ + + recall: typing.Optional[float] = pydantic.Field(default=None) + """ + `numSplitsCorrect / numSplitsExpected`. + """ + + f1: typing.Optional[float] = pydantic.Field(default=None) + """ + Harmonic mean of `precision` and `recall`. + """ + + num_splits_expected: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numSplitsExpected") + ] = pydantic.Field(alias="numSplitsExpected", default=None) + """ + The total number of expected split documents across the evaluation set. + """ + + num_splits_predicted: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="numSplitsPredicted") + ] = pydantic.Field(alias="numSplitsPredicted", default=None) + """ + The total number of split documents the model produced. + """ + + num_splits_correct: typing_extensions.Annotated[typing.Optional[float], FieldMetadata(alias="numSplitsCorrect")] = ( + pydantic.Field(alias="numSplitsCorrect", default=None) + ) + """ + The number of predicted splits that exactly matched an expected split. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/splitter_version.py b/src/extend_ai/types/splitter_version.py index 86f1cf2..c8dd33a 100644 --- a/src/extend_ai/types/splitter_version.py +++ b/src/extend_ai/types/splitter_version.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .split_config import SplitConfig @@ -55,6 +56,9 @@ class SplitterVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/splitter_version_summary.py b/src/extend_ai/types/splitter_version_summary.py index 77020db..b67e4e7 100644 --- a/src/extend_ai/types/splitter_version_summary.py +++ b/src/extend_ai/types/splitter_version_summary.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy class SplitterVersionSummary(UncheckedBaseModel): @@ -49,6 +50,9 @@ class SplitterVersionSummary(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/webhook_endpoint.py b/src/extend_ai/types/webhook_endpoint.py index 878168b..acb6a79 100644 --- a/src/extend_ai/types/webhook_endpoint.py +++ b/src/extend_ai/types/webhook_endpoint.py @@ -9,6 +9,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .api_version_enum import ApiVersionEnum +from .created_by import CreatedBy from .webhook_advanced_options import WebhookAdvancedOptions from .webhook_endpoint_event_type import WebhookEndpointEventType from .webhook_endpoint_status import WebhookEndpointStatus @@ -56,6 +57,10 @@ class WebhookEndpoint(UncheckedBaseModel): The date and time the webhook endpoint was created. """ + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: diff --git a/src/extend_ai/types/webhook_endpoint_create.py b/src/extend_ai/types/webhook_endpoint_create.py index 2b331a4..15a8b5b 100644 --- a/src/extend_ai/types/webhook_endpoint_create.py +++ b/src/extend_ai/types/webhook_endpoint_create.py @@ -9,6 +9,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .api_version_enum import ApiVersionEnum +from .created_by import CreatedBy from .webhook_advanced_options import WebhookAdvancedOptions from .webhook_endpoint_event_type import WebhookEndpointEventType from .webhook_endpoint_status import WebhookEndpointStatus @@ -69,6 +70,10 @@ class WebhookEndpointCreate(UncheckedBaseModel): The date and time the webhook endpoint was created. """ + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) + if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 else: diff --git a/src/extend_ai/types/workflow.py b/src/extend_ai/types/workflow.py index 17832ee..b37238b 100644 --- a/src/extend_ai/types/workflow.py +++ b/src/extend_ai/types/workflow.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .updated_at import UpdatedAt from .workflow_version import WorkflowVersion @@ -41,6 +42,9 @@ class Workflow(UncheckedBaseModel): draft_version: typing_extensions.Annotated[WorkflowVersion, FieldMetadata(alias="draftVersion")] = pydantic.Field( alias="draftVersion" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/workflow_run_package.py b/src/extend_ai/types/workflow_run_package.py new file mode 100644 index 0000000..813217c --- /dev/null +++ b/src/extend_ai/types/workflow_run_package.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel +from .workflow_run_package_files_item import WorkflowRunPackageFilesItem + + +class WorkflowRunPackage(UncheckedBaseModel): + """ + An ordered collection of files to process together in a single workflow run. Use this instead of `file` when a set of documents should be treated as one package — every file is ingested up front, and the workflow's steps reason over the full set, producing a single `WorkflowRun`. + + Exactly one of `file` or `package` must be provided on a request — they are mutually exclusive. + """ + + files: typing.List[WorkflowRunPackageFilesItem] = pydantic.Field() + """ + The files to process, in submission order. Each entry can be a URL or an existing Extend file ID. Raw text and base64 inputs are not supported for package runs. + + Duplicate file IDs and duplicate URLs are rejected — each file may appear only once. A URL and a file ID are never treated as duplicates of each other, even if they resolve to the same document. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/extend_ai/types/workflow_run_package_files_item.py b/src/extend_ai/types/workflow_run_package_files_item.py new file mode 100644 index 0000000..0a7232e --- /dev/null +++ b/src/extend_ai/types/workflow_run_package_files_item.py @@ -0,0 +1,8 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .file_from_id import FileFromId +from .file_from_url import FileFromUrl + +WorkflowRunPackageFilesItem = typing.Union[FileFromUrl, FileFromId] diff --git a/src/extend_ai/types/workflow_summary.py b/src/extend_ai/types/workflow_summary.py index d462457..e7b0a4b 100644 --- a/src/extend_ai/types/workflow_summary.py +++ b/src/extend_ai/types/workflow_summary.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .updated_at import UpdatedAt @@ -37,6 +38,9 @@ class WorkflowSummary(UncheckedBaseModel): updated_at: typing_extensions.Annotated[UpdatedAt, FieldMetadata(alias="updatedAt")] = pydantic.Field( alias="updatedAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/workflow_version.py b/src/extend_ai/types/workflow_version.py index 130746d..6b97b19 100644 --- a/src/extend_ai/types/workflow_version.py +++ b/src/extend_ai/types/workflow_version.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy from .workflow_step_definition import WorkflowStepDefinition @@ -44,6 +45,9 @@ class WorkflowVersion(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/types/workflow_version_summary.py b/src/extend_ai/types/workflow_version_summary.py index 0250db2..e4ddda0 100644 --- a/src/extend_ai/types/workflow_version_summary.py +++ b/src/extend_ai/types/workflow_version_summary.py @@ -8,6 +8,7 @@ from ..core.serialization import FieldMetadata from ..core.unchecked_base_model import UncheckedBaseModel from .created_at import CreatedAt +from .created_by import CreatedBy class WorkflowVersionSummary(UncheckedBaseModel): @@ -38,6 +39,9 @@ class WorkflowVersionSummary(UncheckedBaseModel): created_at: typing_extensions.Annotated[CreatedAt, FieldMetadata(alias="createdAt")] = pydantic.Field( alias="createdAt" ) + created_by: typing_extensions.Annotated[typing.Optional[CreatedBy], FieldMetadata(alias="createdBy")] = ( + pydantic.Field(alias="createdBy", default=None) + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/extend_ai/workflow_runs/client.py b/src/extend_ai/workflow_runs/client.py index 742c494..222826f 100644 --- a/src/extend_ai/workflow_runs/client.py +++ b/src/extend_ai/workflow_runs/client.py @@ -5,6 +5,7 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from ..requests.workflow_reference import WorkflowReferenceParams +from ..requests.workflow_run_package import WorkflowRunPackageParams from ..types.max_page_size import MaxPageSize from ..types.next_page_token import NextPageToken from ..types.run_metadata import RunMetadata @@ -127,7 +128,8 @@ def create( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: typing.Optional[WorkflowRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[WorkflowRunPackageParams] = OMIT, outputs: typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = OMIT, priority: typing.Optional[RunPriority] = OMIT, metadata: typing.Optional[RunMetadata] = OMIT, @@ -135,7 +137,9 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> WorkflowRun: """ - Run a workflow with a file. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + Run a workflow. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + + Pass `file` for a single document, or `package` to process 2-50 files together as one package in a single run. Exactly one of `file` or `package` must be provided. The request returns immediately with a `PROCESSING` status. Use webhooks or poll the Get Workflow Run endpoint for results. @@ -143,11 +147,14 @@ def create( ---------- workflow : WorkflowReferenceParams - file : WorkflowRunsCreateRequestFileParams - The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. If you wish to process more at a time, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + file : typing.Optional[WorkflowRunsCreateRequestFileParams] + The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. Mutually exclusive with `package` — provide one or the other. If you wish to process many files as independent runs, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + + package : typing.Optional[WorkflowRunPackageParams] + A set of 2–50 files to process together in a single workflow run. Mutually exclusive with `file` — provide one or the other. outputs : typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] - Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. + Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. Not supported on package runs — a package run produces a single merged result across all files and cannot accept pre-supplied per-processor outputs. priority : typing.Optional[RunPriority] @@ -178,6 +185,7 @@ def create( _response = self._raw_client.create( workflow=workflow, file=file, + package=package, outputs=outputs, priority=priority, metadata=metadata, @@ -557,7 +565,8 @@ async def create( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: typing.Optional[WorkflowRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[WorkflowRunPackageParams] = OMIT, outputs: typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = OMIT, priority: typing.Optional[RunPriority] = OMIT, metadata: typing.Optional[RunMetadata] = OMIT, @@ -565,7 +574,9 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> WorkflowRun: """ - Run a workflow with a file. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + Run a workflow. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + + Pass `file` for a single document, or `package` to process 2-50 files together as one package in a single run. Exactly one of `file` or `package` must be provided. The request returns immediately with a `PROCESSING` status. Use webhooks or poll the Get Workflow Run endpoint for results. @@ -573,11 +584,14 @@ async def create( ---------- workflow : WorkflowReferenceParams - file : WorkflowRunsCreateRequestFileParams - The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. If you wish to process more at a time, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + file : typing.Optional[WorkflowRunsCreateRequestFileParams] + The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. Mutually exclusive with `package` — provide one or the other. If you wish to process many files as independent runs, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + + package : typing.Optional[WorkflowRunPackageParams] + A set of 2–50 files to process together in a single workflow run. Mutually exclusive with `file` — provide one or the other. outputs : typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] - Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. + Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. Not supported on package runs — a package run produces a single merged result across all files and cannot accept pre-supplied per-processor outputs. priority : typing.Optional[RunPriority] @@ -616,6 +630,7 @@ async def main() -> None: _response = await self._raw_client.create( workflow=workflow, file=file, + package=package, outputs=outputs, priority=priority, metadata=metadata, diff --git a/src/extend_ai/workflow_runs/raw_client.py b/src/extend_ai/workflow_runs/raw_client.py index e3d7595..a3e5264 100644 --- a/src/extend_ai/workflow_runs/raw_client.py +++ b/src/extend_ai/workflow_runs/raw_client.py @@ -19,6 +19,7 @@ from ..errors.unauthorized_error import UnauthorizedError from ..errors.unprocessable_entity_error import UnprocessableEntityError from ..requests.workflow_reference import WorkflowReferenceParams +from ..requests.workflow_run_package import WorkflowRunPackageParams from ..types.api_error import ApiError as types_api_error_ApiError from ..types.max_page_size import MaxPageSize from ..types.next_page_token import NextPageToken @@ -230,7 +231,8 @@ def create( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: typing.Optional[WorkflowRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[WorkflowRunPackageParams] = OMIT, outputs: typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = OMIT, priority: typing.Optional[RunPriority] = OMIT, metadata: typing.Optional[RunMetadata] = OMIT, @@ -238,7 +240,9 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[WorkflowRun]: """ - Run a workflow with a file. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + Run a workflow. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + + Pass `file` for a single document, or `package` to process 2-50 files together as one package in a single run. Exactly one of `file` or `package` must be provided. The request returns immediately with a `PROCESSING` status. Use webhooks or poll the Get Workflow Run endpoint for results. @@ -246,11 +250,14 @@ def create( ---------- workflow : WorkflowReferenceParams - file : WorkflowRunsCreateRequestFileParams - The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. If you wish to process more at a time, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + file : typing.Optional[WorkflowRunsCreateRequestFileParams] + The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. Mutually exclusive with `package` — provide one or the other. If you wish to process many files as independent runs, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + + package : typing.Optional[WorkflowRunPackageParams] + A set of 2–50 files to process together in a single workflow run. Mutually exclusive with `file` — provide one or the other. outputs : typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] - Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. + Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. Not supported on package runs — a package run produces a single merged result across all files and cannot accept pre-supplied per-processor outputs. priority : typing.Optional[RunPriority] @@ -276,6 +283,9 @@ def create( "file": convert_and_respect_annotation_metadata( object_=file, annotation=WorkflowRunsCreateRequestFileParams, direction="write" ), + "package": convert_and_respect_annotation_metadata( + object_=package, annotation=WorkflowRunPackageParams, direction="write" + ), "outputs": convert_and_respect_annotation_metadata( object_=outputs, annotation=typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams], @@ -1351,7 +1361,8 @@ async def create( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: typing.Optional[WorkflowRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[WorkflowRunPackageParams] = OMIT, outputs: typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = OMIT, priority: typing.Optional[RunPriority] = OMIT, metadata: typing.Optional[RunMetadata] = OMIT, @@ -1359,7 +1370,9 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[WorkflowRun]: """ - Run a workflow with a file. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + Run a workflow. A workflow is a sequence of steps that process files and data in a specific order to achieve a desired outcome. + + Pass `file` for a single document, or `package` to process 2-50 files together as one package in a single run. Exactly one of `file` or `package` must be provided. The request returns immediately with a `PROCESSING` status. Use webhooks or poll the Get Workflow Run endpoint for results. @@ -1367,11 +1380,14 @@ async def create( ---------- workflow : WorkflowReferenceParams - file : WorkflowRunsCreateRequestFileParams - The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. If you wish to process more at a time, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + file : typing.Optional[WorkflowRunsCreateRequestFileParams] + The file to be processed. Supported file types can be found [here](https://docs.extend.ai/2026-02-09/general/supported-file-types). Files can be provided as a URL, an Extend file ID, or raw text. Mutually exclusive with `package` — provide one or the other. If you wish to process many files as independent runs, consider using the [Batch Run Workflow](https://docs.extend.ai/2026-02-09/api-reference/endpoints/workflow/batch-create-workflow-runs) endpoint. + + package : typing.Optional[WorkflowRunPackageParams] + A set of 2–50 files to process together in a single workflow run. Mutually exclusive with `file` — provide one or the other. outputs : typing.Optional[typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams]] - Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. + Predetermined outputs to be used for the workflow run. Generally not recommended for most use cases, however, can be useful in cases of overriding a classification in a workflow, or a subset of extraction fields when data is known. Not supported on package runs — a package run produces a single merged result across all files and cannot accept pre-supplied per-processor outputs. priority : typing.Optional[RunPriority] @@ -1397,6 +1413,9 @@ async def create( "file": convert_and_respect_annotation_metadata( object_=file, annotation=WorkflowRunsCreateRequestFileParams, direction="write" ), + "package": convert_and_respect_annotation_metadata( + object_=package, annotation=WorkflowRunPackageParams, direction="write" + ), "outputs": convert_and_respect_annotation_metadata( object_=outputs, annotation=typing.Sequence[WorkflowRunsCreateRequestOutputsItemParams], From f01e44ab55dc908d1f35e36d2bf4feba8c10ee00 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:02:28 +0000 Subject: [PATCH 2/7] [fern-replay] Applied customizations Patches with unresolved conflicts (2): - patch-29b579f8: patch - patch-4df4e73b: bump Run `fern-replay resolve` to apply these customizations. --- .fern/replay.lock | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.fern/replay.lock b/.fern/replay.lock index 054eb14..4de6fdf 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -12,7 +12,13 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 4.53.0 -current_generation: 5127f8898b00255cf4632d0d7caf1a18f12e2910 + - commit_sha: ba094f1571e793f1e79af26fd47c40a070550444 + tree_hash: 2ffd229afc07a3b2ab89bc2df4ab6f5e0d908cb2 + timestamp: 2026-07-29T21:02:26.153Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 4.53.0 +current_generation: ba094f1571e793f1e79af26fd47c40a070550444 patches: - id: patch-29b579f8 content_hash: sha256:19cd61f1a6dd83c32c2b84be87c4195e51f4c531a09b504a5a4ec17b1de9b4a9 From b1bb16494c91320baf97e8e5531041f55bdf1357 Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Wed, 29 Jul 2026 17:03:36 -0400 Subject: [PATCH 3/7] [fern-replay] Resolved conflicts Patches replayed: - patch-29b579f8: patch - patch-4df4e73b: bump --- .fern/replay.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.fern/replay.lock b/.fern/replay.lock index 4de6fdf..21cd52b 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -143,7 +143,7 @@ patches: [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - status: unresolved + status: resolving - id: patch-4df4e73b content_hash: sha256:5ae6cf843c83300578dbbe88ac7e88028f95221b2174366370482ab9a604ad28 original_commit: 4df4e73b49b9e59112fafc32c047ddff9d18881f @@ -296,4 +296,4 @@ patches: token = await self._async_token() headers["Authorization"] = f"Bearer {token}" return headers - status: unresolved + status: resolving From a2b91f1a4504795a65171aabe82147d3aeae0f51 Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Wed, 29 Jul 2026 13:21:48 -0400 Subject: [PATCH 4/7] Add pydantic model support for extraction schemas Pass a pydantic BaseModel subclass as config["schema"] (or extractor["override_config"]["schema"]) and the SDK converts it to Extend's JSON Schema subset for the request, then validates the extraction output back into model instances (TypedExtractRun). Mirrors the TypeScript SDK's Zod support across the same four integration points: extract(), extract_runs.create_and_poll(), extractors.create()/update(), and extractor_versions.create(). - New wrapper/schema module: ExtendDate/ExtendCurrency/ExtendSignature field types, pydantic_to_extend_schema conversion with SchemaConversionError, typed run wrappers, config detection helpers - Works with pydantic v1 and v2 - Overload signatures give full static inference: result.output.value is typed as the schema model --- README.md | 66 +++ src/extend_ai/__init__.py | 32 +- src/extend_ai/wrapper/__init__.py | 19 + src/extend_ai/wrapper/client.py | 256 ++++++++- src/extend_ai/wrapper/resources/__init__.py | 6 + .../wrapper/resources/extract_runs.py | 242 ++++++-- .../wrapper/resources/extractor_versions.py | 84 +++ src/extend_ai/wrapper/resources/extractors.py | 133 +++++ src/extend_ai/wrapper/schema/__init__.py | 68 +++ .../wrapper/schema/config_conversion.py | 93 ++++ src/extend_ai/wrapper/schema/conversion.py | 266 +++++++++ src/extend_ai/wrapper/schema/custom_types.py | 92 +++ src/extend_ai/wrapper/schema/typed_run.py | 147 +++++ tests/wrapper/test_schema_conversion.py | 527 ++++++++++++++++++ tests/wrapper/test_typed_extraction.py | 306 ++++++++++ 15 files changed, 2258 insertions(+), 79 deletions(-) create mode 100644 src/extend_ai/wrapper/resources/extractor_versions.py create mode 100644 src/extend_ai/wrapper/resources/extractors.py create mode 100644 src/extend_ai/wrapper/schema/__init__.py create mode 100644 src/extend_ai/wrapper/schema/config_conversion.py create mode 100644 src/extend_ai/wrapper/schema/conversion.py create mode 100644 src/extend_ai/wrapper/schema/custom_types.py create mode 100644 src/extend_ai/wrapper/schema/typed_run.py create mode 100644 tests/wrapper/test_schema_conversion.py create mode 100644 tests/wrapper/test_typed_extraction.py diff --git a/README.md b/README.md index 7710dce..f195951 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,72 @@ edit_run = client.edit( > **Note:** The synchronous methods above have a 5-minute timeout and are best suited for onboarding and testing. For production workloads, use [polling helpers](#polling-helpers) or [webhooks](#webhook-verification) instead. +## Typed extraction with Pydantic + +The SDK supports [pydantic](https://docs.pydantic.dev/) models for fully typed extraction -- define your schema once and get end-to-end type safety from request to response: + +```python +from typing import List, Optional + +from pydantic import BaseModel, Field + +from extend_ai import Extend, ExtendCurrency, ExtendDate + +class LineItem(BaseModel): + description: Optional[str] = None + amount: Optional[ExtendCurrency] = None + +class Invoice(BaseModel): + invoice_number: Optional[str] = Field(None, description="The invoice number") + invoice_date: ExtendDate = Field(None, description="The invoice date") + line_items: List[LineItem] = Field(default_factory=list, description="Line items on the invoice") + total: Optional[ExtendCurrency] = Field(None, description="Total amount due") + +client = Extend(token="YOUR_API_KEY") + +result = client.extract( + file={"url": "https://example.com/invoice.pdf"}, + config={"schema": Invoice}, +) + +# output.value is a validated Invoice instance +print(result.output.value.invoice_number) # str | None +print(result.output.value.invoice_date) # datetime.date | None +print(result.output.value.total.amount) # float | None +print(result.output.value.total.iso_4217_currency_code) # str | None +``` + +The model is converted to [Extend's JSON Schema format](https://docs.extend.ai/2026-02-09/extraction/schema) for the request, and the extraction output is validated back into model instances. Use `Field(description=...)` to guide the extraction, and declare fields as `Optional` -- extraction can return `null` for any field, and non-nullable fields will fail output validation. + +Pydantic model schemas are accepted everywhere an extraction schema can be provided: + +```python +# Polling (see below), including extractor config overrides +result = client.extract_runs.create_and_poll( + file={"url": "https://example.com/invoice.pdf"}, + config={"schema": Invoice}, +) + +# Creating and updating extractors +extractor = client.extractors.create(name="Invoice Extractor", config={"schema": Invoice}) +client.extractors.update(extractor.id, config={"schema": Invoice}) + +# Publishing extractor versions +client.extractor_versions.create(extractor.id, release_type="major", config={"schema": Invoice}) +``` + +### Custom field types + +The SDK provides field types for Extend-specific extraction behavior: + +| Type | Output type | Description | +|---|---|---| +| `ExtendDate` | `datetime.date \| None` | ISO date (plain `datetime.date` annotations work too) | +| `ExtendCurrency` | `ExtendCurrency(amount, iso_4217_currency_code)` | Currency with amount and code | +| `ExtendSignature` | `ExtendSignature(printed_name, signature_date, is_signed, title_or_role)` | Signature detection | + +Supported field types: `str`, `float`, `int`, `bool`, `datetime.date`, `Literal[...]` / string enums (converted to nullable enums), nested models, and lists of any of these. Unsupported constructs (unions, dicts, etc.) raise `SchemaConversionError`. + ## Polling helpers Every run resource exposes a `create_and_poll()` method that creates the run and automatically polls until it reaches a terminal state (`PROCESSED`, `FAILED`, or `CANCELLED`): diff --git a/src/extend_ai/__init__.py b/src/extend_ai/__init__.py index 1fb7b15..36c9d08 100644 --- a/src/extend_ai/__init__.py +++ b/src/extend_ai/__init__.py @@ -438,7 +438,21 @@ ClassifyRunsListResponse, ClassifyRunsListResponseParams, ) - from .wrapper import AsyncExtend, Extend, Webhooks, PollingOptions, PollingTimeoutError + from .wrapper import ( + AsyncExtend, + Extend, + ExtendCurrency, + ExtendDate, + ExtendSignature, + PollingOptions, + PollingTimeoutError, + SchemaConversionError, + TypedExtractOutput, + TypedExtractRun, + Webhooks, + parse_extract_run, + pydantic_to_extend_schema, + ) from .edit_runs import ( EditRunsCreateRequestFile, EditRunsCreateRequestFileParams, @@ -1102,6 +1116,14 @@ "Webhooks": ".wrapper", "PollingOptions": ".wrapper", "PollingTimeoutError": ".wrapper", + "ExtendCurrency": ".wrapper", + "ExtendDate": ".wrapper", + "ExtendSignature": ".wrapper", + "SchemaConversionError": ".wrapper", + "TypedExtractOutput": ".wrapper", + "TypedExtractRun": ".wrapper", + "parse_extract_run": ".wrapper", + "pydantic_to_extend_schema": ".wrapper", "ExtendEnvironment": ".environment", "ExternalDataValidationResult": ".types", "ExternalDataValidationResultParams": ".requests", @@ -1985,10 +2007,18 @@ def __dir__(): "ExcelSheetRange", "ExcelSheetRangeParams", "Extend", + "ExtendCurrency", + "ExtendDate", "ExtendEnvironment", + "ExtendSignature", "Webhooks", "PollingOptions", "PollingTimeoutError", + "SchemaConversionError", + "TypedExtractOutput", + "TypedExtractRun", + "parse_extract_run", + "pydantic_to_extend_schema", "ExternalDataValidationResult", "ExternalDataValidationResultParams", "ExternalDataValidationResultResponse", diff --git a/src/extend_ai/wrapper/__init__.py b/src/extend_ai/wrapper/__init__.py index d5edef6..aa25490 100644 --- a/src/extend_ai/wrapper/__init__.py +++ b/src/extend_ai/wrapper/__init__.py @@ -30,12 +30,31 @@ WebhookSignatureVerificationError, ) from .polling import PollingOptions, calculate_backoff_delay, poll_until_done, poll_until_done_async +from .schema import ( + ExtendCurrency, + ExtendDate, + ExtendSignature, + SchemaConversionError, + TypedExtractOutput, + TypedExtractRun, + parse_extract_run, + pydantic_to_extend_schema, +) from .webhooks import RawWebhookEvent, SignedDataUrlPayload, WebhookEventWithSignedUrl, Webhooks __all__ = [ # Client "Extend", "AsyncExtend", + # Typed extraction schemas + "ExtendCurrency", + "ExtendDate", + "ExtendSignature", + "SchemaConversionError", + "TypedExtractOutput", + "TypedExtractRun", + "parse_extract_run", + "pydantic_to_extend_schema", # Webhooks "Webhooks", "RawWebhookEvent", diff --git a/src/extend_ai/wrapper/client.py b/src/extend_ai/wrapper/client.py index 55dff90..1051da8 100644 --- a/src/extend_ai/wrapper/client.py +++ b/src/extend_ai/wrapper/client.py @@ -32,44 +32,89 @@ import typing import httpx - +from ..batch_processor_run.client import AsyncBatchProcessorRunClient, BatchProcessorRunClient +from ..classifier_versions.client import AsyncClassifierVersionsClient, ClassifierVersionsClient +from ..classifiers.client import AsyncClassifiersClient, ClassifiersClient from ..client import AsyncExtend as GeneratedAsyncExtend from ..client import Extend as GeneratedExtend -from ..environment import ExtendEnvironment # Import all client types for proper type annotations -from ..files.client import FilesClient, AsyncFilesClient -from ..extractors.client import ExtractorsClient, AsyncExtractorsClient -from ..extractor_versions.client import ExtractorVersionsClient, AsyncExtractorVersionsClient -from ..classifiers.client import ClassifiersClient, AsyncClassifiersClient -from ..classifier_versions.client import ClassifierVersionsClient, AsyncClassifierVersionsClient -from ..splitters.client import SplittersClient, AsyncSplittersClient -from ..splitter_versions.client import SplitterVersionsClient, AsyncSplitterVersionsClient -from ..workflows.client import WorkflowsClient, AsyncWorkflowsClient -from ..evaluation_sets.client import EvaluationSetsClient, AsyncEvaluationSetsClient -from ..evaluation_set_items.client import EvaluationSetItemsClient, AsyncEvaluationSetItemsClient -from ..evaluation_set_runs.client import EvaluationSetRunsClient, AsyncEvaluationSetRunsClient -from ..processor.client import ProcessorClient, AsyncProcessorClient -from ..processor_run.client import ProcessorRunClient, AsyncProcessorRunClient -from ..processor_version.client import ProcessorVersionClient, AsyncProcessorVersionClient -from ..batch_processor_run.client import BatchProcessorRunClient, AsyncBatchProcessorRunClient - +from ..core.request_options import RequestOptions +from ..environment import ExtendEnvironment +from ..evaluation_set_items.client import AsyncEvaluationSetItemsClient, EvaluationSetItemsClient +from ..evaluation_set_runs.client import AsyncEvaluationSetRunsClient, EvaluationSetRunsClient +from ..evaluation_sets.client import AsyncEvaluationSetsClient, EvaluationSetsClient +from ..files.client import AsyncFilesClient, FilesClient +from ..processor.client import AsyncProcessorClient, ProcessorClient +from ..processor_run.client import AsyncProcessorRunClient, ProcessorRunClient +from ..processor_version.client import AsyncProcessorVersionClient, ProcessorVersionClient +from ..requests.extract_config_json import ExtractConfigJsonParams +from ..requests.extract_request_extractor import ExtractRequestExtractorParams +from ..requests.extract_request_file import ExtractRequestFileParams +from ..requests.multi_file_run_package import MultiFileRunPackageParams +from ..splitter_versions.client import AsyncSplitterVersionsClient, SplitterVersionsClient +from ..splitters.client import AsyncSplittersClient, SplittersClient +from ..types.extract_run import ExtractRun +from ..types.run_metadata import RunMetadata +from ..workflows.client import AsyncWorkflowsClient, WorkflowsClient from .resources import ( AsyncClassifyRunsClient, AsyncEditRunsClient, + AsyncExtractorsClient, + AsyncExtractorVersionsClient, AsyncExtractRunsClient, AsyncParseRunsClient, AsyncSplitRunsClient, AsyncWorkflowRunsClient, ClassifyRunsClient, EditRunsClient, + ExtractorsClient, + ExtractorVersionsClient, ExtractRunsClient, ParseRunsClient, SplitRunsClient, WorkflowRunsClient, ) +from .schema import ( + TypedExtractConfigParams, + TypedExtractorParams, + TypedExtractRun, + convert_typed_config, + convert_typed_extractor, + get_extractor_schema_model, + get_schema_model, + parse_extract_run, +) +from .schema.typed_run import ModelT from .webhooks import Webhooks +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +def _convert_extract_request( + extractor: typing.Any, config: typing.Any +) -> typing.Tuple[typing.Any, typing.Any, typing.Optional[type]]: + """ + Convert a pydantic model schema in an extract request's `config` or + `extractor["override_config"]` to Extend JSON Schema. Returns the + (possibly converted) extractor and config, and the schema model if one + was supplied. + """ + schema_model: typing.Optional[type] = None + + model = get_schema_model(config) + if model is not None: + schema_model = model + config = convert_typed_config(config) + + model = get_extractor_schema_model(extractor) + if model is not None: + schema_model = model + extractor = convert_typed_extractor(extractor) + + return extractor, config, schema_model + class Extend(GeneratedExtend): """ @@ -161,12 +206,95 @@ def __init__( self._workflow_runs_client: typing.Optional[WorkflowRunsClient] = None self._edit_runs_client: typing.Optional[EditRunsClient] = None self._parse_runs_client: typing.Optional[ParseRunsClient] = None + self._extractors_client: typing.Optional[ExtractorsClient] = None + self._extractor_versions_client: typing.Optional[ExtractorVersionsClient] = None @property def webhooks(self) -> Webhooks: """Webhook utilities for signature verification and event parsing.""" return self._webhooks + @typing.overload + def extract( + self, + *, + config: TypedExtractConfigParams[ModelT], + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + def extract( + self, + *, + extractor: TypedExtractorParams[ModelT], + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + def extract( + self, + *, + extractor: typing.Optional[ExtractRequestExtractorParams] = OMIT, + config: typing.Optional[ExtractConfigJsonParams] = OMIT, + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractRun: ... + + def extract( + self, + *, + extractor: typing.Optional[ + typing.Union[ExtractRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = OMIT, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = OMIT, + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.Union[ExtractRun, TypedExtractRun[ModelT]]: + """ + Extract structured data from a file synchronously, waiting for the result. + + In addition to the generated `extract()` behavior, `config["schema"]` + (or `extractor["override_config"]["schema"]`) may be a pydantic model + class. The model is converted to Extend's JSON Schema format for the + request, and the extraction output is validated into instances of the + model, returned as a TypedExtractRun. + + Example: + from typing import Optional + from pydantic import BaseModel + + class Invoice(BaseModel): + invoice_number: Optional[str] = None + + result = client.extract( + file={"url": "https://example.com/invoice.pdf"}, + config={"schema": Invoice}, + ) + print(result.output.value.invoice_number) # typed! + """ + converted_extractor, converted_config, schema_model = _convert_extract_request(extractor, config) + result = super().extract( + extractor=converted_extractor, + config=converted_config, + file=file, + package=package, + metadata=metadata, + request_options=request_options, + ) + if schema_model is not None: + return parse_extract_run(result, typing.cast(typing.Type[ModelT], schema_model)) + return result + # Run resources with create_and_poll support @property def extract_runs(self) -> ExtractRunsClient: @@ -218,13 +346,17 @@ def files(self) -> FilesClient: @property def extractors(self) -> ExtractorsClient: - """Extractors client.""" - return super().extractors # type: ignore[return-value] + """Extractors client with typed (pydantic) schema support.""" + if self._extractors_client is None: + self._extractors_client = ExtractorsClient(client_wrapper=self._client_wrapper) + return self._extractors_client @property def extractor_versions(self) -> ExtractorVersionsClient: - """Extractor versions client.""" - return super().extractor_versions # type: ignore[return-value] + """Extractor versions client with typed (pydantic) schema support.""" + if self._extractor_versions_client is None: + self._extractor_versions_client = ExtractorVersionsClient(client_wrapper=self._client_wrapper) + return self._extractor_versions_client @property def classifiers(self) -> ClassifiersClient: @@ -349,12 +481,80 @@ def __init__( self._workflow_runs_client: typing.Optional[AsyncWorkflowRunsClient] = None self._edit_runs_client: typing.Optional[AsyncEditRunsClient] = None self._parse_runs_client: typing.Optional[AsyncParseRunsClient] = None + self._extractors_client: typing.Optional[AsyncExtractorsClient] = None + self._extractor_versions_client: typing.Optional[AsyncExtractorVersionsClient] = None @property def webhooks(self) -> Webhooks: """Webhook utilities for signature verification and event parsing.""" return self._webhooks + @typing.overload + async def extract( + self, + *, + config: TypedExtractConfigParams[ModelT], + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + async def extract( + self, + *, + extractor: TypedExtractorParams[ModelT], + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + async def extract( + self, + *, + extractor: typing.Optional[ExtractRequestExtractorParams] = OMIT, + config: typing.Optional[ExtractConfigJsonParams] = OMIT, + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractRun: ... + + async def extract( + self, + *, + extractor: typing.Optional[ + typing.Union[ExtractRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = OMIT, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = OMIT, + file: typing.Optional[ExtractRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> typing.Union[ExtractRun, TypedExtractRun[ModelT]]: + """ + Extract structured data from a file synchronously, waiting for the result (async version). + + `config["schema"]` (or `extractor["override_config"]["schema"]`) may be + a pydantic model class, in which case the extraction output is validated + into instances of the model and returned as a TypedExtractRun. + """ + converted_extractor, converted_config, schema_model = _convert_extract_request(extractor, config) + result = await super().extract( + extractor=converted_extractor, + config=converted_config, + file=file, + package=package, + metadata=metadata, + request_options=request_options, + ) + if schema_model is not None: + return parse_extract_run(result, typing.cast(typing.Type[ModelT], schema_model)) + return result + # Run resources with create_and_poll support @property def extract_runs(self) -> AsyncExtractRunsClient: @@ -406,13 +606,17 @@ def files(self) -> AsyncFilesClient: @property def extractors(self) -> AsyncExtractorsClient: - """Extractors client.""" - return super().extractors # type: ignore[return-value] + """Extractors client with typed (pydantic) schema support.""" + if self._extractors_client is None: + self._extractors_client = AsyncExtractorsClient(client_wrapper=self._client_wrapper) + return self._extractors_client @property def extractor_versions(self) -> AsyncExtractorVersionsClient: - """Extractor versions client.""" - return super().extractor_versions # type: ignore[return-value] + """Extractor versions client with typed (pydantic) schema support.""" + if self._extractor_versions_client is None: + self._extractor_versions_client = AsyncExtractorVersionsClient(client_wrapper=self._client_wrapper) + return self._extractor_versions_client @property def classifiers(self) -> AsyncClassifiersClient: diff --git a/src/extend_ai/wrapper/resources/__init__.py b/src/extend_ai/wrapper/resources/__init__.py index b0c2345..ec7be97 100644 --- a/src/extend_ai/wrapper/resources/__init__.py +++ b/src/extend_ai/wrapper/resources/__init__.py @@ -3,6 +3,8 @@ from .classify_runs import AsyncClassifyRunsClient, ClassifyRunsClient from .edit_runs import AsyncEditRunsClient, EditRunsClient from .extract_runs import AsyncExtractRunsClient, ExtractRunsClient +from .extractor_versions import AsyncExtractorVersionsClient, ExtractorVersionsClient +from .extractors import AsyncExtractorsClient, ExtractorsClient from .parse_runs import AsyncParseRunsClient, ParseRunsClient from .split_runs import AsyncSplitRunsClient, SplitRunsClient from .workflow_runs import AsyncWorkflowRunsClient, WorkflowRunsClient @@ -10,6 +12,10 @@ __all__ = [ "ExtractRunsClient", "AsyncExtractRunsClient", + "ExtractorsClient", + "AsyncExtractorsClient", + "ExtractorVersionsClient", + "AsyncExtractorVersionsClient", "ClassifyRunsClient", "AsyncClassifyRunsClient", "SplitRunsClient", diff --git a/src/extend_ai/wrapper/resources/extract_runs.py b/src/extend_ai/wrapper/resources/extract_runs.py index 0733efd..0405ab8 100644 --- a/src/extend_ai/wrapper/resources/extract_runs.py +++ b/src/extend_ai/wrapper/resources/extract_runs.py @@ -1,5 +1,5 @@ """ -Extended ExtractRuns client with polling utilities. +Extended ExtractRuns client with polling utilities and typed schemas. Example: from extend_ai import Extend @@ -14,9 +14,22 @@ if result.status == "PROCESSED": print(result.output) + + # Or pass a pydantic model as the schema for typed output + from typing import Optional + from pydantic import BaseModel + + class Invoice(BaseModel): + invoice_number: Optional[str] = None + + result = client.extract_runs.create_and_poll( + file={"id": "file_xxx"}, + config={"schema": Invoice}, + ) + print(result.output.value.invoice_number) # typed! """ -from typing import Any, Dict, Optional +import typing from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...extract_runs.client import AsyncExtractRunsClient as GeneratedAsyncExtractRunsClient @@ -28,10 +41,20 @@ from ...types.extract_run import ExtractRun from ...types.run_metadata import RunMetadata from ...types.run_priority import RunPriority -from ..polling import PollingOptions, poll_until_done, poll_until_done_async # Re-export for convenience -from ..polling import PollingTimeoutError +from ..polling import PollingOptions, PollingTimeoutError, poll_until_done, poll_until_done_async +from ..schema import ( + TypedExtractConfigParams, + TypedExtractorParams, + TypedExtractRun, + convert_typed_config, + convert_typed_extractor, + get_extractor_schema_model, + get_schema_model, + parse_extract_run, +) +from ..schema.typed_run import ModelT __all__ = ["ExtractRunsClient", "AsyncExtractRunsClient", "PollingTimeoutError"] @@ -45,6 +68,51 @@ def _is_terminal_status(status: str) -> bool: return status not in ("PROCESSING", "PENDING", "CANCELLING") +def _build_create_kwargs( + *, + file: typing.Optional[ExtractRunsCreateRequestFileParams], + package: typing.Optional[MultiFileRunPackageParams], + extractor: typing.Any, + config: typing.Any, + priority: typing.Optional[RunPriority], + metadata: typing.Optional[RunMetadata], +) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Optional[type]]: + """ + Build create() kwargs (omitting None values), converting any pydantic model + schema to Extend JSON Schema. Returns the kwargs and the schema model, if + one was supplied. + """ + schema_model: typing.Optional[type] = None + + if config is not None: + model = get_schema_model(config) + if model is not None: + schema_model = model + config = convert_typed_config(config) + + if extractor is not None: + model = get_extractor_schema_model(extractor) + if model is not None: + schema_model = model + extractor = convert_typed_extractor(extractor) + + kwargs: typing.Dict[str, typing.Any] = {} + if file is not None: + kwargs["file"] = file + if package is not None: + kwargs["package"] = package + if extractor is not None: + kwargs["extractor"] = extractor + if config is not None: + kwargs["config"] = config + if priority is not None: + kwargs["priority"] = priority + if metadata is not None: + kwargs["metadata"] = metadata + + return kwargs, schema_model + + class ExtractRunsClient(GeneratedExtractRunsClient): """ Extended ExtractRuns client with create_and_poll method. @@ -56,17 +124,56 @@ class ExtractRunsClient(GeneratedExtractRunsClient): def __init__(self, *, client_wrapper: SyncClientWrapper): super().__init__(client_wrapper=client_wrapper) + @typing.overload + def create_and_poll( + self, + *, + config: TypedExtractConfigParams[ModelT], + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + def create_and_poll( + self, + *, + extractor: TypedExtractorParams[ModelT], + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + def create_and_poll( + self, + *, + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + extractor: typing.Optional[ExtractRunsCreateRequestExtractorParams] = None, + config: typing.Optional[ExtractConfigJsonParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> ExtractRun: ... + def create_and_poll( self, *, - file: Optional[ExtractRunsCreateRequestFileParams] = None, - package: Optional[MultiFileRunPackageParams] = None, - extractor: Optional[ExtractRunsCreateRequestExtractorParams] = None, - config: Optional[ExtractConfigJsonParams] = None, - priority: Optional[RunPriority] = None, - metadata: Optional[RunMetadata] = None, - polling_options: Optional[PollingOptions] = None, - ) -> ExtractRun: + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + extractor: typing.Optional[ + typing.Union[ExtractRunsCreateRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = None, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> typing.Union[ExtractRun, TypedExtractRun[ModelT]]: """ Creates an extract run and polls until it reaches a terminal state. @@ -81,13 +188,17 @@ def create_and_poll( package: A package of files for multi-file extraction. Mutually exclusive with `file` — provide one or the other. extractor: Reference to an existing extractor. - config: Inline extract configuration. + config: Inline extract configuration. `config["schema"]` may be a + pydantic model class, in which case the extraction output is + validated into instances of that model. priority: Priority of the run. metadata: Additional metadata for the run. polling_options: Options for polling behavior. Returns: - The final extract run when processing is complete. + The final extract run when processing is complete. If a pydantic + model was supplied as the schema, a TypedExtractRun whose output + values are instances of the model. Raises: PollingTimeoutError: If the run doesn't complete within max_wait_ms. @@ -101,32 +212,25 @@ def create_and_poll( if result.status == "PROCESSED": print(result.output) """ - # Build kwargs, only including non-None values to avoid passing null - kwargs: Dict[str, Any] = {} - if file is not None: - kwargs["file"] = file - if package is not None: - kwargs["package"] = package - if extractor is not None: - kwargs["extractor"] = extractor - if config is not None: - kwargs["config"] = config - if priority is not None: - kwargs["priority"] = priority - if metadata is not None: - kwargs["metadata"] = metadata + kwargs, schema_model = _build_create_kwargs( + file=file, package=package, extractor=extractor, config=config, priority=priority, metadata=metadata + ) # Create the extract run create_response = self.create(**kwargs) run_id = create_response.id # Poll until terminal state - return poll_until_done( + result = poll_until_done( retrieve=lambda: self.retrieve(run_id), is_terminal=lambda response: _is_terminal_status(response.status), options=polling_options, ) + if schema_model is not None: + return parse_extract_run(result, typing.cast(typing.Type[ModelT], schema_model)) + return result + class AsyncExtractRunsClient(GeneratedAsyncExtractRunsClient): """ @@ -136,44 +240,78 @@ class AsyncExtractRunsClient(GeneratedAsyncExtractRunsClient): def __init__(self, *, client_wrapper: AsyncClientWrapper): super().__init__(client_wrapper=client_wrapper) + @typing.overload + async def create_and_poll( + self, + *, + config: TypedExtractConfigParams[ModelT], + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload async def create_and_poll( self, *, - file: Optional[ExtractRunsCreateRequestFileParams] = None, - package: Optional[MultiFileRunPackageParams] = None, - extractor: Optional[ExtractRunsCreateRequestExtractorParams] = None, - config: Optional[ExtractConfigJsonParams] = None, - priority: Optional[RunPriority] = None, - metadata: Optional[RunMetadata] = None, - polling_options: Optional[PollingOptions] = None, - ) -> ExtractRun: + extractor: TypedExtractorParams[ModelT], + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> TypedExtractRun[ModelT]: ... + + @typing.overload + async def create_and_poll( + self, + *, + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + extractor: typing.Optional[ExtractRunsCreateRequestExtractorParams] = None, + config: typing.Optional[ExtractConfigJsonParams] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> ExtractRun: ... + + async def create_and_poll( + self, + *, + file: typing.Optional[ExtractRunsCreateRequestFileParams] = None, + package: typing.Optional[MultiFileRunPackageParams] = None, + extractor: typing.Optional[ + typing.Union[ExtractRunsCreateRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = None, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = None, + priority: typing.Optional[RunPriority] = None, + metadata: typing.Optional[RunMetadata] = None, + polling_options: typing.Optional[PollingOptions] = None, + ) -> typing.Union[ExtractRun, TypedExtractRun[ModelT]]: """ Creates an extract run and polls until it reaches a terminal state (async version). `file` and `package` are mutually exclusive — provide one or the other. + `config["schema"]` may be a pydantic model class, in which case the + extraction output is validated into instances of that model. """ - # Build kwargs, only including non-None values to avoid passing null - kwargs: Dict[str, Any] = {} - if file is not None: - kwargs["file"] = file - if package is not None: - kwargs["package"] = package - if extractor is not None: - kwargs["extractor"] = extractor - if config is not None: - kwargs["config"] = config - if priority is not None: - kwargs["priority"] = priority - if metadata is not None: - kwargs["metadata"] = metadata + kwargs, schema_model = _build_create_kwargs( + file=file, package=package, extractor=extractor, config=config, priority=priority, metadata=metadata + ) # Create the extract run create_response = await self.create(**kwargs) run_id = create_response.id # Poll until terminal state - return await poll_until_done_async( + result = await poll_until_done_async( retrieve=lambda: self.retrieve(run_id), is_terminal=lambda response: _is_terminal_status(response.status), options=polling_options, ) + + if schema_model is not None: + return parse_extract_run(result, typing.cast(typing.Type[ModelT], schema_model)) + return result diff --git a/src/extend_ai/wrapper/resources/extractor_versions.py b/src/extend_ai/wrapper/resources/extractor_versions.py new file mode 100644 index 0000000..48211b4 --- /dev/null +++ b/src/extend_ai/wrapper/resources/extractor_versions.py @@ -0,0 +1,84 @@ +""" +Extended ExtractorVersions client with typed (pydantic) schema support. + +`config["schema"]` may be a pydantic model class; it is converted to Extend's +JSON Schema format before the request is sent. +""" + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ...extractor_versions.client import AsyncExtractorVersionsClient as GeneratedAsyncExtractorVersionsClient +from ...extractor_versions.client import ExtractorVersionsClient as GeneratedExtractorVersionsClient +from ...requests.extract_config_json import ExtractConfigJsonParams +from ...types.extractor_version import ExtractorVersion +from ...types.release_type import ReleaseType +from ...types.version_description import VersionDescription +from ..schema import TypedExtractConfigParams +from .extractors import convert_config_arg + +__all__ = ["ExtractorVersionsClient", "AsyncExtractorVersionsClient"] + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + +_ConfigParam = typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams]] + + +class ExtractorVersionsClient(GeneratedExtractorVersionsClient): + """ + Extended ExtractorVersions client that accepts a pydantic model class as + `config["schema"]` in create(). + """ + + def __init__(self, *, client_wrapper: SyncClientWrapper): + super().__init__(client_wrapper=client_wrapper) + + def create( + self, + extractor_id: str, + *, + release_type: ReleaseType, + extend_workspace_id: typing.Optional[str] = None, + description: typing.Optional[VersionDescription] = OMIT, + config: _ConfigParam = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractorVersion: + return super().create( + extractor_id, + release_type=release_type, + extend_workspace_id=extend_workspace_id, + description=description, + config=convert_config_arg(config), + request_options=request_options, + ) + + +class AsyncExtractorVersionsClient(GeneratedAsyncExtractorVersionsClient): + """ + Extended AsyncExtractorVersions client that accepts a pydantic model class + as `config["schema"]` in create(). + """ + + def __init__(self, *, client_wrapper: AsyncClientWrapper): + super().__init__(client_wrapper=client_wrapper) + + async def create( + self, + extractor_id: str, + *, + release_type: ReleaseType, + extend_workspace_id: typing.Optional[str] = None, + description: typing.Optional[VersionDescription] = OMIT, + config: _ConfigParam = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractorVersion: + return await super().create( + extractor_id, + release_type=release_type, + extend_workspace_id=extend_workspace_id, + description=description, + config=convert_config_arg(config), + request_options=request_options, + ) diff --git a/src/extend_ai/wrapper/resources/extractors.py b/src/extend_ai/wrapper/resources/extractors.py new file mode 100644 index 0000000..c6f1a8c --- /dev/null +++ b/src/extend_ai/wrapper/resources/extractors.py @@ -0,0 +1,133 @@ +""" +Extended Extractors client with typed (pydantic) schema support. + +`config["schema"]` may be a pydantic model class; it is converted to Extend's +JSON Schema format before the request is sent. + +Example: + from typing import Optional + from pydantic import BaseModel, Field + from extend_ai import Extend + + class Invoice(BaseModel): + invoice_number: Optional[str] = Field(None, description="The invoice number") + + client = Extend(token="...") + extractor = client.extractors.create( + name="Invoice Extractor", + config={"schema": Invoice}, + ) +""" + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ...extractors.client import AsyncExtractorsClient as GeneratedAsyncExtractorsClient +from ...extractors.client import ExtractorsClient as GeneratedExtractorsClient +from ...extractors.requests.extractors_create_request_generate import ExtractorsCreateRequestGenerateParams +from ...requests.extract_config_json import ExtractConfigJsonParams +from ...types.extractor import Extractor +from ..schema import TypedExtractConfigParams, convert_typed_config, get_schema_model + +__all__ = ["ExtractorsClient", "AsyncExtractorsClient"] + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + +_ConfigParam = typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams]] + + +def convert_config_arg(config: typing.Any) -> typing.Any: + """Convert a pydantic model schema in a config argument, passing other values through.""" + if get_schema_model(config) is not None: + return convert_typed_config(config) + return config + + +class ExtractorsClient(GeneratedExtractorsClient): + """ + Extended Extractors client that accepts a pydantic model class as + `config["schema"]` in create() and update(). + """ + + def __init__(self, *, client_wrapper: SyncClientWrapper): + super().__init__(client_wrapper=client_wrapper) + + def create( + self, + *, + name: str, + clone_extractor_id: typing.Optional[str] = OMIT, + config: _ConfigParam = OMIT, + generate: typing.Optional[ExtractorsCreateRequestGenerateParams] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Extractor: + return super().create( + name=name, + clone_extractor_id=clone_extractor_id, + config=convert_config_arg(config), + generate=generate, + request_options=request_options, + ) + + def update( + self, + id: str, + *, + extend_workspace_id: typing.Optional[str] = None, + name: typing.Optional[str] = OMIT, + config: _ConfigParam = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Extractor: + return super().update( + id, + extend_workspace_id=extend_workspace_id, + name=name, + config=convert_config_arg(config), + request_options=request_options, + ) + + +class AsyncExtractorsClient(GeneratedAsyncExtractorsClient): + """ + Extended AsyncExtractors client that accepts a pydantic model class as + `config["schema"]` in create() and update(). + """ + + def __init__(self, *, client_wrapper: AsyncClientWrapper): + super().__init__(client_wrapper=client_wrapper) + + async def create( + self, + *, + name: str, + clone_extractor_id: typing.Optional[str] = OMIT, + config: _ConfigParam = OMIT, + generate: typing.Optional[ExtractorsCreateRequestGenerateParams] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Extractor: + return await super().create( + name=name, + clone_extractor_id=clone_extractor_id, + config=convert_config_arg(config), + generate=generate, + request_options=request_options, + ) + + async def update( + self, + id: str, + *, + extend_workspace_id: typing.Optional[str] = None, + name: typing.Optional[str] = OMIT, + config: _ConfigParam = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Extractor: + return await super().update( + id, + extend_workspace_id=extend_workspace_id, + name=name, + config=convert_config_arg(config), + request_options=request_options, + ) diff --git a/src/extend_ai/wrapper/schema/__init__.py b/src/extend_ai/wrapper/schema/__init__.py new file mode 100644 index 0000000..a83be7b --- /dev/null +++ b/src/extend_ai/wrapper/schema/__init__.py @@ -0,0 +1,68 @@ +""" +Schema utilities for typed extraction with pydantic models. + +Define your extraction schema as a pydantic model and pass it as +``config["schema"]`` to get end-to-end typing: the SDK converts the model to +Extend's JSON Schema format for the request, and validates the extraction +output back into model instances. + +Example: + from typing import List, Optional + from pydantic import BaseModel, Field + from extend_ai import Extend, ExtendCurrency, ExtendDate + + class LineItem(BaseModel): + description: Optional[str] = None + quantity: Optional[float] = None + price: Optional[ExtendCurrency] = None + + class Invoice(BaseModel): + invoice_number: Optional[str] = Field(None, description="The invoice number") + invoice_date: ExtendDate = Field(None, description="The invoice date") + line_items: List[LineItem] = Field(default_factory=list) + total: Optional[ExtendCurrency] = Field(None, description="Total amount due") + + client = Extend(token="...") + result = client.extract( + file={"url": "https://example.com/invoice.pdf"}, + config={"schema": Invoice}, + ) + + # output.value is a validated Invoice instance + print(result.output.value.invoice_number) + print(result.output.value.total.amount) +""" + +from .config_conversion import ( + TypedExtractConfigParams, + TypedExtractorParams, + convert_typed_config, + convert_typed_extractor, + get_extractor_schema_model, + get_schema_model, +) +from .conversion import SchemaConversionError, pydantic_to_extend_schema +from .custom_types import ExtendCurrency, ExtendDate, ExtendSignature +from .typed_run import TypedExtractOutput, TypedExtractRun, parse_extract_run + +__all__ = [ + # Custom field types + "ExtendCurrency", + "ExtendDate", + "ExtendSignature", + # Conversion + "SchemaConversionError", + "pydantic_to_extend_schema", + # Typed configs (for annotations / advanced usage) + "TypedExtractConfigParams", + "TypedExtractorParams", + # Typed runs + "TypedExtractOutput", + "TypedExtractRun", + "parse_extract_run", + # Internal conversion helpers + "convert_typed_config", + "convert_typed_extractor", + "get_extractor_schema_model", + "get_schema_model", +] diff --git a/src/extend_ai/wrapper/schema/config_conversion.py b/src/extend_ai/wrapper/schema/config_conversion.py new file mode 100644 index 0000000..6a8601a --- /dev/null +++ b/src/extend_ai/wrapper/schema/config_conversion.py @@ -0,0 +1,93 @@ +""" +Detection and conversion of typed (pydantic) extract configs to API format. + +Used by ``Extend.extract()``, ``ExtractRunsClient.create_and_poll()``, +``ExtractorsClient.create()/update()``, and ``ExtractorVersionsClient.create()``. +""" + +import typing + +import pydantic +import typing_extensions +from ...requests.extract_advanced_options import ExtractAdvancedOptionsParams +from ...requests.parse_config import ParseConfigParams +from ...types.extract_base_processor import ExtractBaseProcessor +from ...types.processor_version_string import ProcessorVersionString +from .conversion import pydantic_to_extend_schema +from .typed_run import ModelT + +__all__ = [ + "TypedExtractConfigParams", + "TypedExtractorParams", + "convert_typed_config", + "convert_typed_extractor", + "get_extractor_schema_model", + "get_schema_model", +] + +_OVERRIDE_CONFIG_KEYS = ("override_config", "overrideConfig") + + +class TypedExtractConfigParams(typing_extensions.TypedDict, typing.Generic[ModelT], total=False): + """ + Extract configuration whose ``schema`` is a pydantic model class. + Extraction output will be validated against the model. + """ + + schema: typing_extensions.Required[typing.Type[ModelT]] + base_processor: ExtractBaseProcessor + base_version: str + extraction_rules: str + advanced_options: ExtractAdvancedOptionsParams + parse_config: ParseConfigParams + + +class TypedExtractorParams(typing_extensions.TypedDict, typing.Generic[ModelT]): + """ + Reference to an existing extractor whose ``override_config.schema`` is a + pydantic model class. Extraction output will be validated against the model. + """ + + id: str + version: typing_extensions.NotRequired[ProcessorVersionString] + override_config: TypedExtractConfigParams[ModelT] + + +def _as_schema_model(schema: typing.Any) -> typing.Optional[typing.Type[pydantic.BaseModel]]: + if isinstance(schema, type) and issubclass(schema, pydantic.BaseModel): + return schema + return None + + +def get_schema_model(config: typing.Any) -> typing.Optional[typing.Type[pydantic.BaseModel]]: + """Return the pydantic model used as ``config["schema"]``, if there is one.""" + if isinstance(config, typing.Mapping): + return _as_schema_model(config.get("schema")) + return None + + +def convert_typed_config(config: typing.Mapping[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Return a copy of ``config`` with its pydantic model schema converted to JSON Schema.""" + converted = dict(config) + converted["schema"] = pydantic_to_extend_schema(converted["schema"]) + return converted + + +def get_extractor_schema_model(extractor: typing.Any) -> typing.Optional[typing.Type[pydantic.BaseModel]]: + """Return the pydantic model used as ``extractor["override_config"]["schema"]``, if there is one.""" + if not isinstance(extractor, typing.Mapping): + return None + for key in _OVERRIDE_CONFIG_KEYS: + model = get_schema_model(extractor.get(key)) + if model is not None: + return model + return None + + +def convert_typed_extractor(extractor: typing.Mapping[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Return a copy of ``extractor`` with its override config's schema converted to JSON Schema.""" + converted = dict(extractor) + for key in _OVERRIDE_CONFIG_KEYS: + if get_schema_model(converted.get(key)) is not None: + converted[key] = convert_typed_config(converted[key]) + return converted diff --git a/src/extend_ai/wrapper/schema/conversion.py b/src/extend_ai/wrapper/schema/conversion.py new file mode 100644 index 0000000..495ac3e --- /dev/null +++ b/src/extend_ai/wrapper/schema/conversion.py @@ -0,0 +1,266 @@ +""" +Converts pydantic models to Extend's JSON Schema format. + +Note: The API performs comprehensive validation and transformation of schemas. +This module focuses on structural conversion; complex validation (nesting +limits, property counts, property key format) is handled server-side. +""" + +import datetime as dt +import enum +import types +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from .custom_types import get_extend_type + +__all__ = ["SchemaConversionError", "pydantic_to_extend_schema"] + +_NoneType = type(None) + + +class SchemaConversionError(Exception): + """Raised when a pydantic model cannot be converted to Extend JSON Schema.""" + + def __init__(self, message: str, path: typing.Optional[typing.List[str]] = None): + self.path: typing.List[str] = list(path or []) + if self.path: + message = f"{message} at path: {'.'.join(self.path)}" + super().__init__(message) + + +def _iter_model_fields( + model: typing.Type[pydantic.BaseModel], +) -> typing.Iterator[typing.Tuple[str, typing.Any, typing.Optional[str]]]: + """ + Yield (field_name, annotation, description) for each field of a pydantic + model, working under both pydantic v1 and v2. + """ + # Raw class annotations (via get_type_hints) preserve Optional wrappers, + # which pydantic v1's `outer_type_` strips. + try: + hints = typing_extensions.get_type_hints(model, include_extras=True) + except Exception: + hints = {} + + if IS_PYDANTIC_V2: + for name, field in model.model_fields.items(): # type: ignore[attr-defined] + yield name, hints.get(name, field.annotation), field.description + else: + for name, field in model.__fields__.items(): # type: ignore[attr-defined] + description = getattr(field.field_info, "description", None) # type: ignore[attr-defined] + yield name, hints.get(name, field.outer_type_), description # type: ignore[attr-defined] + + +def _is_union_origin(origin: typing.Any) -> bool: + if origin is typing.Union: + return True + union_type = getattr(types, "UnionType", None) # X | Y syntax on Python 3.10+ + return union_type is not None and origin is union_type + + +def _unwrap_annotation(annotation: typing.Any, path: typing.List[str]) -> typing.Any: + """ + Strip Annotated metadata and Optional/None-unions from an annotation, + returning the inner type. Unions of multiple non-None types are rejected. + """ + while True: + origin = typing_extensions.get_origin(annotation) + if origin is typing_extensions.Annotated: + annotation = typing_extensions.get_args(annotation)[0] + elif _is_union_origin(origin): + non_none = [arg for arg in typing_extensions.get_args(annotation) if arg is not _NoneType] + if len(non_none) != 1: + raise SchemaConversionError( + "Union types are not supported (only Optional[...] is allowed)", path + ) + annotation = non_none[0] + else: + return annotation + + +def _with_description(schema: typing.Dict[str, typing.Any], description: typing.Optional[str]) -> typing.Dict[str, typing.Any]: + if description: + schema["description"] = description + return schema + + +def _date_schema() -> typing.Dict[str, typing.Any]: + return {"type": ["string", "null"], "extend:type": "date"} + + +def _currency_schema() -> typing.Dict[str, typing.Any]: + return { + "type": "object", + "extend:type": "currency", + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]}, + }, + "required": ["amount", "iso_4217_currency_code"], + "additionalProperties": False, + } + + +def _signature_schema() -> typing.Dict[str, typing.Any]: + return { + "type": "object", + "extend:type": "signature", + "properties": { + "printed_name": {"type": ["string", "null"]}, + "signature_date": {"type": ["string", "null"], "extend:type": "date"}, + "is_signed": {"type": ["boolean", "null"]}, + "title_or_role": {"type": ["string", "null"]}, + }, + "required": ["printed_name", "signature_date", "is_signed", "title_or_role"], + "additionalProperties": False, + } + + +def _enum_values(annotation: typing.Any, path: typing.List[str]) -> typing.List[typing.Optional[str]]: + """Extract string enum values from a Literal[...] or string Enum class.""" + if typing_extensions.get_origin(annotation) is typing_extensions.Literal: + raw_values: typing.List[typing.Any] = [v for v in typing_extensions.get_args(annotation) if v is not None] + else: # enum.Enum subclass + raw_values = [member.value for member in annotation] + + values: typing.List[typing.Optional[str]] = [] + for value in raw_values: + if not isinstance(value, str): + raise SchemaConversionError( + f"Enums must only contain strings, got {type(value).__name__}: {value!r}", path + ) + values.append(value) + values.append(None) + return values + + +def _is_enum_annotation(annotation: typing.Any) -> bool: + if typing_extensions.get_origin(annotation) is typing_extensions.Literal: + return True + return isinstance(annotation, type) and issubclass(annotation, enum.Enum) + + +def pydantic_to_extend_schema(model: typing.Type[pydantic.BaseModel]) -> typing.Dict[str, typing.Any]: + """ + Convert a pydantic model class to Extend's JSON Schema format. + + All primitive fields become nullable (per Extend's schema requirements), + every property is listed as required, and `Optional[...]` wrappers are + unwrapped. Field descriptions come from ``Field(description=...)``. + + Args: + model: A ``pydantic.BaseModel`` subclass describing the data to extract. + + Returns: + The Extend JSON Schema as a plain dict. + + Raises: + SchemaConversionError: If the model uses unsupported types. + """ + if not (isinstance(model, type) and issubclass(model, pydantic.BaseModel)): + raise SchemaConversionError(f"Schema must be a pydantic BaseModel subclass, got {model!r}") + return _convert_object(model, []) + + +def _convert_object(model: typing.Type[pydantic.BaseModel], path: typing.List[str]) -> typing.Dict[str, typing.Any]: + properties: typing.Dict[str, typing.Any] = {} + required: typing.List[str] = [] + + for name, annotation, description in _iter_model_fields(model): + properties[name] = _convert_annotation(annotation, description, path + [name]) + required.append(name) + + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +def _convert_annotation( + annotation: typing.Any, description: typing.Optional[str], path: typing.List[str] +) -> typing.Dict[str, typing.Any]: + inner = _unwrap_annotation(annotation, path) + + if typing_extensions.get_origin(inner) is list or inner is list: + args = typing_extensions.get_args(inner) + if not args: + raise SchemaConversionError("Arrays must declare an item type (use List[...])", path) + return _with_description({"type": "array", "items": _convert_array_item(args[0], path)}, description) + + if _is_enum_annotation(inner): + return _with_description({"enum": _enum_values(inner, path)}, description) + + if isinstance(inner, type): + if issubclass(inner, pydantic.BaseModel): + extend_type = get_extend_type(inner) + if extend_type == "currency": + return _with_description(_currency_schema(), description) + if extend_type == "signature": + return _with_description(_signature_schema(), description) + return _with_description(_convert_object(inner, path), description) + if issubclass(inner, bool): + return _with_description({"type": ["boolean", "null"]}, description) + if issubclass(inner, int): + return _with_description({"type": ["integer", "null"]}, description) + if issubclass(inner, float): + return _with_description({"type": ["number", "null"]}, description) + if issubclass(inner, dt.datetime): + raise SchemaConversionError( + "datetime.datetime is not supported; use datetime.date (or ExtendDate) for date fields", path + ) + if issubclass(inner, dt.date): + return _with_description(_date_schema(), description) + if issubclass(inner, str): + return _with_description({"type": ["string", "null"]}, description) + + raise SchemaConversionError(f"Unsupported type: {inner!r}", path) + + +def _convert_array_item(annotation: typing.Any, path: typing.List[str]) -> typing.Dict[str, typing.Any]: + """ + Convert array item types, which have different rules than top-level types: + items can be objects or primitives, and primitive items are NOT nullable. + """ + inner = _unwrap_annotation(annotation, path) + + if _is_enum_annotation(inner): + raise SchemaConversionError( + "Enums are not supported as array items. " + "Array items must be objects or primitives (string, number, integer, boolean).", + path, + ) + + if isinstance(inner, type) and issubclass(inner, pydantic.BaseModel): + extend_type = get_extend_type(inner) + if extend_type == "currency": + return _currency_schema() + if extend_type == "signature": + return _signature_schema() + return _convert_object(inner, path) + + if isinstance(inner, type): + if issubclass(inner, bool): + return {"type": "boolean"} + if issubclass(inner, int): + return {"type": "integer"} + if issubclass(inner, float): + return {"type": "number"} + if issubclass(inner, dt.datetime): + raise SchemaConversionError( + "datetime.datetime is not supported; use datetime.date (or ExtendDate) for date fields", path + ) + if issubclass(inner, dt.date): + return {"type": "string", "extend:type": "date"} + if issubclass(inner, str): + return {"type": "string"} + + raise SchemaConversionError( + f"Unsupported array item type: {inner!r}. " + "Array items must be objects or primitives (string, number, integer, boolean).", + path, + ) diff --git a/src/extend_ai/wrapper/schema/custom_types.py b/src/extend_ai/wrapper/schema/custom_types.py new file mode 100644 index 0000000..fab07dc --- /dev/null +++ b/src/extend_ai/wrapper/schema/custom_types.py @@ -0,0 +1,92 @@ +""" +Custom pydantic field types for Extend-specific extraction fields. + +These map to the `extend:type` custom field types in Extend's JSON Schema +format (see https://docs.extend.ai/2026-02-09/extraction/schema). Use them as +field annotations in the pydantic model you pass as an extraction schema. + +Example: + import datetime + from typing import Optional + from pydantic import BaseModel, Field + from extend_ai import ExtendCurrency, ExtendDate, ExtendSignature + + class Invoice(BaseModel): + invoice_number: Optional[str] = Field(None, description="The invoice number") + invoice_date: ExtendDate = Field(None, description="The invoice date") + total: Optional[ExtendCurrency] = Field(None, description="Total amount due") + signature: Optional[ExtendSignature] = None +""" + +import datetime as dt +import typing + +import pydantic + +__all__ = ["ExtendCurrency", "ExtendDate", "ExtendSignature"] + + +# Fields annotated with `ExtendDate` (or plain `datetime.date`) convert to: +# +# {"type": ["string", "null"], "extend:type": "date"} +# +# The API returns an ISO date string (yyyy-mm-dd) or null; pydantic parses it +# back into a `datetime.date` when the output is validated. +ExtendDate = typing.Optional[dt.date] + + +class ExtendCurrency(pydantic.BaseModel): + """ + Currency field type. + + Converts to Extend's currency schema: + + { + "type": "object", + "extend:type": "currency", + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]} + }, + "required": ["amount", "iso_4217_currency_code"] + } + """ + + __extend_type__: typing.ClassVar[str] = "currency" + + amount: typing.Optional[float] = None + iso_4217_currency_code: typing.Optional[str] = None + + +class ExtendSignature(pydantic.BaseModel): + """ + Signature field type. + + Converts to Extend's signature schema, which enables advanced signature + detection during parsing and post-processing heuristics that reduce false + positives on unsigned signature blocks: + + { + "type": "object", + "extend:type": "signature", + "properties": { + "printed_name": {"type": ["string", "null"]}, + "signature_date": {"type": ["string", "null"], "extend:type": "date"}, + "is_signed": {"type": ["boolean", "null"]}, + "title_or_role": {"type": ["string", "null"]} + }, + "required": ["printed_name", "signature_date", "is_signed", "title_or_role"] + } + """ + + __extend_type__: typing.ClassVar[str] = "signature" + + printed_name: typing.Optional[str] = None + signature_date: typing.Optional[dt.date] = None + is_signed: typing.Optional[bool] = None + title_or_role: typing.Optional[str] = None + + +def get_extend_type(model: type) -> typing.Optional[str]: + """Return the extend:type marker for a model class, if it has one.""" + return getattr(model, "__extend_type__", None) diff --git a/src/extend_ai/wrapper/schema/typed_run.py b/src/extend_ai/wrapper/schema/typed_run.py new file mode 100644 index 0000000..006ec35 --- /dev/null +++ b/src/extend_ai/wrapper/schema/typed_run.py @@ -0,0 +1,147 @@ +""" +Typed extract run wrappers. + +When an extraction is created with a pydantic model as its schema, the SDK +returns a :class:`TypedExtractRun` whose ``output.value`` (and +``initial_output.value`` / ``reviewed_output.value``) are validated instances +of that model instead of plain dicts. +""" + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...types.extract_config import ExtractConfig +from ...types.extract_output import ExtractOutput +from ...types.extract_output_edits import ExtractOutputEdits +from ...types.extract_output_metadata import ExtractOutputMetadata +from ...types.extract_run import ExtractRun +from ...types.extractor_summary import ExtractorSummary +from ...types.extractor_version_summary import ExtractorVersionSummary +from ...types.file_summary import FileSummary +from ...types.processor_run_status import ProcessorRunStatus +from ...types.run_metadata import RunMetadata +from ...types.run_usage import RunUsage + +__all__ = ["ModelT", "TypedExtractOutput", "TypedExtractRun", "parse_extract_run"] + +ModelT = typing.TypeVar("ModelT", bound=pydantic.BaseModel) + + +def _validate_model(model: typing.Type[ModelT], value: typing.Any) -> ModelT: + if IS_PYDANTIC_V2: + return typing.cast(ModelT, model.model_validate(value)) # type: ignore[attr-defined] + return typing.cast(ModelT, model.parse_obj(value)) + + +class TypedExtractOutput(typing.Generic[ModelT]): + """Extract output whose value is a validated pydantic model instance.""" + + value: ModelT + metadata: ExtractOutputMetadata + + def __init__(self, *, value: ModelT, metadata: ExtractOutputMetadata) -> None: + self.value = value + self.metadata = metadata + + def __repr__(self) -> str: + return f"TypedExtractOutput(value={self.value!r})" + + +class TypedExtractRun(typing.Generic[ModelT]): + """ + An extract run whose outputs are validated instances of the pydantic model + that was used as the extraction schema. + + Mirrors :class:`~extend_ai.types.extract_run.ExtractRun`; the original + response is available as ``raw``. + """ + + object: str + id: str + status: ProcessorRunStatus + output: typing.Optional[TypedExtractOutput[ModelT]] + initial_output: typing.Optional[TypedExtractOutput[ModelT]] + reviewed_output: typing.Optional[TypedExtractOutput[ModelT]] + failure_reason: typing.Optional[str] + failure_message: typing.Optional[str] + metadata: typing.Optional[RunMetadata] + reviewed: bool + edited: bool + edits: typing.Optional[typing.Dict[str, typing.Optional[ExtractOutputEdits]]] + config: ExtractConfig + extractor: typing.Optional[ExtractorSummary] + extractor_version: typing.Optional[ExtractorVersionSummary] + file: typing.Optional[FileSummary] + files: typing.Optional[typing.List[FileSummary]] + parse_run_id: typing.Optional[str] + dashboard_url: str + usage: typing.Optional[RunUsage] + created_at: typing.Any + updated_at: typing.Any + raw: ExtractRun + """The original, untyped extract run response.""" + + def __init__(self, run: ExtractRun, model: typing.Type[ModelT]) -> None: + self.raw = run + self.object = run.object + self.id = run.id + self.status = run.status + self.output = _parse_output(run.output, model) + self.initial_output = _parse_output(run.initial_output, model) + self.reviewed_output = _parse_output(run.reviewed_output, model) + self.failure_reason = run.failure_reason + self.failure_message = run.failure_message + self.metadata = run.metadata + self.reviewed = run.reviewed + self.edited = run.edited + self.edits = run.edits + self.config = run.config + self.extractor = run.extractor + self.extractor_version = run.extractor_version + self.file = run.file + self.files = run.files + self.parse_run_id = run.parse_run_id + self.dashboard_url = run.dashboard_url + self.usage = run.usage + self.created_at = run.created_at + self.updated_at = run.updated_at + + def __repr__(self) -> str: + return f"TypedExtractRun(id={self.id!r}, status={self.status!r}, output={self.output!r})" + + +def _parse_output( + output: typing.Optional[ExtractOutput], model: typing.Type[ModelT] +) -> typing.Optional[TypedExtractOutput[ModelT]]: + if output is None: + return None + value = getattr(output, "value", None) + if value is None: + raise ValueError( + "Extract run output has no 'value' field; typed schemas are only supported " + "for runs created with a JSON Schema config." + ) + return TypedExtractOutput( + value=_validate_model(model, value), + metadata=typing.cast(ExtractOutputMetadata, getattr(output, "metadata", None)), + ) + + +def parse_extract_run(run: ExtractRun, model: typing.Type[ModelT]) -> TypedExtractRun[ModelT]: + """ + Validate an extract run's outputs against a pydantic model. + + Args: + run: A completed extract run. + model: The pydantic model class that was used as the extraction schema. + + Returns: + A :class:`TypedExtractRun` whose output values are instances of ``model``. + + Raises: + pydantic.ValidationError: If an output value does not conform to the model. + Extraction can return ``null`` for any field, so model fields should + be declared ``Optional``. + """ + return TypedExtractRun(run, model) diff --git a/tests/wrapper/test_schema_conversion.py b/tests/wrapper/test_schema_conversion.py new file mode 100644 index 0000000..8e0f318 --- /dev/null +++ b/tests/wrapper/test_schema_conversion.py @@ -0,0 +1,527 @@ +"""Tests for pydantic-to-Extend JSON Schema conversion.""" + +import datetime as dt +import enum +from typing import Dict, List, Optional, Union + +import pydantic +import pytest +from typing_extensions import Literal + +from extend_ai.wrapper.schema import ( + ExtendCurrency, + ExtendDate, + ExtendSignature, + SchemaConversionError, + pydantic_to_extend_schema, +) + +CURRENCY_SCHEMA = { + "type": "object", + "extend:type": "currency", + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]}, + }, + "required": ["amount", "iso_4217_currency_code"], + "additionalProperties": False, +} + +SIGNATURE_SCHEMA = { + "type": "object", + "extend:type": "signature", + "properties": { + "printed_name": {"type": ["string", "null"]}, + "signature_date": {"type": ["string", "null"], "extend:type": "date"}, + "is_signed": {"type": ["boolean", "null"]}, + "title_or_role": {"type": ["string", "null"]}, + }, + "required": ["printed_name", "signature_date", "is_signed", "title_or_role"], + "additionalProperties": False, +} + + +class TestBasicSchemaCreation: + def test_generates_valid_json_schema_from_model(self): + class Schema(pydantic.BaseModel): + name: Optional[str] = None + + assert pydantic_to_extend_schema(Schema) == { + "type": "object", + "properties": {"name": {"type": ["string", "null"]}}, + "required": ["name"], + "additionalProperties": False, + } + + def test_sets_additional_properties_false_at_root(self): + class Schema(pydantic.BaseModel): + name: Optional[str] = None + + assert pydantic_to_extend_schema(Schema)["additionalProperties"] is False + + def test_adds_all_properties_to_required(self): + class Schema(pydantic.BaseModel): + field1: Optional[str] = None + field2: Optional[float] = None + field3: Optional[bool] = None + + assert pydantic_to_extend_schema(Schema)["required"] == ["field1", "field2", "field3"] + + def test_rejects_non_model_schema(self): + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(dict) # type: ignore[arg-type] + + +class TestPrimitiveTypes: + def test_converts_nullable_string(self): + class Schema(pydantic.BaseModel): + field: Optional[str] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["field"] == {"type": ["string", "null"]} + + def test_converts_nullable_number(self): + class Schema(pydantic.BaseModel): + field: Optional[float] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["field"] == {"type": ["number", "null"]} + + def test_converts_nullable_integer(self): + class Schema(pydantic.BaseModel): + field: Optional[int] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["field"] == {"type": ["integer", "null"]} + + def test_converts_nullable_boolean(self): + class Schema(pydantic.BaseModel): + field: Optional[bool] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["field"] == {"type": ["boolean", "null"]} + + def test_non_optional_primitives_are_forced_nullable(self): + class Schema(pydantic.BaseModel): + name: str + count: int + + properties = pydantic_to_extend_schema(Schema)["properties"] + assert properties["name"] == {"type": ["string", "null"]} + assert properties["count"] == {"type": ["integer", "null"]} + + def test_includes_descriptions(self): + class Schema(pydantic.BaseModel): + name: Optional[str] = pydantic.Field(None, description="The customer name") + age: Optional[float] = pydantic.Field(None, description="Customer age in years") + + properties = pydantic_to_extend_schema(Schema)["properties"] + assert properties["name"] == {"type": ["string", "null"], "description": "The customer name"} + assert properties["age"] == {"type": ["number", "null"], "description": "Customer age in years"} + + +class TestEnumTypes: + def test_converts_literal_with_null_added(self): + class Schema(pydantic.BaseModel): + status: Optional[Literal["active", "inactive"]] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == {"enum": ["active", "inactive", None]} + + def test_converts_string_enum_with_null_added(self): + class Status(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + class Schema(pydantic.BaseModel): + status: Optional[Status] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == {"enum": ["active", "inactive", None]} + + def test_preserves_description_on_enums(self): + class Schema(pydantic.BaseModel): + status: Optional[Literal["active", "inactive"]] = pydantic.Field(None, description="Account status") + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == { + "enum": ["active", "inactive", None], + "description": "Account status", + } + + def test_does_not_duplicate_null_in_literal(self): + class Schema(pydantic.BaseModel): + status: Optional[Literal["active", "inactive", None]] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == {"enum": ["active", "inactive", None]} + + def test_converts_single_string_literal(self): + class Schema(pydantic.BaseModel): + type: Optional[Literal["invoice"]] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["type"] == {"enum": ["invoice", None]} + + def test_rejects_non_string_literals(self): + class Schema(pydantic.BaseModel): + value: Optional[Literal[42]] = None + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + def test_rejects_non_string_enums(self): + class Number(enum.Enum): + ONE = 1 + TWO = 2 + + class Schema(pydantic.BaseModel): + value: Optional[Number] = None + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + +class TestArrayTypes: + def test_converts_array_of_objects(self): + class Item(pydantic.BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + class Schema(pydantic.BaseModel): + items: List[Item] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["items"] == { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": ["string", "null"]}, + "price": {"type": ["number", "null"]}, + }, + "required": ["name", "price"], + "additionalProperties": False, + }, + } + + def test_converts_array_of_strings_non_nullable_items(self): + class Schema(pydantic.BaseModel): + tags: List[str] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["tags"] == { + "type": "array", + "items": {"type": "string"}, + } + + def test_converts_array_of_numbers(self): + class Schema(pydantic.BaseModel): + values: List[float] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["values"] == { + "type": "array", + "items": {"type": "number"}, + } + + def test_converts_array_of_integers(self): + class Schema(pydantic.BaseModel): + counts: List[int] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["counts"] == { + "type": "array", + "items": {"type": "integer"}, + } + + def test_converts_array_of_booleans(self): + class Schema(pydantic.BaseModel): + flags: List[bool] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["flags"] == { + "type": "array", + "items": {"type": "boolean"}, + } + + def test_converts_optional_array(self): + class Schema(pydantic.BaseModel): + tags: Optional[List[str]] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["tags"] == { + "type": "array", + "items": {"type": "string"}, + } + + def test_includes_description_on_arrays(self): + class Schema(pydantic.BaseModel): + items: List[str] = pydantic.Field(default_factory=list, description="List of items") + + assert pydantic_to_extend_schema(Schema)["properties"]["items"] == { + "type": "array", + "items": {"type": "string"}, + "description": "List of items", + } + + +class TestNestedObjects: + def test_converts_nested_objects(self): + class Address(pydantic.BaseModel): + street: Optional[str] = None + city: Optional[str] = None + zip: Optional[str] = None + + class Schema(pydantic.BaseModel): + address: Optional[Address] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["address"] == { + "type": "object", + "properties": { + "street": {"type": ["string", "null"]}, + "city": {"type": ["string", "null"]}, + "zip": {"type": ["string", "null"]}, + }, + "required": ["street", "city", "zip"], + "additionalProperties": False, + } + + def test_handles_deeply_nested_objects(self): + class Level3(pydantic.BaseModel): + value: Optional[str] = None + + class Level2(pydantic.BaseModel): + level3: Optional[Level3] = None + + class Level1(pydantic.BaseModel): + level2: Optional[Level2] = None + + class Schema(pydantic.BaseModel): + level1: Optional[Level1] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["level1"] == { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": {"value": {"type": ["string", "null"]}}, + "required": ["value"], + "additionalProperties": False, + }, + }, + "required": ["level3"], + "additionalProperties": False, + }, + }, + "required": ["level2"], + "additionalProperties": False, + } + + def test_preserves_description_on_nested_objects(self): + class Address(pydantic.BaseModel): + street: Optional[str] = None + + class Schema(pydantic.BaseModel): + address: Optional[Address] = pydantic.Field(None, description="Mailing address") + + assert pydantic_to_extend_schema(Schema)["properties"]["address"]["description"] == "Mailing address" + + +class TestExtendDate: + def test_converts_to_extend_type_date(self): + class Schema(pydantic.BaseModel): + invoice_date: ExtendDate = None + + assert pydantic_to_extend_schema(Schema)["properties"]["invoice_date"] == { + "type": ["string", "null"], + "extend:type": "date", + } + + def test_plain_date_annotation_converts_to_extend_type_date(self): + class Schema(pydantic.BaseModel): + invoice_date: Optional[dt.date] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["invoice_date"] == { + "type": ["string", "null"], + "extend:type": "date", + } + + def test_preserves_description(self): + class Schema(pydantic.BaseModel): + invoice_date: ExtendDate = pydantic.Field(None, description="The invoice date") + + assert pydantic_to_extend_schema(Schema)["properties"]["invoice_date"] == { + "type": ["string", "null"], + "extend:type": "date", + "description": "The invoice date", + } + + def test_works_in_arrays_with_non_nullable_format(self): + class Schema(pydantic.BaseModel): + dates: List[dt.date] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["dates"] == { + "type": "array", + "items": {"type": "string", "extend:type": "date"}, + } + + def test_rejects_datetime(self): + class Schema(pydantic.BaseModel): + timestamp: Optional[dt.datetime] = None + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + +class TestExtendCurrency: + def test_converts_to_extend_type_currency(self): + class Schema(pydantic.BaseModel): + total: Optional[ExtendCurrency] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["total"] == CURRENCY_SCHEMA + + def test_preserves_description(self): + class Schema(pydantic.BaseModel): + total: Optional[ExtendCurrency] = pydantic.Field(None, description="Total invoice amount") + + expected = dict(CURRENCY_SCHEMA, description="Total invoice amount") + assert pydantic_to_extend_schema(Schema)["properties"]["total"] == expected + + def test_works_in_arrays(self): + class Schema(pydantic.BaseModel): + amounts: List[ExtendCurrency] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["amounts"] == { + "type": "array", + "items": CURRENCY_SCHEMA, + } + + +class TestExtendSignature: + def test_converts_to_extend_type_signature(self): + class Schema(pydantic.BaseModel): + customer_signature: Optional[ExtendSignature] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["customer_signature"] == SIGNATURE_SCHEMA + + def test_preserves_description(self): + class Schema(pydantic.BaseModel): + signature: Optional[ExtendSignature] = pydantic.Field(None, description="Customer signature") + + assert pydantic_to_extend_schema(Schema)["properties"]["signature"]["description"] == "Customer signature" + + def test_works_in_arrays(self): + class Schema(pydantic.BaseModel): + signatures: List[ExtendSignature] = [] + + assert pydantic_to_extend_schema(Schema)["properties"]["signatures"] == { + "type": "array", + "items": SIGNATURE_SCHEMA, + } + + +class TestSchemaConversionError: + def test_includes_path_in_error_message(self): + error = SchemaConversionError("Unsupported type", ["items", "nested", "field"]) + + assert str(error) == "Unsupported type at path: items.nested.field" + assert error.path == ["items", "nested", "field"] + + def test_works_without_path(self): + error = SchemaConversionError("General error") + + assert str(error) == "General error" + assert error.path == [] + + def test_conversion_errors_carry_field_path(self): + class Inner(pydantic.BaseModel): + mapping: Dict[str, str] = {} + + class Schema(pydantic.BaseModel): + inner: Optional[Inner] = None + + with pytest.raises(SchemaConversionError) as exc_info: + pydantic_to_extend_schema(Schema) + assert exc_info.value.path == ["inner", "mapping"] + + +class TestUnsupportedTypes: + def test_rejects_dict_fields(self): + class Schema(pydantic.BaseModel): + mapping: Dict[str, str] = {} + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + def test_rejects_non_optional_unions(self): + class Schema(pydantic.BaseModel): + value: Union[str, int] = "" + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + def test_rejects_array_of_enums(self): + class Schema(pydantic.BaseModel): + statuses: List[Literal["a", "b"]] = [] + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + def test_rejects_array_of_string_enums(self): + class Status(str, enum.Enum): + A = "a" + + class Schema(pydantic.BaseModel): + statuses: List[Status] = [] + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + def test_rejects_nested_arrays(self): + class Schema(pydantic.BaseModel): + matrix: List[List[str]] = [] + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) + + +class TestComplexSchemas: + def test_converts_realistic_invoice_schema(self): + class Vendor(pydantic.BaseModel): + name: Optional[str] = pydantic.Field(None, description="Vendor company name") + address: Optional[str] = pydantic.Field(None, description="Vendor address") + + class LineItem(pydantic.BaseModel): + description: Optional[str] = None + quantity: Optional[float] = None + unit_price: Optional[ExtendCurrency] = None + line_total: Optional[ExtendCurrency] = None + + class Invoice(pydantic.BaseModel): + invoice_number: Optional[str] = pydantic.Field(None, description="The invoice number") + invoice_date: ExtendDate = pydantic.Field(None, description="The invoice date") + due_date: ExtendDate = pydantic.Field(None, description="Payment due date") + vendor: Optional[Vendor] = pydantic.Field(None, description="Vendor information") + total_amount: Optional[ExtendCurrency] = pydantic.Field(None, description="Total invoice amount") + line_items: List[LineItem] = pydantic.Field(default_factory=list, description="Invoice line items") + status: Optional[Literal["draft", "sent", "paid", "overdue"]] = pydantic.Field( + None, description="Invoice status" + ) + + json_schema = pydantic_to_extend_schema(Invoice) + + assert json_schema["type"] == "object" + assert "invoice_number" in json_schema["required"] + assert "invoice_date" in json_schema["required"] + assert "line_items" in json_schema["required"] + + assert json_schema["properties"]["invoice_date"]["extend:type"] == "date" + assert json_schema["properties"]["total_amount"]["extend:type"] == "currency" + assert json_schema["properties"]["line_items"]["items"]["properties"]["unit_price"]["extend:type"] == "currency" + assert json_schema["properties"]["status"]["enum"] == ["draft", "sent", "paid", "overdue", None] + + def test_converts_contract_schema_with_signatures(self): + class Term(pydantic.BaseModel): + section: Optional[str] = None + content: Optional[str] = None + + class Contract(pydantic.BaseModel): + contract_id: Optional[str] = None + effective_date: ExtendDate = None + party_a_signature: Optional[ExtendSignature] = pydantic.Field(None, description="Party A signature") + party_b_signature: Optional[ExtendSignature] = pydantic.Field(None, description="Party B signature") + terms: List[Term] = [] + + json_schema = pydantic_to_extend_schema(Contract) + + assert json_schema["properties"]["party_a_signature"]["extend:type"] == "signature" + assert json_schema["properties"]["party_b_signature"]["extend:type"] == "signature" diff --git a/tests/wrapper/test_typed_extraction.py b/tests/wrapper/test_typed_extraction.py new file mode 100644 index 0000000..3df73b0 --- /dev/null +++ b/tests/wrapper/test_typed_extraction.py @@ -0,0 +1,306 @@ +"""Tests for typed (pydantic schema) extraction across the wrapper clients.""" + +import datetime as dt +from typing import List, Optional +from unittest.mock import MagicMock + +import pydantic +import pytest + +from extend_ai.wrapper.schema import ( + ExtendCurrency, + ExtendDate, + TypedExtractOutput, + TypedExtractRun, + parse_extract_run, +) + + +class LineItem(pydantic.BaseModel): + description: Optional[str] = None + amount: Optional[ExtendCurrency] = None + + +class Invoice(pydantic.BaseModel): + invoice_number: Optional[str] = None + invoice_date: ExtendDate = None + total: Optional[ExtendCurrency] = None + line_items: List[LineItem] = [] + + +INVOICE_OUTPUT_VALUE = { + "invoice_number": "INV-123", + "invoice_date": "2026-01-15", + "total": {"amount": 99.5, "iso_4217_currency_code": "USD"}, + "line_items": [ + {"description": "Widget", "amount": {"amount": 99.5, "iso_4217_currency_code": "USD"}}, + ], +} + + +def create_mock_run(status: str = "PROCESSED", value=None): + """Create a mock extract run with a JSON-schema output.""" + run = MagicMock() + run.id = "extract_run_test123" + run.status = status + run.object = "extract_run" + if value is None: + run.output = None + else: + run.output = MagicMock() + run.output.value = value + run.initial_output = None + run.reviewed_output = None + return run + + +# ============================================================================ +# parse_extract_run +# ============================================================================ + + +class TestParseExtractRun: + def test_parses_output_value_into_model_instance(self): + run = create_mock_run(value=INVOICE_OUTPUT_VALUE) + + typed = parse_extract_run(run, Invoice) + + assert isinstance(typed, TypedExtractRun) + assert isinstance(typed.output, TypedExtractOutput) + assert isinstance(typed.output.value, Invoice) + assert typed.output.value.invoice_number == "INV-123" + assert typed.output.value.invoice_date == dt.date(2026, 1, 15) + assert isinstance(typed.output.value.total, ExtendCurrency) + assert typed.output.value.total.amount == 99.5 + assert typed.output.value.line_items[0].description == "Widget" + + def test_copies_run_fields_and_keeps_raw(self): + run = create_mock_run(value=INVOICE_OUTPUT_VALUE) + + typed = parse_extract_run(run, Invoice) + + assert typed.id == run.id + assert typed.status == run.status + assert typed.raw is run + + def test_handles_none_outputs(self): + run = create_mock_run(status="FAILED", value=None) + + typed = parse_extract_run(run, Invoice) + + assert typed.output is None + assert typed.initial_output is None + assert typed.reviewed_output is None + + def test_null_field_values_validate_into_none(self): + run = create_mock_run( + value={"invoice_number": None, "invoice_date": None, "total": None, "line_items": []} + ) + + typed = parse_extract_run(run, Invoice) + + assert typed.output.value.invoice_number is None + assert typed.output.value.total is None + + def test_raises_for_output_without_value(self): + run = create_mock_run(value=INVOICE_OUTPUT_VALUE) + run.output = MagicMock(spec=[]) # legacy output shape: no `value` attribute + + with pytest.raises(ValueError): + parse_extract_run(run, Invoice) + + +# ============================================================================ +# ExtractRunsClient.create_and_poll with typed schemas +# ============================================================================ + + +class TestCreateAndPollTypedSchema: + def setup_method(self): + from extend_ai.wrapper.resources.extract_runs import ExtractRunsClient + + self.wrapper = MagicMock(spec=ExtractRunsClient) + self.wrapper.create = MagicMock() + self.wrapper.retrieve = MagicMock() + self.wrapper.create_and_poll = ExtractRunsClient.create_and_poll.__get__(self.wrapper, ExtractRunsClient) + + def test_converts_config_schema_and_returns_typed_run(self): + self.wrapper.create.return_value = create_mock_run("PROCESSING") + self.wrapper.retrieve.return_value = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + + result = self.wrapper.create_and_poll( + file={"id": "file_1"}, + config={"schema": Invoice, "base_processor": "extraction_performance"}, + ) + + create_kwargs = self.wrapper.create.call_args.kwargs + sent_schema = create_kwargs["config"]["schema"] + assert isinstance(sent_schema, dict) + assert sent_schema["type"] == "object" + assert sent_schema["properties"]["invoice_number"] == {"type": ["string", "null"]} + # Other config keys pass through untouched + assert create_kwargs["config"]["base_processor"] == "extraction_performance" + + assert isinstance(result, TypedExtractRun) + assert isinstance(result.output.value, Invoice) + assert result.output.value.invoice_number == "INV-123" + + def test_converts_extractor_override_config_schema(self): + self.wrapper.create.return_value = create_mock_run("PROCESSING") + self.wrapper.retrieve.return_value = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + + result = self.wrapper.create_and_poll( + file={"id": "file_1"}, + extractor={"id": "extractor_abc", "override_config": {"schema": Invoice}}, + ) + + create_kwargs = self.wrapper.create.call_args.kwargs + sent_extractor = create_kwargs["extractor"] + assert sent_extractor["id"] == "extractor_abc" + assert isinstance(sent_extractor["override_config"]["schema"], dict) + assert sent_extractor["override_config"]["schema"]["type"] == "object" + + assert isinstance(result, TypedExtractRun) + + def test_untyped_config_passes_through_and_returns_plain_run(self): + self.wrapper.create.return_value = create_mock_run("PROCESSING") + processed = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + self.wrapper.retrieve.return_value = processed + + json_config = {"schema": {"type": "object", "properties": {}}} + result = self.wrapper.create_and_poll(file={"id": "file_1"}, config=json_config) + + assert self.wrapper.create.call_args.kwargs["config"] is json_config + assert result is processed + assert not isinstance(result, TypedExtractRun) + + def test_typed_failed_run_has_no_output(self): + self.wrapper.create.return_value = create_mock_run("PROCESSING") + self.wrapper.retrieve.return_value = create_mock_run("FAILED", value=None) + + result = self.wrapper.create_and_poll(file={"id": "file_1"}, config={"schema": Invoice}) + + assert isinstance(result, TypedExtractRun) + assert result.status == "FAILED" + assert result.output is None + + +class TestAsyncCreateAndPollTypedSchema: + def setup_method(self): + from unittest.mock import AsyncMock + + from extend_ai.wrapper.resources.extract_runs import AsyncExtractRunsClient + + self.wrapper = MagicMock(spec=AsyncExtractRunsClient) + self.wrapper.create = AsyncMock() + self.wrapper.retrieve = AsyncMock() + self.wrapper.create_and_poll = AsyncExtractRunsClient.create_and_poll.__get__( + self.wrapper, AsyncExtractRunsClient + ) + + async def test_converts_config_schema_and_returns_typed_run(self): + self.wrapper.create.return_value = create_mock_run("PROCESSING") + self.wrapper.retrieve.return_value = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + + result = await self.wrapper.create_and_poll(file={"id": "file_1"}, config={"schema": Invoice}) + + sent_schema = self.wrapper.create.call_args.kwargs["config"]["schema"] + assert isinstance(sent_schema, dict) + assert isinstance(result, TypedExtractRun) + assert result.output.value.invoice_number == "INV-123" + + +# ============================================================================ +# Extend.extract with typed schemas +# ============================================================================ + + +class TestClientExtractTypedSchema: + def setup_method(self): + from extend_ai.wrapper.client import Extend + + self.client = Extend(token="test-token") + self.raw_client = MagicMock() + self.client._raw_client = self.raw_client + + def test_converts_config_schema_and_returns_typed_run(self): + response = MagicMock() + response.data = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + self.raw_client.extract.return_value = response + + result = self.client.extract( + file={"url": "https://example.com/invoice.pdf"}, + config={"schema": Invoice}, + ) + + sent_config = self.raw_client.extract.call_args.kwargs["config"] + assert isinstance(sent_config["schema"], dict) + assert sent_config["schema"]["type"] == "object" + + assert isinstance(result, TypedExtractRun) + assert isinstance(result.output.value, Invoice) + + def test_untyped_extract_returns_plain_run(self): + response = MagicMock() + run = create_mock_run("PROCESSED", value=INVOICE_OUTPUT_VALUE) + response.data = run + self.raw_client.extract.return_value = response + + result = self.client.extract(file={"url": "https://example.com/invoice.pdf"}) + + assert result is run + + +# ============================================================================ +# Extractors / ExtractorVersions with typed schemas +# ============================================================================ + + +class TestExtractorsTypedSchema: + def setup_method(self): + from extend_ai.wrapper.resources.extractors import ExtractorsClient + + self.client = ExtractorsClient(client_wrapper=MagicMock()) + self.raw_client = MagicMock() + self.client._raw_client = self.raw_client + + def test_create_converts_model_schema(self): + self.client.create(name="Invoice Extractor", config={"schema": Invoice}) + + sent_config = self.raw_client.create.call_args.kwargs["config"] + assert isinstance(sent_config["schema"], dict) + assert sent_config["schema"]["type"] == "object" + + def test_create_passes_through_json_schema(self): + json_config = {"schema": {"type": "object", "properties": {}}} + self.client.create(name="Invoice Extractor", config=json_config) + + assert self.raw_client.create.call_args.kwargs["config"] is json_config + + def test_update_converts_model_schema(self): + self.client.update("extractor_abc", config={"schema": Invoice}) + + sent_config = self.raw_client.update.call_args.kwargs["config"] + assert isinstance(sent_config["schema"], dict) + + def test_create_without_config_passes_omit(self): + self.client.create(name="Invoice Extractor") + + sent_config = self.raw_client.create.call_args.kwargs["config"] + assert sent_config is ... + + +class TestExtractorVersionsTypedSchema: + def setup_method(self): + from extend_ai.wrapper.resources.extractor_versions import ExtractorVersionsClient + + self.client = ExtractorVersionsClient(client_wrapper=MagicMock()) + self.raw_client = MagicMock() + self.client._raw_client = self.raw_client + + def test_create_converts_model_schema(self): + self.client.create("extractor_abc", release_type="minor", config={"schema": Invoice}) + + sent_config = self.raw_client.create.call_args.kwargs["config"] + assert isinstance(sent_config["schema"], dict) + assert sent_config["schema"]["properties"]["invoice_number"] == {"type": ["string", "null"]} From c714d8357fde80b828aff5884590e2fcd402b881 Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Wed, 29 Jul 2026 13:29:25 -0400 Subject: [PATCH 5/7] Add signature-parity tests to catch wrapper drift from generated SDK The wrapper layer re-declares parts of the generated API surface, which can silently go stale when Fern regenerates the SDK with new parameters. True overrides (extract(), extractors.create()/update(), etc.) are already protected because mypy rejects overrides incompatible with the generated superclass, but create_and_poll(), the typed config TypedDicts, and TypedExtractRun had no guard. These tests fail CI whenever a generated create() gains a parameter that create_and_poll() doesn't forward, a config key is missing from the typed TypedDicts, or ExtractRun gains a field TypedExtractRun doesn't mirror. Also fixes drift the new tests caught: parse_runs.create_and_poll() was missing the metadata and data_retention parameters that the generated parse_runs.create() accepts. --- src/extend_ai/wrapper/resources/parse_runs.py | 19 ++- tests/wrapper/test_signature_parity.py | 119 ++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 tests/wrapper/test_signature_parity.py diff --git a/src/extend_ai/wrapper/resources/parse_runs.py b/src/extend_ai/wrapper/resources/parse_runs.py index 277ea51..03b5d55 100644 --- a/src/extend_ai/wrapper/resources/parse_runs.py +++ b/src/extend_ai/wrapper/resources/parse_runs.py @@ -21,12 +21,13 @@ from ...parse_runs.client import AsyncParseRunsClient as GeneratedAsyncParseRunsClient from ...parse_runs.client import ParseRunsClient as GeneratedParseRunsClient from ...parse_runs.requests.parse_runs_create_request_file import ParseRunsCreateRequestFileParams +from ...requests.data_retention import DataRetentionParams from ...requests.parse_config import ParseConfigParams from ...types.parse_run import ParseRun -from ..polling import PollingOptions, poll_until_done, poll_until_done_async +from ...types.run_metadata import RunMetadata # Re-export for convenience -from ..polling import PollingTimeoutError +from ..polling import PollingOptions, PollingTimeoutError, poll_until_done, poll_until_done_async __all__ = ["ParseRunsClient", "AsyncParseRunsClient", "PollingTimeoutError"] @@ -58,6 +59,8 @@ def create_and_poll( *, file: ParseRunsCreateRequestFileParams, config: Optional[ParseConfigParams] = None, + metadata: Optional[RunMetadata] = None, + data_retention: Optional[DataRetentionParams] = None, polling_options: Optional[PollingOptions] = None, ) -> ParseRun: """ @@ -68,6 +71,8 @@ def create_and_poll( Args: file: The file to parse (FileFromId or FileFromUrl). config: Parse configuration options. + metadata: Additional metadata for the run. + data_retention: Data retention policy override for the run. polling_options: Options for polling behavior. Returns: @@ -88,6 +93,10 @@ def create_and_poll( kwargs: Dict[str, Any] = {"file": file} if config is not None: kwargs["config"] = config + if metadata is not None: + kwargs["metadata"] = metadata + if data_retention is not None: + kwargs["data_retention"] = data_retention # Create the parse run create_response = self.create(**kwargs) @@ -114,6 +123,8 @@ async def create_and_poll( *, file: ParseRunsCreateRequestFileParams, config: Optional[ParseConfigParams] = None, + metadata: Optional[RunMetadata] = None, + data_retention: Optional[DataRetentionParams] = None, polling_options: Optional[PollingOptions] = None, ) -> ParseRun: """ @@ -123,6 +134,10 @@ async def create_and_poll( kwargs: Dict[str, Any] = {"file": file} if config is not None: kwargs["config"] = config + if metadata is not None: + kwargs["metadata"] = metadata + if data_retention is not None: + kwargs["data_retention"] = data_retention # Create the parse run create_response = await self.create(**kwargs) diff --git a/tests/wrapper/test_signature_parity.py b/tests/wrapper/test_signature_parity.py new file mode 100644 index 0000000..84ba374 --- /dev/null +++ b/tests/wrapper/test_signature_parity.py @@ -0,0 +1,119 @@ +""" +Guards against wrapper code drifting from the Fern-generated SDK. + +The wrapper layer re-declares parts of the generated API surface: + +- `create_and_poll()` mirrors each generated `create()` signature +- `TypedExtractConfigParams` / `TypedExtractorParams` mirror the generated + request TypedDicts (with `schema` retyped to a pydantic model class) +- `TypedExtractRun` mirrors the fields of the generated `ExtractRun` + +When SDK regeneration adds a parameter, key, or field, these tests fail so the +wrapper gets updated in the same change. (Methods that *override* a generated +method, like `Extend.extract()` and `ExtractorsClient.create()`, are already +covered: mypy rejects overrides whose signatures are incompatible with the +generated superclass.) +""" + +import inspect +import typing + +import pytest + +from extend_ai.core.pydantic_utilities import IS_PYDANTIC_V2 +from extend_ai.wrapper.resources import ( + classify_runs, + edit_runs, + extract_runs, + parse_runs, + split_runs, + workflow_runs, +) + + +def _param_names(func: typing.Any) -> typing.Set[str]: + return {name for name in inspect.signature(func).parameters if name != "self"} + + +def _typed_dict_keys(td: typing.Any) -> typing.Set[str]: + return set(td.__annotations__) + + +RUN_CLIENTS = [ + classify_runs.ClassifyRunsClient, + classify_runs.AsyncClassifyRunsClient, + edit_runs.EditRunsClient, + edit_runs.AsyncEditRunsClient, + extract_runs.ExtractRunsClient, + extract_runs.AsyncExtractRunsClient, + parse_runs.ParseRunsClient, + parse_runs.AsyncParseRunsClient, + split_runs.SplitRunsClient, + split_runs.AsyncSplitRunsClient, + workflow_runs.WorkflowRunsClient, + workflow_runs.AsyncWorkflowRunsClient, +] + + +@pytest.mark.parametrize("wrapper_client", RUN_CLIENTS, ids=lambda cls: cls.__name__) +def test_create_and_poll_accepts_all_create_params(wrapper_client): + """Every parameter of the generated create() must be exposed by create_and_poll().""" + generated_client = wrapper_client.__mro__[1] + create_params = _param_names(generated_client.create) - {"request_options"} + create_and_poll_params = _param_names(wrapper_client.create_and_poll) + + missing = create_params - create_and_poll_params + assert not missing, ( + f"{wrapper_client.__name__}.create_and_poll() is missing parameters that " + f"{generated_client.__name__}.create() accepts: {sorted(missing)}. " + "Add them to create_and_poll() and forward them to create()." + ) + + +def test_typed_extract_config_matches_generated_config_keys(): + from extend_ai.requests.extract_config_json import ExtractConfigJsonParams + from extend_ai.wrapper.schema import TypedExtractConfigParams + + generated_keys = _typed_dict_keys(ExtractConfigJsonParams) + typed_keys = _typed_dict_keys(TypedExtractConfigParams) + + missing = generated_keys - typed_keys + assert not missing, ( + f"TypedExtractConfigParams is missing keys that ExtractConfigJsonParams has: {sorted(missing)}. " + "Add them so typed configs accept the same options as untyped configs." + ) + extra = typed_keys - generated_keys + assert not extra, f"TypedExtractConfigParams has keys the generated config does not: {sorted(extra)}" + + +def test_typed_extractor_matches_generated_extractor_keys(): + from extend_ai.extract_runs.requests.extract_runs_create_request_extractor import ( + ExtractRunsCreateRequestExtractorParams, + ) + from extend_ai.wrapper.schema import TypedExtractorParams + + generated_keys = _typed_dict_keys(ExtractRunsCreateRequestExtractorParams) + typed_keys = _typed_dict_keys(TypedExtractorParams) + + assert generated_keys == typed_keys, ( + f"TypedExtractorParams keys {sorted(typed_keys)} differ from generated " + f"ExtractRunsCreateRequestExtractorParams keys {sorted(generated_keys)}." + ) + + +def test_typed_extract_run_mirrors_extract_run_fields(): + from extend_ai.types.extract_run import ExtractRun + from extend_ai.wrapper.schema import TypedExtractRun + + if IS_PYDANTIC_V2: + run_fields = set(ExtractRun.model_fields) + else: + run_fields = set(ExtractRun.__fields__) + + typed_fields = set(TypedExtractRun.__annotations__) + + missing = run_fields - typed_fields + assert not missing, ( + f"TypedExtractRun is missing fields that ExtractRun has: {sorted(missing)}. " + "Add the attributes to TypedExtractRun and copy them in its constructor." + ) From a54c56a1e4350c02b437fd7209862de0014fd1b0 Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Wed, 29 Jul 2026 15:15:27 -0400 Subject: [PATCH 6/7] Harden pydantic schema conversion against late/silent failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop silently force-nullabling primitives in the converter — that was defeating the API's 2026-02-09 strict schema validation and could defer user mistakes until after a paid run completed. Non-Optional primitives, enums, and dates now raise SchemaConversionError before any request is sent. Also: - Detect recursive models and raise SchemaConversionError instead of a fatal stack overflow - Recognize typing.Literal on Python 3.8 (distinct from typing_extensions) - Reject field aliases (they caused silent None validation) and Optional array items - Wrap residual output validation failures in ExtractOutputValidationError that preserves the completed ExtractRun (id, dashboard URL, raw output) - Convert pydantic schemas in plain extract_runs.create() so users don't hit a cryptic encoder error - Bump typing_extensions floor to >=4.3.0 for generic TypedDict support --- README.md | 17 ++- poetry.lock | 4 +- pyproject.toml | 3 +- src/extend_ai/__init__.py | 3 + src/extend_ai/wrapper/__init__.py | 2 + src/extend_ai/wrapper/client.py | 3 +- .../wrapper/resources/extract_runs.py | 83 ++++++++++- src/extend_ai/wrapper/schema/__init__.py | 9 +- src/extend_ai/wrapper/schema/conversion.py | 135 ++++++++++++++---- src/extend_ai/wrapper/schema/typed_run.py | 68 ++++++--- tests/wrapper/test_schema_conversion.py | 128 ++++++++++++++++- tests/wrapper/test_signature_parity.py | 50 +++++++ tests/wrapper/test_typed_extraction.py | 52 ++++++- uv.lock | 2 +- 14 files changed, 497 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index f195951..00e995f 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,18 @@ result = client.extract( ) # output.value is a validated Invoice instance -print(result.output.value.invoice_number) # str | None -print(result.output.value.invoice_date) # datetime.date | None -print(result.output.value.total.amount) # float | None -print(result.output.value.total.iso_4217_currency_code) # str | None +if result.status == "PROCESSED" and result.output is not None: + invoice = result.output.value + print(invoice.invoice_number) # str | None + print(invoice.invoice_date) # datetime.date | None + if invoice.total is not None: + print(invoice.total.amount) # float | None + print(invoice.total.iso_4217_currency_code) # str | None ``` -The model is converted to [Extend's JSON Schema format](https://docs.extend.ai/2026-02-09/extraction/schema) for the request, and the extraction output is validated back into model instances. Use `Field(description=...)` to guide the extraction, and declare fields as `Optional` -- extraction can return `null` for any field, and non-nullable fields will fail output validation. +The model is converted to [Extend's JSON Schema format](https://docs.extend.ai/2026-02-09/extraction/schema) for the request, and the extraction output is validated back into model instances. Use `Field(description=...)` to guide the extraction. + +Primitive, enum, and date fields must be declared `Optional` -- extraction can return `null` for any field, so a non-Optional field raises `SchemaConversionError` before any request is sent. In the unlikely event that a completed run's output fails model validation, the SDK raises `ExtractOutputValidationError`, which preserves the completed run (including its raw output) on the error's `run` attribute. Pydantic model schemas are accepted everywhere an extraction schema can be provided: @@ -122,7 +127,7 @@ The SDK provides field types for Extend-specific extraction behavior: | `ExtendCurrency` | `ExtendCurrency(amount, iso_4217_currency_code)` | Currency with amount and code | | `ExtendSignature` | `ExtendSignature(printed_name, signature_date, is_signed, title_or_role)` | Signature detection | -Supported field types: `str`, `float`, `int`, `bool`, `datetime.date`, `Literal[...]` / string enums (converted to nullable enums), nested models, and lists of any of these. Unsupported constructs (unions, dicts, etc.) raise `SchemaConversionError`. +Supported field types: `Optional[str]`, `Optional[float]`, `Optional[int]`, `Optional[bool]`, `Optional[datetime.date]`, `Optional[Literal[...]]` / string enums (converted to nullable enums), nested models, and lists of these (list items are non-Optional, e.g. `List[str]`). Unsupported constructs (non-Optional unions, dicts, recursive models, field aliases, etc.) raise `SchemaConversionError`. ## Polling helpers diff --git a/poetry.lock b/poetry.lock index 9d0c35a..a432a22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -596,4 +596,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "bcf31a142c86d9e556553c8c260a93b563ac64a043076dbd48b26111d422c26e" +content-hash = "f1e2ab5eda730c574ef9098656ce135835405a944b8ad9dcd4c65ef061581e5f" diff --git a/pyproject.toml b/pyproject.toml index 8db04ef..08ef43d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,8 @@ python = "^3.8" httpx = ">=0.21.2" pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2" -typing_extensions = ">= 4.0.0" +# Generic TypedDicts (used by the typed extraction wrappers) require >= 4.3.0 +typing_extensions = ">= 4.3.0" [tool.poetry.group.dev.dependencies] mypy = "==1.13.0" diff --git a/src/extend_ai/__init__.py b/src/extend_ai/__init__.py index 36c9d08..71ae875 100644 --- a/src/extend_ai/__init__.py +++ b/src/extend_ai/__init__.py @@ -444,6 +444,7 @@ ExtendCurrency, ExtendDate, ExtendSignature, + ExtractOutputValidationError, PollingOptions, PollingTimeoutError, SchemaConversionError, @@ -1119,6 +1120,7 @@ "ExtendCurrency": ".wrapper", "ExtendDate": ".wrapper", "ExtendSignature": ".wrapper", + "ExtractOutputValidationError": ".wrapper", "SchemaConversionError": ".wrapper", "TypedExtractOutput": ".wrapper", "TypedExtractRun": ".wrapper", @@ -2011,6 +2013,7 @@ def __dir__(): "ExtendDate", "ExtendEnvironment", "ExtendSignature", + "ExtractOutputValidationError", "Webhooks", "PollingOptions", "PollingTimeoutError", diff --git a/src/extend_ai/wrapper/__init__.py b/src/extend_ai/wrapper/__init__.py index aa25490..1d0ab23 100644 --- a/src/extend_ai/wrapper/__init__.py +++ b/src/extend_ai/wrapper/__init__.py @@ -34,6 +34,7 @@ ExtendCurrency, ExtendDate, ExtendSignature, + ExtractOutputValidationError, SchemaConversionError, TypedExtractOutput, TypedExtractRun, @@ -50,6 +51,7 @@ "ExtendCurrency", "ExtendDate", "ExtendSignature", + "ExtractOutputValidationError", "SchemaConversionError", "TypedExtractOutput", "TypedExtractRun", diff --git a/src/extend_ai/wrapper/client.py b/src/extend_ai/wrapper/client.py index 1051da8..1f43473 100644 --- a/src/extend_ai/wrapper/client.py +++ b/src/extend_ai/wrapper/client.py @@ -280,7 +280,8 @@ class Invoice(BaseModel): file={"url": "https://example.com/invoice.pdf"}, config={"schema": Invoice}, ) - print(result.output.value.invoice_number) # typed! + if result.output is not None: + print(result.output.value.invoice_number) # typed! """ converted_extractor, converted_config, schema_model = _convert_extract_request(extractor, config) result = super().extract( diff --git a/src/extend_ai/wrapper/resources/extract_runs.py b/src/extend_ai/wrapper/resources/extract_runs.py index 0405ab8..932794e 100644 --- a/src/extend_ai/wrapper/resources/extract_runs.py +++ b/src/extend_ai/wrapper/resources/extract_runs.py @@ -26,12 +26,14 @@ class Invoice(BaseModel): file={"id": "file_xxx"}, config={"schema": Invoice}, ) - print(result.output.value.invoice_number) # typed! + if result.output is not None: + print(result.output.value.invoice_number) # typed! """ import typing from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions from ...extract_runs.client import AsyncExtractRunsClient as GeneratedAsyncExtractRunsClient from ...extract_runs.client import ExtractRunsClient as GeneratedExtractRunsClient from ...extract_runs.requests.extract_runs_create_request_extractor import ExtractRunsCreateRequestExtractorParams @@ -58,6 +60,18 @@ class Invoice(BaseModel): __all__ = ["ExtractRunsClient", "AsyncExtractRunsClient", "PollingTimeoutError"] +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +def _convert_create_args(extractor: typing.Any, config: typing.Any) -> typing.Tuple[typing.Any, typing.Any]: + """Convert a pydantic model schema in create() arguments, passing other values through.""" + if get_schema_model(config) is not None: + config = convert_typed_config(config) + if get_extractor_schema_model(extractor) is not None: + extractor = convert_typed_extractor(extractor) + return extractor, config + def _is_terminal_status(status: str) -> bool: """ @@ -124,6 +138,39 @@ class ExtractRunsClient(GeneratedExtractRunsClient): def __init__(self, *, client_wrapper: SyncClientWrapper): super().__init__(client_wrapper=client_wrapper) + def create( + self, + *, + extractor: typing.Optional[ + typing.Union[ExtractRunsCreateRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = OMIT, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = OMIT, + file: typing.Optional[ExtractRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + priority: typing.Optional[RunPriority] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractRun: + """ + Create an extract run. See the generated client for full documentation. + + `config["schema"]` (or `extractor["override_config"]["schema"]`) may be a + pydantic model class; it is converted to Extend's JSON Schema format before + the request is sent. Note that `create()` returns immediately without + output — for validated, typed output use `create_and_poll()` or + `client.extract()` instead. + """ + converted_extractor, converted_config = _convert_create_args(extractor, config) + return super().create( + extractor=converted_extractor, + config=converted_config, + file=file, + package=package, + priority=priority, + metadata=metadata, + request_options=request_options, + ) + @typing.overload def create_and_poll( self, @@ -240,6 +287,40 @@ class AsyncExtractRunsClient(GeneratedAsyncExtractRunsClient): def __init__(self, *, client_wrapper: AsyncClientWrapper): super().__init__(client_wrapper=client_wrapper) + async def create( + self, + *, + extractor: typing.Optional[ + typing.Union[ExtractRunsCreateRequestExtractorParams, TypedExtractorParams[ModelT]] + ] = OMIT, + config: typing.Optional[typing.Union[ExtractConfigJsonParams, TypedExtractConfigParams[ModelT]]] = OMIT, + file: typing.Optional[ExtractRunsCreateRequestFileParams] = OMIT, + package: typing.Optional[MultiFileRunPackageParams] = OMIT, + priority: typing.Optional[RunPriority] = OMIT, + metadata: typing.Optional[RunMetadata] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExtractRun: + """ + Create an extract run (async version). See the generated client for full + documentation. + + `config["schema"]` (or `extractor["override_config"]["schema"]`) may be a + pydantic model class; it is converted to Extend's JSON Schema format before + the request is sent. Note that `create()` returns immediately without + output — for validated, typed output use `create_and_poll()` or + `client.extract()` instead. + """ + converted_extractor, converted_config = _convert_create_args(extractor, config) + return await super().create( + extractor=converted_extractor, + config=converted_config, + file=file, + package=package, + priority=priority, + metadata=metadata, + request_options=request_options, + ) + @typing.overload async def create_and_poll( self, diff --git a/src/extend_ai/wrapper/schema/__init__.py b/src/extend_ai/wrapper/schema/__init__.py index a83be7b..21c9199 100644 --- a/src/extend_ai/wrapper/schema/__init__.py +++ b/src/extend_ai/wrapper/schema/__init__.py @@ -29,8 +29,9 @@ class Invoice(BaseModel): ) # output.value is a validated Invoice instance - print(result.output.value.invoice_number) - print(result.output.value.total.amount) + if result.output is not None: + print(result.output.value.invoice_number) + print(result.output.value.total.amount if result.output.value.total else None) """ from .config_conversion import ( @@ -43,7 +44,7 @@ class Invoice(BaseModel): ) from .conversion import SchemaConversionError, pydantic_to_extend_schema from .custom_types import ExtendCurrency, ExtendDate, ExtendSignature -from .typed_run import TypedExtractOutput, TypedExtractRun, parse_extract_run +from .typed_run import ExtractOutputValidationError, TypedExtractOutput, TypedExtractRun, parse_extract_run __all__ = [ # Custom field types @@ -53,6 +54,8 @@ class Invoice(BaseModel): # Conversion "SchemaConversionError", "pydantic_to_extend_schema", + # Errors + "ExtractOutputValidationError", # Typed configs (for annotations / advanced usage) "TypedExtractConfigParams", "TypedExtractorParams", diff --git a/src/extend_ai/wrapper/schema/conversion.py b/src/extend_ai/wrapper/schema/conversion.py index 495ac3e..cc2c4a3 100644 --- a/src/extend_ai/wrapper/schema/conversion.py +++ b/src/extend_ai/wrapper/schema/conversion.py @@ -1,9 +1,15 @@ """ Converts pydantic models to Extend's JSON Schema format. -Note: The API performs comprehensive validation and transformation of schemas. -This module focuses on structural conversion; complex validation (nesting -limits, property counts, property key format) is handled server-side. +The converter is strict: mistakes that would otherwise surface as a 400 from +the API, or worse as a validation failure after a completed extraction run, +are raised as SchemaConversionError before any request is sent. In +particular, fields whose emitted schema is nullable (primitives, enums, +dates) must be declared Optional, because extraction can return null for any +field and the output is validated back into the model. + +Structural limits (nesting depth, property counts, property key format) are +validated server-side. """ import datetime as dt @@ -20,6 +26,10 @@ _NoneType = type(None) +# typing.Literal and typing_extensions.Literal are distinct objects on some +# Python versions (e.g. 3.8), so origins must be checked against both. +_LITERAL_ORIGINS = {typing_extensions.Literal, getattr(typing, "Literal", typing_extensions.Literal)} + class SchemaConversionError(Exception): """Raised when a pydantic model cannot be converted to Extend JSON Schema.""" @@ -33,10 +43,10 @@ def __init__(self, message: str, path: typing.Optional[typing.List[str]] = None) def _iter_model_fields( model: typing.Type[pydantic.BaseModel], -) -> typing.Iterator[typing.Tuple[str, typing.Any, typing.Optional[str]]]: +) -> typing.Iterator[typing.Tuple[str, typing.Any, typing.Optional[str], typing.Any]]: """ - Yield (field_name, annotation, description) for each field of a pydantic - model, working under both pydantic v1 and v2. + Yield (field_name, annotation, description, alias) for each field of a + pydantic model, working under both pydantic v1 and v2. """ # Raw class annotations (via get_type_hints) preserve Optional wrappers, # which pydantic v1's `outer_type_` strips. @@ -47,11 +57,17 @@ def _iter_model_fields( if IS_PYDANTIC_V2: for name, field in model.model_fields.items(): # type: ignore[attr-defined] - yield name, hints.get(name, field.annotation), field.description + alias = field.alias or getattr(field, "validation_alias", None) + yield name, hints.get(name, field.annotation), field.description, alias else: for name, field in model.__fields__.items(): # type: ignore[attr-defined] - description = getattr(field.field_info, "description", None) # type: ignore[attr-defined] - yield name, hints.get(name, field.outer_type_), description # type: ignore[attr-defined] + info = field.field_info # type: ignore[attr-defined] + annotation = hints.get(name) + if annotation is None: + annotation = field.outer_type_ # type: ignore[attr-defined] + if field.allow_none: # type: ignore[attr-defined] + annotation = typing.Optional[annotation] + yield name, annotation, getattr(info, "description", None), getattr(info, "alias", None) def _is_union_origin(origin: typing.Any) -> bool: @@ -81,6 +97,32 @@ def _unwrap_annotation(annotation: typing.Any, path: typing.List[str]) -> typing return annotation +def _accepts_none(annotation: typing.Any) -> bool: + """Whether a value of None validates against the annotation.""" + origin = typing_extensions.get_origin(annotation) + if origin is typing_extensions.Annotated: + return _accepts_none(typing_extensions.get_args(annotation)[0]) + if _is_union_origin(origin): + return any(arg is _NoneType or _accepts_none(arg) for arg in typing_extensions.get_args(annotation)) + if origin in _LITERAL_ORIGINS: + return None in typing_extensions.get_args(annotation) + return annotation is _NoneType + + +def _require_nullable(annotation: typing.Any, kind: str, path: typing.List[str]) -> None: + """ + Fields whose emitted schema is nullable must accept None, otherwise + extraction output containing null would fail model validation after the + run has already completed. + """ + if not _accepts_none(annotation): + raise SchemaConversionError( + f"Field must be Optional: extraction can return null for any field, " + f"so declare it as Optional[{kind}]", + path, + ) + + def _with_description(schema: typing.Dict[str, typing.Any], description: typing.Optional[str]) -> typing.Dict[str, typing.Any]: if description: schema["description"] = description @@ -121,7 +163,7 @@ def _signature_schema() -> typing.Dict[str, typing.Any]: def _enum_values(annotation: typing.Any, path: typing.List[str]) -> typing.List[typing.Optional[str]]: """Extract string enum values from a Literal[...] or string Enum class.""" - if typing_extensions.get_origin(annotation) is typing_extensions.Literal: + if typing_extensions.get_origin(annotation) in _LITERAL_ORIGINS: raw_values: typing.List[typing.Any] = [v for v in typing_extensions.get_args(annotation) if v is not None] else: # enum.Enum subclass raw_values = [member.value for member in annotation] @@ -138,7 +180,7 @@ def _enum_values(annotation: typing.Any, path: typing.List[str]) -> typing.List[ def _is_enum_annotation(annotation: typing.Any) -> bool: - if typing_extensions.get_origin(annotation) is typing_extensions.Literal: + if typing_extensions.get_origin(annotation) in _LITERAL_ORIGINS: return True return isinstance(annotation, type) and issubclass(annotation, enum.Enum) @@ -147,9 +189,10 @@ def pydantic_to_extend_schema(model: typing.Type[pydantic.BaseModel]) -> typing. """ Convert a pydantic model class to Extend's JSON Schema format. - All primitive fields become nullable (per Extend's schema requirements), - every property is listed as required, and `Optional[...]` wrappers are - unwrapped. Field descriptions come from ``Field(description=...)``. + Every property is listed as required, and field descriptions come from + ``Field(description=...)``. Primitive, enum, and date fields must be + declared ``Optional`` — extraction can return ``null`` for any field, and + the emitted schema marks them nullable per Extend's schema requirements. Args: model: A ``pydantic.BaseModel`` subclass describing the data to extract. @@ -158,19 +201,40 @@ def pydantic_to_extend_schema(model: typing.Type[pydantic.BaseModel]) -> typing. The Extend JSON Schema as a plain dict. Raises: - SchemaConversionError: If the model uses unsupported types. + SchemaConversionError: If the model uses unsupported types, recursive + references, field aliases, or non-Optional nullable fields. """ if not (isinstance(model, type) and issubclass(model, pydantic.BaseModel)): raise SchemaConversionError(f"Schema must be a pydantic BaseModel subclass, got {model!r}") - return _convert_object(model, []) + return _convert_object(model, [], frozenset()) -def _convert_object(model: typing.Type[pydantic.BaseModel], path: typing.List[str]) -> typing.Dict[str, typing.Any]: +def _convert_object( + model: typing.Type[pydantic.BaseModel], + path: typing.List[str], + seen: typing.FrozenSet[type], +) -> typing.Dict[str, typing.Any]: + # Extend's schema format cannot express recursion, and recursive models + # would otherwise overflow the stack (fatally on some Python versions). + if model in seen: + raise SchemaConversionError( + f"Recursive model references are not supported: {model.__name__} refers back to itself", + path, + ) + seen = seen | {model} + properties: typing.Dict[str, typing.Any] = {} required: typing.List[str] = [] - for name, annotation, description in _iter_model_fields(model): - properties[name] = _convert_annotation(annotation, description, path + [name]) + for name, annotation, description, alias in _iter_model_fields(model): + if alias: + raise SchemaConversionError( + f"Field aliases are not supported for extraction schemas " + f"(field {name!r} has alias {alias!r}): the extraction output uses field names, " + f"so aliased fields would silently validate to None. Remove the alias.", + path + [name], + ) + properties[name] = _convert_annotation(annotation, description, path + [name], seen) required.append(name) return { @@ -182,7 +246,10 @@ def _convert_object(model: typing.Type[pydantic.BaseModel], path: typing.List[st def _convert_annotation( - annotation: typing.Any, description: typing.Optional[str], path: typing.List[str] + annotation: typing.Any, + description: typing.Optional[str], + path: typing.List[str], + seen: typing.FrozenSet[type], ) -> typing.Dict[str, typing.Any]: inner = _unwrap_annotation(annotation, path) @@ -190,9 +257,11 @@ def _convert_annotation( args = typing_extensions.get_args(inner) if not args: raise SchemaConversionError("Arrays must declare an item type (use List[...])", path) - return _with_description({"type": "array", "items": _convert_array_item(args[0], path)}, description) + return _with_description({"type": "array", "items": _convert_array_item(args[0], path, seen)}, description) if _is_enum_annotation(inner): + kind = inner.__name__ if isinstance(inner, type) else "Literal[...]" + _require_nullable(annotation, kind, path) return _with_description({"enum": _enum_values(inner, path)}, description) if isinstance(inner, type): @@ -202,32 +271,50 @@ def _convert_annotation( return _with_description(_currency_schema(), description) if extend_type == "signature": return _with_description(_signature_schema(), description) - return _with_description(_convert_object(inner, path), description) + return _with_description(_convert_object(inner, path, seen), description) if issubclass(inner, bool): + _require_nullable(annotation, "bool", path) return _with_description({"type": ["boolean", "null"]}, description) if issubclass(inner, int): + _require_nullable(annotation, "int", path) return _with_description({"type": ["integer", "null"]}, description) if issubclass(inner, float): + _require_nullable(annotation, "float", path) return _with_description({"type": ["number", "null"]}, description) if issubclass(inner, dt.datetime): raise SchemaConversionError( "datetime.datetime is not supported; use datetime.date (or ExtendDate) for date fields", path ) if issubclass(inner, dt.date): + _require_nullable(annotation, "datetime.date", path) return _with_description(_date_schema(), description) if issubclass(inner, str): + _require_nullable(annotation, "str", path) return _with_description({"type": ["string", "null"]}, description) raise SchemaConversionError(f"Unsupported type: {inner!r}", path) -def _convert_array_item(annotation: typing.Any, path: typing.List[str]) -> typing.Dict[str, typing.Any]: +def _convert_array_item( + annotation: typing.Any, + path: typing.List[str], + seen: typing.FrozenSet[type], +) -> typing.Dict[str, typing.Any]: """ Convert array item types, which have different rules than top-level types: items can be objects or primitives, and primitive items are NOT nullable. """ inner = _unwrap_annotation(annotation, path) + # Array items are never null in extraction output, so an Optional item + # annotation would misleadingly suggest otherwise. + if _accepts_none(annotation) and not (isinstance(inner, type) and issubclass(inner, pydantic.BaseModel)): + raise SchemaConversionError( + "Array items must not be Optional: extraction never returns null array items " + "(use e.g. List[str] instead of List[Optional[str]])", + path, + ) + if _is_enum_annotation(inner): raise SchemaConversionError( "Enums are not supported as array items. " @@ -241,7 +328,7 @@ def _convert_array_item(annotation: typing.Any, path: typing.List[str]) -> typin return _currency_schema() if extend_type == "signature": return _signature_schema() - return _convert_object(inner, path) + return _convert_object(inner, path, seen) if isinstance(inner, type): if issubclass(inner, bool): diff --git a/src/extend_ai/wrapper/schema/typed_run.py b/src/extend_ai/wrapper/schema/typed_run.py index 006ec35..e6fec4b 100644 --- a/src/extend_ai/wrapper/schema/typed_run.py +++ b/src/extend_ai/wrapper/schema/typed_run.py @@ -11,6 +11,7 @@ import pydantic from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...types.created_at import CreatedAt from ...types.extract_config import ExtractConfig from ...types.extract_output import ExtractOutput from ...types.extract_output_edits import ExtractOutputEdits @@ -22,12 +23,34 @@ from ...types.processor_run_status import ProcessorRunStatus from ...types.run_metadata import RunMetadata from ...types.run_usage import RunUsage +from ...types.updated_at import UpdatedAt -__all__ = ["ModelT", "TypedExtractOutput", "TypedExtractRun", "parse_extract_run"] +__all__ = [ + "ExtractOutputValidationError", + "ModelT", + "TypedExtractOutput", + "TypedExtractRun", + "parse_extract_run", +] ModelT = typing.TypeVar("ModelT", bound=pydantic.BaseModel) +class ExtractOutputValidationError(Exception): + """ + Raised when a completed extract run's output does not validate against the + pydantic model that was used as the extraction schema. + + The run itself completed successfully — only the client-side validation + failed — so the full run is preserved on the ``run`` attribute (including + ``run.id``, ``run.dashboard_url``, and the raw ``run.output``). + """ + + def __init__(self, message: str, run: ExtractRun): + self.run = run + super().__init__(message) + + def _validate_model(model: typing.Type[ModelT], value: typing.Any) -> ModelT: if IS_PYDANTIC_V2: return typing.cast(ModelT, model.model_validate(value)) # type: ignore[attr-defined] @@ -38,9 +61,9 @@ class TypedExtractOutput(typing.Generic[ModelT]): """Extract output whose value is a validated pydantic model instance.""" value: ModelT - metadata: ExtractOutputMetadata + metadata: typing.Optional[ExtractOutputMetadata] - def __init__(self, *, value: ModelT, metadata: ExtractOutputMetadata) -> None: + def __init__(self, *, value: ModelT, metadata: typing.Optional[ExtractOutputMetadata]) -> None: self.value = value self.metadata = metadata @@ -77,8 +100,8 @@ class TypedExtractRun(typing.Generic[ModelT]): parse_run_id: typing.Optional[str] dashboard_url: str usage: typing.Optional[RunUsage] - created_at: typing.Any - updated_at: typing.Any + created_at: CreatedAt + updated_at: UpdatedAt raw: ExtractRun """The original, untyped extract run response.""" @@ -87,9 +110,9 @@ def __init__(self, run: ExtractRun, model: typing.Type[ModelT]) -> None: self.object = run.object self.id = run.id self.status = run.status - self.output = _parse_output(run.output, model) - self.initial_output = _parse_output(run.initial_output, model) - self.reviewed_output = _parse_output(run.reviewed_output, model) + self.output = _parse_output(run.output, model, run) + self.initial_output = _parse_output(run.initial_output, model, run) + self.reviewed_output = _parse_output(run.reviewed_output, model, run) self.failure_reason = run.failure_reason self.failure_message = run.failure_message self.metadata = run.metadata @@ -112,19 +135,31 @@ def __repr__(self) -> str: def _parse_output( - output: typing.Optional[ExtractOutput], model: typing.Type[ModelT] + output: typing.Optional[ExtractOutput], model: typing.Type[ModelT], run: ExtractRun ) -> typing.Optional[TypedExtractOutput[ModelT]]: if output is None: return None value = getattr(output, "value", None) if value is None: - raise ValueError( - "Extract run output has no 'value' field; typed schemas are only supported " - "for runs created with a JSON Schema config." + raise ExtractOutputValidationError( + f"Extract run {getattr(run, 'id', None)!r} has no 'value' on its output; typed schemas are " + "only supported for runs created with a JSON Schema config. " + "The full run is available on this error's `run` attribute.", + run=run, ) + try: + validated = _validate_model(model, value) + except pydantic.ValidationError as exc: + raise ExtractOutputValidationError( + f"Output of extract run {getattr(run, 'id', None)!r} did not validate against " + f"{model.__name__}: {exc}\n" + "The run completed successfully; the full run (including its raw output) is " + "available on this error's `run` attribute.", + run=run, + ) from exc return TypedExtractOutput( - value=_validate_model(model, value), - metadata=typing.cast(ExtractOutputMetadata, getattr(output, "metadata", None)), + value=validated, + metadata=getattr(output, "metadata", None), ) @@ -140,8 +175,7 @@ def parse_extract_run(run: ExtractRun, model: typing.Type[ModelT]) -> TypedExtra A :class:`TypedExtractRun` whose output values are instances of ``model``. Raises: - pydantic.ValidationError: If an output value does not conform to the model. - Extraction can return ``null`` for any field, so model fields should - be declared ``Optional``. + ExtractOutputValidationError: If an output value does not conform to the + model. The completed run is preserved on the error's ``run`` attribute. """ return TypedExtractRun(run, model) diff --git a/tests/wrapper/test_schema_conversion.py b/tests/wrapper/test_schema_conversion.py index 8e0f318..6fc42e5 100644 --- a/tests/wrapper/test_schema_conversion.py +++ b/tests/wrapper/test_schema_conversion.py @@ -2,6 +2,7 @@ import datetime as dt import enum +import typing from typing import Dict, List, Optional, Union import pydantic @@ -97,14 +98,32 @@ class Schema(pydantic.BaseModel): assert pydantic_to_extend_schema(Schema)["properties"]["field"] == {"type": ["boolean", "null"]} - def test_non_optional_primitives_are_forced_nullable(self): + def test_non_optional_primitives_are_rejected(self): class Schema(pydantic.BaseModel): name: str - count: int - properties = pydantic_to_extend_schema(Schema)["properties"] - assert properties["name"] == {"type": ["string", "null"]} - assert properties["count"] == {"type": ["integer", "null"]} + with pytest.raises(SchemaConversionError, match=r"Optional\[str\].*name"): + pydantic_to_extend_schema(Schema) + + @pytest.mark.parametrize( + "annotation,expected_hint", + [(str, "str"), (int, "int"), (float, "float"), (bool, "bool"), (dt.date, "datetime.date")], + ids=["str", "int", "float", "bool", "date"], + ) + def test_each_non_optional_nullable_kind_is_rejected(self, annotation, expected_hint): + Schema = pydantic.create_model("Schema", field=(annotation, ...)) + + with pytest.raises(SchemaConversionError, match=f"Optional\\[{expected_hint}\\]"): + pydantic_to_extend_schema(Schema) + + def test_non_optional_primitive_with_default_is_still_rejected(self): + # A default only covers a *missing* field; extraction returns explicit + # nulls, which the model would still reject. + class Schema(pydantic.BaseModel): + name: str = "unknown" + + with pytest.raises(SchemaConversionError): + pydantic_to_extend_schema(Schema) def test_includes_descriptions(self): class Schema(pydantic.BaseModel): @@ -172,6 +191,37 @@ class Schema(pydantic.BaseModel): with pytest.raises(SchemaConversionError): pydantic_to_extend_schema(Schema) + def test_typing_literal_is_recognized(self): + # typing.Literal and typing_extensions.Literal are distinct objects on + # some Python versions; both must be treated as enums. + class Schema(pydantic.BaseModel): + status: Optional[typing.Literal["active", "inactive"]] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == {"enum": ["active", "inactive", None]} + + def test_literal_including_none_is_nullable_without_optional(self): + class Schema(pydantic.BaseModel): + status: Literal["active", "inactive", None] = None + + assert pydantic_to_extend_schema(Schema)["properties"]["status"] == {"enum": ["active", "inactive", None]} + + def test_rejects_non_optional_literal(self): + class Schema(pydantic.BaseModel): + status: Literal["active", "inactive"] + + with pytest.raises(SchemaConversionError, match="Optional"): + pydantic_to_extend_schema(Schema) + + def test_rejects_non_optional_string_enum(self): + class Status(str, enum.Enum): + ACTIVE = "active" + + class Schema(pydantic.BaseModel): + status: Status + + with pytest.raises(SchemaConversionError, match="Optional"): + pydantic_to_extend_schema(Schema) + class TestArrayTypes: def test_converts_array_of_objects(self): @@ -473,6 +523,74 @@ class Schema(pydantic.BaseModel): with pytest.raises(SchemaConversionError): pydantic_to_extend_schema(Schema) + def test_rejects_optional_array_items(self): + class Schema(pydantic.BaseModel): + tags: List[Optional[str]] = [] + + with pytest.raises(SchemaConversionError, match="Array items must not be Optional"): + pydantic_to_extend_schema(Schema) + + +class TestRecursiveModels: + def test_rejects_directly_recursive_model(self): + class Node(pydantic.BaseModel): + name: Optional[str] = None + children: List["Node"] = [] + + if hasattr(Node, "model_rebuild"): + Node.model_rebuild() + else: + Node.update_forward_refs(Node=Node) + + with pytest.raises(SchemaConversionError, match="Recursive"): + pydantic_to_extend_schema(Node) + + def test_rejects_mutually_recursive_models(self): + class A(pydantic.BaseModel): + b: Optional["B"] = None + + class B(pydantic.BaseModel): + a: Optional[A] = None + + if hasattr(A, "model_rebuild"): + A.model_rebuild() + else: + A.update_forward_refs(B=B) + + with pytest.raises(SchemaConversionError, match="Recursive"): + pydantic_to_extend_schema(A) + + def test_allows_same_model_in_sibling_fields(self): + class Address(pydantic.BaseModel): + street: Optional[str] = None + + class Schema(pydantic.BaseModel): + billing_address: Optional[Address] = None + shipping_address: Optional[Address] = None + + json_schema = pydantic_to_extend_schema(Schema) + assert json_schema["properties"]["billing_address"] == json_schema["properties"]["shipping_address"] + + +class TestFieldAliases: + def test_rejects_aliased_fields(self): + class Schema(pydantic.BaseModel): + invoice_number: Optional[str] = pydantic.Field(None, alias="invoiceNumber") + + with pytest.raises(SchemaConversionError, match="alias"): + pydantic_to_extend_schema(Schema) + + def test_rejects_aliased_fields_in_nested_models(self): + class Inner(pydantic.BaseModel): + value: Optional[str] = pydantic.Field(None, alias="theValue") + + class Schema(pydantic.BaseModel): + inner: Optional[Inner] = None + + with pytest.raises(SchemaConversionError) as exc_info: + pydantic_to_extend_schema(Schema) + assert exc_info.value.path == ["inner", "value"] + class TestComplexSchemas: def test_converts_realistic_invoice_schema(self): diff --git a/tests/wrapper/test_signature_parity.py b/tests/wrapper/test_signature_parity.py index 84ba374..1de6c6b 100644 --- a/tests/wrapper/test_signature_parity.py +++ b/tests/wrapper/test_signature_parity.py @@ -17,6 +17,7 @@ import inspect import typing +from unittest.mock import AsyncMock, MagicMock import pytest @@ -70,6 +71,55 @@ def test_create_and_poll_accepts_all_create_params(wrapper_client): ) +SYNC_RUN_CLIENTS = [cls for cls in RUN_CLIENTS if not cls.__name__.startswith("Async")] +ASYNC_RUN_CLIENTS = [cls for cls in RUN_CLIENTS if cls.__name__.startswith("Async")] + + +def _create_sentinel_kwargs(wrapper_client) -> typing.Dict[str, object]: + """One unique sentinel per generated create() parameter.""" + generated_client = wrapper_client.__mro__[1] + return {name: object() for name in _param_names(generated_client.create) - {"request_options"}} + + +def _bind_create_and_poll(wrapper_client, mock_cls): + client = MagicMock(spec=wrapper_client) + client.create = mock_cls() + client.retrieve = mock_cls() + client.retrieve.return_value = MagicMock(status="PROCESSED") + client.create_and_poll = wrapper_client.create_and_poll.__get__(client, wrapper_client) + return client + + +def _assert_all_forwarded(client, sentinels: typing.Dict[str, object], wrapper_client) -> None: + create_kwargs = client.create.call_args.kwargs + not_forwarded = [name for name, sentinel in sentinels.items() if create_kwargs.get(name) is not sentinel] + assert not not_forwarded, ( + f"{wrapper_client.__name__}.create_and_poll() accepted these parameters but did not " + f"forward them to create(): {sorted(not_forwarded)}" + ) + + +@pytest.mark.parametrize("wrapper_client", SYNC_RUN_CLIENTS, ids=lambda cls: cls.__name__) +def test_create_and_poll_forwards_all_create_params(wrapper_client): + """Accepting a parameter is not enough — it must reach create().""" + client = _bind_create_and_poll(wrapper_client, MagicMock) + sentinels = _create_sentinel_kwargs(wrapper_client) + + client.create_and_poll(**sentinels) + + _assert_all_forwarded(client, sentinels, wrapper_client) + + +@pytest.mark.parametrize("wrapper_client", ASYNC_RUN_CLIENTS, ids=lambda cls: cls.__name__) +async def test_async_create_and_poll_forwards_all_create_params(wrapper_client): + client = _bind_create_and_poll(wrapper_client, AsyncMock) + sentinels = _create_sentinel_kwargs(wrapper_client) + + await client.create_and_poll(**sentinels) + + _assert_all_forwarded(client, sentinels, wrapper_client) + + def test_typed_extract_config_matches_generated_config_keys(): from extend_ai.requests.extract_config_json import ExtractConfigJsonParams from extend_ai.wrapper.schema import TypedExtractConfigParams diff --git a/tests/wrapper/test_typed_extraction.py b/tests/wrapper/test_typed_extraction.py index 3df73b0..22c33a0 100644 --- a/tests/wrapper/test_typed_extraction.py +++ b/tests/wrapper/test_typed_extraction.py @@ -10,6 +10,7 @@ from extend_ai.wrapper.schema import ( ExtendCurrency, ExtendDate, + ExtractOutputValidationError, TypedExtractOutput, TypedExtractRun, parse_extract_run, @@ -106,8 +107,21 @@ def test_raises_for_output_without_value(self): run = create_mock_run(value=INVOICE_OUTPUT_VALUE) run.output = MagicMock(spec=[]) # legacy output shape: no `value` attribute - with pytest.raises(ValueError): + with pytest.raises(ExtractOutputValidationError) as exc_info: parse_extract_run(run, Invoice) + assert exc_info.value.run is run + + def test_validation_failure_preserves_completed_run_on_error(self): + run = create_mock_run(value={"invoice_number": "INV-1", "line_items": "not-a-list"}) + + with pytest.raises(ExtractOutputValidationError) as exc_info: + parse_extract_run(run, Invoice) + + error = exc_info.value + assert error.run is run + assert error.run.output.value["line_items"] == "not-a-list" + assert "Invoice" in str(error) + assert isinstance(error.__cause__, pydantic.ValidationError) # ============================================================================ @@ -210,6 +224,42 @@ async def test_converts_config_schema_and_returns_typed_run(self): assert result.output.value.invoice_number == "INV-123" +# ============================================================================ +# ExtractRunsClient.create (plain, non-polling) with typed schemas +# ============================================================================ + + +class TestPlainCreateTypedSchema: + def setup_method(self): + from extend_ai.wrapper.resources.extract_runs import ExtractRunsClient + + self.client = ExtractRunsClient(client_wrapper=MagicMock()) + self.raw_client = MagicMock() + self.client._raw_client = self.raw_client + + def test_create_converts_model_schema(self): + self.client.create(file={"id": "file_1"}, config={"schema": Invoice}) + + sent_config = self.raw_client.create.call_args.kwargs["config"] + assert isinstance(sent_config["schema"], dict) + assert sent_config["schema"]["type"] == "object" + + def test_create_converts_extractor_override_config_schema(self): + self.client.create( + file={"id": "file_1"}, + extractor={"id": "extractor_abc", "override_config": {"schema": Invoice}}, + ) + + sent_extractor = self.raw_client.create.call_args.kwargs["extractor"] + assert isinstance(sent_extractor["override_config"]["schema"], dict) + + def test_create_passes_through_json_schema(self): + json_config = {"schema": {"type": "object", "properties": {}}} + self.client.create(file={"id": "file_1"}, config=json_config) + + assert self.raw_client.create.call_args.kwargs["config"] is json_config + + # ============================================================================ # Extend.extract with typed schemas # ============================================================================ diff --git a/uv.lock b/uv.lock index ce265ca..5bdf938 100644 --- a/uv.lock +++ b/uv.lock @@ -93,7 +93,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.21.2" }, { name = "pydantic", specifier = ">=1.9.2" }, { name = "pydantic-core", specifier = ">=2.18.2" }, - { name = "typing-extensions", specifier = ">=4.0.0" }, + { name = "typing-extensions", specifier = ">=4.3.0" }, ] [[package]] From 30a508d37119f0e94f48e05ef9afe299888b7f9f Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Wed, 29 Jul 2026 17:05:08 -0400 Subject: [PATCH 7/7] Forward workflow_runs package param through create_and_poll The 1.17.0 regeneration added package (multifile) support to workflow_runs.create(); create_and_poll now accepts and forwards it, matching the file/package mutual-exclusivity pattern used elsewhere. --- .../wrapper/resources/workflow_runs.py | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/extend_ai/wrapper/resources/workflow_runs.py b/src/extend_ai/wrapper/resources/workflow_runs.py index 0b2f9ba..45beee4 100644 --- a/src/extend_ai/wrapper/resources/workflow_runs.py +++ b/src/extend_ai/wrapper/resources/workflow_runs.py @@ -20,6 +20,7 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...requests.workflow_reference import WorkflowReferenceParams +from ...requests.workflow_run_package import WorkflowRunPackageParams from ...types.run_metadata import RunMetadata from ...types.run_priority import RunPriority from ...types.run_secrets import RunSecrets @@ -63,7 +64,8 @@ def create_and_poll( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: Optional[WorkflowRunsCreateRequestFileParams] = None, + package: Optional[WorkflowRunPackageParams] = None, outputs: Optional[Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = None, priority: Optional[RunPriority] = None, metadata: Optional[RunMetadata] = None, @@ -80,7 +82,10 @@ def create_and_poll( Args: workflow: Reference to the workflow to run. - file: The file to process. + file: The file to process. Mutually exclusive with `package` — + provide one or the other. + package: A package of files to process together. Mutually exclusive + with `file` — provide one or the other. outputs: Optional list of output configurations. priority: Priority of the run. metadata: Additional metadata for the run. @@ -108,7 +113,11 @@ def create_and_poll( print("Failed:", result.failure_message) """ # Build kwargs, only including non-None values to avoid passing null - kwargs: Dict[str, Any] = {"workflow": workflow, "file": file} + kwargs: Dict[str, Any] = {"workflow": workflow} + if file is not None: + kwargs["file"] = file + if package is not None: + kwargs["package"] = package if outputs is not None: kwargs["outputs"] = outputs if priority is not None: @@ -142,7 +151,8 @@ async def create_and_poll( self, *, workflow: WorkflowReferenceParams, - file: WorkflowRunsCreateRequestFileParams, + file: Optional[WorkflowRunsCreateRequestFileParams] = None, + package: Optional[WorkflowRunPackageParams] = None, outputs: Optional[Sequence[WorkflowRunsCreateRequestOutputsItemParams]] = None, priority: Optional[RunPriority] = None, metadata: Optional[RunMetadata] = None, @@ -151,9 +161,15 @@ async def create_and_poll( ) -> WorkflowRun: """ Creates a workflow run and polls until it reaches a terminal state (async version). + + `file` and `package` are mutually exclusive — provide one or the other. """ # Build kwargs, only including non-None values to avoid passing null - kwargs: Dict[str, Any] = {"workflow": workflow, "file": file} + kwargs: Dict[str, Any] = {"workflow": workflow} + if file is not None: + kwargs["file"] = file + if package is not None: + kwargs["package"] = package if outputs is not None: kwargs["outputs"] = outputs if priority is not None: