opensecureconf-client 1.0.2__py3-none-any.whl → 2.0.2__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.
opensecureconf_client.py CHANGED
@@ -1,15 +1,31 @@
1
1
  """
2
- OpenSecureConf Python Client
2
+ OpenSecureConf Python Client - Enhanced Edition
3
3
 
4
4
  A Python client library for interacting with the OpenSecureConf API,
5
- which provides encrypted configuration management with multithreading support.
5
+ which provides encrypted configuration management with clustering support.
6
+
7
+ Enhanced Features:
8
+ - Automatic retry logic with exponential backoff
9
+ - Cluster awareness (status, health)
10
+ - Connection pooling
11
+ - Structured logging
12
+ - Batch operations
13
+ - Enhanced input validation
14
+ - Health check utilities
6
15
  """
7
16
 
8
17
  from typing import Any, Dict, List, Optional
9
-
18
+ import logging
19
+ import time
10
20
  import requests
11
- from requests.exceptions import Timeout
21
+ from requests.adapters import HTTPAdapter
22
+ from requests.exceptions import Timeout, RequestException
23
+ from urllib3.util.retry import Retry
24
+
12
25
 
26
+ # ============================================================================
27
+ # EXCEPTIONS
28
+ # ============================================================================
13
29
 
14
30
  class OpenSecureConfError(Exception):
15
31
  """Base exception for OpenSecureConf client errors."""
@@ -27,9 +43,17 @@ class ConfigurationExistsError(OpenSecureConfError):
27
43
  """Raised when attempting to create a configuration that already exists."""
28
44
 
29
45
 
46
+ class ClusterError(OpenSecureConfError):
47
+ """Raised when cluster operations fail."""
48
+
49
+
50
+ # ============================================================================
51
+ # CLIENT
52
+ # ============================================================================
53
+
30
54
  class OpenSecureConfClient:
31
55
  """
32
- Client for interacting with the OpenSecureConf API.
56
+ Enhanced client for interacting with the OpenSecureConf API.
33
57
 
34
58
  This client provides methods to create, read, update, delete, and list
35
59
  encrypted configuration entries stored in an OpenSecureConf service.
@@ -39,12 +63,15 @@ class OpenSecureConfClient:
39
63
  user_key (str): The encryption key used for authentication and encryption/decryption.
40
64
  api_key (Optional[str]): Optional API key for additional authentication.
41
65
  timeout (int): Request timeout in seconds.
66
+ logger (logging.Logger): Logger instance for debugging.
42
67
 
43
68
  Example:
44
69
  >>> client = OpenSecureConfClient(
45
70
  ... base_url="http://localhost:9000",
46
71
  ... user_key="my-secret-key-123",
47
- ... api_key="optional-api-key"
72
+ ... api_key="optional-api-key",
73
+ ... enable_retry=True,
74
+ ... log_level="INFO"
48
75
  ... )
49
76
  >>> config = client.create("database", {"host": "localhost", "port": 5432})
50
77
  >>> print(config["value"])
@@ -57,10 +84,16 @@ class OpenSecureConfClient:
57
84
  user_key: str,
58
85
  api_key: Optional[str] = None,
59
86
  timeout: int = 30,
60
- verify_ssl: bool = True
87
+ verify_ssl: bool = True,
88
+ enable_retry: bool = True,
89
+ max_retries: int = 3,
90
+ backoff_factor: float = 1.0,
91
+ pool_connections: int = 10,
92
+ pool_maxsize: int = 20,
93
+ log_level: str = "WARNING"
61
94
  ):
62
95
  """
63
- Initialize the OpenSecureConf client.
96
+ Initialize the OpenSecureConf client with enhanced features.
64
97
 
65
98
  Args:
66
99
  base_url: The base URL of the OpenSecureConf API (e.g., "http://localhost:9000")
@@ -68,35 +101,87 @@ class OpenSecureConfClient:
68
101
  api_key: Optional API key for additional authentication
69
102
  timeout: Request timeout in seconds (default: 30)
70
103
  verify_ssl: Whether to verify SSL certificates (default: True)
104
+ enable_retry: Enable automatic retry with exponential backoff (default: True)
105
+ max_retries: Maximum number of retries for failed requests (default: 3)
106
+ backoff_factor: Backoff factor for retry delays (default: 1.0)
107
+ pool_connections: Number of connection pools (default: 10)
108
+ pool_maxsize: Maximum pool size (default: 20)
109
+ log_level: Logging level (default: WARNING)
71
110
 
