secretref 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.
Files changed (56) hide show
  1. secretref/__init__.py +99 -0
  2. secretref/__main__.py +11 -0
  3. secretref/auditors/__init__.py +56 -0
  4. secretref/auditors/auditor.py +185 -0
  5. secretref/auditors/known_format.py +42 -0
  6. secretref/auditors/placeholder.py +41 -0
  7. secretref/auditors/secret_key.py +25 -0
  8. secretref/cache.py +130 -0
  9. secretref/cli/__init__.py +145 -0
  10. secretref/cli/audit_command.py +151 -0
  11. secretref/cli/exit_codes.py +29 -0
  12. secretref/cli/policy_command.py +34 -0
  13. secretref/cli/show_command.py +109 -0
  14. secretref/constants.py +104 -0
  15. secretref/exceptions/__init__.py +36 -0
  16. secretref/exceptions/base.py +11 -0
  17. secretref/exceptions/command.py +18 -0
  18. secretref/exceptions/config.py +13 -0
  19. secretref/exceptions/environment.py +16 -0
  20. secretref/exceptions/file.py +16 -0
  21. secretref/exceptions/resolution.py +18 -0
  22. secretref/models/__init__.py +21 -0
  23. secretref/models/config.py +181 -0
  24. secretref/models/finding.py +47 -0
  25. secretref/models/reference.py +315 -0
  26. secretref/models/severity.py +55 -0
  27. secretref/models/source.py +20 -0
  28. secretref/parsers/__init__.py +29 -0
  29. secretref/parsers/base.py +52 -0
  30. secretref/parsers/json_parser.py +39 -0
  31. secretref/parsers/yaml_parser.py +42 -0
  32. secretref/patterns.py +101 -0
  33. secretref/policies/__init__.py +36 -0
  34. secretref/policies/command.py +281 -0
  35. secretref/policies/environment.py +153 -0
  36. secretref/policies/file.py +276 -0
  37. secretref/policies/settings/__init__.py +14 -0
  38. secretref/policies/settings/command.py +45 -0
  39. secretref/policies/settings/environment.py +37 -0
  40. secretref/policies/settings/file.py +40 -0
  41. secretref/py.typed +0 -0
  42. secretref/resolvers/__init__.py +26 -0
  43. secretref/resolvers/base.py +35 -0
  44. secretref/resolvers/command.py +91 -0
  45. secretref/resolvers/environment.py +21 -0
  46. secretref/resolvers/file.py +21 -0
  47. secretref/resolvers/literal.py +20 -0
  48. secretref/utils/__init__.py +16 -0
  49. secretref/utils/repository.py +46 -0
  50. secretref/utils/tree.py +137 -0
  51. secretref/utils/ttl_store.py +87 -0
  52. secretref-0.1.0.dist-info/METADATA +613 -0
  53. secretref-0.1.0.dist-info/RECORD +56 -0
  54. secretref-0.1.0.dist-info/WHEEL +4 -0
  55. secretref-0.1.0.dist-info/entry_points.txt +3 -0
  56. secretref-0.1.0.dist-info/licenses/LICENSE +21 -0
