Skip to content

Commit 175411c

Browse files
bdracojpbedepre-commit-ci[bot]
authored
feat: initial checkin (#1)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 985ea54 commit 175411c

File tree

19 files changed

+2751
-17
lines changed

19 files changed

+2751
-17
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,10 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
python-version:
39-
- "3.9"
40-
- "3.10"
41-
- "3.11"
4239
- "3.12"
4340
- "3.13"
4441
os:
4542
- ubuntu-latest
46-
- windows-latest
47-
- macOS-latest
4843
runs-on: ${{ matrix.os }}
4944
steps:
5045
- uses: actions/checkout@v4

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,5 @@ repos:
5454
rev: v1.15.0
5555
hooks:
5656
- id: mypy
57-
additional_dependencies: []
57+
additional_dependencies: ["types-PyYAML"]
58+
exclude: "tests"

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ classifiers = [
2626
]
2727

2828
dependencies = [
29+
"propcache>0.1",
30+
"pyyaml>=6.0.1",
31+
"voluptuous>0.15",
2932
]
3033
urls."Bug Tracker" = "https://github.com/home-assistant-libs/annotatedyaml/issues"
3134
urls.Changelog = "https://github.com/home-assistant-libs/annotatedyaml/blob/main/CHANGELOG.md"
@@ -104,7 +107,7 @@ exclude_lines = [
104107

105108
[tool.mypy]
106109
check_untyped_defs = true
107-
disallow_any_generics = true
110+
disallow_any_generics = false
108111
disallow_incomplete_defs = true
109112
disallow_untyped_defs = true
110113
mypy_path = "src/"

src/annotatedyaml/__init__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,25 @@
1-
__version__ = "0.0.0"
1+
"""YAML utility functions."""
2+
3+
from .const import SECRET_YAML
4+
from .dumper import dump, save_yaml
5+
from .exceptions import YAMLException, YamlTypeError
6+
from .input import UndefinedSubstitution, extract_inputs, substitute
7+
from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml
8+
from .objects import Input
9+
10+
__all__ = [
11+
"SECRET_YAML",
12+
"Input",
13+
"Secrets",
14+
"UndefinedSubstitution",
15+
"YAMLException",
16+
"YamlTypeError",
17+
"dump",
18+
"extract_inputs",
19+
"load_yaml",
20+
"load_yaml_dict",
21+
"parse_yaml",
22+
"save_yaml",
23+
"secret_yaml",
24+
"substitute",
25+
]

src/annotatedyaml/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Constants."""
2+
3+
SECRET_YAML = "secrets.yaml" # noqa: S105

src/annotatedyaml/dumper.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Custom dumper and representers."""
2+
3+
from collections import OrderedDict
4+
from typing import Any
5+
6+
import yaml
7+
8+
from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass
9+
10+
# mypy: allow-untyped-calls, no-warn-return-any
11+
12+
13+
try:
14+
from yaml import CSafeDumper as FastestAvailableSafeDumper
15+
except ImportError:
16+
from yaml import ( # type: ignore[assignment]
17+
SafeDumper as FastestAvailableSafeDumper,
18+
)
19+
20+
21+
def dump(_dict: dict | list) -> str:
22+
"""Dump YAML to a string and remove null."""
23+
return yaml.dump(
24+
_dict,
25+
default_flow_style=False,
26+
allow_unicode=True,
27+
sort_keys=False,
28+
Dumper=FastestAvailableSafeDumper,
29+
).replace(": null\n", ":\n")
30+
31+
32+
def save_yaml(path: str, data: dict) -> None:
33+
"""Save YAML to a file."""
34+
# Dump before writing to not truncate the file if dumping fails
35+
str_data = dump(data)
36+
with open(path, "w", encoding="utf-8") as outfile:
37+
outfile.write(str_data)
38+
39+
40+
# From: https://gist.github.com/miracle2k/3184458
41+
def represent_odict( # type: ignore[no-untyped-def]
42+
dumper, tag, mapping, flow_style=None
43+
) -> yaml.MappingNode:
44+
"""Like BaseRepresenter.represent_mapping but does not issue the sort()."""
45+
value: list = []
46+
node = yaml.MappingNode(tag, value, flow_style=flow_style)
47+
if dumper.alias_key is not None:
48+
dumper.represented_objects[dumper.alias_key] = node
49+
best_style = True
50+
if hasattr(mapping, "items"):
51+
mapping = mapping.items()
52+
for item_key, item_value in mapping:
53+
node_key = dumper.represent_data(item_key)
54+
node_value = dumper.represent_data(item_value)
55+
if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style):
56+
best_style = False
57+
if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style):
58+
best_style = False
59+
value.append((node_key, node_value))
60+
if flow_style is None:
61+
if dumper.default_flow_style is not None:
62+
node.flow_style = dumper.default_flow_style
63+
else:
64+
node.flow_style = best_style
65+
return node
66+
67+
68+
def add_representer(klass: Any, representer: Any) -> None:
69+
"""Add to representer to the dumper."""
70+
FastestAvailableSafeDumper.add_representer(klass, representer)
71+
72+
73+
add_representer(
74+
OrderedDict,
75+
lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value),
76+
)
77+
78+
add_representer(
79+
NodeDictClass,
80+
lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value),
81+
)
82+
83+
add_representer(
84+
NodeListClass,
85+
lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value),
86+
)
87+
88+
add_representer(
89+
NodeStrClass,
90+
lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)),
91+
)
92+
93+
add_representer(
94+
Input,
95+
lambda dumper, value: dumper.represent_scalar("!input", value.name),
96+
)

src/annotatedyaml/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
4+
class YAMLException(Exception):
5+
"""Base class for all YAML exceptions."""
6+
7+
8+
class YamlTypeError(YAMLException):
9+
"""Raised by load_yaml_dict if top level data is not a dict."""

src/annotatedyaml/input.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Deal with YAML input."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from .objects import Input
8+
9+
10+
class UndefinedSubstitution(Exception):
11+
"""Error raised when we find a substitution that is not defined."""
12+
13+
def __init__(self, input_name: str) -> None:
14+
"""Initialize the undefined substitution exception."""
15+
super().__init__(f"No substitution found for input {input_name}")
16+
self.input = input
17+
18+
19+
def extract_inputs(obj: Any) -> set[str]:
20+
"""Extract input from a structure."""
21+
found: set[str] = set()
22+
_extract_inputs(obj, found)
23+
return found
24+
25+
26+
def _extract_inputs(obj: Any, found: set[str]) -> None:
27+
"""Extract input from a structure."""
28+
if isinstance(obj, Input):
29+
found.add(obj.name)
30+
return
31+
32+
if isinstance(obj, list):
33+
for val in obj:
34+
_extract_inputs(val, found)
35+
return
36+
37+
if isinstance(obj, dict):
38+
for val in obj.values():
39+
_extract_inputs(val, found)
40+
return
41+
42+
43+
def substitute(obj: Any, substitutions: dict[str, Any]) -> Any:
44+
"""Substitute values."""
45+
if isinstance(obj, Input):
46+
if obj.name not in substitutions:
47+
raise UndefinedSubstitution(obj.name)
48+
return substitutions[obj.name]
49+
50+
if isinstance(obj, list):
51+
return [substitute(val, substitutions) for val in obj]
52+
53+
if isinstance(obj, dict):
54+
return {key: substitute(val, substitutions) for key, val in obj.items()}
55+
56+
return obj

0 commit comments

Comments
 (0)