Skip to content

Commit 2e93e75

Browse files
committed
feat: Implement BuildAPIWrapper for fluent workflow building
- Complete Builder API with fluent interface for chaining operations - Support multiple document processing steps in a single API call - Map tool names to Build API action types - Add output options configuration (metadata, optimization) - Include comprehensive unit tests for all builder functionality - Support both in-memory and file output options
1 parent a8dac54 commit 2e93e75

2 files changed

Lines changed: 403 additions & 8 deletions

File tree

src/nutrient/builder.py

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,72 @@
11
"""Builder API implementation for multi-step workflows."""
22

3-
from typing import Any, Dict, Optional
3+
import json
4+
from typing import Any, Dict, List, Optional, Union
5+
6+
from nutrient.file_handler import FileInput, prepare_file_for_upload, save_file_output
47

58

69
class BuildAPIWrapper:
7-
"""Builder pattern implementation for chaining document operations."""
10+
"""Builder pattern implementation for chaining document operations.
11+
12+
This class provides a fluent interface for building complex document
13+
processing workflows using the Nutrient Build API.
14+
15+
Example:
16+
>>> client.build(input_file="document.pdf") \\
17+
... .add_step(tool="rotate-pages", options={"degrees": 90}) \\
18+
... .add_step(tool="ocr-pdf", options={"language": "en"}) \\
19+
... .add_step(tool="watermark-pdf", options={"text": "CONFIDENTIAL"}) \\
20+
... .execute(output_path="processed.pdf")
21+
"""
822

9-
def __init__(self, client, input_file) -> None:
10-
"""Initialize builder with client and input file."""
23+
def __init__(self, client, input_file: FileInput) -> None:
24+
"""Initialize builder with client and input file.
25+
26+
Args:
27+
client: NutrientClient instance.
28+
input_file: Input file to process.
29+
"""
1130
self._client = client
1231
self._input_file = input_file
13-
self._steps: list[Dict[str, Any]] = []
32+
self._parts: List[Dict[str, Any]] = []
33+
self._actions: List[Dict[str, Any]] = []
34+
self._output_options: Dict[str, Any] = {}
1435

1536
def add_step(self, tool: str, options: Optional[Dict[str, Any]] = None) -> "BuildAPIWrapper":
1637
"""Add a processing step to the workflow.
1738
1839
Args:
19-
tool: Tool identifier from the API.
40+
tool: Tool identifier (e.g., 'rotate-pages', 'ocr-pdf').
2041
options: Optional parameters for the tool.
2142
2243
Returns:
2344
Self for method chaining.
45+
46+
Example:
47+
>>> builder.add_step(tool="rotate-pages", options={"degrees": 180})
2448
"""
25-
raise NotImplementedError("Builder API not yet implemented")
49+
action = self._map_tool_to_action(tool, options or {})
50+
self._actions.append(action)
51+
return self
52+
53+
def set_output_options(self, **options: Any) -> "BuildAPIWrapper":
54+
"""Set output options for the final document.
55+
56+
Args:
57+
**options: Output options (e.g., metadata, optimization).
58+
59+
Returns:
60+
Self for method chaining.
61+
62+
Example:
63+
>>> builder.set_output_options(
64+
... metadata={"title": "My Document", "author": "John Doe"},
65+
... optimize=True
66+
... )
67+
"""
68+
self._output_options.update(options)
69+
return self
2670

2771
def execute(self, output_path: Optional[str] = None) -> Optional[bytes]:
2872
"""Execute the workflow.
@@ -32,5 +76,115 @@ def execute(self, output_path: Optional[str] = None) -> Optional[bytes]:
3276
3377
Returns:
3478
Processed file bytes, or None if output_path is provided.
79+
80+
Raises:
81+
AuthenticationError: If API key is missing or invalid.
82+
APIError: For other API errors.
3583
"""
36-
raise NotImplementedError("Builder API not yet implemented")
84+
# Prepare the build instructions
85+
instructions = self._build_instructions()
86+
87+
# Prepare file for upload
88+
file_field, file_data = prepare_file_for_upload(self._input_file)
89+
files = {file_field: file_data}
90+
91+
# Make API request
92+
result = self._client._http_client.post(
93+
"/build",
94+
files=files,
95+
json_data=instructions,
96+
)
97+
98+
# Handle output
99+
if output_path:
100+
save_file_output(result, output_path)
101+
return None
102+
else:
103+
return result
104+
105+
def _build_instructions(self) -> Dict[str, Any]:
106+
"""Build the instructions payload for the API.
107+
108+
Returns:
109+
Instructions dictionary for the Build API.
110+
"""
111+
# Add the input file as the first part
112+
instructions = {
113+
"parts": [
114+
{"file": "file"} # Reference to the uploaded file
115+
],
116+
"actions": self._actions,
117+
}
118+
119+
# Add output options if specified
120+
if self._output_options:
121+
instructions["output"] = self._output_options
122+
123+
return instructions
124+
125+
def _map_tool_to_action(self, tool: str, options: Dict[str, Any]) -> Dict[str, Any]:
126+
"""Map tool name and options to Build API action format.
127+
128+
Args:
129+
tool: Tool identifier.
130+
options: Tool options.
131+
132+
Returns:
133+
Action dictionary for the Build API.
134+
"""
135+
# Map tool names to action types
136+
tool_mapping = {
137+
"rotate-pages": "rotate",
138+
"ocr-pdf": "ocr",
139+
"watermark-pdf": "watermark",
140+
"flatten-annotations": "flatten",
141+
"apply-instant-json": "applyInstantJson",
142+
"apply-xfdf": "applyXfdf",
143+
"create-redactions": "createRedactions",
144+
"apply-redactions": "applyRedactions",
145+
}
146+
147+
action_type = tool_mapping.get(tool, tool)
148+
149+
# Build action dictionary
150+
action = {"type": action_type}
151+
152+
# Handle special cases for different action types
153+
if action_type == "rotate":
154+
action["rotateBy"] = options.get("degrees", 0)
155+
if "page_indexes" in options:
156+
action["pageIndexes"] = options["page_indexes"]
157+
158+
elif action_type == "ocr":
159+
if "language" in options:
160+
action["language"] = options["language"]
161+
162+
elif action_type == "watermark":
163+
if "text" in options:
164+
action["text"] = options["text"]
165+
if "image_url" in options:
166+
action["image"] = {"url": options["image_url"]}
167+
if "opacity" in options:
168+
action["opacity"] = options["opacity"]
169+
if "position" in options:
170+
action["position"] = options["position"]
171+
172+
else:
173+
# For other actions, pass options directly
174+
action.update(options)
175+
176+
return action
177+
178+
def __str__(self) -> str:
179+
"""String representation of the build workflow."""
180+
steps = [f"{action['type']}" for action in self._actions]
181+
return f"BuildAPIWrapper(steps={steps})"
182+
183+
def __repr__(self) -> str:
184+
"""Detailed representation of the build workflow."""
185+
return (
186+
f"BuildAPIWrapper("
187+
f"input_file={self._input_file!r}, "
188+
f"actions={self._actions!r}, "
189+
f"output_options={self._output_options!r})"
190+
)

0 commit comments

Comments
 (0)