secretref/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """Config files that name their secrets instead of containing them.
2
+
3
+ An `.env` file works until the config needs a list, a nested section, or a
4
+ number that is really a number. JSON and YAML handle all three — but then the
5
+ secrets are sitting in a file next to the code, which is how they end up in a
6
+ repository.
7
+
8
+ So a secret in a config file here is a *reference*:
9
+
10
+ {
11
+ "providers": [
12
+ {"name": "openai", "api_key": {"env": "OPENAI_API_KEY"}},
13
+ {"name": "anthropic", "api_key": {"file": "/run/secrets/anthropic"}},
14
+ {"name": "internal", "api_key": {"command": "op read op://vault/key"}}
15
+ ],
16
+ "timeout_seconds": 30,
17
+ "languages": ["en", "fil"]
18
+ }
19
+
20
+ The file is committable, because it says where each secret lives rather than
21
+ what it is. Resolution is lazy, so a config naming twenty secrets reads none
22
+ of them at load — which is what lets one file serve development and
23
+ production.
24
+
25
+ from secretref import load
26
+
27
+ config = load("config.json")
28
+ key = config.secret("providers.0.api_key").resolve()
29
+
30
+ And the part that makes it worth a library rather than a helper function:
31
+
32
+ from secretref import audit
33
+
34
+ for finding in audit(config):
35
+ print(finding)
36
+ # error: providers.0.api_key: a literal secret inside a repository
37
+ # try: move it to an environment variable and reference it
38
+
39
+ `audit` reports a literal secret as an **error** when it finds a repository
40
+ above the file, because such a value is one commit from a remote. Outside a
41
+ repository the same value is a warning. That check is the reason this exists:
42
+ the failure it prevents is not a crash but a leak, which no test catches.
43
+
44
+ No dependencies. A library that reads your secrets should be auditable in an
45
+ afternoon.
46
+ """
47
+
48
+ from secretref.auditors import audit, repository_root
49
+ from secretref.cache import ResolutionCache
50
+ from secretref.constants import REDACTED
51
+ from secretref.exceptions import (
52
+ CommandNotAllowedError,
53
+ ConfigError,
54
+ FileNotAllowedError,
55
+ ResolutionError,
56
+ SecretrefError,
57
+ VariableNotAllowedError,
58
+ )
59
+ from secretref.models import (
60
+ Config,
61
+ Finding,
62
+ SecretRef,
63
+ Severity,
64
+ Source,
65
+ )
66
+ from secretref.policies import CommandPolicy, EnvironmentPolicy, FilePolicy
67
+
68
+ __all__ = [
69
+ "REDACTED",
70
+ "CommandNotAllowedError",
71
+ "CommandPolicy",
72
+ "Config",
73
+ "ConfigError",
74
+ "EnvironmentPolicy",
75
+ "FileNotAllowedError",
76
+ "FilePolicy",
77
+ "Finding",
78
+ "ResolutionCache",
79
+ "ResolutionError",
80
+ "SecretRef",
81
+ "SecretrefError",
82
+ "Severity",
83
+ "Source",
84
+ "VariableNotAllowedError",
85
+ "__version__",
86
+ "audit",
87
+ "find_config",
88
+ "load",
89
+ "loads",
90
+ "repository_root",
91
+ ]
92
+ __version__ = "0.1.0"
93
+
94
+ # The public API stays functions, because `load("config.json")` is what a
95
+ # caller wants to write. Config holds the implementation, so the pieces live
96
+ # with the type they build rather than in a module of their own.
97
+ load = Config.load
98
+ loads = Config.loads
99
+ find_config = Config.find
secretref/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Lets the tool run as `python -m secretref`, not only as `secretref`.
2
+
3
+ Useful when the package is installed somewhere the console script is not on
4
+ PATH, which is common in a container.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from secretref.cli import run
10
+
11
+ run()
@@ -0,0 +1,56 @@
1
+ """Checking a config file for secrets it should not be holding.
2
+
3
+ The failure this prevents is not a crash. A hardcoded key works perfectly —
4
+ right up until the repository is public. Nothing breaks, no test fails, and
5
+ the problem is discovered by someone else.
6
+
7
+ So the central question is not "is this value valid" but "could this value
8
+ already have been published". That is why the severity depends on whether
9
+ there is a repository above the file:
10
+
11
+ ERROR a literal secret inside a working copy. One commit from a
12
+ remote, and git history keeps it after the fix.
13
+ WARNING a literal secret outside one. Careless, but contained.
14
+ NOTE a reference that currently resolves to nothing. Usually a typo
15
+ in a variable name, occasionally a deliberately absent option.
16
+
17
+ What this is not: a repository scanner. It reads one config file and knows
18
+ nothing about history, other files, or anything already committed. For that,
19
+ gitleaks and detect-secrets answer a different question and answer it better.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from secretref.auditors.auditor import Auditor
25
+ from secretref.auditors.placeholder import Placeholder
26
+ from secretref.auditors.secret_key import SecretKey
27
+ from secretref.models.config import Config
28
+ from secretref.models.finding import Finding
29
+ from secretref.utils.repository import Repository
30
+
31
+ __all__ = [
32
+ "Auditor",
33
+ "Placeholder",
34
+ "SecretKey",
35
+ "audit",
36
+ "repository_root",
37
+ ]
38
+
39
+
40
+ def audit(config: Config, *, resolve: bool = False) -> list[Finding]:
41
+ """Every problem in a config file, most severe first.
42
+
43
+ Does not resolve by default, which is a security boundary rather than a
44
+ performance one: resolving a command reference runs it, and auditing a
45
+ config you did not write is the main use for this. A CI check on a pull
46
+ request would otherwise have been remote code execution.
47
+
48
+ Pass `resolve=True` for your own config at startup, where confirming
49
+ that a secret is actually reachable is worth more than the caution.
50
+ """
51
+ return Auditor(config, resolve=resolve).run()
52
+
53
+
54
+ # `repository_root` was the audit's own helper before the VCS walk was
55
+ # separated out. Kept as a name here because it is public API.
56
+ repository_root = Repository.root
@@ -0,0 +1,185 @@
1
+ """Running the checks over one config file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from secretref.auditors.known_format import KnownFormat
6
+ from secretref.auditors.placeholder import Placeholder
7
+ from secretref.auditors.secret_key import SecretKey
8
+ from secretref.models.config import Config
9
+ from secretref.models.finding import Finding, Severity
10
+ from secretref.models.source import Source
11
+ from secretref.utils.repository import Repository
12
+ from secretref.utils.tree import Tree
13
+
14
+
15
+ class Auditor:
16
+ """Carries the config and the repository verdict through the checks.
17
+
18
+ A class because every check needs both, and threading them through
19
+ arguments made the functions longer than the checks they performed.
20
+ """
21
+
22
+ def __init__(self, config: Config, *, resolve: bool = False) -> None:
23
+ """Audit a config, optionally checking that its secrets resolve.
24
+
25
+ `resolve` is off by default, and that default is a security
26
+ boundary rather than a performance one. Resolving a command
27
+ reference *runs* it, so an audit that resolved would execute
28
+ whatever the config asked for — and auditing a config you did not
29
+ write is the main use for this. A CI check on a pull request would
30
+ have been remote code execution.
31
+
32
+ Turn it on for your own config at startup, where knowing a secret
33
+ is actually reachable is worth more than the caution.
34
+ """
35
+ self._config = config
36
+ self._resolve = resolve
37
+ self._repository = Repository.root(config.path) if config.path else None
38
+
39
+ def run(self) -> list[Finding]:
40
+ """Run every check and order the results by severity."""
41
+ findings = [*self._reference_findings(), *self._literal_findings()]
42
+ order = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.NOTE: 2}
43
+ findings.sort(key=lambda finding: (order[finding.severity], finding.path))
44
+ return findings
45
+
46
+ def _may_check(self, reference) -> bool:
47
+ """Whether this reference can be verified without doing anything.
48
+
49
+ A reference with side effects is only resolved when the caller has
50
+ said the config is theirs.
51
+ """
52
+ return self._resolve or not reference.has_side_effects()
53
+
54
+ # -- checks -----------------------------------------------------------
55
+
56
+ def _reference_findings(self) -> list[Finding]:
57
+ """Problems with values that are already references."""
58
+ findings = []
59
+ for path, reference in self._config.secrets().items():
60
+ if reference.source is Source.LITERAL:
61
+ findings.append(self._literal_finding(path, reference.locator))
62
+ elif broken := reference.malformed():
63
+ # Checked first: a reference that cannot be parsed will not
64
+ # work whatever the policy says, and naming the syntax error
65
+ # is more useful than reporting it as unverified.
66
+ findings.append(
67
+ Finding(
68
+ severity=Severity.ERROR,
69
+ path=path,
70
+ message=f"{reference.described()} is {broken}",
71
+ remedy="fix the reference in the config",
72
+ )
73
+ )
74
+ elif refused := reference.refusal():
75
+ # A policy refusal is a config error, not a deployment one:
76
+ # the config asks for something it is not permitted to do,
77
+ # and no amount of setting variables will change that. An
78
+ # error whatever the repository, because the application
79
+ # will not start.
80
+ findings.append(
81
+ Finding(
82
+ severity=Severity.ERROR,
83
+ path=path,
84
+ message=f"refused by policy — {refused.rstrip('.')}",
85
+ remedy="widen the policy, or point the reference somewhere it permits",
86
+ )
87
+ )
88
+ elif not self._may_check(reference):
89
+ findings.append(
90
+ Finding(
91
+ severity=Severity.NOTE,
92
+ path=path,
93
+ message=f"{reference.described()} was not verified",
94
+ remedy=(
95
+ "resolving it would run a command, which an audit "
96
+ "does not do. Pass resolve=True if this config is "
97
+ "yours and you want it checked"
98
+ ),
99
+ )
100
+ )
101
+ elif not reference.is_resolvable():
102
+ findings.append(
103
+ Finding(
104
+ severity=Severity.NOTE,
105
+ path=path,
106
+ message=f"{reference.described()} resolves to nothing",
107
+ remedy='set it, or add "required": false if it is optional',
108
+ )
109
+ )
110
+ return findings
111
+
112
+ def _literal_findings(self) -> list[Finding]:
113
+ """Plain strings that hold a secret.
114
+
115
+ Two independent checks, because each catches what the other misses.
116
+ A published credential format is recognized from the value, whatever
117
+ the key is called — which is the case key names cannot cover, since
118
+ nothing stops a live key sitting under `tracking.ref`. A
119
+ secret-looking key name catches the rest: an internal password has no
120
+ published shape to match.
121
+ """
122
+ findings = []
123
+ for path, value in Tree.strings(self._config.data):
124
+ if identified := KnownFormat.identify(value):
125
+ findings.append(self._known_format_finding(path, identified))
126
+ elif self._looks_like_a_secret(path, value):
127
+ findings.append(self._literal_finding(path, value))
128
+ return findings
129
+
130
+ @staticmethod
131
+ def _looks_like_a_secret(path: str, value: str) -> bool:
132
+ """Whether a key name suggests a credential and the value could be one."""
133
+ if not SecretKey.matches(path):
134
+ return False
135
+ return not (Placeholder.too_short_to_judge(value) or Placeholder.matches(value))
136
+
137
+ def _known_format_finding(self, path: str, identified: str) -> Finding:
138
+ """One credential matching a published format.
139
+
140
+ Always an error, and always regardless of the key name or whether a
141
+ repository was found. A value matching an issuer's published shape is
142
+ a real credential — there is no reading of it as a placeholder, and
143
+ the right response is to rotate it rather than to move it.
144
+ """
145
+ inside = (
146
+ " inside a repository, so git history keeps it"
147
+ if self._repository is not None
148
+ else " in a config file"
149
+ )
150
+ return Finding(
151
+ severity=Severity.ERROR,
152
+ path=path,
153
+ message=f"{identified}{inside}",
154
+ remedy=(
155
+ "rotate this credential, then reference it instead of storing "
156
+ 'it: {"env": "YOUR_VAR"}'
157
+ ),
158
+ )
159
+
160
+ def _literal_finding(self, path: str, value: str) -> Finding:
161
+ """One literal, with a severity that depends on the repository."""
162
+ if Placeholder.is_noise(value):
163
+ return Finding(
164
+ severity=Severity.NOTE,
165
+ path=path,
166
+ message="a literal value, which looks like a placeholder",
167
+ remedy="replace it with a reference before deploying",
168
+ )
169
+ if self._repository is not None:
170
+ return Finding(
171
+ severity=Severity.ERROR,
172
+ path=path,
173
+ message="a literal secret inside a repository",
174
+ remedy=(
175
+ "move it to an environment variable and reference it: "
176
+ '{"env": "YOUR_VAR"} — and rotate it, since git history '
177
+ "keeps the old value"
178
+ ),
179
+ )
180
+ return Finding(
181
+ severity=Severity.WARNING,
182
+ path=path,
183
+ message="a literal secret in a config file",
184
+ remedy='reference it instead of storing it: {"env": "YOUR_VAR"}',
185
+ )
@@ -0,0 +1,42 @@
1
+ """Whether a value is a credential whose issuer publishes its shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from secretref.patterns import KNOWN_SECRET_FORMATS
9
+
10
+
11
+ class KnownFormat:
12
+ """Whether a value matches a published credential format.
13
+
14
+ The one kind of secret that can be identified from the value alone. Every
15
+ other check in this package starts from the key name, which is the thing
16
+ an audit cannot depend on: a live Stripe key under `payments.api_key` is
17
+ found either way, while the same key under `tracking.ref` is found only
18
+ here.
19
+
20
+ A match is reported as an error regardless of the key, and regardless of
21
+ whether the config is inside a repository — a key matching a published
22
+ format is a real credential, not a placeholder someone typed.
23
+ """
24
+
25
+ FORMATS: ClassVar[tuple[tuple[str, re.Pattern[str]], ...]] = KNOWN_SECRET_FORMATS
26
+
27
+ @classmethod
28
+ def identify(cls, value: str) -> str:
29
+ """Name the credential format this value matches, or return empty.
30
+
31
+ The first match wins. The patterns are specific enough that two
32
+ matching the same value would mean one of them is wrong.
33
+ """
34
+ for name, pattern in cls.FORMATS:
35
+ if pattern.search(value):
36
+ return name
37
+ return ""
38
+
39
+ @classmethod
40
+ def matches(cls, value: str) -> bool:
41
+ """Whether this value matches any published credential format."""
42
+ return bool(cls.identify(value))
@@ -0,0 +1,41 @@
1
+ """Whether a value is filler rather than a real secret."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from secretref.constants import MIN_SECRET_LENGTH
9
+ from secretref.patterns import PLACEHOLDERS
10
+
11
+
12
+ class Placeholder:
13
+ """Whether a value is filler rather than a real secret."""
14
+
15
+ PATTERNS: ClassVar[tuple[re.Pattern[str], ...]] = PLACEHOLDERS
16
+
17
+ @classmethod
18
+ def matches(cls, value: str) -> bool:
19
+ """Whether a value is obviously filler.
20
+
21
+ Generous on purpose. A false negative here costs one spurious error,
22
+ which a reader dismisses. A false positive reports a real secret as
23
+ filler and hides it, which is the failure that matters.
24
+ """
25
+ candidate = value.strip()
26
+ return any(pattern.search(candidate) for pattern in cls.PATTERNS)
27
+
28
+ @classmethod
29
+ def too_short_to_judge(cls, value: str) -> bool:
30
+ """Whether a value is too short to be worth reporting.
31
+
32
+ A short string under a secret-looking key is more often a flag or an
33
+ abbreviation than a credential, and reporting those is how an audit
34
+ trains people to ignore it.
35
+ """
36
+ return len(value.strip()) < MIN_SECRET_LENGTH
37
+
38
+ @classmethod
39
+ def is_noise(cls, value: str) -> bool:
40
+ """Whether a value should be a note rather than an error."""
41
+ return cls.matches(value) or cls.too_short_to_judge(value)
@@ -0,0 +1,25 @@
1
+ """Whether a config path names a credential."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from secretref.patterns import SECRET_KEY
9
+
10
+
11
+ class SecretKey:
12
+ """Whether a config path names a credential."""
13
+
14
+ PATTERN: ClassVar[re.Pattern[str]] = SECRET_KEY
15
+
16
+ @classmethod
17
+ def matches(cls, dotted: str) -> bool:
18
+ """Whether a path's final segment names a credential.
19
+
20
+ Only the final segment, so "providers.0.api_key" is judged on
21
+ "api_key". A parent path saying "providers" tells you nothing about
22
+ whether this particular leaf holds a secret.
23
+ """
24
+ last = dotted.rsplit(".", 1)[-1]
25
+ return bool(cls.PATTERN.search(last))
secretref/cache.py ADDED
@@ -0,0 +1,130 @@
1
+ """One shared store of already-resolved values.
2
+
3
+ A thin wrapper over `utils.TtlStore`, which holds the mechanics. What is left
4
+ here is the part that is actually about secrets:
5
+
6
+ the key is (source, locator), so an env var and a file path that happen
7
+ to share a name do not collide
8
+
9
+ an empty resolution is never stored, because caching "this resolved to
10
+ nothing" would hide a secret that appears later — the opposite of helpful
11
+ during a deployment
12
+
13
+ it is a singleton, because two references to the same variable should not
14
+ each spawn their own subprocess, and a per-reference cache caches nothing
15
+
16
+ That last point is the only place in this library where a singleton is the
17
+ right answer, and the contrast is the useful part. `Tree`, `Repository` and
18
+ the parsers are classmethods on classes nobody constructs — stronger than a
19
+ singleton, since there is no instance to mutate. `Config`, `SecretRef` and
20
+ `Finding` must not be singletons at all, because more than one is the normal
21
+ case. Here the shared state *is* the feature.
22
+
23
+ Off by default, because caching changes two things a caller should decide:
24
+
25
+ a secret stays in memory after the only read that needed it
26
+
27
+ a rotated secret keeps serving the old value until the entry expires,
28
+ which is the usual reason to set a ttl rather than leave it unbounded
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from threading import Lock
34
+ from typing import ClassVar
35
+
36
+ from secretref.utils.ttl_store import TtlStore
37
+
38
+
39
+ class ResolutionCache:
40
+ """A process-wide store of resolved secrets, keyed by reference."""
41
+
42
+ _instance: ClassVar[ResolutionCache | None] = None
43
+ _singleton_lock: ClassVar[Lock] = Lock()
44
+
45
+ def __new__(cls) -> ResolutionCache:
46
+ """Return the one instance, creating it on first use.
47
+
48
+ Double-checked under a lock, because two threads resolving at startup
49
+ would otherwise each build a store and one would win — leaving the
50
+ other thread's writes in an object nothing else can see.
51
+ """
52
+ if cls._instance is None:
53
+ with cls._singleton_lock:
54
+ if cls._instance is None:
55
+ instance = super().__new__(cls)
56
+ instance._initialize()
57
+ cls._instance = instance
58
+ return cls._instance
59
+
60
+ def _initialize(self) -> None:
61
+ """Set up the store. Called once, by __new__."""
62
+ self._store: TtlStore[tuple[str, str], str] = TtlStore()
63
+ self._enabled = False
64
+
65
+ # -- configuration -----------------------------------------------------
66
+
67
+ def enable(self, ttl_seconds: float | None = None) -> None:
68
+ """Start caching, optionally expiring entries after `ttl_seconds`.
69
+
70
+ A ttl is how a long-running process picks up a rotated secret without
71
+ restarting. Without one an entry lives until the cache is cleared,
72
+ which suits a short-lived process and not much else.
73
+ """
74
+ self._enabled = True
75
+ self._store.ttl = ttl_seconds
76
+
77
+ def disable(self) -> None:
78
+ """Stop caching and drop everything held."""
79
+ self._enabled = False
80
+ self._store.clear()
81
+
82
+ def clear(self) -> None:
83
+ """Drop everything held, without changing whether caching is on.
84
+
85
+ What to call after rotating a secret, and what a test fixture calls
86
+ so one test cannot see another's values.
87
+ """
88
+ self._store.clear()
89
+
90
+ @property
91
+ def enabled(self) -> bool:
92
+ """Whether resolved values are being kept."""
93
+ return self._enabled
94
+
95
+ # -- use ---------------------------------------------------------------
96
+
97
+ def get(self, source: str, locator: str) -> str | None:
98
+ """Return a cached value for this reference, or None.
99
+
100
+ None means "resolve it" rather than "it resolved to nothing" — an
101
+ empty resolution is never stored, so the two cannot be confused.
102
+ """
103
+ if not self._enabled:
104
+ return None
105
+ return self._store.get((source, locator))
106
+
107
+ def put(self, source: str, locator: str, value: str) -> None:
108
+ """Store a resolved value.
109
+
110
+ An empty value is not stored. Caching "this resolved to nothing"
111
+ would make a secret that appears later invisible until the cache is
112
+ cleared, which is the opposite of helpful during a deployment.
113
+ """
114
+ if not self._enabled or not value:
115
+ return
116
+ self._store.put((source, locator), value)
117
+
118
+ @property
119
+ def hits(self) -> int:
120
+ """Lookups served from the store."""
121
+ return self._store.hits
122
+
123
+ @property
124
+ def misses(self) -> int:
125
+ """Lookups that had to resolve."""
126
+ return self._store.misses
127
+
128
+ def stats(self) -> dict[str, int | bool | float | None]:
129
+ """Counters for logging. Contains no values and no locators."""
130
+ return {"enabled": self._enabled, **self._store.counters()}