72
111
  Raises:
73
- ValueError: If user_key is shorter than 8 characters
112
+ ValueError: If user_key is shorter than 8 characters or invalid parameters
74
113
  """
114
+ # Validation
75
115
  if len(user_key) < 8:
76
116
  raise ValueError("User key must be at least 8 characters long")
77
-
117
+ if not base_url:
118
+ raise ValueError("base_url cannot be empty")
119
+ if timeout <= 0:
120
+ raise ValueError("timeout must be positive")
121
+ if max_retries < 0:
122
+ raise ValueError("max_retries must be non-negative")
123
+
124
+ # Configuration
78
125
  self.base_url = base_url.rstrip("/")
79
126
  self.user_key = user_key
80
127
  self.api_key = api_key
81
128
  self.timeout = timeout
82
129
  self.verify_ssl = verify_ssl
130
+
131
+ # Setup logging
132
+ self.logger = logging.getLogger(__name__)
133
+ self.logger.setLevel(getattr(logging, log_level.upper(), logging.WARNING))
134
+ if not self.logger.handlers:
135
+ handler = logging.StreamHandler()
136
+ formatter = logging.Formatter(
137
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
138
+ )
139
+ handler.setFormatter(formatter)
140
+ self.logger.addHandler(handler)
141
+
142
+ # Initialize session
83
143
  self._session = requests.Session()
84
144
 
145
+ # Setup connection pooling
146
+ adapter = HTTPAdapter(
147
+ pool_connections=pool_connections,
148
+ pool_maxsize=pool_maxsize
149
+ )
150
+
151
+ # Setup retry strategy if enabled
152
+ if enable_retry:
153
+ retry_strategy = Retry(
154
+ total=max_retries,
155
+ backoff_factor=backoff_factor,
156
+ status_forcelist=[429, 500, 502, 503, 504],
157
+ allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE", "POST"]
158
+ )
159
+ adapter = HTTPAdapter(
160
+ max_retries=retry_strategy,
161
+ pool_connections=pool_connections,
162
+ pool_maxsize=pool_maxsize
163
+ )
164
+ self.logger.info(f"Retry enabled: max_retries={max_retries}, backoff_factor={backoff_factor}")
165
+
166
+ self._session.mount("http://", adapter)
167
+ self._session.mount("https://", adapter)
168
+
85
169
  # Setup headers
86
170
  headers = {
87
- "x-user-key": self.user_key,
171
+ "x-user-key": self.user_key,
88
172
  "Content-Type": "application/json"
89
173
  }
90
174
 
91
- # Add X-API-Key header if api_key is provided
92
175
  if self.api_key:
93
176
  headers["X-API-Key"] = self.api_key
94
177
 
95
178
  self._session.headers.update(headers)
96
179
 
180
+ self.logger.info(f"Client initialized for {self.base_url}")
181
+
97
182
  def _make_request(self, method: str, endpoint: str, **kwargs) -> Any:
98
183
  """
99
- Make an HTTP request to the API with error handling.
184
+ Make an HTTP request to the API with error handling and logging.
100
185
 
101
186
  Args:
102
187
  method: HTTP method (GET, POST, PUT, DELETE)
@@ -117,38 +202,89 @@ class OpenSecureConfClient:
117
202
  kwargs.setdefault("timeout", self.timeout)
118
203
  kwargs.setdefault("verify", self.verify_ssl)
119
204
 
205
+ start_time = time.time()
206
+ self.logger.debug(f"{method} {url}")
207
+
120
208
  try:
121
209
  response = self._session.request(method, url, **kwargs)
210
+ duration = time.time() - start_time
122
211
 
212
+ self.logger.info(
213
+ f"{method} {endpoint} - Status: {response.status_code} - Duration: {duration:.3f}s"
214
+ )
215
+
216
+ # Handle error responses
123
217
  if response.status_code == 401:
218
+ self.logger.error("Authentication failed: invalid or missing user key")
124
219
  raise AuthenticationError(
125
220
  "Authentication failed: invalid or missing user key"
126
221
  )
222
+
223
+ if response.status_code == 403:
224
+ self.logger.error("Forbidden: invalid API key")
225
+ raise AuthenticationError("Forbidden: invalid API key")
226
+
127
227
  if response.status_code == 404:
128
- raise ConfigurationNotFoundError("Configuration not found")
228
+ error_detail = response.json().get("detail", "Configuration not found")
229
+ self.logger.warning(f"Not found: {error_detail}")
230
+ raise ConfigurationNotFoundError(error_detail)
231
+
129
232
  if response.status_code == 400:
