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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.