devaci 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.
- devaci/__init__.py +20 -0
- devaci/cobra/__init__.py +93 -0
- devaci/cobra/base.py +28 -0
- devaci/cobra/builders.py +1886 -0
- devaci/cobra/registry.py +24 -0
- devaci/console.py +34 -0
- devaci/data.py +81 -0
- devaci/deploy.py +320 -0
- devaci/exceptions.py +23 -0
- devaci/filters.py +103 -0
- devaci/jinja.py +42 -0
- devaci/py.typed +0 -0
- devaci/results.py +71 -0
- devaci-0.1.0.dist-info/METADATA +110 -0
- devaci-0.1.0.dist-info/RECORD +17 -0
- devaci-0.1.0.dist-info/WHEEL +4 -0
- devaci-0.1.0.dist-info/licenses/LICENSE +21 -0
devaci/cobra/registry.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Explicit handler registry for Cobra object builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
Handler = Callable[..., None]
|
|
8
|
+
|
|
9
|
+
REGISTRY: dict[str, Handler] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(key: str) -> Callable[[Handler], Handler]:
|
|
13
|
+
"""Register a builder function under a template/YAML key."""
|
|
14
|
+
|
|
15
|
+
def decorator(func: Handler) -> Handler:
|
|
16
|
+
REGISTRY[key] = func
|
|
17
|
+
return func
|
|
18
|
+
|
|
19
|
+
return decorator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_handler(key: str) -> Handler | None:
|
|
23
|
+
"""Return the handler registered under ``key``, or ``None``."""
|
|
24
|
+
return REGISTRY.get(key)
|
devaci/console.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Logging and console helpers for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("devaci")
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
16
|
+
"""Return the devaci logger, or a child logger when ``name`` is given."""
|
|
17
|
+
return logger if name is None else logger.getChild(name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
21
|
+
"""Attach a stream handler to the devaci logger (idempotent)."""
|
|
22
|
+
if logger.handlers:
|
|
23
|
+
return
|
|
24
|
+
handler = logging.StreamHandler()
|
|
25
|
+
handler.setFormatter(logging.Formatter("%(levelname)-8s %(name)s: %(message)s"))
|
|
26
|
+
logger.addHandler(handler)
|
|
27
|
+
logger.setLevel(level)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_syntax(
|
|
31
|
+
content: str, lexer: str, theme: str = "fruity", line_numbers: bool = True
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Pretty-print highlighted source (XML/JSON) to the terminal."""
|
|
34
|
+
console.print(Syntax(content, lexer, theme=theme, line_numbers=line_numbers))
|
devaci/data.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Input data loading (xlsx/csv) and filtering for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from devaci.exceptions import DataError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_filter(
|
|
16
|
+
df: pd.DataFrame,
|
|
17
|
+
column: str,
|
|
18
|
+
filters: Iterable[str] | None = None,
|
|
19
|
+
) -> list[dict[str, Any]]:
|
|
20
|
+
"""Filter a DataFrame by ``column`` and return a list of records.
|
|
21
|
+
|
|
22
|
+
When no ``filters`` are given, or the column is missing, all records are
|
|
23
|
+
returned. Empty and ``nan`` values are always excluded from filtering.
|
|
24
|
+
"""
|
|
25
|
+
if not filters or not column or column not in df.columns:
|
|
26
|
+
if not filters:
|
|
27
|
+
return cast("list[dict[str, Any]]", df.to_dict("records"))
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
series = df[column]
|
|
31
|
+
return cast(
|
|
32
|
+
"list[dict[str, Any]]",
|
|
33
|
+
df[series.notna() & series.astype(str).str.strip().ne("") & series.isin(filters)].to_dict(
|
|
34
|
+
orient="records"
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_csv(
|
|
40
|
+
path: str | Path,
|
|
41
|
+
filters: Iterable[str] | None = None,
|
|
42
|
+
by: str = "tag",
|
|
43
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
44
|
+
"""Load a CSV file into ``{filename: [records]}``."""
|
|
45
|
+
path = Path(path)
|
|
46
|
+
df = pd.read_csv(path)
|
|
47
|
+
return {path.stem: apply_filter(df, by, filters)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_xlsx(
|
|
51
|
+
path: str | Path,
|
|
52
|
+
filters: Iterable[str] | None = None,
|
|
53
|
+
by: str = "tag",
|
|
54
|
+
filters_source_sheet: str | None = None,
|
|
55
|
+
filters_condition_field: str = "enabled",
|
|
56
|
+
filters_output_field: str = "name",
|
|
57
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
58
|
+
"""Load an XLSX workbook into ``{sheet_name: [records]}``.
|
|
59
|
+
|
|
60
|
+
Optionally derives the filter values from a dedicated sheet
|
|
61
|
+
(``filters_source_sheet``) using a boolean condition column.
|
|
62
|
+
"""
|
|
63
|
+
path = Path(path)
|
|
64
|
+
with warnings.catch_warnings():
|
|
65
|
+
warnings.filterwarnings(
|
|
66
|
+
"ignore",
|
|
67
|
+
message="Data Validation extension is not supported*",
|
|
68
|
+
category=UserWarning,
|
|
69
|
+
)
|
|
70
|
+
sheets = pd.read_excel(path, sheet_name=None)
|
|
71
|
+
|
|
72
|
+
resolved_filters = filters
|
|
73
|
+
if filters_source_sheet:
|
|
74
|
+
df_filters = sheets.get(filters_source_sheet)
|
|
75
|
+
if df_filters is None:
|
|
76
|
+
raise DataError(f"Sheet '{filters_source_sheet}' does not exist in '{path}'!")
|
|
77
|
+
resolved_filters = df_filters.loc[
|
|
78
|
+
df_filters[filters_condition_field], filters_output_field
|
|
79
|
+
].tolist()
|
|
80
|
+
|
|
81
|
+
return {name: apply_filter(df, by, resolved_filters) for name, df in sheets.items()}
|
devaci/deploy.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Deployment orchestrator for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import xml.dom.minidom
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import cobra.mit.access
|
|
16
|
+
import cobra.mit.session
|
|
17
|
+
import urllib3
|
|
18
|
+
|
|
19
|
+
from devaci.cobra import CobraBuilder
|
|
20
|
+
from devaci.console import logger, print_syntax
|
|
21
|
+
from devaci.data import load_csv, load_xlsx
|
|
22
|
+
from devaci.exceptions import DeployError
|
|
23
|
+
from devaci.jinja import JinjaRenderer
|
|
24
|
+
from devaci.results import DeployResult
|
|
25
|
+
|
|
26
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DeployClass:
|
|
30
|
+
"""Deployment manager for Cisco ACI using the Cobra SDK.
|
|
31
|
+
|
|
32
|
+
Handles the full workflow: template rendering, Cobra model rendering,
|
|
33
|
+
optional input filtering, output visualization/persistence, and optional
|
|
34
|
+
commit to APIC.
|
|
35
|
+
|
|
36
|
+
Configuration options:
|
|
37
|
+
- ip (str): APIC IPv4 address
|
|
38
|
+
- username (str): APIC username
|
|
39
|
+
- password (str): APIC password
|
|
40
|
+
- testing (bool): enable dry-run mode (default: False)
|
|
41
|
+
- filter_by (str): variable name used for filtering (default: "tag")
|
|
42
|
+
- timer (int): countdown in seconds before commit (default: 5)
|
|
43
|
+
- show_output (bool): pretty-print rendered output to the terminal
|
|
44
|
+
- file_output (str): output filename (JSON or XML)
|
|
45
|
+
- secure (bool): verify SSL certificates (default: False)
|
|
46
|
+
- render_to_xml (bool): render output as XML instead of JSON (default: True)
|
|
47
|
+
- working_folder (Path): base directory for templates and data files
|
|
48
|
+
- logging_output (str): logging filename (default: "outputs/logs/logging.json")
|
|
49
|
+
- logging (bool): enable execution logging to file
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
53
|
+
self._testing = kwargs.get("testing", False)
|
|
54
|
+
|
|
55
|
+
self._ip = kwargs.get("ip")
|
|
56
|
+
self._username = kwargs.get("username")
|
|
57
|
+
self._password = kwargs.get("password")
|
|
58
|
+
|
|
59
|
+
if not self._testing:
|
|
60
|
+
if not self._ip:
|
|
61
|
+
self._ip = input("APIC IP Address: ").strip()
|
|
62
|
+
if not self._username:
|
|
63
|
+
self._username = input("APIC Username: ").strip()
|
|
64
|
+
if not self._password:
|
|
65
|
+
self._password = getpass.getpass("APIC Password: ")
|
|
66
|
+
|
|
67
|
+
self._url = f"https://{self._ip}" if self._ip else None
|
|
68
|
+
self._timeout = kwargs.get("timeout", 180)
|
|
69
|
+
self._secure = kwargs.get("secure", False)
|
|
70
|
+
self._timer = kwargs.get("timer", 5)
|
|
71
|
+
self._show_output = kwargs.get("show_output", False)
|
|
72
|
+
self._file_output = kwargs.get("file_output")
|
|
73
|
+
self._logging_output = kwargs.get("logging_output", "outputs/logs/logging.json")
|
|
74
|
+
self._logging = kwargs.get("logging", True)
|
|
75
|
+
self._render_to_xml = kwargs.get("render_to_xml", True)
|
|
76
|
+
self._filters_source_sheet = kwargs.get("filters_source_sheet")
|
|
77
|
+
self._filters_condition_field = kwargs.get("filters_condition_field", "enabled")
|
|
78
|
+
self._filters_output_field = kwargs.get("filters_output_field", "name")
|
|
79
|
+
self._filters = kwargs.get("filters")
|
|
80
|
+
self._filter_by = kwargs.get("filter_by", "tag")
|
|
81
|
+
self._working_folder: Path = kwargs.get("working_folder", Path.cwd())
|
|
82
|
+
|
|
83
|
+
self._cobra = CobraBuilder()
|
|
84
|
+
self._session = cobra.mit.session.LoginSession(
|
|
85
|
+
self._url, self._username, self._password, self._secure, self._timeout
|
|
86
|
+
)
|
|
87
|
+
self._modir = cobra.mit.access.MoDirectory(self._session)
|
|
88
|
+
|
|
89
|
+
self._template: list[tuple[str, Path]] = []
|
|
90
|
+
self._variables: dict[str, Any] = {}
|
|
91
|
+
self._results: list[dict[str, Any]] = []
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ Control
|
|
94
|
+
|
|
95
|
+
def deploy(self) -> None:
|
|
96
|
+
"""Execute the deployment workflow for all configured templates."""
|
|
97
|
+
if not self._template:
|
|
98
|
+
logger.warning("[Deploy] -> [ConfigError]: No templates configured!")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
for content, path in self._template:
|
|
102
|
+
self._results.append(self._deploy_one(content, path).to_dict())
|
|
103
|
+
|
|
104
|
+
if self._show_output:
|
|
105
|
+
self.print_output()
|
|
106
|
+
|
|
107
|
+
if self._file_output:
|
|
108
|
+
self.save_output(self._file_output)
|
|
109
|
+
|
|
110
|
+
if not self._testing and self._cobra.result is not None and self._cobra.result.success:
|
|
111
|
+
self._commit()
|
|
112
|
+
|
|
113
|
+
if self._logging:
|
|
114
|
+
self.save_logging()
|
|
115
|
+
|
|
116
|
+
def _deploy_one(self, content: str, path: Path) -> DeployResult:
|
|
117
|
+
name = path.name if isinstance(path, Path) else str(path)
|
|
118
|
+
logs: list[str] = []
|
|
119
|
+
success = False
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
renderer = JinjaRenderer()
|
|
123
|
+
output = renderer.render(content, name=name, **self._variables)
|
|
124
|
+
logs.append(f"[Jinja]: Template {name} was rendered successfully.")
|
|
125
|
+
logger.info(logs[-1])
|
|
126
|
+
|
|
127
|
+
cobra_result = self._cobra.render(output)
|
|
128
|
+
logs.extend(cobra_result.log)
|
|
129
|
+
if not cobra_result.success:
|
|
130
|
+
raise DeployError(f"Template {name} failed to render Cobra objects.")
|
|
131
|
+
|
|
132
|
+
logs.append(f"[Deploy]: Template {name} was rendered successfully.")
|
|
133
|
+
logger.info(logs[-1])
|
|
134
|
+
success = True
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logs.append(f"[Deploy] -> [{type(exc).__name__}]: Failed to render template {name}.")
|
|
137
|
+
logger.error(
|
|
138
|
+
f"[Deploy] -> [{type(exc).__name__}]: Failed to render template {name}: {exc}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return DeployResult(success=success, log=logs, path=str(path), name=name)
|
|
142
|
+
|
|
143
|
+
def _commit(self) -> None:
|
|
144
|
+
try:
|
|
145
|
+
self._countdown()
|
|
146
|
+
self._modir.login()
|
|
147
|
+
self._modir.commit(self._cobra.config)
|
|
148
|
+
msg = f"[Deploy]: Template was successfully deployed to APIC: {self._ip}."
|
|
149
|
+
logger.info(msg)
|
|
150
|
+
self._results.append(
|
|
151
|
+
{"date": datetime.now().strftime("%d/%m/%Y-%H:%M:%S"), "success": True, "log": msg}
|
|
152
|
+
)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
msg = f"[Deploy] -> [{type(exc).__name__}]: Unable to deploy to APIC: {self._ip}, {exc}"
|
|
155
|
+
logger.error(msg)
|
|
156
|
+
self._results.append(
|
|
157
|
+
{"date": datetime.now().strftime("%d/%m/%Y-%H:%M:%S"), "success": False, "log": msg}
|
|
158
|
+
)
|
|
159
|
+
finally:
|
|
160
|
+
with contextlib.suppress(Exception):
|
|
161
|
+
self._modir.logout()
|
|
162
|
+
|
|
163
|
+
def _countdown(self) -> None:
|
|
164
|
+
message = f"Deploying templates to APIC [{self._ip}] in"
|
|
165
|
+
for remaining in range(self._timer, -1, -1):
|
|
166
|
+
sys.stdout.write(f"\r{message} {remaining} seconds")
|
|
167
|
+
sys.stdout.flush()
|
|
168
|
+
time.sleep(1)
|
|
169
|
+
sys.stdout.write("\n")
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------ Output
|
|
172
|
+
|
|
173
|
+
def save_output(self, name: str = "output") -> None:
|
|
174
|
+
"""Save rendered configuration to disk (XML or JSON)."""
|
|
175
|
+
content = self.config
|
|
176
|
+
if content is None:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
suffix = "xml" if self._render_to_xml else "json"
|
|
180
|
+
output_path = self._working_folder / f"{name}.{suffix}"
|
|
181
|
+
try:
|
|
182
|
+
if self._render_to_xml:
|
|
183
|
+
dom = xml.dom.minidom.parseString(content)
|
|
184
|
+
text = dom.toprettyxml(indent="\t")
|
|
185
|
+
else:
|
|
186
|
+
text = json.dumps(content, indent=4, ensure_ascii=False)
|
|
187
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
output_path.write_text(text, encoding="utf-8")
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.error(f"[SaveOutputError]: Failed to save output file! {exc}")
|
|
191
|
+
|
|
192
|
+
def print_output(self, theme: str = "fruity", line_numbers: bool = True) -> None:
|
|
193
|
+
"""Pretty-print the rendered configuration to the terminal."""
|
|
194
|
+
content = self.config
|
|
195
|
+
if content is None:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
if self._render_to_xml:
|
|
200
|
+
text = xml.dom.minidom.parseString(content).toprettyxml(indent="\t")
|
|
201
|
+
lexer = "xml"
|
|
202
|
+
else:
|
|
203
|
+
text = json.dumps(content, indent=4, ensure_ascii=False)
|
|
204
|
+
lexer = "json"
|
|
205
|
+
print_syntax(text, lexer, theme=theme, line_numbers=line_numbers)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
logger.error(f"[PrintOutputError]: Error printing output! {exc}")
|
|
208
|
+
|
|
209
|
+
def save_logging(self) -> None:
|
|
210
|
+
"""Append execution results to a JSON log file defensively."""
|
|
211
|
+
if not self._logging:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
log_file = Path(self._working_folder / self._logging_output).with_suffix(".json")
|
|
215
|
+
history: list[Any] = []
|
|
216
|
+
try:
|
|
217
|
+
if log_file.exists():
|
|
218
|
+
try:
|
|
219
|
+
with open(log_file, encoding="utf-8") as handle:
|
|
220
|
+
history = json.load(handle)
|
|
221
|
+
if not isinstance(history, list):
|
|
222
|
+
history = []
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
history = []
|
|
225
|
+
|
|
226
|
+
history.extend(self._results)
|
|
227
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
with open(log_file, "w", encoding="utf-8") as handle:
|
|
229
|
+
json.dump(history, handle, indent=4, ensure_ascii=False)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.error(f"[LoggingError]: {type(exc).__name__}: {exc}")
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------ Properties
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def results(self) -> list[dict[str, Any]]:
|
|
237
|
+
return self._results
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def variables(self) -> dict[str, Any]:
|
|
241
|
+
return self._variables
|
|
242
|
+
|
|
243
|
+
@variables.setter
|
|
244
|
+
def variables(self, value: dict[str, Any]) -> None:
|
|
245
|
+
self._variables = value
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def template(self) -> list[tuple[str, Path]]:
|
|
249
|
+
return self._template
|
|
250
|
+
|
|
251
|
+
@template.setter
|
|
252
|
+
def template(self, value: Any) -> None:
|
|
253
|
+
"""Load template content into memory.
|
|
254
|
+
|
|
255
|
+
Accepted inputs:
|
|
256
|
+
- str: template filename (loaded from working_folder)
|
|
257
|
+
- list[str]: multiple template filenames
|
|
258
|
+
- tuple[str, str]: (template_content, template_name)
|
|
259
|
+
- list[tuple[str, str]]: multiple in-memory templates
|
|
260
|
+
"""
|
|
261
|
+
if not value:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
values = value if isinstance(value, list) else [value]
|
|
265
|
+
for item in values:
|
|
266
|
+
try:
|
|
267
|
+
if isinstance(item, tuple) and len(item) == 2:
|
|
268
|
+
content, name = item
|
|
269
|
+
if not isinstance(content, str) or not isinstance(name, str):
|
|
270
|
+
raise ValueError("Template content and name must be strings")
|
|
271
|
+
self._template.append((content, Path(name)))
|
|
272
|
+
elif isinstance(item, str):
|
|
273
|
+
path = self._working_folder / item
|
|
274
|
+
with open(path, encoding="utf-8") as handle:
|
|
275
|
+
self._template.append((handle.read(), path))
|
|
276
|
+
else:
|
|
277
|
+
raise ValueError("Invalid template format")
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
logger.error(f"[TemplateException]: Error loading template: {exc}")
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def config(self) -> Any:
|
|
283
|
+
return self._cobra.xml if self._render_to_xml else self._cobra.json
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def csv(self) -> dict[str, Any]:
|
|
287
|
+
return self._variables
|
|
288
|
+
|
|
289
|
+
@csv.setter
|
|
290
|
+
def csv(self, value: Any) -> None:
|
|
291
|
+
"""Load CSV file(s) into the variables dict."""
|
|
292
|
+
files = [value] if isinstance(value, str) else value
|
|
293
|
+
for file in files:
|
|
294
|
+
try:
|
|
295
|
+
self._variables |= load_csv(
|
|
296
|
+
self._working_folder / file, self._filters, self._filter_by
|
|
297
|
+
)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
logger.error(f"[CSVException]: Error loading CSV file: {exc}")
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def xlsx(self) -> dict[str, Any]:
|
|
303
|
+
return self._variables
|
|
304
|
+
|
|
305
|
+
@xlsx.setter
|
|
306
|
+
def xlsx(self, value: Any) -> None:
|
|
307
|
+
"""Load XLSX file(s) into the variables dict."""
|
|
308
|
+
files = [value] if isinstance(value, str) else value
|
|
309
|
+
for file in files:
|
|
310
|
+
try:
|
|
311
|
+
self._variables |= load_xlsx(
|
|
312
|
+
self._working_folder / file,
|
|
313
|
+
self._filters,
|
|
314
|
+
self._filter_by,
|
|
315
|
+
self._filters_source_sheet,
|
|
316
|
+
self._filters_condition_field,
|
|
317
|
+
self._filters_output_field,
|
|
318
|
+
)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
logger.error(f"[XLSXException]: Error loading XLSX file '{file}': {exc}")
|
devaci/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exception hierarchy for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DevaciError(Exception):
|
|
7
|
+
"""Base exception for all devaci errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CobraError(DevaciError):
|
|
11
|
+
"""Raised when building or committing Cobra model objects fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JinjaError(DevaciError):
|
|
15
|
+
"""Raised when rendering a Jinja template fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataError(DevaciError):
|
|
19
|
+
"""Raised when loading input data (xlsx/csv) fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeployError(DevaciError):
|
|
23
|
+
"""Raised when the deployment workflow fails."""
|
devaci/filters.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Jinja filters and YAML loading helpers for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from yaml import load
|
|
8
|
+
from yaml.composer import Composer
|
|
9
|
+
from yaml.constructor import SafeConstructor
|
|
10
|
+
from yaml.parser import Parser
|
|
11
|
+
from yaml.reader import Reader
|
|
12
|
+
from yaml.resolver import Resolver
|
|
13
|
+
from yaml.scanner import Scanner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def split_filter(value: Any, delimiter: str = ",") -> list[str]:
|
|
17
|
+
"""Split a value into a list of strings."""
|
|
18
|
+
return str(value).split(delimiter)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def range_filter(value: Any) -> list[int]:
|
|
22
|
+
"""Expand a string such as ``1-3,5`` into a list of integers."""
|
|
23
|
+
result: list[int] = []
|
|
24
|
+
for part in str(value).split(","):
|
|
25
|
+
if "-" in part:
|
|
26
|
+
start, end = part.split("-")
|
|
27
|
+
result.extend(range(int(start), int(end) + 1))
|
|
28
|
+
else:
|
|
29
|
+
result.append(int(part))
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def nan_filter(value: Any) -> bool:
|
|
34
|
+
"""Return False when the value is the string ``nan``."""
|
|
35
|
+
return str(value) != "nan"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def str_to_bool(value: Any) -> bool:
|
|
39
|
+
"""Convert common truthy strings to a boolean."""
|
|
40
|
+
if isinstance(value, bool):
|
|
41
|
+
return value
|
|
42
|
+
return str(value).lower() in ("true", "yes", "1")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def no_convert_int_constructor(loader: Any, node: Any) -> Any:
|
|
46
|
+
"""Keep YAML integers as their original string."""
|
|
47
|
+
return node.value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def no_convert_float_constructor(loader: Any, node: Any) -> Any:
|
|
51
|
+
"""Keep YAML floats as their original string."""
|
|
52
|
+
return node.value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def replace_str_nan_with_empty(obj: Any) -> Any:
|
|
56
|
+
"""Recursively replace string values equal to ``nan`` with ``""``."""
|
|
57
|
+
if isinstance(obj, dict):
|
|
58
|
+
return {k: replace_str_nan_with_empty(v) for k, v in obj.items()}
|
|
59
|
+
if isinstance(obj, list):
|
|
60
|
+
return [replace_str_nan_with_empty(v) for v in obj]
|
|
61
|
+
if isinstance(obj, str) and obj.strip().lower() == "nan":
|
|
62
|
+
return ""
|
|
63
|
+
return obj
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _SafeConstructor(SafeConstructor):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bool_constructor(loader: Any, node: Any) -> Any:
|
|
71
|
+
"""Keep YAML booleans as their original string."""
|
|
72
|
+
return loader.construct_scalar(node)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_SafeConstructor.add_constructor("tag:yaml.org,2002:bool", bool_constructor)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SafeLoader(Reader, Scanner, Parser, Composer, _SafeConstructor, Resolver):
|
|
79
|
+
"""YAML loader that leaves int/float/bool values as strings."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, stream: Any) -> None:
|
|
82
|
+
Reader.__init__(self, stream)
|
|
83
|
+
Scanner.__init__(self)
|
|
84
|
+
Parser.__init__(self)
|
|
85
|
+
Composer.__init__(self)
|
|
86
|
+
_SafeConstructor.__init__(self)
|
|
87
|
+
Resolver.__init__(self)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
SafeLoader.add_constructor("tag:yaml.org,2002:int", no_convert_int_constructor)
|
|
91
|
+
SafeLoader.add_constructor("tag:yaml.org,2002:float", no_convert_float_constructor)
|
|
92
|
+
|
|
93
|
+
for _first_char, _resolvers in list(SafeLoader.yaml_implicit_resolvers.items()):
|
|
94
|
+
_filtered = [r for r in _resolvers if r[0] != "tag:yaml.org,2002:bool"]
|
|
95
|
+
if _filtered:
|
|
96
|
+
SafeLoader.yaml_implicit_resolvers[_first_char] = _filtered
|
|
97
|
+
else:
|
|
98
|
+
del SafeLoader.yaml_implicit_resolvers[_first_char]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_yaml(stream: Any) -> Any:
|
|
102
|
+
"""Load YAML without type coercion and normalise ``nan`` strings."""
|
|
103
|
+
return replace_str_nan_with_empty(load(stream, SafeLoader)) # type: ignore[arg-type]
|
devaci/jinja.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Jinja2 template rendering for devaci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import jinja2
|
|
8
|
+
|
|
9
|
+
from devaci.exceptions import JinjaError
|
|
10
|
+
from devaci.filters import load_yaml, nan_filter, range_filter, str_to_bool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JinjaRenderer:
|
|
14
|
+
"""Render a Jinja2 template into a Python dict.
|
|
15
|
+
|
|
16
|
+
The rendered output is expected to be YAML whose top-level keys map to
|
|
17
|
+
ACI object types (see :mod:`devaci.cobra`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._env = jinja2.Environment(
|
|
22
|
+
loader=jinja2.BaseLoader(),
|
|
23
|
+
extensions=["jinja2.ext.do"],
|
|
24
|
+
)
|
|
25
|
+
self._env.filters["bool"] = str_to_bool
|
|
26
|
+
self._env.filters["range"] = range_filter
|
|
27
|
+
self._env.filters["nan"] = nan_filter
|
|
28
|
+
|
|
29
|
+
def render(self, template: str, name: str | None = None, **variables: Any) -> dict[str, Any]:
|
|
30
|
+
"""Render ``template`` with ``variables`` and parse the YAML output."""
|
|
31
|
+
try:
|
|
32
|
+
rendered = self._env.from_string(template).render(**variables)
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
line = getattr(exc, "lineno", None)
|
|
35
|
+
raise JinjaError(f"[Jinja] -> [{type(exc).__name__}]: {exc}. Line: {line}") from exc
|
|
36
|
+
|
|
37
|
+
output = load_yaml(rendered)
|
|
38
|
+
if not isinstance(output, dict):
|
|
39
|
+
raise JinjaError(
|
|
40
|
+
f"[Jinja] -> [ConfigError]: Template {name} did not produce a mapping."
|
|
41
|
+
)
|
|
42
|
+
return output
|
devaci/py.typed
ADDED
|
File without changes
|