130
233
  error_detail = response.json().get("detail", "Bad request")
131
234
  if "already exists" in error_detail.lower():
235
+ self.logger.warning(f"Configuration exists: {error_detail}")
132
236
  raise ConfigurationExistsError(error_detail)
237
+ self.logger.error(f"Bad request: {error_detail}")
133
238
  raise OpenSecureConfError(f"Bad request: {error_detail}")
239
+
134
240
  if response.status_code >= 400:
135
241
  error_detail = response.json().get("detail", "Unknown error")
242
+ self.logger.error(f"API error {response.status_code}: {error_detail}")
136
243
  raise OpenSecureConfError(
137
244
  f"API error ({response.status_code}): {error_detail}"
138
245
  )
139
246
 
247
+ # Handle successful responses
140
248
  if response.status_code == 204 or not response.content:
141
249
  return None
142
250
 
143
251
  return response.json()
144
252
 
145
253
  except (ConnectionError, Timeout) as e:
254
+ self.logger.error(f"Connection error: {str(e)}")
146
255
  raise ConnectionError(
147
256
  f"Failed to connect to {self.base_url}: {str(e)}"
148
257
  ) from e
258
+ except RequestException as e:
259
+ self.logger.error(f"Request error: {str(e)}")
260
+ raise OpenSecureConfError(f"Request failed: {str(e)}") from e
149
261
  except ValueError as e:
262
+ self.logger.error(f"Invalid JSON response: {str(e)}")
150
263
  raise OpenSecureConfError(f"Invalid JSON response: {str(e)}") from e
151
264
 
265
+ # ========================================================================
266
+ # HEALTH & STATUS
267
+ # ========================================================================
268
+
269
+ def ping(self) -> bool:
270
+ """
271
+ Check if the API server is reachable and responding.
272
+
273
+ Returns:
274
+ True if server is healthy, False otherwise
275
+
276
+ Example:
277
+ >>> if client.ping():
278
+ ... print("Server is healthy")
279
+ """
280
+ try:
281
+ self.get_service_info()
282
+ self.logger.debug("Ping successful")
283
+ return True
284
+ except Exception as e:
285
+ self.logger.warning(f"Ping failed: {str(e)}")
286
+ return False
287
+
152
288
  def get_service_info(self) -> Dict[str, Any]:
153
289
  """
154
290
  Get information about the OpenSecureConf service.
@@ -159,12 +295,65 @@ class OpenSecureConfClient:
159
295
  Example:
160
296
  >>> info = client.get_service_info()
161
297
  >>> print(info["version"])
162
- 1.0.0
298
+ 2.1.0
163
299
  """
164
300
  return self._make_request("GET", "/")
165
301
 
302
+ # ========================================================================
303
+ # CLUSTER OPERATIONS
304
+ # ========================================================================
305
+
306
+ def get_cluster_status(self) -> Dict[str, Any]:
307
+ """
308
+ Get cluster status and node information.
309
+
310
+ Returns:
311
+ Dictionary containing cluster status with fields:
312
+ - enabled: Whether clustering is enabled
313
+ - mode: Cluster mode (replica or federated)
314
+ - node_id: Current node identifier
315
+ - total_nodes: Total number of nodes in cluster
316
+ - healthy_nodes: Number of healthy nodes
317
+
318
+ Raises:
319
+ ClusterError: If cluster status cannot be retrieved
320
+
321
+ Example:
322
+ >>> status = client.get_cluster_status()
323
+ >>> print(f"Cluster mode: {status['mode']}")
324
+ >>> print(f"Healthy nodes: {status['healthy_nodes']}/{status['total_nodes']}")
325
+ """
326
+ try:
327
+ return self._make_request("GET", "/cluster/status")
328
+ except OpenSecureConfError as e:
329
+ raise ClusterError(f"Failed to get cluster status: {str(e)}") from e
330
+
331
+ def get_cluster_health(self) -> Dict[str, Any]:
332
+ """
333
+ Check cluster node health.
334
+
335
+ Returns:
336
+ Dictionary containing health status
337
+
338
+ Example:
339
+ >>> health = client.get_cluster_health()
340
+ >>> print(health["status"])
341
+ healthy
342
+ """
343
+ try:
344
+ return self._make_request("GET", "/cluster/health")
345
+ except OpenSecureConfError as e:
346
+ raise ClusterError(f"Failed to check cluster health: {str(e)}") from e
347
+
348
+ # ========================================================================
349
+ # CONFIGURATION CRUD OPERATIONS
350
+ # ========================================================================
351
+
166
352
  def create(
167
- self, key: str, value: Dict[str, Any], category: Optional[str] = None
353
+ self,
354
+ key: str,
355
+ value: Dict[str, Any],
356
+ category: Optional[str] = None
168
357
  ) -> Dict[str, Any]:
