netadopt 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.
netadopt/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # The avd submodule's tag, and pyavd's pin in the dev group.
2
+ AVD_TESTED = "6.4.0"
netadopt/ansible.py ADDED
@@ -0,0 +1,144 @@
1
+ """Finding the Ansible to run.
2
+
3
+ PATH first, the `ansible` extra behind it; `source` says which one answered.
4
+ The answer is always a value -- never an exception, never sys.exit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from dataclasses import dataclass, replace
14
+ from pathlib import Path
15
+
16
+ # Probed rather than `ansible`: it is the one being run, and on a broken install the
17
+ # two do not always agree.
18
+ PLAYBOOK_EXE = "ansible-playbook"
19
+
20
+ _CORE = re.compile(r"\[core ([^\]]+)\]") # 2.10+ "ansible-playbook [core 2.16.3]"
21
+ _OLD = re.compile(r"^\S+\s+([0-9][^\s]*)") # 2.9 "ansible-playbook 2.9.27"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Ansible:
26
+ """What `ansible-playbook --version` said, or why it said nothing."""
27
+
28
+ exe: str | None = None
29
+ core: str | None = None
30
+ python: str | None = None
31
+ config_file: str | None = None
32
+ collections: tuple[str, ...] = ()
33
+ problem: str | None = None # set if and only if unusable
34
+ source: str | None = None # "given" | "PATH" | "bundled"
35
+
36
+ @property
37
+ def usable(self) -> bool:
38
+ return self.problem is None
39
+
40
+ def beside(self, name: str) -> Path | None:
41
+ """Another executable from this same install, or None if it is not there.
42
+
43
+ Taken from this install's own directory, not from PATH.
44
+ """
45
+ if self.exe is None:
46
+ return None
47
+ found = Path(self.exe).with_name(name)
48
+ return found if found.exists() else None
49
+
50
+ @property
51
+ def bundled(self) -> bool:
52
+ """True when the fallback answered, and not an Ansible already installed."""
53
+ return self.source == "bundled"
54
+
55
+
56
+ def find_ansible(exe: str | None = None) -> Ansible:
57
+ """Locate an ansible-playbook and read its --version.
58
+
59
+ `exe` overrides PATH -- a name or a path; a venv is selected by pointing here.
60
+ """
61
+ target = exe or PLAYBOOK_EXE
62
+ found = shutil.which(target)
63
+ if found is None:
64
+ # a typed path and a name looked up on PATH fail for different reasons
65
+ where = "not found" if Path(target).name != target else "not found on PATH"
66
+ return Ansible(problem=f"{target} {where}")
67
+
68
+ try:
69
+ done = subprocess.run(
70
+ [found, "--version"],
71
+ check=False,
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=60,
75
+ # closed, so a subprocess cannot stop for a prompt
76
+ stdin=subprocess.DEVNULL,
77
+ )
78
+ except OSError as err: # not executable, wrong architecture, bad interpreter
79
+ return Ansible(exe=found, problem=f"{found} could not be run: {err}")
80
+ except subprocess.TimeoutExpired:
81
+ return Ansible(exe=found, problem=f"{found} --version did not return in 60s")
82
+
83
+ if done.returncode != 0:
84
+ # an install broken by its own dependencies fails here; its stderr, whole
85
+ detail = (done.stderr or done.stdout).strip() or f"exit {done.returncode}"
86
+ return Ansible(exe=found, problem=f"{found} --version failed: {detail}")
87
+
88
+ return _parse(found, done.stdout)
89
+
90
+
91
+ def resolve_ansible(exe: str | None = None) -> Ansible:
92
+ """PATH first, the bundled ansible-core second."""
93
+ if exe:
94
+ return replace(find_ansible(exe), source="given")
95
+
96
+ found = find_ansible()
97
+ if found.usable:
98
+ return replace(found, source="PATH")
99
+
100
+ # The extra installs ansible-playbook beside this interpreter. Under uvx that
101
+ # directory need not be on PATH, so it is found by location and not by name.
102
+ bundled = Path(sys.executable).parent / PLAYBOOK_EXE
103
+ if not bundled.exists():
104
+ return found # keep the PATH problem: with no fallback it is the one to report
105
+ return replace(find_ansible(str(bundled)), source="bundled")
106
+
107
+
108
+ def _parse(exe: str, out: str) -> Ansible:
109
+ lines = out.splitlines()
110
+ head = lines[0] if lines else ""
111
+ match = _CORE.search(head) or _OLD.match(head)
112
+ core = match.group(1) if match else None
113
+
114
+ # The rest is " key = value", and unknown keys are ignored rather than
115
+ # rejected: the set has changed across releases and will change again.
116
+ fields: dict[str, str] = {}
117
+ for line in lines[1:]:
118
+ key, sep, value = line.partition("=")
119
+ if sep:
120
+ fields[key.strip()] = value.strip()
121
+
122
+ python = fields.get("python version", "").split(" ", 1)[0] or None
123
+ config = fields.get("config file") or None
124
+ if config == "None": # Ansible's own word for "no ansible.cfg was found"
125
+ config = None
126
+ # "ansible collection location", with the prefix -- unlike "config file".
127
+ raw = fields.get("ansible collection location", "")
128
+ collections = tuple(p for p in raw.split(":") if p)
129
+
130
+ if core is None:
131
+ return Ansible(
132
+ exe=exe,
133
+ python=python,
134
+ config_file=config,
135
+ collections=collections,
136
+ problem=f"could not read a version from: {head!r}",
137
+ )
138
+ return Ansible(
139
+ exe=exe,
140
+ core=core,
141
+ python=python,
142
+ config_file=config,
143
+ collections=collections,
144
+ )
@@ -0,0 +1,80 @@
1
+ """Reading and writing YAML the way Ansible does.
2
+
3
+ `!vault` and `!unsafe` are Ansible's, and `yaml.safe_load` raises on both. Here they
4
+ become the shapes `ansible-inventory --list` prints: {"__ansible_vault": ...} and
5
+ {"__ansible_unsafe": ...}. `dump` writes those shapes back as the tags.
6
+
7
+ Any other tag still raises, with its name and the line it is on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ VAULT_KEY = "__ansible_vault"
18
+ UNSAFE_KEY = "__ansible_unsafe"
19
+
20
+
21
+ class AnsibleLoader(yaml.SafeLoader):
22
+ """SafeLoader plus the two tags Ansible puts in ordinary vars files."""
23
+
24
+
25
+ def _vault(loader: AnsibleLoader, node: yaml.Node) -> dict[str, str]:
26
+ # the ciphertext, whole -- carrying it needs no password
27
+ return {VAULT_KEY: loader.construct_scalar(node)}
28
+
29
+
30
+ def _unsafe(loader: AnsibleLoader, node: yaml.Node) -> dict[str, str]:
31
+ # !unsafe means "do not template this". Losing the marker would turn a literal
32
+ # {{ ... }} into something Ansible would try to resolve.
33
+ return {UNSAFE_KEY: loader.construct_scalar(node)}
34
+
35
+
36
+ AnsibleLoader.add_constructor("!vault", _vault)
37
+ AnsibleLoader.add_constructor("!unsafe", _unsafe)
38
+
39
+
40
+ def load(text: str, path: Path | str | None = None) -> object:
41
+ """Parse one document. Raises yaml.YAMLError, naming `path` in the message."""
42
+ stream = io.StringIO(text)
43
+ if path is not None:
44
+ # PyYAML reports the stream name in its error marks: the filename, not
45
+ # "<unicode string>".
46
+ stream.name = str(path)
47
+ return yaml.load(stream, AnsibleLoader)
48
+
49
+
50
+ class AnsibleDumper(yaml.SafeDumper):
51
+ """SafeDumper writing the two shapes `load` makes back as Ansible's tags."""
52
+
53
+ def ignore_aliases(self, data: object) -> bool:
54
+ # an object met twice is written twice, never as &anchor and *alias
55
+ return True
56
+
57
+
58
+ def _mapping(dumper: AnsibleDumper, data: dict) -> yaml.Node:
59
+ if len(data) == 1:
60
+ ((key, value),) = data.items()
61
+ if key == VAULT_KEY and isinstance(value, str):
62
+ return dumper.represent_scalar("!vault", value, style="|")
63
+ if key == UNSAFE_KEY and isinstance(value, str):
64
+ return dumper.represent_scalar("!unsafe", value)
65
+ return dumper.represent_dict(data)
66
+
67
+
68
+ AnsibleDumper.add_representer(dict, _mapping)
69
+
70
+
71
+ def dump(data: object) -> str:
72
+ """One document, keys in their own order, long strings never folded."""
73
+ return yaml.dump(
74
+ data,
75
+ Dumper=AnsibleDumper,
76
+ sort_keys=False,
77
+ allow_unicode=True,
78
+ default_flow_style=False,
79
+ width=float("inf"),
80
+ )
netadopt/ansiblecfg.py ADDED
@@ -0,0 +1,74 @@
1
+ """ansible.cfg as the repository writes it, for `Fabric.spec.ansibleCfg`.
2
+
3
+ The one in the repository's root, and only that one: it is where Ansible looks when
4
+ run in the repository. `ANSIBLE_CONFIG`, `~/.ansible.cfg` and `/etc/ansible/ansible.cfg`
5
+ belong to whoever runs it.
6
+
7
+ Parsed as Ansible parses it -- stdlib configparser, `;` as an inline comment, keys
8
+ folded to lower case, a duplicate key an error -- so a value here is the value Ansible
9
+ reads. Two settings keep the text as written: no interpolation, so a `%(name)s` is
10
+ left for Ansible to expand; and `[DEFAULT]` stays a section of its own instead of
11
+ being copied into every other one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import configparser
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ CONFIG_FILE = "ansible.cfg"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AnsibleCfg:
25
+ path: Path | None = None # None when the repository has no ansible.cfg
26
+ sections: dict[str, dict[str, str]] = field(default_factory=dict)
27
+ problem: str | None = None
28
+
29
+ @property
30
+ def usable(self) -> bool:
31
+ return self.problem is None
32
+
33
+ @property
34
+ def inventory(self) -> tuple[str, ...]:
35
+ """The sources `[defaults] inventory` names, split on commas as Ansible splits them."""
36
+ raw = self.sections.get("defaults", {}).get("inventory", "")
37
+ return tuple(part.strip() for part in raw.split(",") if part.strip())
38
+
39
+ @property
40
+ def vault_password_file(self) -> str | None:
41
+ """The path `[defaults] vault_password_file` names, as written."""
42
+ return self.sections.get("defaults", {}).get("vault_password_file") or None
43
+
44
+
45
+ def read_ansible_cfg(repo: Path) -> AnsibleCfg:
46
+ """`repo/ansible.cfg`, section by section, every value the string it is written as.
47
+
48
+ No file is not a problem: Ansible then runs on its defaults, and `sections` is empty.
49
+ """
50
+ path = repo / CONFIG_FILE
51
+ if not path.is_file():
52
+ return AnsibleCfg()
53
+
54
+ try:
55
+ text = path.read_text(encoding="utf-8")
56
+ except UnicodeDecodeError as err:
57
+ return AnsibleCfg(path=path, problem=f"{path} is not UTF-8: {err}")
58
+ except OSError as err:
59
+ return AnsibleCfg(path=path, problem=f"{path} could not be read: {err}")
60
+
61
+ parser = configparser.ConfigParser(
62
+ inline_comment_prefixes=(";",),
63
+ interpolation=None,
64
+ # A section header is never empty, so no section is the default one.
65
+ default_section="",
66
+ )
67
+ try:
68
+ parser.read_string(text, source=str(path))
69
+ except configparser.Error as err:
70
+ # what Ansible stops on as well: a duplicate key, a line before any section
71
+ return AnsibleCfg(path=path, problem=str(err).replace("\n", " "))
72
+
73
+ sections = {name: dict(parser[name]) for name in parser.sections()}
74
+ return AnsibleCfg(path=path, sections=sections)