standard 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.
- standard-0.1.0.dist-info/METADATA +99 -0
- standard-0.1.0.dist-info/RECORD +18 -0
- standard-0.1.0.dist-info/WHEEL +4 -0
- standard-0.1.0.dist-info/entry_points.txt +2 -0
- standard-0.1.0.dist-info/licenses/LICENSE +21 -0
- std/__init__.py +3 -0
- std/aws/__init__.py +107 -0
- std/cli.py +106 -0
- std/collections.py +100 -0
- std/config/__init__.py +31 -0
- std/config/errors.py +40 -0
- std/config/loader.py +480 -0
- std/config/snapshot.py +90 -0
- std/config/sources.py +161 -0
- std/fastapi/__init__.py +39 -0
- std/files.py +71 -0
- std/py.typed +0 -0
- std/skills/idiomatic-python.md +48 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: standard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A predictable Python utility library with an agent-first interface
|
|
5
|
+
Author: Standard contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: configuration,pydantic,toml,utilities,yaml
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: pydantic-settings<3,>=2.6
|
|
21
|
+
Requires-Dist: pyyaml<7,>=6
|
|
22
|
+
Provides-Extra: aws
|
|
23
|
+
Requires-Dist: boto3<2,>=1.35; extra == 'aws'
|
|
24
|
+
Provides-Extra: fastapi
|
|
25
|
+
Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Standard
|
|
29
|
+
|
|
30
|
+
**A small Python utility library with predictable behavior and agent-first guidance.**
|
|
31
|
+
|
|
32
|
+
The project and intended PyPI distribution are named `standard`; the Python import package and command are named `std`. Standard is currently an alpha source checkout, not a published PyPI release. The PyPI project named [`standard`](https://pypi.org/project/standard/) belongs to someone else, so do not install `standard` from PyPI expecting this project.
|
|
33
|
+
|
|
34
|
+
The core provides YAML/TOML configuration, Pydantic Settings sources, immutable snapshots, recursive mapping helpers, and atomic text writes. FastAPI and AWS integrations are optional extras.
|
|
35
|
+
|
|
36
|
+
## Install from this checkout
|
|
37
|
+
|
|
38
|
+
Run these commands from the repository root. Choose one integration command if you need that extra; each installs this checkout and its dependencies.
|
|
39
|
+
|
|
40
|
+
| Need | Command | Import path |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| Core only | `python -m pip install -e .` | `std` |
|
|
43
|
+
| FastAPI | `python -m pip install -e '.[fastapi]'` | `std.fastapi` |
|
|
44
|
+
| AWS | `python -m pip install -e '.[aws]'` | `std.aws` |
|
|
45
|
+
| Both integrations | `python -m pip install -e '.[fastapi,aws]'` | `std.fastapi`, `std.aws` |
|
|
46
|
+
|
|
47
|
+
The intended published install forms are `standard`, `standard[fastapi]`, and `standard[aws]`. They are not available for this project until the existing PyPI name is resolved.
|
|
48
|
+
|
|
49
|
+
## First use
|
|
50
|
+
|
|
51
|
+
Create `config/settings.yaml`:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
database:
|
|
55
|
+
host: localhost
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then load a value from it:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from std.config import ConfigManager
|
|
62
|
+
|
|
63
|
+
config = ConfigManager.from_file("config/settings.yaml")
|
|
64
|
+
host = config.snapshot.get_path("database.host", "localhost")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For Pydantic Settings, create `config/app.yaml`:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
host: localhost
|
|
71
|
+
port: 8080
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from std.config import YamlSettings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Settings(YamlSettings):
|
|
79
|
+
std_config_file = "config/app.yaml"
|
|
80
|
+
host: str
|
|
81
|
+
port: int = 8080
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
settings = Settings()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
By default, Pydantic Settings resolves values in this order: constructor values, Standard config files, process environment, dotenv, and file secrets. Callers can change the order through Pydantic's `settings_customise_sources` hook.
|
|
88
|
+
|
|
89
|
+
## Agent guidance
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
std guide
|
|
93
|
+
std skill show
|
|
94
|
+
std skill install
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`std guide` summarizes the library for coding agents. `std skill show` prints the packaged idiomatic-Python skill. `std skill install` writes it to `.agents/skills/standard-python/SKILL.md` and refuses to overwrite an existing file unless you pass `--force`.
|
|
98
|
+
|
|
99
|
+
The source repository contains the development checks, end-to-end tests, and full documentation site. The published distribution is intended to contain only the working library, its CLI, and the required package metadata, README, and license.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
std/__init__.py,sha256=QlL2NiR-0TsgDyskZc9kIkGR-RgiXC9n2IovyHAZsWQ,56
|
|
2
|
+
std/cli.py,sha256=mHeUcPWw-QUtUbXH4UEryYqk6r3-0n5bV6ZdkSKPvXU,3942
|
|
3
|
+
std/collections.py,sha256=HC9BUaAqb193z5446d66KTOUsf0N85fPGEqt19Gg-f4,3797
|
|
4
|
+
std/files.py,sha256=M6CF_4A9vDXiUEF6vX20HkRxTrX4ousOE-RzX9mWkL0,1985
|
|
5
|
+
std/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
std/aws/__init__.py,sha256=Rib0dLOc3ZFztFtPtf5bhkoq_WZ3wIPpNDO6tb_XE94,3864
|
|
7
|
+
std/config/__init__.py,sha256=rfcLky6wH4Tmp_cJSIn4CUAeGU0cp-aK1nB30dFGAEg,730
|
|
8
|
+
std/config/errors.py,sha256=Wh2hJTZGQPASKJH3Z4gA3JPhFF87YsKLmlSE9a_jSCc,1159
|
|
9
|
+
std/config/loader.py,sha256=nsetK9BGBsiJ8sKv3Q1woolb45WqjpVgIO2xeQQE19w,17254
|
|
10
|
+
std/config/snapshot.py,sha256=8AQf6YYfTK-ptbbWTbpzkBuNqJnxf5Um0KrmrH3fZ8w,3071
|
|
11
|
+
std/config/sources.py,sha256=kvPqVJ2vokMKKpNR0IqgyebKxq3UjRJj4Wjolw7ukLg,5677
|
|
12
|
+
std/fastapi/__init__.py,sha256=SMNNt6B4YDWSCzUGCqrLlNHRqS0VlFeEqpyerVPe36Q,1269
|
|
13
|
+
std/skills/idiomatic-python.md,sha256=9L5Iudh-TCB9dd09F5raOZl3Z6PAYIoPdwlORk26xxg,3763
|
|
14
|
+
standard-0.1.0.dist-info/METADATA,sha256=JVa-7n-30nqPNOit32hjYzjsCwkLRr0dYyE1WOLeLb0,3714
|
|
15
|
+
standard-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
standard-0.1.0.dist-info/entry_points.txt,sha256=ch5_ikNJzT7M1ZAKXs-STbZjQtPdWmqr6Zo24YwxJVc,37
|
|
17
|
+
standard-0.1.0.dist-info/licenses/LICENSE,sha256=sTIUP5MvMnfCSD0TdNTwjyFwBdAsnTBG2PfgodF7c9k,1078
|
|
18
|
+
standard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Standard contributors
|
|
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.
|
std/__init__.py
ADDED
std/aws/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Optional AWS Secrets Manager settings source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from typing import Any, NoReturn
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
# The optional type environment intentionally omits third-party boto3 stubs.
|
|
12
|
+
boto3: Any = import_module("boto3")
|
|
13
|
+
except ImportError as error: # pragma: no cover - depends on installation extra
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"AWS integration requires the aws extra; from a checkout run `pip install -e '.[aws]'`"
|
|
16
|
+
) from error
|
|
17
|
+
|
|
18
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AwsSecretError(RuntimeError):
|
|
22
|
+
"""A secret could not be retrieved or did not contain a JSON object."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
26
|
+
"""Build a JSON object while rejecting ambiguous duplicate keys."""
|
|
27
|
+
result: dict[str, Any] = {}
|
|
28
|
+
for key, value in pairs:
|
|
29
|
+
if key in result:
|
|
30
|
+
raise ValueError("duplicate JSON object key")
|
|
31
|
+
result[key] = value
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _reject_non_finite_number(value: str) -> NoReturn:
|
|
36
|
+
"""Reject Python's non-standard JSON NaN and Infinity extensions."""
|
|
37
|
+
raise ValueError(f"non-standard JSON numeric constant: {value}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_secret(
|
|
41
|
+
secret_id: str,
|
|
42
|
+
*,
|
|
43
|
+
region_name: str | None = None,
|
|
44
|
+
client: Any | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Load a JSON object from AWS Secrets Manager without exposing secret data."""
|
|
47
|
+
if not secret_id.strip():
|
|
48
|
+
raise ValueError("secret_id must not be empty")
|
|
49
|
+
try:
|
|
50
|
+
secrets_client = (
|
|
51
|
+
client
|
|
52
|
+
if client is not None
|
|
53
|
+
else boto3.client("secretsmanager", region_name=region_name)
|
|
54
|
+
)
|
|
55
|
+
response = secrets_client.get_secret_value(SecretId=secret_id)
|
|
56
|
+
if "SecretString" in response:
|
|
57
|
+
raw = response["SecretString"]
|
|
58
|
+
elif "SecretBinary" in response:
|
|
59
|
+
binary = response["SecretBinary"]
|
|
60
|
+
raw = (
|
|
61
|
+
binary.decode("utf-8")
|
|
62
|
+
if isinstance(binary, bytes)
|
|
63
|
+
else bytes(binary).decode("utf-8")
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
raise AwsSecretError("AWS Secrets Manager returned no secret payload")
|
|
67
|
+
value = json.loads(
|
|
68
|
+
raw,
|
|
69
|
+
object_pairs_hook=_unique_json_object,
|
|
70
|
+
parse_constant=_reject_non_finite_number,
|
|
71
|
+
)
|
|
72
|
+
except AwsSecretError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception:
|
|
75
|
+
raise AwsSecretError("AWS Secrets Manager request or JSON decoding failed") from None
|
|
76
|
+
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
|
77
|
+
raise AwsSecretError("AWS secret payload must be a JSON object with string keys")
|
|
78
|
+
return dict(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AwsSecretsManagerSettingsSource(PydanticBaseSettingsSource):
|
|
82
|
+
"""Pydantic source that maps an AWS Secrets Manager JSON object to fields."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
settings_cls: type[BaseSettings],
|
|
87
|
+
secret_id: str,
|
|
88
|
+
*,
|
|
89
|
+
region_name: str | None = None,
|
|
90
|
+
client: Any | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Configure the source with a Settings model and secret identifier."""
|
|
93
|
+
super().__init__(settings_cls)
|
|
94
|
+
self.secret_id = secret_id
|
|
95
|
+
self.region_name = region_name
|
|
96
|
+
self.client = client
|
|
97
|
+
|
|
98
|
+
def __call__(self) -> dict[str, object]:
|
|
99
|
+
"""Return the secret object as a mapping of Settings field values."""
|
|
100
|
+
return load_secret(self.secret_id, region_name=self.region_name, client=self.client)
|
|
101
|
+
|
|
102
|
+
def get_field_value(self, field: object, field_name: str) -> tuple[object, str, bool]:
|
|
103
|
+
"""Satisfy the Pydantic source protocol; this source returns a full mapping."""
|
|
104
|
+
return None, field_name, False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["AwsSecretError", "AwsSecretsManagerSettingsSource", "load_secret"]
|
std/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Agent-facing command line guidance for the Standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from std import __version__
|
|
13
|
+
from std.files import atomic_write_text
|
|
14
|
+
|
|
15
|
+
_DISTRIBUTION_NAME = "standard"
|
|
16
|
+
|
|
17
|
+
_GUIDE = """Standard targets the `standard` distribution and is imported as `std`.
|
|
18
|
+
|
|
19
|
+
Use `std.config.ConfigManager` for YAML/TOML configuration and Pydantic Settings
|
|
20
|
+
sources. Snapshots are immutable; call `to_dict()` for an isolated mutable copy.
|
|
21
|
+
`reload()` is explicit and atomic. Core helpers are `std.collections` and
|
|
22
|
+
`std.files`. FastAPI and AWS adapters are optional: install
|
|
23
|
+
`standard[fastapi]` or `standard[aws]` before importing `std.fastapi` or
|
|
24
|
+
`std.aws`. The existing PyPI project named `standard` is unrelated; until the
|
|
25
|
+
name is resolved, install extras from this checkout with `pip install -e '.[fastapi]'`
|
|
26
|
+
or `pip install -e '.[aws]'`.
|
|
27
|
+
|
|
28
|
+
Run `std skill show` to print the portable idiomatic-Python skill. Run
|
|
29
|
+
`std skill install` to copy it to `.agents/skills/standard-python/SKILL.md`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _skill_text() -> str:
|
|
34
|
+
return (
|
|
35
|
+
resources.files("std").joinpath("skills", "idiomatic-python.md").read_text(encoding="utf-8")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="std",
|
|
42
|
+
description="Standard library and coding-agent guidance",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--version", action="version", version=f"std {__version__}")
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
guide = subparsers.add_parser("guide", help="show concise instructions for coding agents")
|
|
48
|
+
guide.add_argument("--format", choices=("text", "json"), default="text")
|
|
49
|
+
|
|
50
|
+
skill = subparsers.add_parser("skill", help="show or install the idiomatic Python skill")
|
|
51
|
+
skill_subparsers = skill.add_subparsers(dest="skill_command", required=True)
|
|
52
|
+
skill_subparsers.add_parser("show", help="print the portable skill document")
|
|
53
|
+
skill_subparsers.add_parser("path", help="print the packaged skill resource path")
|
|
54
|
+
install = skill_subparsers.add_parser(
|
|
55
|
+
"install", help="copy the skill into the current repository"
|
|
56
|
+
)
|
|
57
|
+
install.add_argument(
|
|
58
|
+
"destination",
|
|
59
|
+
nargs="?",
|
|
60
|
+
default=".agents/skills/standard-python/SKILL.md",
|
|
61
|
+
help="destination file (default: .agents/skills/standard-python/SKILL.md)",
|
|
62
|
+
)
|
|
63
|
+
install.add_argument("--force", action="store_true", help="replace an existing destination")
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
68
|
+
"""Run the ``std`` command."""
|
|
69
|
+
args = _parser().parse_args(argv)
|
|
70
|
+
if args.command == "guide":
|
|
71
|
+
if args.format == "json":
|
|
72
|
+
print(
|
|
73
|
+
json.dumps(
|
|
74
|
+
{
|
|
75
|
+
"distribution": _DISTRIBUTION_NAME,
|
|
76
|
+
"import": "std",
|
|
77
|
+
"version": __version__,
|
|
78
|
+
"guide": _GUIDE,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
print(_GUIDE.rstrip())
|
|
84
|
+
return 0
|
|
85
|
+
if args.skill_command == "show":
|
|
86
|
+
print(_skill_text(), end="")
|
|
87
|
+
return 0
|
|
88
|
+
if args.skill_command == "path":
|
|
89
|
+
print(resources.files("std").joinpath("skills", "idiomatic-python.md"))
|
|
90
|
+
return 0
|
|
91
|
+
target = Path(args.destination)
|
|
92
|
+
if (target.exists() or target.is_symlink()) and not args.force:
|
|
93
|
+
print(f"refusing to overwrite existing path: {target} (pass --force)", file=sys.stderr)
|
|
94
|
+
return 2
|
|
95
|
+
try:
|
|
96
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
atomic_write_text(target, _skill_text(), mode=0o644)
|
|
98
|
+
except OSError as error:
|
|
99
|
+
print(f"could not install skill at {target}: {error}", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
print(target)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|
std/collections.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Small, predictable helpers for nested mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def deep_merge(*mappings: Mapping[str, Any]) -> dict[str, Any]:
|
|
14
|
+
"""Merge mappings without mutating inputs; lists append and later conflicts win.
|
|
15
|
+
|
|
16
|
+
Nested mappings merge recursively. Lists concatenate in argument order.
|
|
17
|
+
Any other type conflict replaces the earlier value with the later value.
|
|
18
|
+
Values are copied recursively, so the result is independent of mutable inputs.
|
|
19
|
+
"""
|
|
20
|
+
result: dict[str, Any] = {}
|
|
21
|
+
for mapping in mappings:
|
|
22
|
+
if not isinstance(mapping, Mapping):
|
|
23
|
+
raise TypeError("deep_merge accepts mappings only")
|
|
24
|
+
result = _merge_pair(result, mapping)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _copy_value(value: Any, active: set[int] | None = None) -> Any:
|
|
29
|
+
if isinstance(value, (Mapping, list, tuple)):
|
|
30
|
+
current_active = active if active is not None else set()
|
|
31
|
+
identity = id(value)
|
|
32
|
+
if identity in current_active:
|
|
33
|
+
raise ValueError("cyclic mappings and sequences cannot be merged")
|
|
34
|
+
current_active.add(identity)
|
|
35
|
+
try:
|
|
36
|
+
if isinstance(value, Mapping):
|
|
37
|
+
return {key: _copy_value(item, current_active) for key, item in value.items()}
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [_copy_value(item, current_active) for item in value]
|
|
40
|
+
return tuple(_copy_value(item, current_active) for item in value)
|
|
41
|
+
finally:
|
|
42
|
+
current_active.remove(identity)
|
|
43
|
+
return deepcopy(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _merge_pair(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
|
|
47
|
+
merged = {key: _copy_value(value) for key, value in left.items()}
|
|
48
|
+
for key, value in right.items():
|
|
49
|
+
previous = merged.get(key, _MISSING)
|
|
50
|
+
if isinstance(previous, Mapping) and isinstance(value, Mapping):
|
|
51
|
+
merged[key] = _merge_pair(previous, value)
|
|
52
|
+
elif isinstance(previous, list) and isinstance(value, list):
|
|
53
|
+
merged[key] = _copy_value(previous) + _copy_value(value)
|
|
54
|
+
else:
|
|
55
|
+
merged[key] = _copy_value(value)
|
|
56
|
+
return merged
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_path(
|
|
60
|
+
mapping: Mapping[str, Any],
|
|
61
|
+
path: str | Sequence[str],
|
|
62
|
+
default: V | None = None,
|
|
63
|
+
) -> Any | V | None:
|
|
64
|
+
"""Read a dotted or segmented path from nested mappings.
|
|
65
|
+
|
|
66
|
+
A missing segment returns ``default``. Empty path segments in a dotted path
|
|
67
|
+
are ignored, so ``"a..b"`` is treated as ``("a", "b")``.
|
|
68
|
+
"""
|
|
69
|
+
parts = (
|
|
70
|
+
tuple(part for part in path.split(".") if part) if isinstance(path, str) else tuple(path)
|
|
71
|
+
)
|
|
72
|
+
current: Any = mapping
|
|
73
|
+
for part in parts:
|
|
74
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
75
|
+
return default
|
|
76
|
+
current = current[part]
|
|
77
|
+
return current
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def set_path(mapping: dict[str, Any], path: str | Sequence[str], value: Any) -> None:
|
|
81
|
+
"""Set a nested mapping value, creating intermediate dictionaries in place."""
|
|
82
|
+
parts = (
|
|
83
|
+
tuple(part for part in path.split(".") if part) if isinstance(path, str) else tuple(path)
|
|
84
|
+
)
|
|
85
|
+
if not parts or any(not isinstance(part, str) or not part for part in parts):
|
|
86
|
+
raise ValueError("path must contain at least one non-empty string segment")
|
|
87
|
+
current: dict[str, Any] = mapping
|
|
88
|
+
for part in parts[:-1]:
|
|
89
|
+
if part not in current:
|
|
90
|
+
child: Any = {}
|
|
91
|
+
current[part] = child
|
|
92
|
+
else:
|
|
93
|
+
child = current[part]
|
|
94
|
+
if not isinstance(child, dict):
|
|
95
|
+
raise TypeError(f"cannot descend through non-mapping segment {part!r}")
|
|
96
|
+
current = child
|
|
97
|
+
current[parts[-1]] = value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["deep_merge", "get_path", "set_path"]
|
std/config/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""YAML/TOML configuration loading and Pydantic Settings integration."""
|
|
2
|
+
|
|
3
|
+
from std.config.errors import (
|
|
4
|
+
ConfigError,
|
|
5
|
+
ConfigFormatError,
|
|
6
|
+
ConfigInterpolationError,
|
|
7
|
+
MissingConfigSourceError,
|
|
8
|
+
)
|
|
9
|
+
from std.config.loader import ConfigManager
|
|
10
|
+
from std.config.snapshot import ConfigSnapshot
|
|
11
|
+
from std.config.sources import (
|
|
12
|
+
ConfigSettingsSource,
|
|
13
|
+
TomlSettings,
|
|
14
|
+
TomlSettingsSource,
|
|
15
|
+
YamlSettings,
|
|
16
|
+
YamlSettingsSource,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"ConfigFormatError",
|
|
22
|
+
"ConfigInterpolationError",
|
|
23
|
+
"ConfigManager",
|
|
24
|
+
"ConfigSettingsSource",
|
|
25
|
+
"ConfigSnapshot",
|
|
26
|
+
"MissingConfigSourceError",
|
|
27
|
+
"TomlSettings",
|
|
28
|
+
"TomlSettingsSource",
|
|
29
|
+
"YamlSettings",
|
|
30
|
+
"YamlSettingsSource",
|
|
31
|
+
]
|
std/config/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Public errors raised while loading and resolving configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(Exception):
|
|
9
|
+
"""Base error with optional source and location context."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
source: Path | None = None,
|
|
16
|
+
location: str | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Build a safe error message with optional source and location context."""
|
|
19
|
+
self.message = message
|
|
20
|
+
self.source = source
|
|
21
|
+
self.location = location
|
|
22
|
+
context = []
|
|
23
|
+
if source is not None:
|
|
24
|
+
context.append(str(source))
|
|
25
|
+
if location:
|
|
26
|
+
context.append(location)
|
|
27
|
+
prefix = ":".join(context)
|
|
28
|
+
super().__init__(f"{prefix}: {message}" if prefix else message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MissingConfigSourceError(ConfigError):
|
|
32
|
+
"""A required configuration file or directory is missing."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigFormatError(ConfigError):
|
|
36
|
+
"""A source has invalid syntax, a bad root, or an unsupported format."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigInterpolationError(ConfigError):
|
|
40
|
+
"""A placeholder is malformed or cannot be resolved safely."""
|