audiopod 1.0.0__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.
audiopod/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ AudioPod API Client
3
+ Professional Audio Processing SDK for Python
4
+
5
+ This package provides a comprehensive Python SDK for the AudioPod API,
6
+ enabling developers to integrate advanced audio processing capabilities
7
+ into their applications.
8
+
9
+ Basic Usage:
10
+ >>> import audiopod
11
+ >>> client = audiopod.Client(api_key="your-api-key")
12
+ >>>
13
+ >>> # Voice cloning
14
+ >>> job = client.voice.clone_voice(
15
+ ... voice_file="path/to/voice.wav",
16
+ ... text="Hello, this is a cloned voice!"
17
+ ... )
18
+ >>>
19
+ >>> # Music generation
20
+ >>> music = client.music.generate(
21
+ ... prompt="upbeat electronic dance music"
22
+ ... )
23
+ >>>
24
+ >>> # Audio transcription
25
+ >>> transcript = client.transcription.transcribe(
26
+ ... audio_file="path/to/audio.mp3",
27
+ ... language="en"
28
+ ... )
29
+
30
+ For more examples and documentation, visit: https://docs.audiopod.ai
31
+ """
32
+
33
+ from .client import Client, AsyncClient
34
+ from .exceptions import (
35
+ AudioPodError,
36
+ AuthenticationError,
37
+ APIError,
38
+ RateLimitError,
39
+ ValidationError,
40
+ ProcessingError
41
+ )
42
+ from .models import (
43
+ Job,
44
+ VoiceProfile,
45
+ TranscriptionResult,
46
+ MusicGenerationResult,
47
+ TranslationResult
48
+ )
49
+
50
+ __version__ = "1.0.0"
51
+ __author__ = "AudioPod AI"
52
+ __email__ = "support@audiopod.ai"
53
+ __license__ = "MIT"
54
+
55
+ # Public API
56
+ __all__ = [
57
+ # Main clients
58
+ "Client",
59
+ "AsyncClient",
60
+
61
+ # Exceptions
62
+ "AudioPodError",
63
+ "AuthenticationError",
64
+ "APIError",
65
+ "RateLimitError",
66
+ "ValidationError",
67
+ "ProcessingError",
68
+
69
+ # Models
70
+ "Job",
71
+ "VoiceProfile",
72
+ "TranscriptionResult",
73
+ "MusicGenerationResult",
74
+ "TranslationResult",
75
+ ]
76
+
77
+ # Package metadata
78
+ __all__.extend([
79
+ "__version__",
80
+ "__author__",
81
+ "__email__",
82
+ "__license__"
83
+ ])
audiopod/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ """
2
+ AudioPod CLI - Command Line Interface
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import json
8
+ import click
9
+ from typing import Optional
10
+
11
+ from .client import Client
12
+ from .exceptions import AudioPodError
13
+
14
+
15
+ @click.group()
16
+ @click.option('--api-key', envvar='AUDIOPOD_API_KEY', help='AudioPod API key')
17
+ @click.option('--base-url', default='https://api.audiopod.ai', help='API base URL')
18
+ @click.option('--debug', is_flag=True, help='Enable debug logging')
19
+ @click.pass_context
20
+ def cli(ctx, api_key: str, base_url: str, debug: bool):
21
+ """AudioPod CLI - Professional Audio Processing powered by AI"""
22
+ ctx.ensure_object(dict)
23
+
24
+ if not api_key:
25
+ click.echo("Error: API key is required. Set AUDIOPOD_API_KEY environment variable or use --api-key option.")
26
+ sys.exit(1)
27
+
28
+ try:
29
+ ctx.obj['client'] = Client(
30
+ api_key=api_key,
31
+ base_url=base_url,
32
+ debug=debug
33
+ )
34
+ except AudioPodError as e:
35
+ click.echo(f"Error: {e.message}")
36
+ sys.exit(1)
37
+
38
+
39
+ @cli.command()
40
+ @click.pass_context
41
+ def health(ctx):
42
+ """Check API health status"""
43
+ try:
44
+ client = ctx.obj['client']
45
+ status = client.check_health()
46
+ click.echo(f"API Status: {status.get('status', 'Unknown')}")
47
+ except AudioPodError as e:
48
+ click.echo(f"Health check failed: {e.message}")
49
+ sys.exit(1)
50
+
51
+
52
+ @cli.group()
53
+ def credits():
54
+ """Credit management commands"""
55
+ pass
56
+
57
+
58
+ @credits.command('balance')
59
+ @click.pass_context
60
+ def credits_balance(ctx):
61
+ """Get current credit balance"""
62
+ try:
63
+ client = ctx.obj['client']
64
+ credits = client.credits.get_credit_balance()
65
+
66
+ click.echo("Credit Balance:")
67
+ click.echo(f" Subscription Credits: {credits.balance:,}")
68
+ click.echo(f" Pay-as-you-go Credits: {credits.payg_balance:,}")
69
+ click.echo(f" Total Available: {credits.total_available_credits:,}")
70
+ click.echo(f" Total Used: {credits.total_credits_used:,}")
71
+ if credits.next_reset_date:
72
+ click.echo(f" Next Reset: {credits.next_reset_date}")
73
+
74
+ except AudioPodError as e:
75
+ click.echo(f"Failed to get credit balance: {e.message}")
76
+ sys.exit(1)
77
+
78
+
79
+ @credits.command('usage')
80
+ @click.pass_context
81
+ def credits_usage(ctx):
82
+ """Get credit usage history"""
83
+ try:
84
+ client = ctx.obj['client']
85
+ usage = client.credits.get_usage_history()
86
+
87
+ if not usage:
88
+ click.echo("No usage history found.")
89
+ return
90
+
91
+ click.echo("Recent Credit Usage:")
92
+ for record in usage[:10]: # Show last 10 records
93
+ click.echo(f" {record['created_at']}: {record['service_type']} - {record['credits_used']} credits")
94
+
95
+ except AudioPodError as e:
96
+ click.echo(f"Failed to get usage history: {e.message}")
97
+ sys.exit(1)
98
+
99
+
100
+ @cli.group()
101
+ def voice():
102
+ """Voice cloning and TTS commands"""
103
+ pass
104
+
105
+
106
+ @voice.command('clone')
107
+ @click.argument('voice_file', type=click.Path(exists=True))
108
+ @click.argument('text')
109
+ @click.option('--language', '-l', help='Target language code (e.g., en, es)')
110
+ @click.option('--speed', '-s', default=1.0, help='Speech speed (0.5-2.0)')
111
+ @click.option('--wait', is_flag=True, help='Wait for completion')
112
+ @click.option('--output', '-o', help='Output file path')
113
+ @click.pass_context
114
+ def voice_clone(ctx, voice_file: str, text: str, language: Optional[str],
115
+ speed: float, wait: bool, output: Optional[str]):
116
+ """Clone a voice from audio file"""
117
+ try:
118
+ client = ctx.obj['client']
119
+
120
+ click.echo(f"Cloning voice from {voice_file}...")
121
+
122
+ job = client.voice.clone_voice(
123
+ voice_file=voice_file,
124
+ text=text,
125
+ language=language,
126
+ speed=speed,
127
+ wait_for_completion=wait
128
+ )
129
+
130
+ if wait:
131
+ click.echo(f"Voice cloning completed!")
132
+ if 'output_url' in job:
133
+ click.echo(f"Generated audio URL: {job['output_url']}")
134
+ if output:
135
+ # Here you could add download functionality
136
+ click.echo(f"To download: curl -o {output} '{job['output_url']}'")
137
+ else:
138
+ click.echo(f"Voice cloning job started with ID: {job.id}")
139
+ click.echo(f"Check status with: audiopod voice status {job.id}")
140
+
141
+ except AudioPodError as e:
142
+ click.echo(f"Voice cloning failed: {e.message}")
143
+ sys.exit(1)
144
+
145
+
146
+ @voice.command('list')
147
+ @click.option('--limit', default=20, help='Maximum number of voices to show')
148
+ @click.pass_context
149
+ def voice_list(ctx, limit: int):
150
+ """List available voice profiles"""
151
+ try:
152
+ client = ctx.obj['client']
153
+ voices = client.voice.list_voice_profiles(limit=limit)
154
+
155
+ if not voices:
156
+ click.echo("No voice profiles found.")
157
+ return
158
+
159
+ click.echo("Available Voice Profiles:")
160
+ for voice in voices:
161
+ status_icon = "✓" if voice.status == "completed" else "⏳"
162
+ visibility = "Public" if voice.is_public else "Private"
163
+ click.echo(f" {status_icon} {voice.name} (ID: {voice.id}) - {visibility}")
164
+ if voice.description:
165
+ click.echo(f" {voice.description}")
166
+
167
+ except AudioPodError as e:
168
+ click.echo(f"Failed to list voices: {e.message}")
169
+ sys.exit(1)
170
+
171
+
172
+ @cli.group()
173
+ def music():
174
+ """Music generation commands"""
175
+ pass
176
+
177
+
178
+ @music.command('generate')
179
+ @click.argument('prompt')
180
+ @click.option('--duration', '-d', default=120.0, help='Duration in seconds')
181
+ @click.option('--wait', is_flag=True, help='Wait for completion')
182
+ @click.option('--output', '-o', help='Output file path')
183
+ @click.pass_context
184
+ def music_generate(ctx, prompt: str, duration: float, wait: bool, output: Optional[str]):
185
+ """Generate music from text prompt"""
186
+ try:
187
+ client = ctx.obj['client']
188
+
189
+ click.echo(f"Generating music: '{prompt}'...")
190
+
191
+ job = client.music.generate_music(
192
+ prompt=prompt,
193
+ duration=duration,
194
+ wait_for_completion=wait
195
+ )
196
+
197
+ if wait:
198
+ click.echo("Music generation completed!")
199
+ if hasattr(job, 'output_url') and job.output_url:
200
+ click.echo(f"Generated music URL: {job.output_url}")
201
+ if output:
202
+ click.echo(f"To download: curl -o {output} '{job.output_url}'")
203
+ else:
204
+ click.echo(f"Music generation job started with ID: {job.id}")
205
+ click.echo(f"Check status with: audiopod music status {job.id}")
206
+
207
+ except AudioPodError as e:
208
+ click.echo(f"Music generation failed: {e.message}")
209
+ sys.exit(1)
210
+
211
+
212
+ @music.command('list')
213
+ @click.option('--limit', default=20, help='Maximum number of tracks to show')
214
+ @click.pass_context
215
+ def music_list(ctx, limit: int):
216
+ """List generated music tracks"""
217
+ try:
218
+ client = ctx.obj['client']
219
+ tracks = client.music.list_music_jobs(limit=limit)
220
+
221
+ if not tracks:
222
+ click.echo("No music tracks found.")
223
+ return
224
+
225
+ click.echo("Generated Music Tracks:")
226
+ for track in tracks:
227
+ status_icon = "✓" if track.job.status == "completed" else "⏳"
228
+ prompt = track.job.parameters.get('prompt', 'N/A') if track.job.parameters else 'N/A'
229
+ click.echo(f" {status_icon} Job {track.job.id}: '{prompt[:50]}...'")
230
+
231
+ except AudioPodError as e:
232
+ click.echo(f"Failed to list music tracks: {e.message}")
233
+ sys.exit(1)
234
+
235
+
236
+ @cli.group()
237
+ def transcription():
238
+ """Transcription commands"""
239
+ pass
240
+
241
+
242
+ @transcription.command('transcribe')
243
+ @click.argument('audio_file', type=click.Path(exists=True))
244
+ @click.option('--language', '-l', help='Language code (auto-detect if not specified)')
245
+ @click.option('--speakers', is_flag=True, help='Enable speaker diarization')
246
+ @click.option('--wait', is_flag=True, help='Wait for completion')
247
+ @click.option('--format', '-f', default='txt', help='Output format (txt, json, srt)')
248
+ @click.pass_context
249
+ def transcription_transcribe(ctx, audio_file: str, language: Optional[str],
250
+ speakers: bool, wait: bool, format: str):
251
+ """Transcribe audio to text"""
252
+ try:
253
+ client = ctx.obj['client']
254
+
255
+ click.echo(f"Transcribing {audio_file}...")
256
+
257
+ job = client.transcription.transcribe_audio(
258
+ audio_file=audio_file,
259
+ language=language,
260
+ enable_speaker_diarization=speakers,
261
+ wait_for_completion=wait
262
+ )
263
+
264
+ if wait:
265
+ click.echo("Transcription completed!")
266
+ if hasattr(job, 'transcript') and job.transcript:
267
+ click.echo("Transcript:")
268
+ click.echo(job.transcript)
269
+ if speakers and hasattr(job, 'segments') and job.segments:
270
+ click.echo(f"\nFound {len(job.segments)} speaker segments")
271
+ else:
272
+ click.echo(f"Transcription job started with ID: {job.id}")
273
+
274
+ except AudioPodError as e:
275
+ click.echo(f"Transcription failed: {e.message}")
276
+ sys.exit(1)
277
+
278
+
279
+ def main():
280
+ """Main CLI entry point"""
281
+ cli()
282
+
283
+
284
+ if __name__ == '__main__':
285
+ main()
audiopod/client.py ADDED
@@ -0,0 +1,332 @@
1
+ """
2
+ AudioPod API Client
3
+ Main client classes for synchronous and asynchronous API access
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from typing import Optional, Dict, Any, Union
9
+ from urllib.parse import urljoin
10
+
11
+ import requests
12
+ import aiohttp
13
+ from requests.adapters import HTTPAdapter
14
+ from urllib3.util.retry import Retry
15
+
16
+ from .config import ClientConfig
17
+ from .exceptions import AuthenticationError, APIError, RateLimitError
18
+ from .services import (
19
+ VoiceService,
20
+ MusicService,
21
+ TranscriptionService,
22
+ TranslationService,
23
+ SpeakerService,
24
+ DenoiserService,
25
+ KaraokeService,
26
+ CreditService
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class BaseClient:
33
+ """Base client with common functionality"""
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str] = None,
38
+ base_url: Optional[str] = None,
39
+ timeout: int = 30,
40
+ max_retries: int = 3,
41
+ verify_ssl: bool = True,
42
+ debug: bool = False
43
+ ):
44
+ """
45
+ Initialize the AudioPod API client
46
+
47
+ Args:
48
+ api_key: Your AudioPod API key. If None, will try to read from AUDIOPOD_API_KEY env var
49
+ base_url: API base URL. Defaults to production endpoint
50
+ timeout: Request timeout in seconds
51
+ max_retries: Maximum number of retries for failed requests
52
+ verify_ssl: Whether to verify SSL certificates
53
+ debug: Enable debug logging
54
+ """
55
+ # Set up logging
56
+ if debug:
57
+ logging.basicConfig(level=logging.DEBUG)
58
+
59
+ # Get API key
60
+ self.api_key = api_key or os.getenv("AUDIOPOD_API_KEY")
61
+ if not self.api_key:
62
+ raise AuthenticationError(
63
+ "API key is required. Pass it as 'api_key' parameter or set AUDIOPOD_API_KEY environment variable."
64
+ )
65
+
66
+ # Validate API key format
67
+ if not self.api_key.startswith("ap_"):
68
+ raise AuthenticationError("Invalid API key format. AudioPod API keys start with 'ap_'")
69
+
70
+ # Configuration
71
+ self.config = ClientConfig(
72
+ base_url=base_url or "https://api.audiopod.ai",
73
+ timeout=timeout,
74
+ max_retries=max_retries,
75
+ verify_ssl=verify_ssl,
76
+ debug=debug
77
+ )
78
+
79
+ def _get_headers(self) -> Dict[str, str]:
80
+ """Get standard headers for API requests"""
81
+ return {
82
+ "Authorization": f"Bearer {self.api_key}",
83
+ "Content-Type": "application/json",
84
+ "User-Agent": f"audiopod-python/{self.config.version}",
85
+ "Accept": "application/json"
86
+ }
87
+
88
+ def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
89
+ """Handle API response and raise appropriate exceptions"""
90
+ try:
91
+ response.raise_for_status()
92
+ return response.json()
93
+ except requests.exceptions.HTTPError as e:
94
+ if response.status_code == 401:
95
+ raise AuthenticationError("Invalid API key or authentication failed")
96
+ elif response.status_code == 429:
97
+ raise RateLimitError("Rate limit exceeded. Please try again later.")
98
+ elif response.status_code >= 400:
99
+ try:
100
+ error_data = response.json()
101
+ message = error_data.get("detail", str(e))
102
+ except:
103
+ message = str(e)
104
+ raise APIError(f"API request failed: {message}", status_code=response.status_code)
105
+ else:
106
+ raise APIError(f"Unexpected HTTP error: {e}")
107
+ except requests.exceptions.RequestException as e:
108
+ raise APIError(f"Request failed: {e}")
109
+
110
+
111
+ class Client(BaseClient):
112
+ """
113
+ Synchronous AudioPod API Client
114
+
115
+ Provides access to all AudioPod services through a simple Python interface.
116
+ """
117
+
118
+ def __init__(self, **kwargs):
119
+ super().__init__(**kwargs)
120
+
121
+ # Configure session with retries
122
+ self.session = requests.Session()
123
+ retry_strategy = Retry(
124
+ total=self.config.max_retries,
125
+ status_forcelist=[429, 500, 502, 503, 504],
126
+ method_whitelist=["HEAD", "GET", "OPTIONS", "POST"],
127
+ backoff_factor=1
128
+ )
129
+ adapter = HTTPAdapter(max_retries=retry_strategy)
130
+ self.session.mount("http://", adapter)
131
+ self.session.mount("https://", adapter)
132
+
133
+ # Initialize services
134
+ self.voice = VoiceService(self)
135
+ self.music = MusicService(self)
136
+ self.transcription = TranscriptionService(self)
137
+ self.translation = TranslationService(self)
138
+ self.speaker = SpeakerService(self)
139
+ self.denoiser = DenoiserService(self)
140
+ self.karaoke = KaraokeService(self)
141
+ self.credits = CreditService(self)
142
+
143
+ def request(
144
+ self,
145
+ method: str,
146
+ endpoint: str,
147
+ data: Optional[Dict[str, Any]] = None,
148
+ files: Optional[Dict[str, Any]] = None,
149
+ params: Optional[Dict[str, Any]] = None,
150
+ **kwargs
151
+ ) -> Dict[str, Any]:
152
+ """
153
+ Make a request to the AudioPod API
154
+
155
+ Args:
156
+ method: HTTP method (GET, POST, etc.)
157
+ endpoint: API endpoint path
158
+ data: JSON data to send
159
+ files: Files to upload
160
+ params: URL parameters
161
+ **kwargs: Additional requests parameters
162
+
163
+ Returns:
164
+ API response data
165
+ """
166
+ url = urljoin(self.config.base_url, endpoint)
167
+ headers = self._get_headers()
168
+
169
+ # Handle file uploads (don't set Content-Type for multipart)
170
+ if files:
171
+ headers.pop("Content-Type", None)
172
+
173
+ try:
174
+ response = self.session.request(
175
+ method=method,
176
+ url=url,
177
+ headers=headers,
178
+ json=data,
179
+ files=files,
180
+ params=params,
181
+ timeout=self.config.timeout,
182
+ verify=self.config.verify_ssl,
183
+ **kwargs
184
+ )
185
+ return self._handle_response(response)
186
+
187
+ except Exception as e:
188
+ logger.error(f"Request failed: {method} {url} - {e}")
189
+ raise
190
+
191
+ def get_user_info(self) -> Dict[str, Any]:
192
+ """Get current user information"""
193
+ return self.request("GET", "/api/v1/auth/me")
194
+
195
+ def check_health(self) -> Dict[str, Any]:
196
+ """Check API health status"""
197
+ return self.request("GET", "/api/v1/health")
198
+
199
+ def close(self):
200
+ """Close the client session"""
201
+ self.session.close()
202
+
203
+ def __enter__(self):
204
+ return self
205
+
206
+ def __exit__(self, exc_type, exc_val, exc_tb):
207
+ self.close()
208
+
209
+
210
+ class AsyncClient(BaseClient):
211
+ """
212
+ Asynchronous AudioPod API Client
213
+
214
+ Provides async/await support for better performance in async applications.
215
+ """
216
+
217
+ def __init__(self, **kwargs):
218
+ super().__init__(**kwargs)
219
+ self._session: Optional[aiohttp.ClientSession] = None
220
+
221
+ # Initialize async services
222
+ self.voice = VoiceService(self, async_mode=True)
223
+ self.music = MusicService(self, async_mode=True)
224
+ self.transcription = TranscriptionService(self, async_mode=True)
225
+ self.translation = TranslationService(self, async_mode=True)
226
+ self.speaker = SpeakerService(self, async_mode=True)
227
+ self.denoiser = DenoiserService(self, async_mode=True)
228
+ self.karaoke = KaraokeService(self, async_mode=True)
229
+ self.credits = CreditService(self, async_mode=True)
230
+
231
+ @property
232
+ def session(self) -> aiohttp.ClientSession:
233
+ """Get or create aiohttp session"""
234
+ if self._session is None or self._session.closed:
235
+ timeout = aiohttp.ClientTimeout(total=self.config.timeout)
236
+ connector = aiohttp.TCPConnector(verify_ssl=self.config.verify_ssl)
237
+ self._session = aiohttp.ClientSession(
238
+ timeout=timeout,
239
+ connector=connector,
240
+ headers=self._get_headers()
241
+ )
242
+ return self._session
243
+
244
+ async def request(
245
+ self,
246
+ method: str,
247
+ endpoint: str,
248
+ data: Optional[Dict[str, Any]] = None,
249
+ files: Optional[Dict[str, Any]] = None,
250
+ params: Optional[Dict[str, Any]] = None,
251
+ **kwargs
252
+ ) -> Dict[str, Any]:
253
+ """
254
+ Make an async request to the AudioPod API
255
+
256
+ Args:
257
+ method: HTTP method (GET, POST, etc.)
258
+ endpoint: API endpoint path
259
+ data: JSON data to send
260
+ files: Files to upload
261
+ params: URL parameters
262
+ **kwargs: Additional aiohttp parameters
263
+
264
+ Returns:
265
+ API response data
266
+ """
267
+ url = urljoin(self.config.base_url, endpoint)
268
+
269
+ try:
270
+ if files:
271
+ # Handle file uploads
272
+ form_data = aiohttp.FormData()
273
+ for key, value in (data or {}).items():
274
+ form_data.add_field(key, str(value))
275
+ for key, file_data in files.items():
276
+ form_data.add_field(key, file_data)
277
+ data = form_data
278
+
279
+ async with self.session.request(
280
+ method=method,
281
+ url=url,
282
+ json=data if not files else None,
283
+ data=data if files else None,
284
+ params=params,
285
+ **kwargs
286
+ ) as response:
287
+ return await self._handle_async_response(response)
288
+
289
+ except Exception as e:
290
+ logger.error(f"Async request failed: {method} {url} - {e}")
291
+ raise
292
+
293
+ async def _handle_async_response(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
294
+ """Handle async API response and raise appropriate exceptions"""
295
+ try:
296
+ response.raise_for_status()
297
+ return await response.json()
298
+ except aiohttp.ClientResponseError as e:
299
+ if response.status == 401:
300
+ raise AuthenticationError("Invalid API key or authentication failed")
301
+ elif response.status == 429:
302
+ raise RateLimitError("Rate limit exceeded. Please try again later.")
303
+ elif response.status >= 400:
304
+ try:
305
+ error_data = await response.json()
306
+ message = error_data.get("detail", str(e))
307
+ except:
308
+ message = str(e)
309
+ raise APIError(f"API request failed: {message}", status_code=response.status)
310
+ else:
311
+ raise APIError(f"Unexpected HTTP error: {e}")
312
+ except aiohttp.ClientError as e:
313
+ raise APIError(f"Request failed: {e}")
314
+
315
+ async def get_user_info(self) -> Dict[str, Any]:
316
+ """Get current user information"""
317
+ return await self.request("GET", "/api/v1/auth/me")
318
+
319
+ async def check_health(self) -> Dict[str, Any]:
320
+ """Check API health status"""
321
+ return await self.request("GET", "/api/v1/health")
322
+
323
+ async def close(self):
324
+ """Close the async client session"""
325
+ if self._session and not self._session.closed:
326
+ await self._session.close()
327
+
328
+ async def __aenter__(self):
329
+ return self
330
+
331
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
332
+ await self.close()