loudly-py-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: loudly-py-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Loudly Music API
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.25.0
9
+ Requires-Dist: python-dotenv>=1.0.0
File without changes
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "loudly-py-sdk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for Loudly Music API"
5
+ requires-python = ">=3.8"
6
+ dependencies = [
7
+ "requests>=2.25.0",
8
+ "python-dotenv>=1.0.0"
9
+ ]
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+
13
+ [tool.uv]
14
+ python = ">=3.8"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ from .client import LoudlyClient
2
+ from .exceptions import LoudlyAPIError
@@ -0,0 +1,401 @@
1
+ import os
2
+ import requests
3
+ from typing import Optional, Dict, Any, List
4
+ from dotenv import load_dotenv
5
+ from .exceptions import LoudlyAPIError
6
+
7
+ # Load .env variables automatically
8
+ load_dotenv()
9
+
10
+
11
+ class LoudlyClient:
12
+ DEFAULT_BASE_URL = "https://api.loudly.com/v1"
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: Optional[str] = None,
17
+ config: Optional[Dict[str, Any]] = None,
18
+ timeout: float = 10.0,
19
+ base_url: Optional[str] = None,
20
+ ):
21
+ if config:
22
+ api_key = config.get("apiKey", api_key)
23
+
24
+ self.api_key = api_key or os.getenv("LOUDLY_API_KEY")
25
+ self.timeout = timeout
26
+ self.base_url = base_url or self.DEFAULT_BASE_URL
27
+
28
+ self.session = requests.Session()
29
+ if self.api_key:
30
+ self._set_auth_header(self.api_key)
31
+
32
+ # -------------------
33
+ # Fluent setters
34
+ # -------------------
35
+ def with_api_key(self, api_key: str) -> "LoudlyClient":
36
+ self._set_auth_header(api_key)
37
+ return self
38
+
39
+ def with_timeout(self, timeout: float) -> "LoudlyClient":
40
+ self.timeout = timeout
41
+ return self
42
+
43
+ def with_base_url(self, base_url: str) -> "LoudlyClient":
44
+ self.base_url = base_url
45
+ return self
46
+
47
+ # -------------------
48
+ # Internal helpers
49
+ # -------------------
50
+ def _set_auth_header(self, api_key: str):
51
+ self.api_key = api_key
52
+ self.session.headers.update({
53
+ "Authorization": f"Bearer {self.api_key}",
54
+ "Content-Type": "application/json",
55
+ "Accept": "application/json",
56
+ })
57
+
58
+ def _ensure_api_key(self):
59
+ if not self.api_key:
60
+ raise ValueError(
61
+ "No API key set. Use api_key=..., config={'apiKey': ...}, "
62
+ "LOUDLY_API_KEY in a .env file, or .with_api_key()."
63
+ )
64
+
65
+ def _request(self, method: str, path: str, params=None, json=None, headers=None):
66
+ self._ensure_api_key()
67
+ url = f"{self.base_url}{path}"
68
+ try:
69
+ resp = self.session.request(
70
+ method=method,
71
+ url=url,
72
+ params=params,
73
+ json=json,
74
+ headers=headers,
75
+ timeout=self.timeout
76
+ )
77
+ except requests.RequestException as e:
78
+ raise LoudlyAPIError(-1, f"Network error: {e}")
79
+
80
+ if not resp.ok:
81
+ try:
82
+ err = resp.json()
83
+ message = err.get("error", err.get("message", resp.text))
84
+ except ValueError:
85
+ message = resp.text
86
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
87
+
88
+ try:
89
+ return resp.json()
90
+ except ValueError:
91
+ return {"raw": resp.text}
92
+
93
+ # -------------------
94
+ # API Methods
95
+ # -------------------
96
+
97
+ # 1. Genres
98
+ def list_genres(self) -> List[Dict[str, Any]]:
99
+ self._ensure_api_key()
100
+ headers = {
101
+ "API-KEY": self.api_key,
102
+ "Accept": "application/json"
103
+ }
104
+ url = "https://soundtracks.loudly.com/api/ai/genres"
105
+
106
+ try:
107
+ resp = requests.get(url, headers=headers, timeout=self.timeout)
108
+ except requests.RequestException as e:
109
+ raise LoudlyAPIError(-1, f"Network error: {e}")
110
+
111
+ if not resp.ok:
112
+ try:
113
+ err = resp.json()
114
+ message = err.get("error", err.get("message", resp.text))
115
+ except ValueError:
116
+ message = resp.text
117
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
118
+
119
+ try:
120
+ return resp.json()
121
+ except ValueError:
122
+ return {"raw": resp.text}
123
+
124
+ # 2. Structures
125
+ def list_structures(self) -> List[Dict[str, Any]]:
126
+ self._ensure_api_key()
127
+ headers = {
128
+ "API-KEY": self.api_key,
129
+ "Accept": "application/json"
130
+ }
131
+ url = "https://soundtracks.loudly.com/api/ai/structures"
132
+
133
+ try:
134
+ resp = requests.get(url, headers=headers, timeout=self.timeout)
135
+ except requests.RequestException as e:
136
+ raise LoudlyAPIError(-1, f"Network error: {e}")
137
+
138
+ if not resp.ok:
139
+ try:
140
+ err = resp.json()
141
+ message = err.get("error", err.get("message", resp.text))
142
+ except ValueError:
143
+ message = resp.text
144
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
145
+
146
+ try:
147
+ return resp.json()
148
+ except ValueError:
149
+ return {"raw": resp.text}
150
+
151
+ # 3. Random prompt
152
+ def get_random_prompt(self) -> Dict[str, Any]:
153
+ self._ensure_api_key()
154
+ headers = {
155
+ "API-KEY": self.api_key,
156
+ "Accept": "application/json"
157
+ }
158
+ url = "https://soundtracks.loudly.com/api/ai/prompt/random"
159
+
160
+ try:
161
+ resp = requests.get(url, headers=headers, timeout=self.timeout)
162
+ except requests.RequestException as e:
163
+ raise LoudlyAPIError(-1, f"Network error: {e}")
164
+
165
+ if not resp.ok:
166
+ try:
167
+ err = resp.json()
168
+ message = err.get("error", err.get("message", resp.text))
169
+ except ValueError:
170
+ message = resp.text
171
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
172
+
173
+ try:
174
+ return resp.json()
175
+ except ValueError:
176
+ return {"raw": resp.text}
177
+
178
+ # 4. Song tags
179
+ def get_song_tags(
180
+ self,
181
+ mood: Optional[List[str]] = None,
182
+ genre: Optional[List[str]] = None,
183
+ key: Optional[List[str]] = None
184
+ ) -> Dict[str, Any]:
185
+ self._ensure_api_key()
186
+ headers = {
187
+ "API-KEY": self.api_key,
188
+ "Accept": "application/json"
189
+ }
190
+ url = "https://soundtracks.loudly.com/api/songs/tags"
191
+ payload = {}
192
+ if mood: payload["mood"] = mood
193
+ if genre: payload["genre"] = genre
194
+ if key: payload["key"] = key
195
+
196
+ try:
197
+ resp = requests.get(url, headers=headers, json=payload, timeout=self.timeout)
198
+ except requests.RequestException as e:
199
+ raise LoudlyAPIError(-1, f"Network error: {e}")
200
+
201
+ if not resp.ok:
202
+ try:
203
+ err = resp.json()
204
+ message = err.get("error", err.get("message", resp.text))
205
+ except ValueError:
206
+ message = resp.text
207
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
208
+
209
+ try:
210
+ return resp.json()
211
+ except ValueError:
212
+ return {"raw": resp.text}
213
+
214
+ # 5. List songs
215
+ def list_songs(
216
+ self,
217
+ page: int = 1,
218
+ per_page: int = 20
219
+ ) -> Dict[str, Any]:
220
+ self._ensure_api_key()
221
+ headers = {
222
+ "API-KEY": self.api_key,
223
+ "Accept": "application/json"
224
+ }
225
+ params = {"page": page, "per_page": per_page}
226
+ url = "https://soundtracks.loudly.com/api/songs"
227
+
228
+ try:
229
+ resp = requests.get(url, headers=headers, params=params, timeout=self.timeout)
230
+ except requests.RequestException as e:
231
+ raise LoudlyAPIError(-1, f"Network error: {e}")
232
+
233
+ if not resp.ok:
234
+ try:
235
+ err = resp.json()
236
+ message = err.get("error", err.get("message", resp.text))
237
+ except ValueError:
238
+ message = resp.text
239
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
240
+
241
+ try:
242
+ return resp.json()
243
+ except ValueError:
244
+ return {"raw": resp.text}
245
+
246
+ # 6. Song Generation
247
+ def generate_ai_song(
248
+ self,
249
+ genre: str,
250
+ genre_blend: str = "",
251
+ duration: Optional[int] = None,
252
+ energy: Optional[str] = "",
253
+ bpm: Optional[int] = None,
254
+ key_root: Optional[str] = "",
255
+ key_quality: Optional[str] = "",
256
+ instruments: Optional[str] = "",
257
+ structure_id: Optional[int] = None,
258
+ test: Optional[bool] = False
259
+ ) -> Dict[str, Any]:
260
+ """Generate AI song using form data"""
261
+
262
+ if not genre:
263
+ raise ValueError("genre is required")
264
+
265
+ self._ensure_api_key()
266
+
267
+ url = "https://soundtracks.loudly.com/api/ai/songs"
268
+
269
+ # Prepare form data - only include non-empty values
270
+ data = {"genre": genre}
271
+
272
+ if genre_blend:
273
+ data["genre_blend"] = genre_blend
274
+ if duration is not None:
275
+ data["duration"] = str(duration)
276
+ if energy:
277
+ data["energy"] = energy
278
+ if bpm is not None:
279
+ data["bpm"] = str(bpm)
280
+ if key_root:
281
+ data["key_root"] = key_root
282
+ if key_quality:
283
+ data["key_quality"] = key_quality
284
+ if instruments:
285
+ data["instruments"] = instruments
286
+ if structure_id is not None:
287
+ data["structure_id"] = str(structure_id)
288
+ if test is not None:
289
+ data["test"] = str(test).lower()
290
+
291
+ headers = {
292
+ "API-KEY": self.api_key,
293
+ "Accept": "application/json"
294
+ # Don't set Content-Type - requests will set it automatically for form data
295
+ }
296
+
297
+ try:
298
+ resp = requests.post(url, headers=headers, data=data, timeout=self.timeout)
299
+ except requests.RequestException as e:
300
+ raise LoudlyAPIError(-1, f"Network error: {e}")
301
+
302
+ if not resp.ok:
303
+ try:
304
+ err = resp.json()
305
+ message = err.get("error", err.get("message", resp.text))
306
+ except ValueError:
307
+ message = resp.text
308
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
309
+
310
+ try:
311
+ return resp.json()
312
+ except ValueError:
313
+ return {"raw": resp.text}
314
+
315
+ # 7. Song generation from prompt
316
+ def generate_song_from_prompt(
317
+ self,
318
+ prompt: str,
319
+ duration: Optional[int] = None,
320
+ test: Optional[bool] = False,
321
+ structure_id: Optional[int] = None
322
+ ) -> Dict[str, Any]:
323
+
324
+ if not prompt:
325
+ raise ValueError("Prompt is required")
326
+
327
+ self._ensure_api_key()
328
+
329
+ url = "https://soundtracks.loudly.com/api/ai/prompt/songs"
330
+
331
+ data = {"prompt": prompt}
332
+
333
+ if duration is not None:
334
+ data["duration"] = str(duration)
335
+
336
+ if test is not None:
337
+ data["test"] = str(test).lower()
338
+
339
+ if structure_id is not None:
340
+ data["structure_id"] = str(structure_id)
341
+
342
+ headers = {
343
+ "API-KEY": self.api_key,
344
+ "Accept": "application/json"
345
+ }
346
+
347
+ try:
348
+ resp = requests.post(url, headers=headers, data=data, timeout=self.timeout)
349
+ except requests.RequestException as e:
350
+ raise LoudlyAPIError(-1, f"Network error: {e}")
351
+
352
+ if not resp.ok:
353
+ try:
354
+ err = resp.json()
355
+ message = err.get("error", err.get("message", resp.text))
356
+ except ValueError:
357
+ message = resp.text
358
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
359
+
360
+ try:
361
+ return resp.json()
362
+ except ValueError:
363
+ return {"raw": resp.text}
364
+
365
+ # 8. Limits Account
366
+ def get_limits(
367
+ self,
368
+ date_from: Optional[str] = None,
369
+ date_to: Optional[str] = None
370
+ ) -> List[Dict[str, Any]]:
371
+ self._ensure_api_key()
372
+ headers = {
373
+ "API-KEY": self.api_key,
374
+ "Accept": "application/json"
375
+ }
376
+ # Use the soundtracks.loudly.com domain for limits as well
377
+ url = "https://soundtracks.loudly.com/api/account/limits"
378
+
379
+ params: Dict[str, Any] = {}
380
+ if date_from:
381
+ params["date_from"] = date_from
382
+ if date_to:
383
+ params["date_to"] = date_to
384
+
385
+ try:
386
+ resp = requests.get(url, headers=headers, params=params, timeout=self.timeout)
387
+ except requests.RequestException as e:
388
+ raise LoudlyAPIError(-1, f"Network error: {e}")
389
+
390
+ if not resp.ok:
391
+ try:
392
+ err = resp.json()
393
+ message = err.get("error", err.get("message", resp.text))
394
+ except ValueError:
395
+ message = resp.text
396
+ raise LoudlyAPIError(resp.status_code, message, response=resp)
397
+
398
+ try:
399
+ return resp.json()
400
+ except ValueError:
401
+ return {"raw": resp.text}
@@ -0,0 +1,7 @@
1
+ class LoudlyAPIError(Exception):
2
+ """Custom exception for Loudly API errors."""
3
+
4
+ def __init__(self, status_code: int, message: str, response=None):
5
+ super().__init__(f"HTTP {status_code}: {message}")
6
+ self.status_code = status_code
7
+ self.response = response
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: loudly-py-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Loudly Music API
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.25.0
9
+ Requires-Dist: python-dotenv>=1.0.0
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/loudly_py_sdk/__init__.py
4
+ src/loudly_py_sdk/client.py
5
+ src/loudly_py_sdk/exceptions.py
6
+ src/loudly_py_sdk.egg-info/PKG-INFO
7
+ src/loudly_py_sdk.egg-info/SOURCES.txt
8
+ src/loudly_py_sdk.egg-info/dependency_links.txt
9
+ src/loudly_py_sdk.egg-info/requires.txt
10
+ src/loudly_py_sdk.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ requests>=2.25.0
2
+ python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ loudly_py_sdk
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Loudly API SDK Test Script
4
+ Tests all major functionality of the Loudly API client with separate functions for each API call.
5
+ """
6
+
7
+ from loudly import LoudlyClient, LoudlyAPIError
8
+
9
+
10
+ def test_genres(sdk: LoudlyClient) -> None:
11
+ """Test listing available genres."""
12
+ print("\n=== GENRES ===")
13
+ try:
14
+ genres = sdk.list_genres()
15
+ print(f"Found {len(genres)} genres:")
16
+ for genre in genres:
17
+ print(f" {genre['id']}: {genre['name']} ({genre['description']})")
18
+ except LoudlyAPIError as e:
19
+ print(f"Genres API Error [{e.status_code}]: {e.message}")
20
+ except Exception as e:
21
+ print(f"Genres Error: {e}")
22
+
23
+
24
+ def test_structures(sdk: LoudlyClient) -> None:
25
+ """Test listing available song structures."""
26
+ print("\n=== STRUCTURES ===")
27
+ try:
28
+ structures = sdk.list_structures()
29
+ print(f" Found {len(structures)} structures:")
30
+ for s in structures:
31
+ print(f" {s['id']}: {s['name']} ({s['description']})")
32
+ except LoudlyAPIError as e:
33
+ print(f"Structures API Error [{e.status_code}]: {e.message}")
34
+ except Exception as e:
35
+ print(f"Structures Error: {e}")
36
+
37
+
38
+ def test_random_prompt(sdk: LoudlyClient) -> None:
39
+ """Test getting a random prompt."""
40
+ print("\n=== RANDOM PROMPT ===")
41
+ try:
42
+ prompt_data = sdk.get_random_prompt()
43
+ print(f"Random prompt: {prompt_data['prompt']}")
44
+ except LoudlyAPIError as e:
45
+ print(f"Random Prompt API Error [{e.status_code}]: {e.message}")
46
+ except Exception as e:
47
+ print(f"Random Prompt Error: {e}")
48
+
49
+
50
+ def test_song_tags(sdk: LoudlyClient) -> None:
51
+ """Test getting song tags with filters."""
52
+ print("\n=== SONG TAGS ===")
53
+ try:
54
+ tags = sdk.get_song_tags(
55
+ mood=["Dreamy", "Laid Back", "Dark"],
56
+ genre=["Funk", "Beats", "Indian"],
57
+ key=["E Major", "Ab/G# Major"]
58
+ )
59
+ print("Song tags retrieved:")
60
+ print(f"Filters used: mood=['Dreamy', 'Laid Back', 'Dark'], genre=['Funk', 'Beats', 'Indian'], key=['E Major', 'Ab/G# Major']")
61
+ print(f"Result: {tags}")
62
+ except LoudlyAPIError as e:
63
+ print(f"Song Tags API Error [{e.status_code}]: {e.message}")
64
+ except Exception as e:
65
+ print(f"Song Tags Error: {e}")
66
+
67
+
68
+ def test_list_songs(sdk: LoudlyClient) -> None:
69
+ """Test listing songs with pagination."""
70
+ print("\n=== SONGS LIST ===")
71
+ try:
72
+ songs_data = sdk.list_songs(page=1, per_page=10)
73
+ print(f"Found {len(songs_data['items'])} songs on page 1:")
74
+ for song in songs_data["items"]:
75
+ print(f" {song['id']}: {song['title']} ({song['duration']} sec)")
76
+ except LoudlyAPIError as e:
77
+ print(f"List Songs API Error [{e.status_code}]: {e.message}")
78
+ except Exception as e:
79
+ print(f"List Songs Error: {e}")
80
+
81
+
82
+ def test_generate_ai_song(sdk: LoudlyClient) -> None:
83
+ """Test AI song generation with parameters."""
84
+ print("\n=== AI SONG GENERATION ===")
85
+ try:
86
+ song = sdk.generate_ai_song(
87
+ genre="House",
88
+ duration=30,
89
+ energy="high",
90
+ bpm=115,
91
+ key_root="D",
92
+ key_quality="minor",
93
+ instruments="Synth,Drums",
94
+ test=True
95
+ )
96
+
97
+ print("AI Song generated successfully:")
98
+ print(f"Title: {song['title']}")
99
+ print(f"Music File: {song['music_file_path']}")
100
+ print(f"Duration: {song.get('duration', 'N/A')} ms")
101
+ print(f"BPM: {song.get('bpm', 'N/A')}")
102
+ print(f"Key: {song.get('key', {}).get('name', 'N/A')}")
103
+
104
+ except LoudlyAPIError as e:
105
+ print(f"AI Song Generation API Error [{e.status_code}]: {e.message}")
106
+ except Exception as e:
107
+ print(f"AI Song Generation Error: {e}")
108
+
109
+
110
+ def test_generate_song_from_prompt(sdk: LoudlyClient) -> None:
111
+ """Test song generation from text prompt."""
112
+ print("\n=== SONG FROM PROMPT ===")
113
+ try:
114
+ song_from_prompt = sdk.generate_song_from_prompt(
115
+ prompt="A 90-second energetic house track with tropical vibes and a melodic flute line",
116
+ duration=30,
117
+ test=True
118
+ )
119
+
120
+ print("Song from prompt generated successfully:")
121
+ print(f"Prompt: 'A 90-second energetic house track with tropical vibes and a melodic flute line'")
122
+ print(f"Title: {song_from_prompt['title']}")
123
+ print(f"Music File: {song_from_prompt['music_file_path']}")
124
+ print(f"Duration: {song_from_prompt['duration']} ms")
125
+ print(f"BPM: {song_from_prompt['bpm']}")
126
+ print(f"Key: {song_from_prompt['key']['name']}")
127
+
128
+ except LoudlyAPIError as e:
129
+ print(f"Song from Prompt API Error [{e.status_code}]: {e.message}")
130
+ except Exception as e:
131
+ print(f"Song from Prompt Error: {e}")
132
+
133
+
134
+ def test_account_limits(sdk: LoudlyClient) -> None:
135
+ """Test getting account limits information."""
136
+ print("\n=== ACCOUNT LIMITS ===")
137
+ try:
138
+ # Test without date filters
139
+ print("Getting current limits...")
140
+ limits = sdk.get_limits()
141
+ print("Current limits retrieved:")
142
+ for limit in limits.get('limits', []):
143
+ print(f"{limit['request_type']}: {limit['used']}/{limit['limit']} ({limit['left']} remaining)")
144
+
145
+ top_up = limits.get('top_up', {})
146
+ if top_up.get('total', 0) > 0:
147
+ print(f"Top-up available: {top_up['available']}/{top_up['total']}")
148
+
149
+ # Test with date range
150
+ print("\n Getting limits for specific date range...")
151
+ limits_filtered = sdk.get_limits(date_from="2025-02-25", date_to="2025-03-27")
152
+ print("Filtered limits retrieved:")
153
+ print(f"Date range: 2025-02-25 to 2025-03-27")
154
+ for limit in limits_filtered.get('limits', []):
155
+ print(f"{limit['request_type']}: {limit['used']}/{limit['limit']} ({limit['left']} remaining)")
156
+
157
+ except LoudlyAPIError as e:
158
+ print(f"Account Limits API Error [{e.status_code}]: {e.message}")
159
+ except Exception as e:
160
+ print(f"Account Limits Error: {e}")
161
+
162
+
163
+ def main():
164
+ """Main function to run all tests."""
165
+ print("Loudly API SDK Test Suite")
166
+ print("=" * 60)
167
+
168
+ # Initialize SDK
169
+ sdk = LoudlyClient(
170
+ api_key="gkatOxIWWGY26B4Czb9H8UF01JGwHa2Kiigf8nTiHnI",
171
+ base_url="https://soundtracks.loudly.com"
172
+ )
173
+
174
+ # Run all tests
175
+ test_functions = [
176
+ test_genres,
177
+ test_structures,
178
+ test_random_prompt,
179
+ test_song_tags,
180
+ test_list_songs,
181
+ test_generate_ai_song,
182
+ test_generate_song_from_prompt,
183
+ test_account_limits
184
+ ]
185
+
186
+ for test_func in test_functions:
187
+ try:
188
+ test_func(sdk)
189
+ except KeyboardInterrupt:
190
+ print("\n Test interrupted by user")
191
+ break
192
+ except Exception as e:
193
+ print(f"Unexpected error in {test_func.__name__}: {e}")
194
+
195
+ print("\n" + "=" * 60)
196
+ print("Test suite completed")
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()