pdfdancer-client-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdfdancer/client_v1.py ADDED
@@ -0,0 +1,675 @@
1
+ """
2
+ PDFDancer Python Client V1
3
+
4
+ A Python client that closely mirrors the Java Client class structure and functionality.
5
+ Provides session-based PDF manipulation operations with strict validation.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import List, Optional, Union, BinaryIO
11
+
12
+ import requests
13
+
14
+ from .exceptions import (
15
+ PdfDancerException,
16
+ FontNotFoundException,
17
+ HttpClientException,
18
+ SessionException,
19
+ ValidationException
20
+ )
21
+ from .models import (
22
+ ObjectRef, Position, ObjectType, Font, Image, Paragraph,
23
+ FindRequest, DeleteRequest, MoveRequest, AddRequest, ModifyRequest, ModifyTextRequest, ShapeType, PositionMode
24
+ )
25
+
26
+
27
+ class ClientV1:
28
+ """
29
+ REST API client for interacting with the PDFDancer PDF manipulation service.
30
+ This client provides a convenient Python interface for performing PDF operations
31
+ including session management, object searching, manipulation, and retrieval.
32
+ Handles authentication, session lifecycle, and HTTP communication transparently.
33
+
34
+ Mirrors the Java Client class functionality exactly.
35
+ """
36
+
37
+ def __init__(self, token: str, pdf_data: Union[bytes, Path, str, BinaryIO],
38
+ base_url: str = "http://localhost:8080", read_timeout: float = 30.0):
39
+ """
40
+ Creates a new client with PDF data.
41
+ This constructor initializes the client, uploads the PDF data to create
42
+ a new session, and prepares the client for PDF manipulation operations.
43
+
44
+ Args:
45
+ token: Authentication token for API access
46
+ pdf_data: PDF file data as bytes, Path, filename string, or file-like object
47
+ base_url: Base URL of the PDFDancer API server
48
+ read_timeout: Timeout in seconds for HTTP requests (default: 30.0)
49
+
50
+ Raises:
51
+ ValidationException: If token is empty or PDF data is invalid
52
+ SessionException: If session creation fails
53
+ HttpClientException: If HTTP communication fails
54
+ """
55
+ # Strict validation like Java client
56
+ if not token or not token.strip():
57
+ raise ValidationException("Authentication token cannot be null or empty")
58
+
59
+ self._token = token.strip()
60
+ self._base_url = base_url.rstrip('/')
61
+ self._read_timeout = read_timeout
62
+
63
+ # Process PDF data with validation
64
+ self._pdf_bytes = self._process_pdf_data(pdf_data)
65
+
66
+ # Create HTTP session for connection reuse
67
+ self._session = requests.Session()
68
+ self._session.headers.update({
69
+ 'Authorization': f'Bearer {self._token}'
70
+ })
71
+
72
+ # Create session - equivalent to Java constructor behavior
73
+ self._session_id = self._create_session()
74
+
75
+ def _process_pdf_data(self, pdf_data: Union[bytes, Path, str, BinaryIO]) -> bytes:
76
+ """
77
+ Process PDF data from various input types with strict validation.
78
+ Equivalent to readFile() method in Java client.
79
+ """
80
+ if pdf_data is None:
81
+ raise ValidationException("PDF data cannot be null")
82
+
83
+ try:
84
+ if isinstance(pdf_data, bytes):
85
+ if len(pdf_data) == 0:
86
+ raise ValidationException("PDF data cannot be empty")
87
+ return pdf_data
88
+
89
+ elif isinstance(pdf_data, (Path, str)):
90
+ file_path = Path(pdf_data)
91
+ if not file_path.exists():
92
+ raise ValidationException(f"PDF file does not exist: {file_path}")
93
+ if not file_path.is_file():
94
+ raise ValidationException(f"Path is not a file: {file_path}")
95
+ if not file_path.stat().st_size > 0:
96
+ raise ValidationException(f"PDF file is empty: {file_path}")
97
+
98
+ with open(file_path, 'rb') as f:
99
+ return f.read()
100
+
101
+ elif hasattr(pdf_data, 'read'):
102
+ # File-like object
103
+ data = pdf_data.read()
104
+ if isinstance(data, str):
105
+ data = data.encode('utf-8')
106
+ if len(data) == 0:
107
+ raise ValidationException("PDF data from file-like object is empty")
108
+ return data
109
+
110
+ else:
111
+ raise ValidationException(f"Unsupported PDF data type: {type(pdf_data)}")
112
+
113
+ except (IOError, OSError) as e:
114
+ raise PdfDancerException(f"Failed to read PDF data: {e}", cause=e)
115
+
116
+ def _extract_error_message(self, response: Optional[requests.Response]) -> str:
117
+ """
118
+ Extract meaningful error messages from API response.
119
+ Parses JSON error responses with _embedded.errors structure.
120
+ """
121
+ if response is None:
122
+ return "Unknown error"
123
+
124
+ try:
125
+ # Try to parse JSON response
126
+ error_data = response.json()
127
+
128
+ # Check for embedded errors structure
129
+ if "_embedded" in error_data and "errors" in error_data["_embedded"]:
130
+ errors = error_data["_embedded"]["errors"]
131
+ if errors and isinstance(errors, list):
132
+ # Extract all error messages
133
+ messages = []
134
+ for error in errors:
135
+ if isinstance(error, dict) and "message" in error:
136
+ messages.append(error["message"])
137
+
138
+ if messages:
139
+ return "; ".join(messages)
140
+
141
+ # Check for top-level message
142
+ if "message" in error_data:
143
+ return error_data["message"]
144
+
145
+ # Fallback to response content
146
+ return response.text or f"HTTP {response.status_code}"
147
+
148
+ except (json.JSONDecodeError, KeyError, TypeError):
149
+ # If JSON parsing fails, return response content or status
150
+ return response.text or f"HTTP {response.status_code}"
151
+
152
+ def _create_session(self) -> str:
153
+ """
154
+ Creates a new PDF processing session by uploading the PDF data.
155
+ Equivalent to createSession() method in Java client.
156
+ """
157
+ try:
158
+ files = {
159
+ 'pdf': ('document.pdf', self._pdf_bytes, 'application/pdf')
160
+ }
161
+
162
+ response = self._session.post(
163
+ f"{self._base_url}/session/create",
164
+ files=files,
165
+ timeout=self._read_timeout if self._read_timeout > 0 else None
166
+ )
167
+
168
+ response.raise_for_status()
169
+ session_id = response.text.strip()
170
+
171
+ if not session_id:
172
+ raise SessionException("Server returned empty session ID")
173
+
174
+ return session_id
175
+
176
+ except requests.exceptions.RequestException as e:
177
+ error_message = self._extract_error_message(getattr(e, 'response', None))
178
+ raise HttpClientException(f"Failed to create session: {error_message}",
179
+ response=getattr(e, 'response', None), cause=e) from None
180
+
181
+ def _make_request(self, method: str, path: str, data: Optional[dict] = None,
182
+ params: Optional[dict] = None) -> requests.Response:
183
+ """
184
+ Make HTTP request with session headers and error handling.
185
+ Equivalent to retrieve() method pattern in Java client.
186
+ """
187
+ headers = {
188
+ 'X-Session-Id': self._session_id,
189
+ 'Content-Type': 'application/json'
190
+ }
191
+
192
+ try:
193
+ response = self._session.request(
194
+ method=method,
195
+ url=f"{self._base_url}{path}",
196
+ json=data,
197
+ params=params,
198
+ headers=headers,
199
+ timeout=self._read_timeout if self._read_timeout > 0 else None
200
+ )
201
+
202
+ # Handle FontNotFoundException specifically like Java client
203
+ if response.status_code == 404:
204
+ try:
205
+ error_data = response.json()
206
+ if error_data.get('error') == 'FontNotFoundException':
207
+ raise FontNotFoundException(error_data.get('message', 'Font not found'))
208
+ except (json.JSONDecodeError, KeyError):
209
+ pass
210
+
211
+ response.raise_for_status()
212
+ return response
213
+
214
+ except requests.exceptions.RequestException as e:
215
+ error_message = self._extract_error_message(getattr(e, 'response', None))
216
+ raise HttpClientException(f"API request failed: {error_message}", response=getattr(e, 'response', None),
217
+ cause=e) from None
218
+
219
+ # Search Operations - matching Java client exactly
220
+
221
+ def find(self, object_type: Optional[ObjectType] = None, position: Optional[Position] = None) -> List[ObjectRef]:
222
+ """
223
+ Searches for PDF objects matching the specified criteria.
224
+ This method provides flexible search capabilities across all PDF content,
225
+ allowing filtering by object type and position constraints.
226
+
227
+ Args:
228
+ object_type: The type of objects to find (None for all types)
229
+ position: Positional constraints for the search (None for all positions)
230
+
231
+ Returns:
232
+ List of object references matching the search criteria
233
+ """
234
+ request_data = FindRequest(object_type, position).to_dict()
235
+ response = self._make_request('POST', '/pdf/find', data=request_data)
236
+
237
+ # Parse response into ObjectRef objects
238
+ objects_data = response.json()
239
+ return [self._parse_object_ref(obj_data) for obj_data in objects_data]
240
+
241
+ def find_paragraphs(self, position: Optional[Position] = None) -> List[ObjectRef]:
242
+ """
243
+ Searches for paragraph objects at the specified position.
244
+ Equivalent to findParagraphs() in Java client.
245
+ """
246
+ return self.find(ObjectType.PARAGRAPH, position)
247
+
248
+ def find_images(self, position: Optional[Position] = None) -> List[ObjectRef]:
249
+ """
250
+ Searches for image objects at the specified position.
251
+ Equivalent to findImages() in Java client.
252
+ """
253
+ return self.find(ObjectType.IMAGE, position)
254
+
255
+ def find_forms(self, position: Optional[Position] = None) -> List[ObjectRef]:
256
+ """
257
+ Searches for form field objects at the specified position.
258
+ Equivalent to findForms() in Java client.
259
+ """
260
+ return self.find(ObjectType.FORM, position)
261
+
262
+ def find_paths(self, position: Optional[Position] = None) -> List[ObjectRef]:
263
+ """
264
+ Searches for vector path objects at the specified position.
265
+ Equivalent to findPaths() in Java client.
266
+ """
267
+ return self.find(ObjectType.PATH, position)
268
+
269
+ def find_text_lines(self, position: Optional[Position] = None) -> List[ObjectRef]:
270
+ """
271
+ Searches for text line objects at the specified position.
272
+ Equivalent to findTextLines() in Java client.
273
+ """
274
+ return self.find(ObjectType.TEXT_LINE, position)
275
+
276
+ # Page Operations
277
+
278
+ def get_pages(self) -> List[ObjectRef]:
279
+ """
280
+ Retrieves references to all pages in the PDF document.
281
+ Equivalent to getPages() in Java client.
282
+ """
283
+ response = self._make_request('POST', '/pdf/page/find')
284
+ pages_data = response.json()
285
+ return [self._parse_object_ref(page_data) for page_data in pages_data]
286
+
287
+ def get_page(self, page_index: int) -> Optional[ObjectRef]:
288
+ """
289
+ Retrieves a reference to a specific page by its page index.
290
+ Equivalent to getPage() in Java client.
291
+
292
+ Args:
293
+ page_index: The page index to retrieve (1-based indexing)
294
+
295
+ Returns:
296
+ Object reference for the specified page, or None if not found
297
+ """
298
+ if page_index < 0:
299
+ raise ValidationException(f"Page index must be >= 0, got {page_index}")
300
+
301
+ params = {'pageIndex': page_index}
302
+ response = self._make_request('POST', '/pdf/page/find', params=params)
303
+
304
+ pages_data = response.json()
305
+ if not pages_data:
306
+ return None
307
+
308
+ return self._parse_object_ref(pages_data[0])
309
+
310
+ def delete_page(self, page_ref: ObjectRef) -> bool:
311
+ """
312
+ Deletes a page from the PDF document.
313
+ Equivalent to deletePage() in Java client.
314
+
315
+ Args:
316
+ page_ref: Reference to the page to be deleted
317
+
318
+ Returns:
319
+ True if the page was successfully deleted
320
+ """
321
+ if page_ref is None:
322
+ raise ValidationException("Page reference cannot be null")
323
+
324
+ request_data = page_ref.to_dict()
325
+
326
+ response = self._make_request('DELETE', '/pdf/page/delete', data=request_data)
327
+ return response.json()
328
+
329
+ # Manipulation Operations
330
+
331
+ def delete(self, object_ref: ObjectRef) -> bool:
332
+ """
333
+ Deletes the specified PDF object from the document.
334
+ Equivalent to delete() in Java client.
335
+
336
+ Args:
337
+ object_ref: Reference to the object to be deleted
338
+
339
+ Returns:
340
+ True if the object was successfully deleted
341
+ """
342
+ if object_ref is None:
343
+ raise ValidationException("Object reference cannot be null")
344
+
345
+ request_data = DeleteRequest(object_ref).to_dict()
346
+ response = self._make_request('DELETE', '/pdf/delete', data=request_data)
347
+ return response.json()
348
+
349
+ def move(self, object_ref: ObjectRef, position: Position) -> bool:
350
+ """
351
+ Moves a PDF object to a new position within the document.
352
+ Equivalent to move() in Java client.
353
+
354
+ Args:
355
+ object_ref: Reference to the object to be moved
356
+ position: New position for the object
357
+
358
+ Returns:
359
+ True if the object was successfully moved
360
+ """
361
+ if object_ref is None:
362
+ raise ValidationException("Object reference cannot be null")
363
+ if position is None:
364
+ raise ValidationException("Position cannot be null")
365
+
366
+ request_data = MoveRequest(object_ref, position).to_dict()
367
+ response = self._make_request('PUT', '/pdf/move', data=request_data)
368
+ return response.json()
369
+
370
+ # Add Operations
371
+
372
+ def add_image(self, image: Image, position: Optional[Position] = None) -> bool:
373
+ """
374
+ Adds an image to the PDF document.
375
+ Equivalent to addImage() methods in Java client.
376
+
377
+ Args:
378
+ image: The image object to add
379
+ position: Optional position override
380
+
381
+ Returns:
382
+ True if the image was successfully added
383
+ """
384
+ if image is None:
385
+ raise ValidationException("Image cannot be null")
386
+
387
+ if position is not None:
388
+ image.set_position(position)
389
+
390
+ if image.get_position() is None:
391
+ raise ValidationException("Image position is null")
392
+
393
+ return self._add_object(image)
394
+
395
+ def add_paragraph(self, paragraph: Paragraph) -> bool:
396
+ """
397
+ Adds a paragraph to the PDF document.
398
+ Equivalent to addParagraph() in Java client with validation.
399
+
400
+ Args:
401
+ paragraph: The paragraph object to add
402
+
403
+ Returns:
404
+ True if the paragraph was successfully added
405
+ """
406
+ if paragraph is None:
407
+ raise ValidationException("Paragraph cannot be null")
408
+ if paragraph.get_position() is None:
409
+ raise ValidationException("Paragraph position is null")
410
+ if paragraph.get_position().page_index is None:
411
+ raise ValidationException("Paragraph position page index is null")
412
+ if paragraph.get_position().page_index < 0:
413
+ raise ValidationException("Paragraph position page index is less than 0")
414
+
415
+ return self._add_object(paragraph)
416
+
417
+ def _add_object(self, pdf_object) -> bool:
418
+ """
419
+ Internal method to add any PDF object.
420
+ Equivalent to addObject() in Java client.
421
+ """
422
+ request_data = AddRequest(pdf_object).to_dict()
423
+ response = self._make_request('POST', '/pdf/add', data=request_data)
424
+ return response.json()
425
+
426
+ # Modify Operations
427
+
428
+ def modify_paragraph(self, object_ref: ObjectRef, new_paragraph: Union[Paragraph, str]) -> bool:
429
+ """
430
+ Modifies a paragraph object or its text content.
431
+ Equivalent to modifyParagraph() methods in Java client.
432
+
433
+ Args:
434
+ object_ref: Reference to the paragraph to modify
435
+ new_paragraph: New paragraph object or text string
436
+
437
+ Returns:
438
+ True if the paragraph was successfully modified
439
+ """
440
+ if object_ref is None:
441
+ raise ValidationException("Object reference cannot be null")
442
+ if new_paragraph is None:
443
+ raise ValidationException("New paragraph cannot be null")
444
+
445
+ if isinstance(new_paragraph, str):
446
+ # Text modification
447
+ request_data = ModifyTextRequest(object_ref, new_paragraph).to_dict()
448
+ response = self._make_request('PUT', '/pdf/text/paragraph', data=request_data)
449
+ else:
450
+ # Object modification
451
+ request_data = ModifyRequest(object_ref, new_paragraph).to_dict()
452
+ response = self._make_request('PUT', '/pdf/modify', data=request_data)
453
+
454
+ return response.json()
455
+
456
+ def modify_text_line(self, object_ref: ObjectRef, new_text: str) -> bool:
457
+ """
458
+ Modifies a text line object.
459
+ Equivalent to modifyTextLine() in Java client.
460
+
461
+ Args:
462
+ object_ref: Reference to the text line to modify
463
+ new_text: New text content
464
+
465
+ Returns:
466
+ True if the text line was successfully modified
467
+ """
468
+ if object_ref is None:
469
+ raise ValidationException("Object reference cannot be null")
470
+ if new_text is None:
471
+ raise ValidationException("New text cannot be null")
472
+
473
+ request_data = ModifyTextRequest(object_ref, new_text).to_dict()
474
+ response = self._make_request('PUT', '/pdf/text/line', data=request_data)
475
+ return response.json()
476
+
477
+ # Font Operations
478
+
479
+ def find_fonts(self, font_name: str, font_size: int) -> List[Font]:
480
+ """
481
+ Finds available fonts matching the specified name and size.
482
+ Equivalent to findFonts() in Java client.
483
+
484
+ Args:
485
+ font_name: Name of the font to search for
486
+ font_size: Size of the font
487
+
488
+ Returns:
489
+ List of matching Font objects
490
+ """
491
+ if not font_name or not font_name.strip():
492
+ raise ValidationException("Font name cannot be null or empty")
493
+ if font_size <= 0:
494
+ raise ValidationException(f"Font size must be positive, got {font_size}")
495
+
496
+ params = {'fontName': font_name.strip()}
497
+ response = self._make_request('GET', '/font/find', params=params)
498
+
499
+ font_names = response.json()
500
+ return [Font(name, font_size) for name in font_names]
501
+
502
+ def register_font(self, ttf_file: Union[Path, str, bytes, BinaryIO]) -> str:
503
+ """
504
+ Registers a custom font for use in PDF operations.
505
+ Equivalent to registerFont() in Java client.
506
+
507
+ Args:
508
+ ttf_file: TTF font file as Path, filename, bytes, or file-like object
509
+
510
+ Returns:
511
+ Font registration result
512
+
513
+ Raises:
514
+ ValidationException: If font file is invalid
515
+ HttpClientException: If registration fails
516
+ """
517
+ if ttf_file is None:
518
+ raise ValidationException("TTF file cannot be null")
519
+
520
+ # Process font file with validation similar to PDF processing
521
+ try:
522
+ if isinstance(ttf_file, bytes):
523
+ if len(ttf_file) == 0:
524
+ raise ValidationException("Font data cannot be empty")
525
+ font_data = ttf_file
526
+ filename = 'font.ttf'
527
+
528
+ elif isinstance(ttf_file, (Path, str)):
529
+ font_path = Path(ttf_file)
530
+ if not font_path.exists():
531
+ raise ValidationException(f"TTF file does not exist: {font_path}")
532
+ if not font_path.is_file():
533
+ raise ValidationException(f"TTF file is not a file: {font_path}")
534
+ if not font_path.stat().st_size > 0:
535
+ raise ValidationException(f"TTF file is empty: {font_path}")
536
+
537
+ with open(font_path, 'rb') as f:
538
+ font_data = f.read()
539
+ filename = font_path.name
540
+
541
+ elif hasattr(ttf_file, 'read'):
542
+ font_data = ttf_file.read()
543
+ if isinstance(font_data, str):
544
+ font_data = font_data.encode('utf-8')
545
+ if len(font_data) == 0:
546
+ raise ValidationException("Font data from file-like object is empty")
547
+ filename = getattr(ttf_file, 'name', 'font.ttf')
548
+
549
+ else:
550
+ raise ValidationException(f"Unsupported font file type: {type(ttf_file)}")
551
+
552
+ # Upload font file
553
+ files = {
554
+ 'ttfFile': (filename, font_data, 'font/ttf')
555
+ }
556
+
557
+ headers = {'X-Session-Id': self._session_id}
558
+ response = self._session.post(
559
+ f"{self._base_url}/font/register",
560
+ files=files,
561
+ headers=headers,
562
+ timeout=30
563
+ )
564
+
565
+ response.raise_for_status()
566
+ return response.text.strip()
567
+
568
+ except (IOError, OSError) as e:
569
+ raise PdfDancerException(f"Failed to read font file: {e}", cause=e)
570
+ except requests.exceptions.RequestException as e:
571
+ error_message = self._extract_error_message(getattr(e, 'response', None))
572
+ raise HttpClientException(f"Font registration failed: {error_message}",
573
+ response=getattr(e, 'response', None), cause=e) from None
574
+
575
+ # Document Operations
576
+
577
+ def get_pdf_file(self) -> bytes:
578
+ """
579
+ Downloads the current state of the PDF document with all modifications applied.
580
+ Equivalent to getPDFFile() in Java client.
581
+
582
+ Returns:
583
+ PDF file data as bytes with all session modifications applied
584
+ """
585
+ response = self._make_request('GET', f'/session/{self._session_id}/pdf')
586
+ return response.content
587
+
588
+ def save_pdf(self, file_path: Union[str, Path]) -> None:
589
+ """
590
+ Saves the current PDF to a file.
591
+ Equivalent to savePDF() in Java client.
592
+
593
+ Args:
594
+ file_path: Path where to save the PDF file
595
+
596
+ Raises:
597
+ ValidationException: If file path is invalid
598
+ PdfDancerException: If file writing fails
599
+ """
600
+ if not file_path:
601
+ raise ValidationException("File path cannot be null or empty")
602
+
603
+ try:
604
+ pdf_data = self.get_pdf_file()
605
+ output_path = Path(file_path)
606
+
607
+ # Create parent directories if they don't exist
608
+ output_path.parent.mkdir(parents=True, exist_ok=True)
609
+
610
+ with open(output_path, 'wb') as f:
611
+ f.write(pdf_data)
612
+
613
+ except (IOError, OSError) as e:
614
+ raise PdfDancerException(f"Failed to save PDF file: {e}", cause=e)
615
+
616
+ # Utility Methods
617
+
618
+ def _parse_object_ref(self, obj_data: dict) -> ObjectRef:
619
+ """Parse JSON object data into ObjectRef instance."""
620
+ position_data = obj_data.get('position', {})
621
+ position = self._parse_position(position_data) if position_data else None
622
+
623
+ object_type = ObjectType(obj_data['type'])
624
+
625
+ return ObjectRef(
626
+ internal_id=obj_data['internalId'],
627
+ position=position,
628
+ type=object_type
629
+ )
630
+
631
+ def _parse_position(self, pos_data: dict) -> Position:
632
+ """Parse JSON position data into Position instance."""
633
+ position = Position()
634
+ position.page_index = pos_data.get('pageIndex')
635
+ position.text_starts_with = pos_data.get('textStartsWith')
636
+
637
+ if 'shape' in pos_data:
638
+ position.shape = ShapeType(pos_data['shape'])
639
+ if 'mode' in pos_data:
640
+ position.mode = PositionMode(pos_data['mode'])
641
+
642
+ if 'boundingRect' in pos_data:
643
+ rect_data = pos_data['boundingRect']
644
+ from .models import BoundingRect
645
+ position.bounding_rect = BoundingRect(
646
+ x=rect_data['x'],
647
+ y=rect_data['y'],
648
+ width=rect_data['width'],
649
+ height=rect_data['height']
650
+ )
651
+
652
+ return position
653
+
654
+ # Builder Pattern Support
655
+
656
+ def paragraph_builder(self) -> 'ParagraphBuilder':
657
+ """
658
+ Creates a new ParagraphBuilder for fluent paragraph construction.
659
+ Equivalent to paragraphBuilder() in Java client.
660
+
661
+ Returns:
662
+ A new ParagraphBuilder instance
663
+ """
664
+ from .paragraph_builder import ParagraphBuilder
665
+ return ParagraphBuilder(self)
666
+
667
+ # Context Manager Support (Python enhancement)
668
+ def __enter__(self):
669
+ """Context manager entry."""
670
+ return self
671
+
672
+ def __exit__(self, exc_type, exc_val, exc_tb):
673
+ """Context manager exit - cleanup if needed."""
674
+ # Could add session cleanup here if API supports it
675
+ pass