169
358
  """
170
359
  Create a new encrypted configuration entry.
@@ -183,7 +372,7 @@ class OpenSecureConfClient:
183
372
 
184
373
  Raises:
185
374
  ConfigurationExistsError: If configuration key already exists
186
- ValueError: If key is invalid
375
+ ValueError: If key or value is invalid
187
376
 
188
377
  Example:
189
378
  >>> config = client.create(
@@ -192,11 +381,17 @@ class OpenSecureConfClient:
192
381
  ... category="production"
193
382
  ... )
194
383
  """
195
- if not key or len(key) > 255:
384
+ # Enhanced validation
385
+ if not key or not isinstance(key, str):
386
+ raise ValueError("Key must be a non-empty string")
387
+ if len(key) > 255:
196
388
  raise ValueError("Key must be between 1 and 255 characters")
389
+ if not isinstance(value, dict):
390
+ raise ValueError("Value must be a dictionary")
391
+ if category and len(category) > 100:
392
+ raise ValueError("Category must be max 100 characters")
197
393
 
198
394
  payload = {"key": key, "value": value, "category": category}
199
-
200
395
  return self._make_request("POST", "/configs", json=payload)
201
396
 
202
397
  def read(self, key: str) -> Dict[str, Any]:
@@ -211,16 +406,23 @@ class OpenSecureConfClient:
211
406
 
212
407
  Raises:
213
408
  ConfigurationNotFoundError: If configuration key does not exist
409
+ ValueError: If key is invalid
214
410
 
215
411
  Example:
216
412
  >>> config = client.read("database")
217
413
  >>> print(config["value"]["host"])
218
414
  localhost
219
415
  """
416
+ if not key or not isinstance(key, str):
417
+ raise ValueError("Key must be a non-empty string")
418
+
220
419
  return self._make_request("GET", f"/configs/{key}")
221
420
 
222
421
  def update(
223
- self, key: str, value: Dict[str, Any], category: Optional[str] = None
422
+ self,
423
+ key: str,
424
+ value: Dict[str, Any],
425
+ category: Optional[str] = None
224
426
  ) -> Dict[str, Any]:
225
427
  """
226
428
  Update an existing configuration entry with new encrypted value.
@@ -235,6 +437,7 @@ class OpenSecureConfClient:
235
437
 
236
438
  Raises:
237
439
  ConfigurationNotFoundError: If configuration key does not exist
440
+ ValueError: If parameters are invalid
238
441
 
239
442
  Example:
240
443
  >>> config = client.update(
@@ -242,8 +445,14 @@ class OpenSecureConfClient:
242
445
  ... value={"host": "db.example.com", "port": 5432}
243
446
  ... )
244
447
  """
245
- payload = {"value": value, "category": category}
448
+ if not key or not isinstance(key, str):
449
+ raise ValueError("Key must be a non-empty string")
450
+ if not isinstance(value, dict):
451
+ raise ValueError("Value must be a dictionary")
452
+ if category and len(category) > 100:
453
+ raise ValueError("Category must be max 100 characters")
246
454
 
455
+ payload = {"value": value, "category": category}
247
456
  return self._make_request("PUT", f"/configs/{key}", json=payload)
248
457
 
249
458
  def delete(self, key: str) -> Dict[str, str]:
@@ -258,12 +467,16 @@ class OpenSecureConfClient:
258
467
 
259
468
  Raises:
260
469
  ConfigurationNotFoundError: If configuration key does not exist
470
+ ValueError: If key is invalid
261
471
 
262
472
  Example:
263
473
  >>> result = client.delete("database")
264
474
  >>> print(result["message"])
265
475
  Configuration 'database' deleted successfully
266
476
  """
477
+ if not key or not isinstance(key, str):
478
+ raise ValueError("Key must be a non-empty string")
479
+
267
480
  return self._make_request("DELETE", f"/configs/{key}")
268
481
 
