credchain 0.1.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.
credchain/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """credchain: read credentials from a chain of sources, with automatic fallback.
2
+
3
+ Providers (import directly):
4
+ CredentialsProviderChain — tries providers in order until one succeeds
5
+ EnvironmentCredentialsProvider — reads from environment variables
6
+ FileCredentialsProvider — reads from files, e.g. mounted container secrets
7
+ KeyringCredentialsProvider — reads from the OS keychain (requires credchain[keyring])
8
+
9
+ Usage::
10
+
11
+ from credchain import CredentialsProviderChain, EnvironmentCredentialsProvider
12
+ from credchain.keyring import KeyringCredentialsProvider # optional dep
13
+
14
+ chain = CredentialsProviderChain([
15
+ EnvironmentCredentialsProvider("MY_USERNAME", "MY_PASSWORD"),
16
+ KeyringCredentialsProvider("my-service"),
17
+ ])
18
+ username, password = chain.get_credentials()
19
+ """
20
+
21
+ from importlib.metadata import PackageNotFoundError, version
22
+
23
+ from .chain import CredentialsProviderChain
24
+ from .env import EnvironmentCredentialsProvider
25
+ from .file import FileCredentialsProvider
26
+ from .protocol import CredentialsProvider
27
+
28
+ try:
29
+ __version__ = version("credchain")
30
+ except PackageNotFoundError:
31
+ __version__ = "0.0.0-dev"
32
+
33
+ __all__ = [
34
+ "CredentialsProvider",
35
+ "CredentialsProviderChain",
36
+ "EnvironmentCredentialsProvider",
37
+ "FileCredentialsProvider",
38
+ ]
credchain/chain.py ADDED
@@ -0,0 +1,38 @@
1
+ import logging
2
+
3
+ from .protocol import CredentialsProvider
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class CredentialsProviderChain:
9
+ """Chain of credentials providers invoked in priority order until one succeeds.
10
+
11
+ Nothing derived from a credential is logged, not even a prefix: in token
12
+ mode a provider returns the token in both positions, so a "username" may
13
+ be a secret.
14
+ """
15
+
16
+ def __init__(self, providers: list[CredentialsProvider]):
17
+ self.providers = providers
18
+
19
+ def get_credentials(self) -> tuple[str | None, str | None]:
20
+ logger.debug("Retrieving credentials using credentials provider chain...")
21
+ for i, provider in enumerate(self.providers):
22
+ name = provider.__class__.__name__
23
+ try:
24
+ logger.debug(
25
+ f"Trying provider {i + 1}/{len(self.providers)}: {name}..."
26
+ )
27
+ username, password = provider.get_credentials()
28
+ if username and password:
29
+ logger.debug(f"Retrieved credentials from {name}")
30
+ return username, password
31
+ logger.debug(f"No credentials found from {name}")
32
+ except Exception as e:
33
+ # The exception type only: a message may quote the secret or a path to it.
34
+ logger.error(
35
+ f"Error retrieving credentials from {name}: {type(e).__name__}"
36
+ )
37
+ logger.debug("No credentials found from any provider in the chain")
38
+ return None, None
credchain/env.py ADDED
@@ -0,0 +1,50 @@
1
+ import logging
2
+ import os
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ class EnvironmentCredentialsProvider:
8
+ """Provider for credentials from environment variables.
9
+
10
+ Two-var mode (username + password)::
11
+
12
+ EnvironmentCredentialsProvider("MY_USERNAME", "MY_PASSWORD")
13
+
14
+ Token mode (single env var)::
15
+
16
+ EnvironmentCredentialsProvider.for_token("MY_TOKEN")
17
+ """
18
+
19
+ def __init__(self, username_env_var_name: str, password_env_var_name: str):
20
+ self.username_env_var_name: str | None = username_env_var_name
21
+ self.password_env_var_name = password_env_var_name
22
+
23
+ @classmethod
24
+ def for_token(cls, token_env_var_name: str) -> "EnvironmentCredentialsProvider":
25
+ """Create a token-mode provider that reads a single environment variable."""
26
+ instance = cls.__new__(cls)
27
+ instance.username_env_var_name = None
28
+ instance.password_env_var_name = token_env_var_name
29
+ return instance
30
+
31
+ def get_credentials(self) -> tuple[str | None, str | None]:
32
+ if self.username_env_var_name is None:
33
+ token = os.environ.get(self.password_env_var_name)
34
+ if token:
35
+ logger.debug(f"Read token from ${self.password_env_var_name}")
36
+ return token, token
37
+ logger.debug(f"No token in ${self.password_env_var_name}")
38
+ return None, None
39
+
40
+ username = os.environ.get(self.username_env_var_name)
41
+ password = os.environ.get(self.password_env_var_name)
42
+ if username and password:
43
+ logger.debug(
44
+ f"Read credentials from ${self.username_env_var_name} and ${self.password_env_var_name}"
45
+ )
46
+ return username, password
47
+ logger.debug(
48
+ f"No credentials in ${self.username_env_var_name} and ${self.password_env_var_name}"
49
+ )
50
+ return None, None
credchain/file.py ADDED
@@ -0,0 +1,65 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ class FileCredentialsProvider:
8
+ """Provider for credentials stored in files, such as mounted container secrets.
9
+
10
+ Two-file mode (username + password)::
11
+
12
+ FileCredentialsProvider("/run/secrets/username", "/run/secrets/password")
13
+
14
+ Token mode (single file)::
15
+
16
+ FileCredentialsProvider.for_token("/run/secrets/token")
17
+
18
+ Each file holds one value; one trailing line break is removed, because
19
+ secret files are often written with one. A missing file means no
20
+ credentials, so a provider chain falls through to its next provider;
21
+ any other read error propagates.
22
+ """
23
+
24
+ def __init__(self, username_file: str | Path, password_file: str | Path):
25
+ self.username_file: Path | None = Path(username_file)
26
+ self.password_file = Path(password_file)
27
+
28
+ @classmethod
29
+ def for_token(cls, token_file: str | Path) -> "FileCredentialsProvider":
30
+ """Create a token-mode provider that reads a single file."""
31
+ instance = cls.__new__(cls)
32
+ instance.username_file = None
33
+ instance.password_file = Path(token_file)
34
+ return instance
35
+
36
+ def get_credentials(self) -> tuple[str | None, str | None]:
37
+ if self.username_file is None:
38
+ token = _read(self.password_file)
39
+ if token:
40
+ logger.debug(f"Read token from {self.password_file}")
41
+ return token, token
42
+ logger.debug(f"No token in {self.password_file}")
43
+ return None, None
44
+
45
+ username = _read(self.username_file)
46
+ password = _read(self.password_file)
47
+ if username and password:
48
+ logger.debug(
49
+ f"Read credentials from {self.username_file} and {self.password_file}"
50
+ )
51
+ return username, password
52
+ logger.debug(f"No credentials in {self.username_file} and {self.password_file}")
53
+ return None, None
54
+
55
+
56
+ def _read(path: Path) -> str | None:
57
+ try:
58
+ text = path.read_text(encoding="utf-8")
59
+ except FileNotFoundError:
60
+ return None
61
+ if text.endswith("\r\n"):
62
+ return text[:-2]
63
+ if text.endswith("\n"):
64
+ return text[:-1]
65
+ return text
credchain/keyring.py ADDED
@@ -0,0 +1,115 @@
1
+ import logging
2
+ import threading
3
+
4
+ try:
5
+ import keyring as _keyring
6
+ except ImportError as e:
7
+ raise ImportError(
8
+ "keyring is required for KeyringCredentialsProvider. "
9
+ "Install it with: pip install credchain[keyring]"
10
+ ) from e
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # A hung OS keychain call (e.g. a locked keychain waiting for an unlock prompt
15
+ # that never appears in a headless session) must not stall the caller forever.
16
+ DEFAULT_KEYRING_TIMEOUT = 5.0
17
+
18
+
19
+ class KeyringCredentialsProvider:
20
+ """Provider for credentials stored in the OS keychain via keyring.
21
+
22
+ Two-key mode (username + password stored under separate account keys)::
23
+
24
+ KeyringCredentialsProvider("my-service")
25
+ # reads: keyring get my-service username
26
+ # keyring get my-service password
27
+
28
+ Token mode (single keyring entry)::
29
+
30
+ KeyringCredentialsProvider.for_token("my-service")
31
+ # reads: keyring get my-service token
32
+
33
+ Or with a custom key name::
34
+
35
+ KeyringCredentialsProvider.for_token("my-service", token_key="jwt")
36
+ # reads: keyring get my-service jwt
37
+
38
+ The keychain lookup is bounded by ``timeout`` seconds (default 5). When the
39
+ keychain does not answer in time, a warning is logged and no credentials are
40
+ returned, so a provider chain falls through to its next provider. Pass
41
+ ``timeout=None`` to wait indefinitely.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ service: str,
47
+ username_key: str = "username",
48
+ password_key: str = "password",
49
+ timeout: float | None = DEFAULT_KEYRING_TIMEOUT,
50
+ ):
51
+ self.service = service
52
+ self.username_key: str | None = username_key
53
+ self.password_key = password_key
54
+ self.timeout = timeout
55
+
56
+ @classmethod
57
+ def for_token(
58
+ cls,
59
+ service: str,
60
+ token_key: str = "token",
61
+ timeout: float | None = DEFAULT_KEYRING_TIMEOUT,
62
+ ) -> "KeyringCredentialsProvider":
63
+ """Create a token-mode provider that reads a single keyring entry."""
64
+ instance = cls.__new__(cls)
65
+ instance.service = service
66
+ instance.username_key = None
67
+ instance.password_key = token_key
68
+ instance.timeout = timeout
69
+ return instance
70
+
71
+ def get_credentials(self) -> tuple[str | None, str | None]:
72
+ if self.timeout is None:
73
+ return self._read_credentials()
74
+
75
+ # The keyring API is synchronous and cannot be cancelled, so run it in a
76
+ # daemon thread and stop waiting after the timeout. A thread left hanging
77
+ # in the keychain does not keep the process alive on exit.
78
+ outcome: dict = {}
79
+
80
+ def read() -> None:
81
+ try:
82
+ outcome["result"] = self._read_credentials()
83
+ except BaseException as e:
84
+ outcome["error"] = e
85
+
86
+ thread = threading.Thread(
87
+ target=read, name=f"keyring-{self.service}", daemon=True
88
+ )
89
+ thread.start()
90
+ thread.join(self.timeout)
91
+ if thread.is_alive():
92
+ logger.warning(
93
+ f"Keyring service '{self.service}' did not respond within {self.timeout}s, skipping it"
94
+ )
95
+ return None, None
96
+ if "error" in outcome:
97
+ raise outcome["error"]
98
+ return outcome["result"]
99
+
100
+ def _read_credentials(self) -> tuple[str | None, str | None]:
101
+ if self.username_key is None:
102
+ token = _keyring.get_password(self.service, self.password_key)
103
+ if token:
104
+ logger.debug(f"Read token from keyring service '{self.service}'")
105
+ return token, token
106
+ logger.debug(f"No token in keyring service '{self.service}'")
107
+ return None, None
108
+
109
+ username = _keyring.get_password(self.service, self.username_key)
110
+ password = _keyring.get_password(self.service, self.password_key)
111
+ if username and password:
112
+ logger.debug(f"Read credentials from keyring service '{self.service}'")
113
+ return username, password
114
+ logger.debug(f"No credentials in keyring service '{self.service}'")
115
+ return None, None
credchain/protocol.py ADDED
@@ -0,0 +1,5 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class CredentialsProvider(Protocol):
5
+ def get_credentials(self) -> tuple[str | None, str | None]: ...
credchain/py.typed ADDED
File without changes
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.5
2
+ Name: credchain
3
+ Version: 0.1.0
4
+ Summary: Credential provider chain: read usernames, passwords and tokens from environment variables, the OS keychain or secret files, with automatic fallback.
5
+ Author-email: Stephan Eberle <stephaneberle9@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: keyring
10
+ Requires-Dist: keyring>=25.0.0; extra == 'keyring'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # credchain <!-- omit in toc -->
14
+
15
+ Read the credentials an application needs — a username and password, or an
16
+ API token — from environment variables, the OS keychain or secret files, with
17
+ automatic fallback through a priority chain.
18
+
19
+ `credchain` has no framework dependency. FastMCP servers that also take
20
+ credentials from HTTP headers use
21
+ [fastmcp-creds](https://github.com/stephaneberle9/fastmcp-creds), which builds
22
+ on it.
23
+
24
+ - [Installation](#installation)
25
+ - [Usage](#usage)
26
+ - [Credential provider chain](#credential-provider-chain)
27
+ - [Environment variables](#environment-variables)
28
+ - [OS keychain](#os-keychain)
29
+ - [Secret files](#secret-files)
30
+ - [Logging](#logging)
31
+ - [API Reference](#api-reference)
32
+ - [`CredentialsProvider`](#credentialsprovider)
33
+ - [`CredentialsProviderChain`](#credentialsproviderchain)
34
+ - [`EnvironmentCredentialsProvider`](#environmentcredentialsprovider)
35
+ - [`KeyringCredentialsProvider`](#keyringcredentialsprovider)
36
+ - [`FileCredentialsProvider`](#filecredentialsprovider)
37
+ - [Contributing](#contributing)
38
+ - [License](#license)
39
+
40
+ ## Installation
41
+
42
+ The package is not published to PyPI yet. Install it from its Git repository
43
+ or a local checkout:
44
+
45
+ ```bash
46
+ uv add "credchain @ git+https://github.com/stephaneberle9/python-credchain.git"
47
+ # with the OS keychain provider:
48
+ uv add "credchain[keyring] @ git+https://github.com/stephaneberle9/python-credchain.git"
49
+ ```
50
+
51
+ Requires Python 3.10+.
52
+
53
+ ## Usage
54
+
55
+ ### Credential provider chain
56
+
57
+ Compose providers in priority order. The chain tries each in sequence and
58
+ returns the first result where both username and password (or token) are
59
+ non-empty.
60
+
61
+ ```python
62
+ from credchain import CredentialsProviderChain, EnvironmentCredentialsProvider
63
+ from credchain.keyring import KeyringCredentialsProvider # optional dep
64
+
65
+ chain = CredentialsProviderChain([
66
+ EnvironmentCredentialsProvider("MY_USERNAME", "MY_PASSWORD"),
67
+ KeyringCredentialsProvider("my-service"),
68
+ ])
69
+
70
+ username, password = chain.get_credentials()
71
+ ```
72
+
73
+ A provider that raises is logged (exception type only) and skipped.
74
+
75
+ ### Environment variables
76
+
77
+ ```python
78
+ from credchain import EnvironmentCredentialsProvider
79
+
80
+ provider = EnvironmentCredentialsProvider("MY_USERNAME", "MY_PASSWORD")
81
+ token_provider = EnvironmentCredentialsProvider.for_token("MY_API_TOKEN")
82
+ ```
83
+
84
+ ### OS keychain
85
+
86
+ Reads credentials stored in the OS keychain via
87
+ [`keyring`](https://pypi.org/project/keyring/). Requires `credchain[keyring]`.
88
+
89
+ ```python
90
+ from credchain.keyring import KeyringCredentialsProvider
91
+
92
+ provider = KeyringCredentialsProvider("my-service") # "username" + "password" entries
93
+ provider = KeyringCredentialsProvider.for_token("my-service") # one "token" entry
94
+ provider = KeyringCredentialsProvider("my-service", timeout=10)
95
+ ```
96
+
97
+ The keychain lookup is bounded by a timeout (default 5 seconds), so a locked
98
+ or unresponsive keychain, e.g. one waiting for an unlock prompt in a headless
99
+ session, cannot hang the caller. On timeout, a warning is logged and
100
+ `(None, None)` is returned, so a chain falls through to its next provider.
101
+
102
+ ### Secret files
103
+
104
+ Reads one value per file, such as a secret mounted into a container. One
105
+ trailing line break is removed; a missing file means no credentials.
106
+
107
+ ```python
108
+ from credchain import FileCredentialsProvider
109
+
110
+ provider = FileCredentialsProvider("/run/secrets/username", "/run/secrets/password")
111
+ token_provider = FileCredentialsProvider.for_token("/run/secrets/api-token")
112
+ ```
113
+
114
+ ## Logging
115
+
116
+ Providers log at debug level where they looked and whether they found
117
+ something, never a credential or any part of one: in token mode the token is
118
+ returned in both positions, so even a "username" can be a secret. Errors are
119
+ logged with their exception type only, because messages may quote values.
120
+
121
+ ## API Reference
122
+
123
+ ### `CredentialsProvider`
124
+
125
+ A `Protocol`: any object with a
126
+ `get_credentials() -> tuple[str | None, str | None]` method satisfies it.
127
+
128
+ ### `CredentialsProviderChain`
129
+
130
+ `CredentialsProviderChain(providers)` — tries each provider in order and
131
+ returns the first result where both values are non-empty, else
132
+ `(None, None)`.
133
+
134
+ ### `EnvironmentCredentialsProvider`
135
+
136
+ `EnvironmentCredentialsProvider(username_env_var_name, password_env_var_name)`
137
+ reads two environment variables;
138
+ `EnvironmentCredentialsProvider.for_token(token_env_var_name)` reads one and
139
+ returns it as both elements.
140
+
141
+ ### `KeyringCredentialsProvider`
142
+
143
+ `KeyringCredentialsProvider(service, username_key="username", password_key="password", timeout=5.0)`
144
+ reads two keychain entries of `service`;
145
+ `KeyringCredentialsProvider.for_token(service, token_key="token", timeout=5.0)`
146
+ reads one. `timeout=None` waits indefinitely. Requires `credchain[keyring]`.
147
+
148
+ ### `FileCredentialsProvider`
149
+
150
+ `FileCredentialsProvider(username_file, password_file)` reads two files;
151
+ `FileCredentialsProvider.for_token(token_file)` reads one. Errors other than a
152
+ missing file propagate.
153
+
154
+ ## Contributing
155
+
156
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
157
+
158
+ ## License
159
+
160
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ credchain/__init__.py,sha256=FB50Ct0oPjKBB87EPsn9boQ-Bz_MjMiWVz9_7DXtzEw,1330
2
+ credchain/chain.py,sha256=8G1MvqtqLBRyHBF-arLrzmHaWYQGrTwukUEER6rBgec,1535
3
+ credchain/env.py,sha256=-zrHsKsJiYAx9FkbGGVtoS4w1Jh0xsziRdDGqng9DUc,1855
4
+ credchain/file.py,sha256=Q0bigjHBlzrObXfSAokkqP0K-Tr4kJmbJf5kfNrq2Aw,2228
5
+ credchain/keyring.py,sha256=YZBJa2Q1UCo92QDo9cspT42htEVuu-Nz7998xC078H4,4134
6
+ credchain/protocol.py,sha256=QMZ4brqsqjzs5b-jh1BBGcvSbpZzSGBQ5wPXCK628b0,135
7
+ credchain/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ credchain-0.1.0.dist-info/METADATA,sha256=1UH2vueRnffQX2pfB1NobVKMRDa17tGBaSrebdV0HzQ,5492
9
+ credchain-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ credchain-0.1.0.dist-info/licenses/LICENSE,sha256=gj8yMQmXVQ-u8qsZ6UdnofKnIp2wv0tBByT9E-mkpYE,1071
11
+ credchain-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephan Eberle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.