trailsign 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.
- trailsign/__init__.py +24 -0
- trailsign/settings.py +185 -0
- trailsign-0.1.0.dist-info/METADATA +134 -0
- trailsign-0.1.0.dist-info/RECORD +6 -0
- trailsign-0.1.0.dist-info/WHEEL +4 -0
- trailsign-0.1.0.dist-info/licenses/LICENSE +21 -0
trailsign/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Trailsign: resolves a declarative, self-describing config into plain
|
|
2
|
+
values. See docs/design.md at the repo root for the full design."""
|
|
3
|
+
|
|
4
|
+
from .settings import (
|
|
5
|
+
RESOLVE_KEY,
|
|
6
|
+
EnvironmentVariableResolver,
|
|
7
|
+
OracleKeyVaultResolver,
|
|
8
|
+
PlaintextResolver,
|
|
9
|
+
Settings,
|
|
10
|
+
SettingsError,
|
|
11
|
+
SettingsResolver,
|
|
12
|
+
default_resolvers,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RESOLVE_KEY",
|
|
17
|
+
"EnvironmentVariableResolver",
|
|
18
|
+
"OracleKeyVaultResolver",
|
|
19
|
+
"PlaintextResolver",
|
|
20
|
+
"Settings",
|
|
21
|
+
"SettingsError",
|
|
22
|
+
"SettingsResolver",
|
|
23
|
+
"default_resolvers",
|
|
24
|
+
]
|
trailsign/settings.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Trailsign: resolves a declarative, self-describing config into plain
|
|
2
|
+
values.
|
|
3
|
+
|
|
4
|
+
Each value in the config declares its own source instead of the caller
|
|
5
|
+
assuming where to look -- a plain scalar, an environment variable, and a
|
|
6
|
+
vault secret are all just different `trailsign-resolve:` nodes resolved
|
|
7
|
+
the same way. `trailsign-resolve:` is a reserved, namespaced key --
|
|
8
|
+
never a bare word like `resolve` -- so it can never collide with a
|
|
9
|
+
consuming project's own field names. See docs/design.md for the full
|
|
10
|
+
design (data flow, diagrams, and why `trailsign-resolve:` has to be a
|
|
11
|
+
dedicated key rather than reusing a subsystem's own `type:`-style
|
|
12
|
+
discriminator).
|
|
13
|
+
|
|
14
|
+
A reference implementation in Python, packaged and tested. Extracted
|
|
15
|
+
2026-09-01 from a Telegram news-trend bot's settings work -- the design
|
|
16
|
+
turned out to be genuinely content-independent, so it lives here as its
|
|
17
|
+
own project now.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
RESOLVE_KEY = "trailsign-resolve"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SettingsError(Exception):
|
|
33
|
+
"""A setting couldn't be resolved -- missing path, a
|
|
34
|
+
`trailsign-resolve:` value naming an unregistered resolver, a
|
|
35
|
+
resolver's own required field absent, or (from validate()) one or
|
|
36
|
+
more required paths unresolvable. The message names exactly which."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SettingsResolver(Protocol):
|
|
40
|
+
"""Resolves one typed-value node ({"trailsign-resolve": <this
|
|
41
|
+
resolver's own name>, ...}) to a plain value. One implementation per
|
|
42
|
+
resolver name; a new source (AWS Secrets Manager, Azure Key Vault,
|
|
43
|
+
...) is a new class registered by name, never a change to Settings
|
|
44
|
+
itself."""
|
|
45
|
+
|
|
46
|
+
def resolve(self, node: dict[str, Any], settings: "Settings") -> Any: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PlaintextResolver:
|
|
50
|
+
def resolve(self, node: dict[str, Any], settings: "Settings") -> Any:
|
|
51
|
+
try:
|
|
52
|
+
return node["value"]
|
|
53
|
+
except KeyError:
|
|
54
|
+
raise SettingsError("plaintext value missing its 'value' field") from None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EnvironmentVariableResolver:
|
|
58
|
+
def resolve(self, node: dict[str, Any], settings: "Settings") -> Any:
|
|
59
|
+
name = node.get("name")
|
|
60
|
+
if not name:
|
|
61
|
+
raise SettingsError("environment-variable value missing its 'name' field")
|
|
62
|
+
try:
|
|
63
|
+
return os.environ[name]
|
|
64
|
+
except KeyError:
|
|
65
|
+
raise SettingsError(f"environment variable {name!r} is not set") from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OracleKeyVaultResolver:
|
|
69
|
+
"""The only resolver allowed to import a cloud SDK -- lazily, so a
|
|
70
|
+
consumer with no oracleKeyVault nodes in their config never needs
|
|
71
|
+
the `oci` package installed at all."""
|
|
72
|
+
|
|
73
|
+
def resolve(self, node: dict[str, Any], settings: "Settings") -> Any:
|
|
74
|
+
settings.get_credential_source(node["source"]) # validates 'source' exists
|
|
75
|
+
secret_ocid = node.get("secret_ocid")
|
|
76
|
+
if not secret_ocid:
|
|
77
|
+
raise SettingsError("oracleKeyVault value missing its 'secret_ocid' field")
|
|
78
|
+
|
|
79
|
+
client = _oci_secrets_client()
|
|
80
|
+
response = client.get_secret_bundle(secret_ocid)
|
|
81
|
+
content = response.data.secret_bundle_content.content # base64-encoded
|
|
82
|
+
return base64.b64decode(content).decode("utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _oci_secrets_client() -> Any:
|
|
86
|
+
"""Builds an authenticated OCI SecretsClient using instance-principal
|
|
87
|
+
auth -- confirmed against a real production config (a sibling
|
|
88
|
+
project's local-infra/infrastructure.yaml) as the only auth shape
|
|
89
|
+
actually in use: every deployed secret fetch there runs `oci secrets
|
|
90
|
+
secret-bundle get --auth instance_principal`, no static credential,
|
|
91
|
+
from inside an OCI compute instance. `config={}` alongside a signer
|
|
92
|
+
is the correct SDK shape for this, not a placeholder for a real
|
|
93
|
+
config -- there deliberately isn't one. Calling this off an OCI
|
|
94
|
+
instance fails with an instance-metadata-service error, which is
|
|
95
|
+
expected, not a bug in this function.
|
|
96
|
+
|
|
97
|
+
A `credential_sources` entry's own `vault_ocid`/`compartment_ocid`
|
|
98
|
+
aren't used here: get_secret_bundle(secret_ocid) needs neither under
|
|
99
|
+
instance-principal auth (verified against a real vault secret via
|
|
100
|
+
tools/verify_oracle_vault.py) -- see docs/design.md's 'Still open'
|
|
101
|
+
section for whether they end up load-bearing for some other
|
|
102
|
+
OCI operation later."""
|
|
103
|
+
import oci # local import -- see class docstring
|
|
104
|
+
|
|
105
|
+
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
|
|
106
|
+
return oci.secrets.SecretsClient(config={}, signer=signer)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def default_resolvers() -> dict[str, SettingsResolver]:
|
|
110
|
+
return {
|
|
111
|
+
"plaintext": PlaintextResolver(),
|
|
112
|
+
"environment-variable": EnvironmentVariableResolver(),
|
|
113
|
+
"oracleKeyVault": OracleKeyVaultResolver(),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Settings:
|
|
118
|
+
def __init__(self, raw: dict[str, Any], resolvers: dict[str, SettingsResolver] | None = None):
|
|
119
|
+
self._raw = raw
|
|
120
|
+
self._resolvers = resolvers if resolvers is not None else default_resolvers()
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_yaml(cls, path: str | Path, resolvers: dict[str, SettingsResolver] | None = None) -> "Settings":
|
|
124
|
+
with open(path, encoding="utf-8") as f:
|
|
125
|
+
raw = yaml.safe_load(f)
|
|
126
|
+
return cls(raw, resolvers)
|
|
127
|
+
|
|
128
|
+
def resolved(self, path: str, default: Any = None, required: bool = False) -> Any:
|
|
129
|
+
"""path is dotted, e.g. "news_source.gnews" or "models.main.api-key".
|
|
130
|
+
Returns the node at that path with every {"trailsign-resolve":
|
|
131
|
+
<name>, ...} descendant replaced by its resolved value -- plain
|
|
132
|
+
dicts/lists/scalars for everything else, unchanged."""
|
|
133
|
+
node = self._raw
|
|
134
|
+
for part in path.split("."):
|
|
135
|
+
if not isinstance(node, dict) or part not in node:
|
|
136
|
+
if required:
|
|
137
|
+
raise SettingsError(f"required setting {path!r} is not present")
|
|
138
|
+
return default
|
|
139
|
+
node = node[part]
|
|
140
|
+
return self._resolve_node(node)
|
|
141
|
+
|
|
142
|
+
def get_credential_source(self, name: str) -> dict[str, Any]:
|
|
143
|
+
"""Looked up by resolvers that need a named, reusable connection
|
|
144
|
+
block (see oracleKeyVault's `source:` field) -- not meant to be
|
|
145
|
+
called by ordinary settings consumers."""
|
|
146
|
+
try:
|
|
147
|
+
return self._raw["credential_sources"][name]
|
|
148
|
+
except KeyError:
|
|
149
|
+
raise SettingsError(f"no credential_sources entry named {name!r}") from None
|
|
150
|
+
|
|
151
|
+
def validate(self, required_paths: list[str]) -> None:
|
|
152
|
+
"""Resolve every path up front and fail with ONE error listing
|
|
153
|
+
everything unresolvable, instead of the process starting,
|
|
154
|
+
looking healthy, and dying on first use of a missing key. A
|
|
155
|
+
consuming project's entry point should call this with its own
|
|
156
|
+
required-key list before doing anything else."""
|
|
157
|
+
errors = []
|
|
158
|
+
for path in required_paths:
|
|
159
|
+
try:
|
|
160
|
+
self.resolved(path, required=True)
|
|
161
|
+
except SettingsError as exc:
|
|
162
|
+
errors.append(str(exc))
|
|
163
|
+
if errors:
|
|
164
|
+
raise SettingsError("missing/invalid settings:\n " + "\n ".join(errors))
|
|
165
|
+
|
|
166
|
+
def _resolve_node(self, node: Any) -> Any:
|
|
167
|
+
"""A dict is a resolvable value iff it carries the reserved
|
|
168
|
+
RESOLVE_KEY -- purely structural, independent of what resolvers
|
|
169
|
+
happen to be registered or what any subsystem's own
|
|
170
|
+
`type:`-style discriminator fields happen to say. A
|
|
171
|
+
`trailsign-resolve` value naming an unregistered resolver is an
|
|
172
|
+
error, not a silent pass-through -- once a node declares intent
|
|
173
|
+
to be resolved, an unrecognized target should never resolve to
|
|
174
|
+
itself unresolved."""
|
|
175
|
+
if isinstance(node, dict):
|
|
176
|
+
if RESOLVE_KEY in node:
|
|
177
|
+
name = node[RESOLVE_KEY]
|
|
178
|
+
resolver = self._resolvers.get(name)
|
|
179
|
+
if resolver is None:
|
|
180
|
+
raise SettingsError(f"no resolver registered for '{RESOLVE_KEY}: {name}'")
|
|
181
|
+
return resolver.resolve(node, self)
|
|
182
|
+
return {k: self._resolve_node(v) for k, v in node.items()}
|
|
183
|
+
if isinstance(node, list):
|
|
184
|
+
return [self._resolve_node(v) for v in node]
|
|
185
|
+
return node
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trailsign
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolves a declarative, self-describing config into plain values
|
|
5
|
+
Project-URL: Homepage, https://github.com/nankma/trailsign
|
|
6
|
+
Project-URL: Repository, https://github.com/nankma/trailsign
|
|
7
|
+
Author-email: Nankang Ma <jjkkma@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Nankang Ma
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Requires-Python: >=3.10
|
|
34
|
+
Requires-Dist: pyyaml>=6.0
|
|
35
|
+
Provides-Extra: test
|
|
36
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# Trailsign
|
|
40
|
+
|
|
41
|
+
A small, language-independent library for resolving application
|
|
42
|
+
settings from a declarative, self-describing config — where each value
|
|
43
|
+
states its own source (a literal, an environment variable, a vault
|
|
44
|
+
secret, ...) instead of the calling code assuming where to look.
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
api-key:
|
|
48
|
+
trailsign-resolve: environment-variable
|
|
49
|
+
name: GNEWS_API_KEY
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`trailsign-resolve:` is a reserved, namespaced key — deliberately not a
|
|
53
|
+
bare word like `resolve` — so it can never collide with a consuming
|
|
54
|
+
project's own field names. It dispatches to a pluggable resolver;
|
|
55
|
+
whatever it resolves to is handed to the consumer as a plain value, with
|
|
56
|
+
no trace of where it came from left in the shape.
|
|
57
|
+
|
|
58
|
+
## Status
|
|
59
|
+
|
|
60
|
+
**Python package built out, as of 2026-09-01.** `src/trailsign/` is a
|
|
61
|
+
real installable package (`pyproject.toml`, src layout) with a test
|
|
62
|
+
suite covering the resolve walk, the three built-in resolvers
|
|
63
|
+
(`OracleKeyVaultResolver` verified against a real OCI Vault secret —
|
|
64
|
+
see `tools/verify_oracle_vault.py`), `validate()`'s combined-error
|
|
65
|
+
behavior, and the `trailsign-resolve` vs. `type` non-collision
|
|
66
|
+
regression. MIT licensed (see `LICENSE`). Public on GitHub; CI runs the
|
|
67
|
+
test suite on every push/PR. Not yet published to PyPI. A port to at
|
|
68
|
+
least one other language is still open, since the design's whole point
|
|
69
|
+
is being language-independent, not just Python.
|
|
70
|
+
|
|
71
|
+
### Installing it
|
|
72
|
+
|
|
73
|
+
Not on PyPI yet — until then, install straight from GitHub, ideally
|
|
74
|
+
pinned to a tag once one exists:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
pip install git+https://github.com/nankma/trailsign.git@main
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Install for development on this repo: `pip install -e ".[test]"`, then
|
|
81
|
+
`pytest`.
|
|
82
|
+
|
|
83
|
+
## Start here
|
|
84
|
+
|
|
85
|
+
- [`docs/design.md`](docs/design.md) — the core design: the config
|
|
86
|
+
shape, the resolve/dispatch contract (holds equally for a Go
|
|
87
|
+
`interface`, a Rust `trait`, or a Python `typing.Protocol`), why it's
|
|
88
|
+
shaped this way, two worked examples with diagrams, and what's still
|
|
89
|
+
undecided.
|
|
90
|
+
- [`src/trailsign/settings.py`](src/trailsign/settings.py) — the Python
|
|
91
|
+
reference implementation, matching `docs/design.md` exactly.
|
|
92
|
+
- [`tests/`](tests/) — the test suite; `tests/conftest.py` has a shared
|
|
93
|
+
fixture config mirroring `docs/design.md`'s worked examples.
|
|
94
|
+
- the `writing-system-design-docs` skill (global, not repo-local) — the
|
|
95
|
+
doc-writing convention `docs/design.md` follows, carried over
|
|
96
|
+
from where this project started in case future design docs here want
|
|
97
|
+
the same discipline (language-independent contracts, diagrams, a
|
|
98
|
+
"still open" section that's actually kept honest).
|
|
99
|
+
|
|
100
|
+
## Origin
|
|
101
|
+
|
|
102
|
+
This design started inside a Telegram news-trend bot (Auguring, formerly
|
|
103
|
+
Argus) while building a settings abstraction so that bot could run
|
|
104
|
+
standalone as well as on its current cloud deployment. The design turned
|
|
105
|
+
out to be genuinely content-independent — nothing in it assumes anything
|
|
106
|
+
bot-specific — so it's being extracted into its own project rather than
|
|
107
|
+
staying bot-only. `docs/design.md`'s own "Origin" section has the
|
|
108
|
+
originating project's actual settings inventory, kept for context on why
|
|
109
|
+
the design has the shape it has.
|
|
110
|
+
|
|
111
|
+
## The split that makes this portable
|
|
112
|
+
|
|
113
|
+
Two jobs, two owners, and only one of them is this library's job:
|
|
114
|
+
|
|
115
|
+
1. **Resolving a marked value to a plain value** — Trailsign's job, and
|
|
116
|
+
only Trailsign's job. Nothing here knows or cares what the resolved
|
|
117
|
+
value is *for*.
|
|
118
|
+
2. **Turning a resolved config block into a live object** — never
|
|
119
|
+
Trailsign's job. Each consumer owns its own small factory (a plain
|
|
120
|
+
name→constructor map) that builds whatever it needs from the
|
|
121
|
+
already-resolved values this library hands it.
|
|
122
|
+
|
|
123
|
+
Full reasoning for the split, plus two complete worked examples (a news
|
|
124
|
+
source's API key from an environment variable, a telemetry backend's
|
|
125
|
+
credential from a vault) with diagrams, is in `docs/design.md`.
|
|
126
|
+
|
|
127
|
+
## What's not decided yet
|
|
128
|
+
|
|
129
|
+
See `docs/design.md`'s own "Still open" section for full detail:
|
|
130
|
+
|
|
131
|
+
- A non-instance-principal auth shape for `oracleKeyVault` (today it only
|
|
132
|
+
works from inside an OCI compute instance)
|
|
133
|
+
- Validation-timing default (eager vs. lazy)
|
|
134
|
+
- A port to a second language
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
trailsign/__init__.py,sha256=uTw7Pambk8vzYiJ3w_o-0JzsGxkeNNRlhcBwqZg9Ge8,556
|
|
2
|
+
trailsign/settings.py,sha256=oMXwKI09Q7yrZlOl3ZrtQV6JirDJ9lFfZJGLihfVALg,8139
|
|
3
|
+
trailsign-0.1.0.dist-info/METADATA,sha256=9RGpm3QX0fx-IgVZ5T4wRMHKAFiSwtnZWqlqvGnM3Dk,5974
|
|
4
|
+
trailsign-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
trailsign-0.1.0.dist-info/licenses/LICENSE,sha256=TFPB7yR0f7-B4aM_Vi0q5A9CwmdMpymCjBdGxjDsVK8,1067
|
|
6
|
+
trailsign-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nankang Ma
|
|
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.
|