bt-cred-resolver 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,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: bt-cred-resolver
3
+ Version: 0.1.0
4
+ Summary: Utility classes for miscellaneous functions
5
+ Author-email: Bert Tejeda <berttejeda@gmail.com>
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: keyring>=24.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
10
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
11
+ Requires-Dist: pytest-mock>=3.11.1; extra == "dev"
@@ -0,0 +1,61 @@
1
+ # bt-utils
2
+
3
+ Utility classes for credential resolution and OS password management.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ For development:
12
+
13
+ ```bash
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from bt_cred_resolver import CredentialResolver, RequiredCredentialMissingError
21
+
22
+ # Initialize with optional logger
23
+ resolver = CredentialResolver(logger=logger)
24
+
25
+ # Resolve a credential from the OS password manager
26
+ token = resolver.resolve_credential(
27
+ 'JIRA_PERSONAL_ACCESS_TOKEN',
28
+ keyring_name='JIRA_PERSONAL_ACCESS_TOKEN',
29
+ required=True
30
+ )
31
+
32
+ # Or use the dict-based interface
33
+ jira_url = resolver.resolve(
34
+ 'JIRA_URL',
35
+ sources={'keyring': 'JIRA_URL'},
36
+ required=True
37
+ )
38
+ ```
39
+
40
+ You can also use `PasswordManager` directly:
41
+
42
+ ```python
43
+ from bt_cred_resolver import PasswordManager
44
+
45
+ pm = PasswordManager()
46
+
47
+ # Store a password
48
+ pm.store_password('MyService', 'username', 'secret_password')
49
+
50
+ # Retrieve a password
51
+ password = pm.retrieve_password('MyService', 'username')
52
+
53
+ # Delete a password
54
+ pm.delete_password('MyService', 'username')
55
+ ```
56
+
57
+ ## Running Tests
58
+
59
+ ```bash
60
+ pytest
61
+ ```
@@ -0,0 +1,60 @@
1
+ """
2
+ bt_cred_resolver provides utility classes for credential resolution and OS password management.
3
+ """
4
+ from .credential_resolver import (
5
+ CredentialResolver,
6
+ CredentialResolverError,
7
+ RequiredCredentialMissingError,
8
+ )
9
+ from .password_manager import (
10
+ PasswordManager,
11
+ PasswordManagerError,
12
+ PasswordNotFoundError,
13
+ InvalidInputError,
14
+ StorageError,
15
+ RetrievalError,
16
+ DeletionError,
17
+ )
18
+
19
+ __all__ = [
20
+ "CredentialResolver",
21
+ "CredentialResolverError",
22
+ "RequiredCredentialMissingError",
23
+ "PasswordManager",
24
+ "PasswordManagerError",
25
+ "PasswordNotFoundError",
26
+ "InvalidInputError",
27
+ "StorageError",
28
+ "RetrievalError",
29
+ "DeletionError",
30
+ "__version__",
31
+ ]
32
+
33
+ try:
34
+ from importlib.metadata import version, PackageNotFoundError
35
+ except ImportError:
36
+ # Python < 3.8
37
+ try:
38
+ from importlib_metadata import version, PackageNotFoundError
39
+ except ImportError:
40
+ PackageNotFoundError = Exception
41
+ def version(package_name):
42
+ raise PackageNotFoundError
43
+
44
+ try:
45
+ __version__ = version("bt-utils")
46
+ except (PackageNotFoundError, Exception):
47
+ # Package not installed, fall back to reading from pyproject.toml
48
+ import re
49
+ from pathlib import Path
50
+
51
+ pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
52
+ if pyproject_path.exists():
53
+ content = pyproject_path.read_text(encoding="utf-8")
54
+ match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
55
+ if match:
56
+ __version__ = match.group(1)
57
+ else:
58
+ __version__ = "0.0.0"
59
+ else:
60
+ __version__ = "0.0.0"
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Credential Resolver Class
4
+ Provides a unified interface for resolving credentials from multiple sources:
5
+ 1. OS Password Manager (lowest priority)
6
+
7
+ This follows the DRY principle by centralizing credential resolution logic
8
+ that was previously duplicated across multiple scripts.
9
+
10
+ Usage:
11
+ from bt_cred_resolver import CredentialResolver
12
+
13
+ # Initialize with optional logger
14
+ resolver = CredentialResolver(logger=logger)
15
+
16
+ # Resolve a credential with automatic fallback
17
+ jira_url = resolver.resolve(
18
+ name='JIRA_URL',
19
+ sources={
20
+ 'keyring': 'JIRA_URL'
21
+ },
22
+ required=True
23
+ )
24
+
25
+ # Or use the simplified method for common patterns
26
+ token = resolver.resolve_credential(
27
+ 'JIRA_PERSONAL_ACCESS_TOKEN',
28
+ keyring_name='JIRA_PERSONAL_ACCESS_TOKEN',
29
+ required=True
30
+ )
31
+ """
32
+
33
+ import os
34
+ from typing import Optional, Any, Dict
35
+ from .password_manager import (
36
+ PasswordManager,
37
+ PasswordManagerError,
38
+ PasswordNotFoundError,
39
+ RetrievalError
40
+ )
41
+
42
+
43
+ class CredentialResolverError(Exception):
44
+ """Base exception for credential resolver errors."""
45
+ pass
46
+
47
+
48
+ class RequiredCredentialMissingError(CredentialResolverError):
49
+ """Raised when a required credential cannot be resolved."""
50
+ pass
51
+
52
+
53
+ class CredentialResolver:
54
+ """
55
+ Resolves credentials from multiple sources with automatic fallback.
56
+
57
+ Attributes:
58
+ logger: Optional logger instance for logging resolution attempts.
59
+ password_manager: PasswordManager instance for keyring access.
60
+ password_manager_available: Boolean indicating if password manager is available.
61
+ """
62
+
63
+ def __init__(self, logger=None):
64
+ """
65
+ Initialize the CredentialResolver.
66
+
67
+ Args:
68
+ logger: Optional logger instance for logging. If None, logging is disabled.
69
+ """
70
+ self.logger = logger
71
+ self.password_manager = None
72
+ self.password_manager_available = False
73
+
74
+ # Try to initialize password manager
75
+ try:
76
+ self.password_manager = PasswordManager()
77
+ self.password_manager_available = True
78
+ if self.logger:
79
+ self.logger.info('Password manager initialized successfully')
80
+ except Exception as e:
81
+ if self.logger:
82
+ self.logger.warning(f'Could not initialize password manager: {e}')
83
+
84
+ def _log_info(self, message: str):
85
+ """Log info message if logger is available."""
86
+ if self.logger:
87
+ self.logger.info(message)
88
+
89
+ def _log_debug(self, message: str):
90
+ """Log debug message if logger is available."""
91
+ if self.logger:
92
+ self.logger.debug(message)
93
+
94
+ def _log_warning(self, message: str):
95
+ """Log warning message if logger is available."""
96
+ if self.logger:
97
+ self.logger.warning(message)
98
+
99
+ def _retrieve_from_keyring(self, keyring_name: str) -> Optional[str]:
100
+ """
101
+ Retrieve a credential from the OS password manager.
102
+
103
+ Args:
104
+ keyring_name: The service/username identifier in the keyring.
105
+
106
+ Returns:
107
+ The credential if found, None otherwise.
108
+ """
109
+ if not self.password_manager_available:
110
+ return None
111
+
112
+ try:
113
+ credential = self.password_manager.retrieve_password(keyring_name, keyring_name)
114
+ self._log_info(f"Retrieved {keyring_name} from OS Password Manager")
115
+ return credential
116
+ except PasswordNotFoundError as e:
117
+ self._log_debug(f"{keyring_name} not found in password manager")
118
+ return None
119
+ except RetrievalError as e:
120
+ self._log_warning(f"Failed password manager retrieval for {keyring_name}: {e}")
121
+ return None
122
+ except Exception as e:
123
+ self._log_warning(f"Unexpected error retrieving {keyring_name} from password manager: {e}")
124
+ return None
125
+
126
+ def resolve_credential(
127
+ self,
128
+ name: str,
129
+ keyring_name: Optional[str] = None,
130
+ required: bool = False,
131
+ default: Optional[Any] = None
132
+ ) -> Optional[Any]:
133
+ """
134
+ Resolve a credential from OS password manager with automatic fallback.
135
+
136
+ Resolution order:
137
+ 1. OS Password Manager keyring (if keyring_name provided)
138
+ 2. Default value (if provided)
139
+
140
+ Args:
141
+ name: Human-readable name of the credential (for error messages).
142
+ keyring_name: Name of keyring entry to check.
143
+ required: If True, raises an error if credential cannot be resolved.
144
+ default: Default value if credential cannot be resolved.
145
+
146
+ Returns:
147
+ The resolved credential value, or None if not found and not required.
148
+
149
+ Raises:
150
+ RequiredCredentialMissingError: If required=True and credential cannot be resolved.
151
+
152
+ Example:
153
+ >>> resolver = CredentialResolver(logger=logger)
154
+ >>> jira_url = resolver.resolve_credential(
155
+ ... 'JIRA_URL',
156
+ ... keyring_name='JIRA_URL',
157
+ ... required=True
158
+ ... )
159
+ """
160
+ # Try each source in priority order
161
+ sources_tried = []
162
+
163
+
164
+ # 4. Password manager keyring
165
+ if keyring_name:
166
+ keyring_value = self._retrieve_from_keyring(keyring_name)
167
+ if keyring_value is not None:
168
+ return keyring_value
169
+ sources_tried.append(f'password manager ({keyring_name})')
170
+
171
+ # 5. Default value
172
+ if default is not None:
173
+ self._log_debug(f"Using default value for {name}")
174
+ return default
175
+
176
+ # If we get here, credential was not found
177
+ if required:
178
+ sources_str = ', '.join(sources_tried)
179
+ error_msg = (
180
+ f"Could not determine {name} from {sources_str}. "
181
+ f"Please provide it via CLI argument, configuration file, "
182
+ f"environment variable, or password manager."
183
+ )
184
+ self._log_warning(error_msg)
185
+ raise RequiredCredentialMissingError(error_msg)
186
+
187
+ return None
188
+
189
+ def resolve(
190
+ self,
191
+ name: str,
192
+ sources: Dict[str, Any],
193
+ required: bool = False,
194
+ default: Optional[Any] = None
195
+ ) -> Optional[Any]:
196
+ """
197
+ Resolve a credential using a dictionary of sources.
198
+
199
+ This is an alternative interface that uses a sources dictionary
200
+ instead of individual keyword arguments.
201
+
202
+ Args:
203
+ name: Human-readable name of the credential.
204
+ sources: Dictionary with keys: 'keyring'.
205
+ required: If True, raises an error if credential cannot be resolved.
206
+ default: Default value if credential cannot be resolved.
207
+
208
+ Returns:
209
+ The resolved credential value.
210
+
211
+ Example:
212
+ >>> resolver.resolve(
213
+ ... 'JIRA_URL',
214
+ ... sources={
215
+ ... 'keyring': 'JIRA_URL'
216
+ ... },
217
+ ... required=True
218
+ ... )
219
+ """
220
+ return self.resolve_credential(
221
+ name=name,
222
+ keyring_name=sources.get('keyring'),
223
+ required=required,
224
+ default=default
225
+ )
226
+
227
+ def batch_resolve(
228
+ self,
229
+ credentials: Dict[str, Dict[str, Any]]
230
+ ) -> Dict[str, Any]:
231
+ """
232
+ Resolve multiple credentials at once.
233
+
234
+ Args:
235
+ credentials: Dictionary where keys are credential names and values
236
+ are dictionaries of resolution parameters.
237
+
238
+ Returns:
239
+ Dictionary of resolved credentials.
240
+
241
+ Example:
242
+ >>> resolved = resolver.batch_resolve({
243
+ ... 'jira_url': {
244
+ ... 'keyring_name': 'JIRA_URL',
245
+ ... 'required': True
246
+ ... },
247
+ ... 'jira_token': {
248
+ ... 'keyring_name': 'JIRA_PERSONAL_ACCESS_TOKEN',
249
+ ... 'required': True
250
+ ... }
251
+ ... })
252
+ >>> print(resolved['jira_url'])
253
+ """
254
+ results = {}
255
+ for cred_name, params in credentials.items():
256
+ results[cred_name] = self.resolve_credential(
257
+ name=cred_name,
258
+ **params
259
+ )
260
+ return results
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Password Manager Class
4
+ Secure password storage and retrieval using the OS's native password manager.
5
+ Supports macOS (Darwin), Linux, and Windows platforms.
6
+
7
+ Usage:
8
+ from bt_cred_resolver import PasswordManager
9
+
10
+ pm = PasswordManager()
11
+
12
+ # Store a password
13
+ pm.store_password('MyService', 'username', 'secret_password')
14
+
15
+ # Retrieve a password
16
+ password = pm.retrieve_password('MyService', 'username')
17
+
18
+ # Delete a password
19
+ pm.delete_password('MyService', 'username')
20
+ """
21
+
22
+ import platform
23
+ import keyring
24
+ from typing import Optional, Dict
25
+
26
+
27
+ class PasswordManagerError(Exception):
28
+ """Base exception for password manager errors."""
29
+ pass
30
+
31
+
32
+ class InvalidInputError(PasswordManagerError):
33
+ """Raised when invalid input is provided."""
34
+ pass
35
+
36
+
37
+ class PasswordNotFoundError(PasswordManagerError):
38
+ """Raised when a password is not found in the keyring."""
39
+ pass
40
+
41
+
42
+ class StorageError(PasswordManagerError):
43
+ """Raised when there's an error storing a password."""
44
+ pass
45
+
46
+
47
+ class RetrievalError(PasswordManagerError):
48
+ """Raised when there's an error retrieving a password."""
49
+ pass
50
+
51
+
52
+ class DeletionError(PasswordManagerError):
53
+ """Raised when there's an error deleting a password."""
54
+ pass
55
+
56
+
57
+ class PasswordManager:
58
+ """
59
+ Manages passwords using the operating system's native password manager.
60
+
61
+ Attributes:
62
+ platform_name (str): The detected operating system name.
63
+ backend_name (str): The keyring backend being used.
64
+ manager_name (str): The name of the password manager in use.
65
+ """
66
+
67
+ def __init__(self):
68
+ """Initialize the PasswordManager with platform detection."""
69
+ self.platform_name = self._detect_platform()
70
+ self.backend_name = self._get_keyring_backend()
71
+ self.manager_name = self._get_manager_name()
72
+
73
+ @staticmethod
74
+ def _detect_platform() -> str:
75
+ """
76
+ Detect the operating system.
77
+
78
+ Returns:
79
+ str: The platform name (macOS, Linux, or Windows).
80
+ """
81
+ system = platform.system()
82
+ platform_map = {
83
+ 'Darwin': 'macOS',
84
+ 'Linux': 'Linux',
85
+ 'Windows': 'Windows'
86
+ }
87
+ return platform_map.get(system, system)
88
+
89
+ @staticmethod
90
+ def _get_keyring_backend() -> str:
91
+ """
92
+ Get the current keyring backend name.
93
+
94
+ Returns:
95
+ str: The keyring backend class name.
96
+ """
97
+ try:
98
+ backend = keyring.get_keyring()
99
+ return backend.__class__.__name__
100
+ except Exception as e:
101
+ return f"Unknown ({str(e)})"
102
+
103
+ def _get_manager_name(self) -> str:
104
+ """
105
+ Get the name of the password manager based on the platform.
106
+
107
+ Returns:
108
+ str: The password manager name.
109
+ """
110
+ system = platform.system()
111
+ manager_map = {
112
+ 'Darwin': 'macOS Keychain',
113
+ 'Windows': 'Windows Credential Manager',
114
+ 'Linux': 'Secret Service (GNOME Keyring/KWallet)'
115
+ }
116
+ return manager_map.get(system, 'System Keyring')
117
+
118
+ def _validate_input(self, service: str, username: str,
119
+ password: Optional[str] = None) -> None:
120
+ """
121
+ Validate input parameters.
122
+
123
+ Args:
124
+ service: The service name.
125
+ username: The username.
126
+ password: The password (optional, only for storage).
127
+
128
+ Raises:
129
+ InvalidInputError: If any required parameter is invalid.
130
+ """
131
+ if not service or not isinstance(service, str) or not service.strip():
132
+ raise InvalidInputError("Service name must be a non-empty string")
133
+
134
+ if not username or not isinstance(username, str) or not username.strip():
135
+ raise InvalidInputError("Username must be a non-empty string")
136
+
137
+ if password is not None:
138
+ if not isinstance(password, str) or not password:
139
+ raise InvalidInputError("Password must be a non-empty string")
140
+
141
+ def store_password(self, service: str, username: str, password: str) -> Dict[str, str]:
142
+ """
143
+ Store a password in the OS password manager.
144
+
145
+ Args:
146
+ service: The service name (e.g., 'MySQLDatabase', 'GitHubAPI').
147
+ username: The username/account name.
148
+ password: The password to store.
149
+
150
+ Returns:
151
+ dict: A dictionary with storage confirmation details.
152
+
153
+ Raises:
154
+ InvalidInputError: If any parameter is invalid.
155
+ StorageError: If there's an error storing the password.
156
+
157
+ Example:
158
+ >>> pm = PasswordManager()
159
+ >>> result = pm.store_password('MyApp', 'user123', 'secret_pass')
160
+ >>> print(result['status'])
161
+ 'success'
162
+ """
163
+ # Validate input
164
+ self._validate_input(service, username, password)
165
+
166
+ try:
167
+ # Store the password
168
+ keyring.set_password(service, username, password)
169
+
170
+ return {
171
+ 'status': 'success',
172
+ 'service': service,
173
+ 'username': username,
174
+ 'platform': self.platform_name,
175
+ 'manager': self.manager_name,
176
+ 'message': f'Password stored successfully in {self.manager_name}'
177
+ }
178
+
179
+ except Exception as e:
180
+ raise StorageError(
181
+ f"Failed to store password for {username}@{service}: {str(e)}"
182
+ )
183
+
184
+ def retrieve_password(self, service: str, username: str) -> str:
185
+ """
186
+ Retrieve a password from the OS password manager.
187
+
188
+ Args:
189
+ service: The service name.
190
+ username: The username/account name.
191
+
192
+ Returns:
193
+ str: The retrieved password.
194
+
195
+ Raises:
196
+ InvalidInputError: If any parameter is invalid.
197
+ PasswordNotFoundError: If the password is not found.
198
+ RetrievalError: If there's an error retrieving the password.
199
+
200
+ Example:
201
+ >>> pm = PasswordManager()
202
+ >>> password = pm.retrieve_password('MyApp', 'user123')
203
+ >>> print(password)
204
+ 'secret_pass'
205
+ """
206
+ # Validate input
207
+ self._validate_input(service, username)
208
+
209
+ try:
210
+ # Retrieve the password
211
+ password = keyring.get_password(service, username)
212
+
213
+ if password is None:
214
+ raise PasswordNotFoundError(
215
+ f"No password found for {username}@{service}. "
216
+ f"Use store_password() to store it first."
217
+ )
218
+
219
+ return password
220
+
221
+ except PasswordNotFoundError:
222
+ # Re-raise our custom exception
223
+ raise
224
+ except Exception as e:
225
+ raise RetrievalError(
226
+ f"Failed to retrieve password for {username}@{service}: {str(e)}"
227
+ )
228
+
229
+ def delete_password(self, service: str, username: str) -> Dict[str, str]:
230
+ """
231
+ Delete a password from the OS password manager.
232
+
233
+ Args:
234
+ service: The service name.
235
+ username: The username/account name.
236
+
237
+ Returns:
238
+ dict: A dictionary with deletion confirmation details.
239
+
240
+ Raises:
241
+ InvalidInputError: If any parameter is invalid.
242
+ PasswordNotFoundError: If the password is not found.
243
+ DeletionError: If there's an error deleting the password.
244
+
245
+ Example:
246
+ >>> pm = PasswordManager()
247
+ >>> result = pm.delete_password('MyApp', 'user123')
248
+ >>> print(result['status'])
249
+ 'success'
250
+ """
251
+ # Validate input
252
+ self._validate_input(service, username)
253
+
254
+ try:
255
+ # Check if password exists first
256
+ password = keyring.get_password(service, username)
257
+ if password is None:
258
+ raise PasswordNotFoundError(
259
+ f"No password found for {username}@{service}"
260
+ )
261
+
262
+ # Delete the password
263
+ keyring.delete_password(service, username)
264
+
265
+ return {
266
+ 'status': 'success',
267
+ 'service': service,
268
+ 'username': username,
269
+ 'platform': self.platform_name,
270
+ 'manager': self.manager_name,
271
+ 'message': f'Password deleted successfully from {self.manager_name}'
272
+ }
273
+
274
+ except PasswordNotFoundError:
275
+ # Re-raise our custom exception
276
+ raise
277
+ except Exception as e:
278
+ raise DeletionError(
279
+ f"Failed to delete password for {username}@{service}: {str(e)}"
280
+ )
281
+
282
+ def password_exists(self, service: str, username: str) -> bool:
283
+ """
284
+ Check if a password exists in the OS password manager.
285
+
286
+ Args:
287
+ service: The service name.
288
+ username: The username/account name.
289
+
290
+ Returns:
291
+ bool: True if the password exists, False otherwise.
292
+
293
+ Raises:
294
+ InvalidInputError: If any parameter is invalid.
295
+
296
+ Example:
297
+ >>> pm = PasswordManager()
298
+ >>> if pm.password_exists('MyApp', 'user123'):
299
+ ... print("Password exists")
300
+ """
301
+ # Validate input
302
+ self._validate_input(service, username)
303
+
304
+ try:
305
+ password = keyring.get_password(service, username)
306
+ return password is not None
307
+ except Exception:
308
+ return False
309
+
310
+ def get_info(self) -> Dict[str, str]:
311
+ """
312
+ Get information about the password manager configuration.
313
+
314
+ Returns:
315
+ dict: A dictionary with platform and backend information.
316
+
317
+ Example:
318
+ >>> pm = PasswordManager()
319
+ >>> info = pm.get_info()
320
+ >>> print(info['platform'])
321
+ 'macOS'
322
+ """
323
+ return {
324
+ 'platform': self.platform_name,
325
+ 'backend': self.backend_name,
326
+ 'manager': self.manager_name
327
+ }
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: bt-cred-resolver
3
+ Version: 0.1.0
4
+ Summary: Utility classes for miscellaneous functions
5
+ Author-email: Bert Tejeda <berttejeda@gmail.com>
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: keyring>=24.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
10
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
11
+ Requires-Dist: pytest-mock>=3.11.1; extra == "dev"
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ bt_cred_resolver/__init__.py
5
+ bt_cred_resolver/credential_resolver.py
6
+ bt_cred_resolver/password_manager.py
7
+ bt_cred_resolver.egg-info/PKG-INFO
8
+ bt_cred_resolver.egg-info/SOURCES.txt
9
+ bt_cred_resolver.egg-info/dependency_links.txt
10
+ bt_cred_resolver.egg-info/requires.txt
11
+ bt_cred_resolver.egg-info/top_level.txt
12
+ tests/test_credential_resolver.py
@@ -0,0 +1,6 @@
1
+ keyring>=24.0.0
2
+
3
+ [dev]
4
+ pytest>=7.4.0
5
+ pytest-cov>=4.1.0
6
+ pytest-mock>=3.11.1
@@ -0,0 +1 @@
1
+ bt_cred_resolver
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bt-cred-resolver"
7
+ version = "0.1.0"
8
+ description = "Utility classes for miscellaneous functions"
9
+ requires-python = ">=3.9"
10
+ authors = [{ name = "Bert Tejeda", email = "berttejeda@gmail.com" }]
11
+ dependencies = [
12
+ "keyring>=24.0.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=7.4.0",
18
+ "pytest-cov>=4.1.0",
19
+ "pytest-mock>=3.11.1",
20
+ ]
21
+
22
+ [tool.setuptools]
23
+ packages = ["bt_cred_resolver"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ python_files = ["test_*.py"]
28
+ python_classes = ["Test*"]
29
+ python_functions = ["test_*"]
30
+ addopts = [
31
+ "-v",
32
+ "--strict-markers",
33
+ "--tb=short",
34
+ ]
35
+ markers = [
36
+ "unit: Unit tests",
37
+ "integration: Integration tests",
38
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """Setup script for bt-utils package.
2
+
3
+ This is a minimal setup.py that allows using traditional build commands.
4
+ The actual package configuration is in pyproject.toml.
5
+ """
6
+ from setuptools import setup
7
+
8
+ # setuptools will automatically read from pyproject.toml
9
+ setup()
@@ -0,0 +1,74 @@
1
+ """Tests for the CredentialResolver class."""
2
+ import pytest
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from bt_cred_resolver import (
6
+ CredentialResolver,
7
+ RequiredCredentialMissingError,
8
+ PasswordManager,
9
+ PasswordNotFoundError,
10
+ )
11
+
12
+
13
+ @pytest.fixture
14
+ def mock_logger():
15
+ """Create a mock logger."""
16
+ logger = MagicMock()
17
+ return logger
18
+
19
+
20
+ @pytest.fixture
21
+ def resolver(mock_logger):
22
+ """Create a CredentialResolver with mocked password manager."""
23
+ with patch.object(PasswordManager, '__init__', return_value=None):
24
+ r = CredentialResolver(logger=mock_logger)
25
+ r.password_manager = MagicMock(spec=PasswordManager)
26
+ r.password_manager_available = True
27
+ return r
28
+
29
+
30
+ class TestCredentialResolver:
31
+ """Tests for CredentialResolver."""
32
+
33
+ def test_resolve_from_keyring(self, resolver):
34
+ resolver.password_manager.retrieve_password.return_value = "secret_value"
35
+ result = resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED")
36
+ assert result == "secret_value"
37
+
38
+ def test_resolve_fallback_to_default(self, resolver):
39
+ resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
40
+ result = resolver.resolve_credential(
41
+ "MY_CRED", keyring_name="MY_CRED", default="fallback"
42
+ )
43
+ assert result == "fallback"
44
+
45
+ def test_resolve_required_raises(self, resolver):
46
+ resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
47
+ with pytest.raises(RequiredCredentialMissingError):
48
+ resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED", required=True)
49
+
50
+ def test_resolve_returns_none_when_not_required(self, resolver):
51
+ resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
52
+ result = resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED")
53
+ assert result is None
54
+
55
+ def test_resolve_dict_interface(self, resolver):
56
+ resolver.password_manager.retrieve_password.return_value = "val"
57
+ result = resolver.resolve("MY_CRED", sources={"keyring": "MY_CRED"})
58
+ assert result == "val"
59
+
60
+ def test_batch_resolve(self, resolver):
61
+ resolver.password_manager.retrieve_password.return_value = "val"
62
+ results = resolver.batch_resolve({
63
+ "cred_a": {"keyring_name": "CRED_A"},
64
+ "cred_b": {"keyring_name": "CRED_B"},
65
+ })
66
+ assert results["cred_a"] == "val"
67
+ assert results["cred_b"] == "val"
68
+
69
+ def test_password_manager_unavailable(self, mock_logger):
70
+ with patch.object(PasswordManager, '__init__', side_effect=Exception("no backend")):
71
+ r = CredentialResolver(logger=mock_logger)
72
+ assert r.password_manager_available is False
73
+ result = r.resolve_credential("MY_CRED", keyring_name="MY_CRED")
74
+ assert result is None