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.
@@ -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