269
482
  def list_all(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
@@ -285,16 +498,249 @@ class OpenSecureConfClient:
285
498
  params = {"category": category} if category else {}
286
499
  return self._make_request("GET", "/configs", params=params)
287
500
 
501
+ # ========================================================================
502
+ # BATCH OPERATIONS
503
+ # ========================================================================
504
+
505
+ def bulk_create(
506
+ self,
507
+ configs: List[Dict[str, Any]],
508
+ ignore_errors: bool = False
509
+ ) -> List[Dict[str, Any]]:
510
+ """
511
+ Create multiple configurations in batch.
512
+
513
+ Args:
514
+ configs: List of configuration dictionaries with 'key', 'value', and optional 'category'
515
+ ignore_errors: If True, continue on errors and return partial results
516
+
517
+ Returns:
518
+ List of created configuration dictionaries
519
+
520
+ Raises:
521
+ ValueError: If configs format is invalid
522
+ OpenSecureConfError: If creation fails and ignore_errors is False
523
+
524
+ Example:
525
+ >>> configs = [
526
+ ... {"key": "db1", "value": {"host": "localhost"}, "category": "prod"},
527
+ ... {"key": "db2", "value": {"host": "remote"}, "category": "prod"}
528
+ ... ]
529
+ >>> results = client.bulk_create(configs)
530
+ >>> print(f"Created {len(results)} configurations")
531
+ """
532
+ if not isinstance(configs, list):
533
+ raise ValueError("configs must be a list")
534
+
535
+ results = []
536
+ errors = []
537
+
538
+ for i, config in enumerate(configs):
539
+ if not isinstance(config, dict):
540
+ raise ValueError(f"Config at index {i} must be a dictionary")
541
+ if "key" not in config or "value" not in config:
542
+ raise ValueError(f"Config at index {i} missing required 'key' or 'value'")
543
+
544
+ try:
545
+ result = self.create(
546
+ key=config["key"],
547
+ value=config["value"],
548
+ category=config.get("category")
549
+ )
550
+ results.append(result)
551
+ self.logger.info(f"Bulk create: created '{config['key']}'")
552
+ except Exception as e:
553
+ error_msg = f"Failed to create '{config['key']}': {str(e)}"
554
+ self.logger.error(error_msg)
555
+ errors.append({"key": config["key"], "error": str(e)})
556
+ if not ignore_errors:
557
+ raise OpenSecureConfError(error_msg) from e
558
+
559
+ if errors:
560
+ self.logger.warning(f"Bulk create completed with {len(errors)} errors")
561
+
562
+ return results
563
+
564
+ def bulk_read(
565
+ self,
566
+ keys: List[str],
567
+ ignore_errors: bool = False
568
+ ) -> List[Dict[str, Any]]:
569
+ """
570
+ Read multiple configurations in batch.
571
+
572
+ Args:
573
+ keys: List of configuration keys to retrieve
574
+ ignore_errors: If True, skip missing keys and return partial results
575
+
576
+ Returns:
577
+ List of configuration dictionaries
578
+
579
+ Example:
580
+ >>> configs = client.bulk_read(["db1", "db2", "api"])
581
+ >>> print(f"Retrieved {len(configs)} configurations")
582
+ """
583
+ if not isinstance(keys, list):
584
+ raise ValueError("keys must be a list")
585
+
586
+ results = []
587
+ errors = []
588
+
589
+ for key in keys:
590
+ try:
591
+ result = self.read(key)
592
+ results.append(result)
593
+ except ConfigurationNotFoundError as e:
594
+ self.logger.warning(f"Bulk read: key '{key}' not found")
595
+ errors.append({"key": key, "error": str(e)})
596
+ if not ignore_errors:
597
+ raise
598
+ except Exception as e:
599
+ self.logger.error(f"Bulk read: failed to read '{key}': {str(e)}")
600
+ errors.append({"key": key, "error": str(e)})
601
+ if not ignore_errors:
602
+ raise
603
+
604
+ return results
605
+
606
+ def bulk_delete(
607
+ self,
608
+ keys: List[str],
609
+ ignore_errors: bool = False
610
+ ) -> Dict[str, Any]:
611
+ """
612
+ Delete multiple configurations in batch.
613
+
614
+ Args:
615
+ keys: List of configuration keys to delete
616
+ ignore_errors: If True, continue on errors
617
+
618
+ Returns:
619
+ Dictionary with summary: {"deleted": [...], "failed": [...]}
620
+
621
+ Example:
622
+ >>> result = client.bulk_delete(["temp1", "temp2", "temp3"])
623
+ >>> print(f"Deleted: {len(result['deleted'])}, Failed: {len(result['failed'])}")
624
+ """
625
+ if not isinstance(keys, list):
626
+ raise ValueError("keys must be a list")
627
+
628
+ deleted = []
629
+ failed = []
630
+
631
+ for key in keys:
632
+ try:
633
+ self.delete(key)
634
+ deleted.append(key)
635
+ self.logger.info(f"Bulk delete: deleted '{key}'")
636
+ except Exception as e:
637
+ self.logger.error(f"Bulk delete: failed to delete '{key}': {str(e)}")
638
+ failed.append({"key": key, "error": str(e)})
639
+ if not ignore_errors:
640
+ raise
641
+
642
+ return {"deleted": deleted, "failed": failed}
643
+
644
+ # ========================================================================
645
+ # UTILITY METHODS
646
+ # ========================================================================
647
+
648
+ def exists(self, key: str) -> bool:
649
+ """
650
+ Check if a configuration key exists.
651
+
652
+ Args:
653
+ key: Configuration key to check
654
+
655
+ Returns:
656
+ True if key exists, False otherwise
657
+
658
+ Example:
659
+ >>> if client.exists("database"):
660
+ ... print("Configuration exists")
661
+ """
662
+ try:
663
+ self.read(key)
664
+ return True
665
+ except ConfigurationNotFoundError:
666
+ return False
667
+
668
+ def get_or_default(
669
+ self,
670
+ key: str,
671
+ default: Dict[str, Any]
672
+ ) -> Dict[str, Any]:
673
+ """
674
+ Get configuration value or return default if not found.
675
+
676
+ Args:
677
+ key: Configuration key to retrieve
678
+ default: Default value to return if key not found
679
+
680
+ Returns:
681
+ Configuration dictionary or default value
682
+
683
+ Example:
684
+ >>> config = client.get_or_default(
685
+ ... "database",
686
+ ... {"host": "localhost", "port": 5432}
687
+ ... )
688
+ """
689
+ try:
690
+ return self.read(key)
691
+ except ConfigurationNotFoundError:
692
+ return {"key": key, "value": default, "category": None}
693
+
694
+ def count(self, category: Optional[str] = None) -> int:
695
+ """
696
+ Count total configurations, optionally filtered by category.
697
+
698
+ Args:
699
+ category: Optional category filter
700
+
701
+ Returns:
702
+ Number of configurations
703
+
704
+ Example:
705
+ >>> total = client.count()
706
+ >>> prod_count = client.count(category="production")
707
+ """
708
+ configs = self.list_all(category=category)
709
+ return len(configs)
710
+
711
+ def list_categories(self) -> List[str]:
712
+ """
713
+ Get list of all unique categories.
714
+
715
+ Returns:
716
+ List of category names
717
+
718
+ Example:
719
+ >>> categories = client.list_categories()
720
+ >>> print(f"Categories: {', '.join(categories)}")
721
+ """
722
+ configs = self.list_all()
723
+ categories = set()
724
+ for config in configs:
725
+ cat = config.get("category")
726
+ if cat:
727
+ categories.add(cat)
728
+ return sorted(list(categories))
729
+
730
+ # ========================================================================
731
+ # SESSION MANAGEMENT
732
+ # ========================================================================
733
+
288
734
  def close(self):
289
735
  """
290
736
  Close the underlying HTTP session.
291
-
292
737
  Should be called when the client is no longer needed to free resources.
293
738
 
294
739
  Example:
295
740
  >>> client.close()
296
741
  """
297
742
  self._session.close()
743
+ self.logger.info("Client session closed")
298
744
 
299
745
  def __enter__(self):
300
746
  """Context manager entry."""
@@ -304,6 +750,14 @@ class OpenSecureConfClient:
304
750
  """Context manager exit - automatically closes session."""
305
751
  self.close()
306
752
 
753
+ def __repr__(self):
754
+ """String representation of client."""
755
+ return f"OpenSecureConfClient(base_url='{self.base_url}')"
756
+
757
+
758
+ # ============================================================================
759
+ # EXPORTS
760
+ # ============================================================================
307
761
 
308
762
  __all__ = [
309
763
  "OpenSecureConfClient",
@@ -311,4 +765,5 @@ __all__ = [
311
765
  "AuthenticationError",
312
766
  "ConfigurationNotFoundError",
313
767
  "ConfigurationExistsError",
768
+ "ClusterError",
314
769
  ]