Skip to content

Commit a8dac54

Browse files
committed
feat: Implement NutrientClient with Direct API methods
- Complete NutrientClient with authentication and configuration - Add Direct API methods generated from common operations - Support both parameter and environment variable API key - Implement _process_file method for handling API requests - Add comprehensive unit tests for client functionality - Include context manager support for proper cleanup
1 parent e236553 commit a8dac54

4 files changed

Lines changed: 868 additions & 7 deletions

File tree

scripts/generate_api_methods.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
#!/usr/bin/env python3
2+
"""Generate Direct API methods from OpenAPI specification."""
3+
4+
import re
5+
from pathlib import Path
6+
from typing import Any, Dict, List, Optional, Set
7+
8+
import yaml
9+
10+
11+
def to_snake_case(name: str) -> str:
12+
"""Convert string to snake_case."""
13+
# Handle common patterns
14+
name = name.replace("-", "_")
15+
# Insert underscore before uppercase letters
16+
name = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
17+
return name
18+
19+
20+
def get_python_type(schema: Dict[str, Any]) -> str:
21+
"""Convert OpenAPI schema type to Python type hint."""
22+
if not schema:
23+
return "Any"
24+
25+
type_mapping = {
26+
"string": "str",
27+
"integer": "int",
28+
"number": "float",
29+
"boolean": "bool",
30+
"array": "List[Any]",
31+
"object": "Dict[str, Any]",
32+
}
33+
34+
schema_type = schema.get("type", "string")
35+
return type_mapping.get(schema_type, "Any")
36+
37+
38+
def create_manual_tools() -> List[Dict[str, Any]]:
39+
"""Create tool definitions based on the specification documentation.
40+
41+
Since the Nutrient API uses a build endpoint with actions rather than
42+
individual tool endpoints, we'll create convenience methods that wrap
43+
the build API.
44+
"""
45+
tools = [
46+
{
47+
"tool_name": "convert-to-pdf",
48+
"method_name": "convert_to_pdf",
49+
"summary": "Convert a document to PDF",
50+
"description": "Convert various document formats (DOCX, XLSX, PPTX, etc.) to PDF.",
51+
"parameters": {},
52+
},
53+
{
54+
"tool_name": "convert-to-pdfa",
55+
"method_name": "convert_to_pdfa",
56+
"summary": "Convert a document to PDF/A",
57+
"description": "Convert documents to PDF/A format for long-term archiving.",
58+
"parameters": {
59+
"conformance_level": {
60+
"type": "str",
61+
"required": False,
62+
"description": "PDF/A conformance level (e.g., '2b', '3b')",
63+
"default": "2b",
64+
},
65+
},
66+
},
67+
{
68+
"tool_name": "ocr-pdf",
69+
"method_name": "ocr_pdf",
70+
"summary": "Perform OCR on a PDF",
71+
"description": "Apply optical character recognition to make scanned PDFs searchable.",
72+
"parameters": {
73+
"language": {
74+
"type": "str",
75+
"required": False,
76+
"description": "OCR language code (e.g., 'en', 'de', 'fr')",
77+
"default": "en",
78+
},
79+
},
80+
},
81+
{
82+
"tool_name": "rotate-pages",
83+
"method_name": "rotate_pages",
84+
"summary": "Rotate PDF pages",
85+
"description": "Rotate pages in a PDF document.",
86+
"parameters": {
87+
"degrees": {
88+
"type": "int",
89+
"required": True,
90+
"description": "Rotation angle in degrees (90, 180, 270)",
91+
},
92+
"page_indexes": {
93+
"type": "List[int]",
94+
"required": False,
95+
"description": "List of page indexes to rotate (0-based). If not specified, all pages are rotated.",
96+
},
97+
},
98+
},
99+
{
100+
"tool_name": "flatten-annotations",
101+
"method_name": "flatten_annotations",
102+
"summary": "Flatten PDF annotations",
103+
"description": "Flatten annotations and form fields in a PDF.",
104+
"parameters": {},
105+
},
106+
{
107+
"tool_name": "watermark-pdf",
108+
"method_name": "watermark_pdf",
109+
"summary": "Add watermark to PDF",
110+
"description": "Add text or image watermark to PDF pages.",
111+
"parameters": {
112+
"text": {
113+
"type": "str",
114+
"required": False,
115+
"description": "Watermark text",
116+
},
117+
"image_url": {
118+
"type": "str",
119+
"required": False,
120+
"description": "URL of watermark image",
121+
},
122+
"opacity": {
123+
"type": "float",
124+
"required": False,
125+
"description": "Watermark opacity (0.0 to 1.0)",
126+
"default": 0.5,
127+
},
128+
"position": {
129+
"type": "str",
130+
"required": False,
131+
"description": "Watermark position",
132+
"default": "center",
133+
},
134+
},
135+
},
136+
{
137+
"tool_name": "sign-pdf",
138+
"method_name": "sign_pdf",
139+
"summary": "Digitally sign a PDF",
140+
"description": "Add a digital signature to a PDF document.",
141+
"parameters": {
142+
"certificate_file": {
143+
"type": "FileInput",
144+
"required": True,
145+
"description": "Digital certificate file (P12/PFX format)",
146+
},
147+
"certificate_password": {
148+
"type": "str",
149+
"required": True,
150+
"description": "Certificate password",
151+
},
152+
"reason": {
153+
"type": "str",
154+
"required": False,
155+
"description": "Reason for signing",
156+
},
157+
"location": {
158+
"type": "str",
159+
"required": False,
160+
"description": "Location of signing",
161+
},
162+
},
163+
},
164+
{
165+
"tool_name": "redact-pdf",
166+
"method_name": "redact_pdf",
167+
"summary": "Redact sensitive information from PDF",
168+
"description": "Use AI to automatically redact sensitive information from a PDF.",
169+
"parameters": {
170+
"types": {
171+
"type": "List[str]",
172+
"required": False,
173+
"description": "Types of information to redact (e.g., 'email', 'phone', 'ssn')",
174+
},
175+
},
176+
},
177+
{
178+
"tool_name": "export-pdf-to-office",
179+
"method_name": "export_pdf_to_office",
180+
"summary": "Export PDF to Office format",
181+
"description": "Convert PDF to Microsoft Office formats (DOCX, XLSX, PPTX).",
182+
"parameters": {
183+
"format": {
184+
"type": "str",
185+
"required": True,
186+
"description": "Output format ('docx', 'xlsx', 'pptx')",
187+
},
188+
},
189+
},
190+
{
191+
"tool_name": "export-pdf-to-images",
192+
"method_name": "export_pdf_to_images",
193+
"summary": "Export PDF pages as images",
194+
"description": "Convert PDF pages to image files.",
195+
"parameters": {
196+
"format": {
197+
"type": "str",
198+
"required": False,
199+
"description": "Image format ('png', 'jpeg', 'webp')",
200+
"default": "png",
201+
},
202+
"dpi": {
203+
"type": "int",
204+
"required": False,
205+
"description": "Image resolution in DPI",
206+
"default": 150,
207+
},
208+
"page_indexes": {
209+
"type": "List[int]",
210+
"required": False,
211+
"description": "List of page indexes to export (0-based)",
212+
},
213+
},
214+
},
215+
]
216+
217+
return tools
218+
219+
220+
def generate_method_code(tool_info: Dict[str, Any]) -> str:
221+
"""Generate Python method code for a tool."""
222+
method_name = tool_info["method_name"]
223+
tool_name = tool_info["tool_name"]
224+
summary = tool_info["summary"]
225+
description = tool_info["description"]
226+
parameters = tool_info["parameters"]
227+
228+
# Build parameter list
229+
param_list = ["self", "input_file: FileInput"]
230+
param_docs = []
231+
232+
# Add required parameters first
233+
for param_name, param_info in parameters.items():
234+
if param_info["required"]:
235+
param_type = param_info["type"]
236+
# Handle imports for complex types
237+
if param_type == "FileInput":
238+
param_type = "'FileInput'" # Forward reference
239+
param_list.append(f"{param_name}: {param_type}")
240+
param_docs.append(f" {param_name}: {param_info['description']}")
241+
242+
# Always add output_path
243+
param_list.append("output_path: Optional[str] = None")
244+
245+
# Add optional parameters
246+
for param_name, param_info in parameters.items():
247+
if not param_info["required"]:
248+
param_type = param_info["type"]
249+
# Handle List types
250+
if param_type.startswith("List["):
251+
base_type = param_type
252+
else:
253+
base_type = param_type
254+
255+
default = param_info.get("default")
256+
if default is None:
257+
param_list.append(f"{param_name}: Optional[{base_type}] = None")
258+
else:
259+
if isinstance(default, str):
260+
param_list.append(f'{param_name}: {base_type} = "{default}"')
261+
else:
262+
param_list.append(f"{param_name}: {base_type} = {default}")
263+
param_docs.append(f" {param_name}: {param_info['description']}")
264+
265+
# Build method signature
266+
if len(param_list) > 3: # Multiple parameters
267+
params_str = ",\n ".join(param_list)
268+
method_signature = f" def {method_name}(\n {params_str},\n ) -> Optional[bytes]:"
269+
else:
270+
params_str = ", ".join(param_list)
271+
method_signature = f" def {method_name}({params_str}) -> Optional[bytes]:"
272+
273+
# Build docstring
274+
docstring_lines = [f' """{summary}']
275+
if description and description != summary:
276+
docstring_lines.append("")
277+
docstring_lines.append(f" {description}")
278+
279+
docstring_lines.extend([
280+
"",
281+
" Args:",
282+
" input_file: Input file (path, bytes, or file-like object).",
283+
])
284+
285+
if param_docs:
286+
docstring_lines.extend(param_docs)
287+
288+
docstring_lines.extend([
289+
" output_path: Optional path to save the output file.",
290+
"",
291+
" Returns:",
292+
" Processed file as bytes, or None if output_path is provided.",
293+
"",
294+
" Raises:",
295+
" AuthenticationError: If API key is missing or invalid.",
296+
" APIError: For other API errors.",
297+
' """',
298+
])
299+
300+
# Build method body
301+
method_body = []
302+
303+
# Collect kwargs
304+
kwargs_params = [
305+
f"{name}={name}"
306+
for name in parameters.keys()
307+
]
308+
309+
if kwargs_params:
310+
kwargs_str = ", ".join(kwargs_params)
311+
method_body.append(f' return self._process_file("{tool_name}", input_file, output_path, {kwargs_str})')
312+
else:
313+
method_body.append(f' return self._process_file("{tool_name}", input_file, output_path)')
314+
315+
# Combine all parts
316+
return "\n".join([
317+
method_signature,
318+
"\n".join(docstring_lines),
319+
"\n".join(method_body),
320+
])
321+
322+
323+
def generate_api_methods(spec_path: Path, output_path: Path) -> None:
324+
"""Generate API methods from OpenAPI specification."""
325+
# For Nutrient API, we'll use manually defined tools since they use
326+
# a build endpoint with actions rather than individual endpoints
327+
tools = create_manual_tools()
328+
329+
# Sort tools by method name
330+
tools.sort(key=lambda t: t["method_name"])
331+
332+
# Generate code
333+
code_lines = [
334+
'"""Direct API methods for individual document processing tools.',
335+
'',
336+
'This file provides convenient methods that wrap the Nutrient Build API',
337+
'for common document processing operations.',
338+
'"""',
339+
'',
340+
'from typing import List, Optional',
341+
'',
342+
'from nutrient.file_handler import FileInput',
343+
'',
344+
'',
345+
'class DirectAPIMixin:',
346+
' """Mixin class containing Direct API methods.',
347+
' ',
348+
' These methods provide a simplified interface to common document',
349+
' processing operations. They internally use the Build API.',
350+
' """',
351+
'',
352+
]
353+
354+
# Add methods
355+
for tool in tools:
356+
code_lines.append(generate_method_code(tool))
357+
code_lines.append("") # Empty line between methods
358+
359+
# Write to file
360+
output_path.write_text("\n".join(code_lines))
361+
print(f"Generated {len(tools)} API methods in {output_path}")
362+
363+
364+
if __name__ == "__main__":
365+
spec_path = Path("openapi_spec.yml")
366+
output_path = Path("src/nutrient/api/direct.py")
367+
368+
generate_api_methods(spec_path, output_path)

0 commit comments

Comments
 (0)