devaci 2.0.0a1__tar.gz → 2.0.0a3__tar.gz
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-2.0.0a3/PKG-INFO +228 -0
- devaci-2.0.0a3/README.md +198 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/pyproject.toml +7 -7
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/__init__.py +5 -1
- devaci-2.0.0a3/src/devaci/_values.py +31 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/cobra/__init__.py +26 -21
- devaci-2.0.0a3/src/devaci/cobra/base.py +46 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/cobra/builders.py +131 -126
- devaci-2.0.0a3/src/devaci/config.py +59 -0
- devaci-2.0.0a3/src/devaci/console.py +82 -0
- devaci-2.0.0a3/src/devaci/deploy.py +207 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/exceptions.py +2 -0
- devaci-2.0.0a3/src/devaci/inputs/__init__.py +14 -0
- devaci-2.0.0a3/src/devaci/inputs/datasets.py +149 -0
- devaci-2.0.0a3/src/devaci/inputs/templates.py +51 -0
- devaci-2.0.0a3/src/devaci/output/__init__.py +8 -0
- devaci-2.0.0a3/src/devaci/output/runlog.py +44 -0
- devaci-2.0.0a3/src/devaci/output/writer.py +62 -0
- devaci-2.0.0a3/src/devaci/rendering/__init__.py +16 -0
- devaci-2.0.0a3/src/devaci/rendering/filters.py +45 -0
- {devaci-2.0.0a1/src/devaci → devaci-2.0.0a3/src/devaci/rendering}/jinja.py +4 -5
- devaci-2.0.0a1/src/devaci/filters.py → devaci-2.0.0a3/src/devaci/rendering/yaml_loader.py +7 -30
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/results.py +7 -4
- devaci-2.0.0a3/src/devaci/transport/__init__.py +7 -0
- devaci-2.0.0a3/src/devaci/transport/apic.py +68 -0
- devaci-2.0.0a3/tests/conftest.py +29 -0
- devaci-2.0.0a3/tests/test_cobra/__init__.py +0 -0
- {devaci-2.0.0a1/tests → devaci-2.0.0a3/tests/test_cobra}/test_cobra.py +28 -15
- devaci-2.0.0a3/tests/test_config.py +50 -0
- devaci-2.0.0a3/tests/test_console.py +59 -0
- devaci-2.0.0a3/tests/test_deploy.py +152 -0
- devaci-2.0.0a3/tests/test_inputs/__init__.py +0 -0
- devaci-2.0.0a3/tests/test_inputs/test_datasets.py +113 -0
- devaci-2.0.0a3/tests/test_inputs/test_templates.py +54 -0
- devaci-2.0.0a3/tests/test_output/__init__.py +0 -0
- devaci-2.0.0a3/tests/test_output/test_runlog.py +31 -0
- devaci-2.0.0a3/tests/test_output/test_writer.py +39 -0
- devaci-2.0.0a3/tests/test_rendering/__init__.py +0 -0
- {devaci-2.0.0a1/tests → devaci-2.0.0a3/tests/test_rendering}/test_filters.py +13 -8
- {devaci-2.0.0a1/tests → devaci-2.0.0a3/tests/test_rendering}/test_jinja.py +11 -5
- {devaci-2.0.0a1 → devaci-2.0.0a3}/tests/test_results.py +13 -0
- devaci-2.0.0a3/tests/test_transport/__init__.py +0 -0
- devaci-2.0.0a3/tests/test_transport/test_apic.py +59 -0
- devaci-2.0.0a1/PKG-INFO +0 -110
- devaci-2.0.0a1/README.md +0 -81
- devaci-2.0.0a1/src/devaci/cobra/base.py +0 -28
- devaci-2.0.0a1/src/devaci/cobra/registry.py +0 -24
- devaci-2.0.0a1/src/devaci/console.py +0 -34
- devaci-2.0.0a1/src/devaci/data.py +0 -81
- devaci-2.0.0a1/src/devaci/deploy.py +0 -320
- devaci-2.0.0a1/tests/test_data.py +0 -56
- devaci-2.0.0a1/tests/test_deploy.py +0 -85
- {devaci-2.0.0a1 → devaci-2.0.0a3}/.gitignore +0 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/LICENSE +0 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/src/devaci/py.typed +0 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/tests/__init__.py +0 -0
- {devaci-2.0.0a1 → devaci-2.0.0a3}/tests/test_devaci.py +0 -0
devaci-2.0.0a3/PKG-INFO
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devaci
|
|
3
|
+
Version: 2.0.0a3
|
|
4
|
+
Summary: Python library that generates Cisco ACI configuration and optionally pushes it to an APIC controller via Cisco's official Cobra SDK.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cocuni80/devaci
|
|
6
|
+
Project-URL: Repository, https://github.com/cocuni80/devaci
|
|
7
|
+
Project-URL: Issues, https://github.com/cocuni80/devaci/issues
|
|
8
|
+
Author: Jorge Riveros
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aci,apic,cisco,cobra,networking,sdn
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: jinja2>=3.1.6
|
|
24
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
25
|
+
Requires-Dist: pandas>=2.3.3
|
|
26
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
27
|
+
Requires-Dist: rich>=15.0.0
|
|
28
|
+
Requires-Dist: urllib3>=2.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# devaci
|
|
32
|
+
|
|
33
|
+
Python library that generates Cisco ACI configuration from Jinja2 templates +
|
|
34
|
+
Excel/CSV inputs, and optionally pushes it to an APIC controller via Cisco's
|
|
35
|
+
official Cobra SDK.
|
|
36
|
+
|
|
37
|
+
Pipeline: **data + template → `JinjaRenderer` (Jinja2 → YAML dict) →
|
|
38
|
+
`CobraBuilder` (dict → `ConfigRequest`) → output / APIC commit**, orchestrated
|
|
39
|
+
by `DeployClass`.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python 3.10+
|
|
44
|
+
- The Cisco ACI **Cobra SDK** (`acicobra`, `acimodel`) — see below; it is
|
|
45
|
+
**not on PyPI**.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv sync # create .venv, install devaci + dev dependencies
|
|
51
|
+
source .venv/bin/activate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### External dependency: Cobra SDK
|
|
55
|
+
|
|
56
|
+
The Cisco ACI Cobra SDK is distributed as `.whl`/`.egg` by Cisco and is **not
|
|
57
|
+
on PyPI**. Download the wheels and place them in `vendor/` (not tracked by
|
|
58
|
+
git), then:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv pip install vendor/*.whl
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The importable package is `cobra`. This repository vendors `acicobra` +
|
|
65
|
+
`acimodel`; other Cisco modules are optional. `import devaci` (and therefore
|
|
66
|
+
the whole test suite) fails until the SDK is installed.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
from devaci import DeployClass
|
|
74
|
+
|
|
75
|
+
aci = DeployClass(
|
|
76
|
+
testing=True, # True = dry run, no APIC credentials prompted
|
|
77
|
+
working_folder=Path.cwd(), # base folder for templates/data/outputs
|
|
78
|
+
file_output="outputs/scripts/config", # optional: save XML/JSON
|
|
79
|
+
show_output=False, # print the rendered config to the terminal
|
|
80
|
+
logging=True, # append results to logging_output (JSON)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
aci.xlsx = "data/aci.xlsx" # load every sheet into template variables
|
|
84
|
+
aci.template = "templates/tenant.j2"
|
|
85
|
+
aci.deploy()
|
|
86
|
+
|
|
87
|
+
print(aci.config) # rendered XML (or JSON dict)
|
|
88
|
+
print(aci.results) # per-template result dicts
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`DeployClass` accepts either a typed `DeployConfig` or the keyword arguments
|
|
92
|
+
shown above:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from devaci import DeployConfig, DeployClass
|
|
96
|
+
|
|
97
|
+
config = DeployConfig(testing=True, render_to_xml=False)
|
|
98
|
+
aci = DeployClass(config=config)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
With `testing=False` and no `ip`/`username`/`password`, the APIC credentials are
|
|
102
|
+
prompted interactively (`getpass`). `deploy()` commits to the APIC **only when
|
|
103
|
+
every template in the run succeeded**.
|
|
104
|
+
|
|
105
|
+
## Templates
|
|
106
|
+
|
|
107
|
+
A template is a Jinja2 file that renders to YAML. Each **top-level key maps to
|
|
108
|
+
an ACI object class** handled by `CobraBuilder` (`fvTenant`, `fvAp`, `mcpInstPol`,
|
|
109
|
+
`fabricNodeControl`, ...). Values may be a list of objects or a nested mapping:
|
|
110
|
+
|
|
111
|
+
```jinja
|
|
112
|
+
fvTenant:
|
|
113
|
+
- name: {{ tenant_name }}
|
|
114
|
+
descr: {{ tenant_descr }}
|
|
115
|
+
status: {{ status }}
|
|
116
|
+
|
|
117
|
+
fvAp:
|
|
118
|
+
- name: web
|
|
119
|
+
fvRsCtx:
|
|
120
|
+
tnFvCtxName: {{ vrf }}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Rendering uses a **coercion-free YAML loader**: YAML ints/floats/bools are kept
|
|
124
|
+
as strings so APIC values are never mangled, and the string `nan` becomes `""`.
|
|
125
|
+
|
|
126
|
+
### Template filters
|
|
127
|
+
|
|
128
|
+
| Filter | Example | Result |
|
|
129
|
+
|---------------|-----------------------------|---------------------|
|
|
130
|
+
| `bool` | `{{ "yes" \| bool }}` | `True` |
|
|
131
|
+
| `range` | `{{ "1-3,5" \| range }}` | `[1, 2, 3, 5]` |
|
|
132
|
+
| `nan` | `{{ value \| nan }}` | `False` when `nan` |
|
|
133
|
+
| `split` | `{{ "a,b" \| split }}` | `["a", "b"]` |
|
|
134
|
+
|
|
135
|
+
## Data inputs (XLSX / CSV)
|
|
136
|
+
|
|
137
|
+
Both `aci.xlsx` and `aci.csv` accept a filename or a list of filenames:
|
|
138
|
+
|
|
139
|
+
- An **XLSX** workbook loads one variable per **sheet**, named after the sheet.
|
|
140
|
+
- A **CSV** file loads one variable named after the file **stem**.
|
|
141
|
+
|
|
142
|
+
Each variable is a list of row dicts, so a sheet named `tenants` is used as
|
|
143
|
+
`{% for row in tenants %}`.
|
|
144
|
+
|
|
145
|
+
### Filtering
|
|
146
|
+
|
|
147
|
+
- `filters`: list of values to keep, matched against the `filter_by` column
|
|
148
|
+
(default `tag`). Rows with empty/`NaN` tags are always dropped.
|
|
149
|
+
- `filters_source_sheet`: derive the filter values from a dedicated sheet. Rows
|
|
150
|
+
where `filters_condition_field` (default `enabled`) is truthy contribute their
|
|
151
|
+
`filters_output_field` (default `name`) as a filter value.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
aci = DeployClass(
|
|
155
|
+
testing=True,
|
|
156
|
+
filters=["IBK-6", "IBK-7"],
|
|
157
|
+
filter_by="tag",
|
|
158
|
+
filters_source_sheet="OCs", # optional
|
|
159
|
+
filters_condition_field="enabled",
|
|
160
|
+
filters_output_field="name",
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Output
|
|
165
|
+
|
|
166
|
+
- `render_to_xml=True` (default): config is XML (`.xml`); otherwise JSON (`.json`).
|
|
167
|
+
- `file_output="outputs/scripts/config"`: write the rendered config to disk.
|
|
168
|
+
- `show_output=True`: pretty-print the rendered config to the terminal.
|
|
169
|
+
- `save_output(name)` / `print_output()` can be called directly.
|
|
170
|
+
|
|
171
|
+
## Logging
|
|
172
|
+
|
|
173
|
+
devaci uses the standard library `logging` module. It attaches a
|
|
174
|
+
`NullHandler` on import (no output by default); opt in with the public
|
|
175
|
+
`configure_logging()`:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import logging
|
|
179
|
+
from devaci import configure_logging
|
|
180
|
+
|
|
181
|
+
configure_logging(level=logging.INFO) # idempotent; adds one stream handler
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Module loggers are exposed as `devaci.<module>` (e.g. `devaci.deploy`) and
|
|
185
|
+
propagate to the stdlib root logger, so per-module levels can be tuned with
|
|
186
|
+
normal logging configuration. Terminal output of rendered configuration and the
|
|
187
|
+
APIC countdown go through the shared rich console in `devaci.console`.
|
|
188
|
+
|
|
189
|
+
> `RunLog` (`logging_output`, default `outputs/logs/logging.json`) is a JSON
|
|
190
|
+
> **execution history**, not stdlib logging. Disable with `logging=False`.
|
|
191
|
+
|
|
192
|
+
## Project structure
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
src/devaci/
|
|
196
|
+
├── __init__.py # public API
|
|
197
|
+
├── config.py # typed DeployConfig
|
|
198
|
+
├── console.py # centralized logging + shared rich console
|
|
199
|
+
├── deploy.py # DeployClass orchestrator
|
|
200
|
+
├── exceptions.py
|
|
201
|
+
├── results.py # result dataclasses
|
|
202
|
+
├── _values.py # shared value predicates (nan/empty checks)
|
|
203
|
+
├── inputs/ # TemplateSource + DataLoader (xlsx/csv, filters)
|
|
204
|
+
├── rendering/ # JinjaRenderer, filters, non-coercing YAML loader
|
|
205
|
+
├── output/ # OutputWriter + RunLog
|
|
206
|
+
├── transport/ # ApicSession (login/commit)
|
|
207
|
+
└── cobra/ # CobraBuilder + BUILDERS mapping
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
uv run pytest # full test suite
|
|
214
|
+
uv run pytest --cov=devaci # with coverage (fails under 80%)
|
|
215
|
+
uv run ruff check . # lint
|
|
216
|
+
uv run mypy src # typecheck (strict)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Run order when verifying a change: `ruff check .` → `mypy src` → `pytest`.
|
|
220
|
+
|
|
221
|
+
## Publishing
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
uv build
|
|
225
|
+
uv publish
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Only a PyPI release bumps the version; commits do not. See `CONTRIBUTING.md`.
|
devaci-2.0.0a3/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# devaci
|
|
2
|
+
|
|
3
|
+
Python library that generates Cisco ACI configuration from Jinja2 templates +
|
|
4
|
+
Excel/CSV inputs, and optionally pushes it to an APIC controller via Cisco's
|
|
5
|
+
official Cobra SDK.
|
|
6
|
+
|
|
7
|
+
Pipeline: **data + template → `JinjaRenderer` (Jinja2 → YAML dict) →
|
|
8
|
+
`CobraBuilder` (dict → `ConfigRequest`) → output / APIC commit**, orchestrated
|
|
9
|
+
by `DeployClass`.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python 3.10+
|
|
14
|
+
- The Cisco ACI **Cobra SDK** (`acicobra`, `acimodel`) — see below; it is
|
|
15
|
+
**not on PyPI**.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv sync # create .venv, install devaci + dev dependencies
|
|
21
|
+
source .venv/bin/activate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### External dependency: Cobra SDK
|
|
25
|
+
|
|
26
|
+
The Cisco ACI Cobra SDK is distributed as `.whl`/`.egg` by Cisco and is **not
|
|
27
|
+
on PyPI**. Download the wheels and place them in `vendor/` (not tracked by
|
|
28
|
+
git), then:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv pip install vendor/*.whl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The importable package is `cobra`. This repository vendors `acicobra` +
|
|
35
|
+
`acimodel`; other Cisco modules are optional. `import devaci` (and therefore
|
|
36
|
+
the whole test suite) fails until the SDK is installed.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from devaci import DeployClass
|
|
44
|
+
|
|
45
|
+
aci = DeployClass(
|
|
46
|
+
testing=True, # True = dry run, no APIC credentials prompted
|
|
47
|
+
working_folder=Path.cwd(), # base folder for templates/data/outputs
|
|
48
|
+
file_output="outputs/scripts/config", # optional: save XML/JSON
|
|
49
|
+
show_output=False, # print the rendered config to the terminal
|
|
50
|
+
logging=True, # append results to logging_output (JSON)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
aci.xlsx = "data/aci.xlsx" # load every sheet into template variables
|
|
54
|
+
aci.template = "templates/tenant.j2"
|
|
55
|
+
aci.deploy()
|
|
56
|
+
|
|
57
|
+
print(aci.config) # rendered XML (or JSON dict)
|
|
58
|
+
print(aci.results) # per-template result dicts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`DeployClass` accepts either a typed `DeployConfig` or the keyword arguments
|
|
62
|
+
shown above:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from devaci import DeployConfig, DeployClass
|
|
66
|
+
|
|
67
|
+
config = DeployConfig(testing=True, render_to_xml=False)
|
|
68
|
+
aci = DeployClass(config=config)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
With `testing=False` and no `ip`/`username`/`password`, the APIC credentials are
|
|
72
|
+
prompted interactively (`getpass`). `deploy()` commits to the APIC **only when
|
|
73
|
+
every template in the run succeeded**.
|
|
74
|
+
|
|
75
|
+
## Templates
|
|
76
|
+
|
|
77
|
+
A template is a Jinja2 file that renders to YAML. Each **top-level key maps to
|
|
78
|
+
an ACI object class** handled by `CobraBuilder` (`fvTenant`, `fvAp`, `mcpInstPol`,
|
|
79
|
+
`fabricNodeControl`, ...). Values may be a list of objects or a nested mapping:
|
|
80
|
+
|
|
81
|
+
```jinja
|
|
82
|
+
fvTenant:
|
|
83
|
+
- name: {{ tenant_name }}
|
|
84
|
+
descr: {{ tenant_descr }}
|
|
85
|
+
status: {{ status }}
|
|
86
|
+
|
|
87
|
+
fvAp:
|
|
88
|
+
- name: web
|
|
89
|
+
fvRsCtx:
|
|
90
|
+
tnFvCtxName: {{ vrf }}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Rendering uses a **coercion-free YAML loader**: YAML ints/floats/bools are kept
|
|
94
|
+
as strings so APIC values are never mangled, and the string `nan` becomes `""`.
|
|
95
|
+
|
|
96
|
+
### Template filters
|
|
97
|
+
|
|
98
|
+
| Filter | Example | Result |
|
|
99
|
+
|---------------|-----------------------------|---------------------|
|
|
100
|
+
| `bool` | `{{ "yes" \| bool }}` | `True` |
|
|
101
|
+
| `range` | `{{ "1-3,5" \| range }}` | `[1, 2, 3, 5]` |
|
|
102
|
+
| `nan` | `{{ value \| nan }}` | `False` when `nan` |
|
|
103
|
+
| `split` | `{{ "a,b" \| split }}` | `["a", "b"]` |
|
|
104
|
+
|
|
105
|
+
## Data inputs (XLSX / CSV)
|
|
106
|
+
|
|
107
|
+
Both `aci.xlsx` and `aci.csv` accept a filename or a list of filenames:
|
|
108
|
+
|
|
109
|
+
- An **XLSX** workbook loads one variable per **sheet**, named after the sheet.
|
|
110
|
+
- A **CSV** file loads one variable named after the file **stem**.
|
|
111
|
+
|
|
112
|
+
Each variable is a list of row dicts, so a sheet named `tenants` is used as
|
|
113
|
+
`{% for row in tenants %}`.
|
|
114
|
+
|
|
115
|
+
### Filtering
|
|
116
|
+
|
|
117
|
+
- `filters`: list of values to keep, matched against the `filter_by` column
|
|
118
|
+
(default `tag`). Rows with empty/`NaN` tags are always dropped.
|
|
119
|
+
- `filters_source_sheet`: derive the filter values from a dedicated sheet. Rows
|
|
120
|
+
where `filters_condition_field` (default `enabled`) is truthy contribute their
|
|
121
|
+
`filters_output_field` (default `name`) as a filter value.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
aci = DeployClass(
|
|
125
|
+
testing=True,
|
|
126
|
+
filters=["IBK-6", "IBK-7"],
|
|
127
|
+
filter_by="tag",
|
|
128
|
+
filters_source_sheet="OCs", # optional
|
|
129
|
+
filters_condition_field="enabled",
|
|
130
|
+
filters_output_field="name",
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Output
|
|
135
|
+
|
|
136
|
+
- `render_to_xml=True` (default): config is XML (`.xml`); otherwise JSON (`.json`).
|
|
137
|
+
- `file_output="outputs/scripts/config"`: write the rendered config to disk.
|
|
138
|
+
- `show_output=True`: pretty-print the rendered config to the terminal.
|
|
139
|
+
- `save_output(name)` / `print_output()` can be called directly.
|
|
140
|
+
|
|
141
|
+
## Logging
|
|
142
|
+
|
|
143
|
+
devaci uses the standard library `logging` module. It attaches a
|
|
144
|
+
`NullHandler` on import (no output by default); opt in with the public
|
|
145
|
+
`configure_logging()`:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import logging
|
|
149
|
+
from devaci import configure_logging
|
|
150
|
+
|
|
151
|
+
configure_logging(level=logging.INFO) # idempotent; adds one stream handler
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Module loggers are exposed as `devaci.<module>` (e.g. `devaci.deploy`) and
|
|
155
|
+
propagate to the stdlib root logger, so per-module levels can be tuned with
|
|
156
|
+
normal logging configuration. Terminal output of rendered configuration and the
|
|
157
|
+
APIC countdown go through the shared rich console in `devaci.console`.
|
|
158
|
+
|
|
159
|
+
> `RunLog` (`logging_output`, default `outputs/logs/logging.json`) is a JSON
|
|
160
|
+
> **execution history**, not stdlib logging. Disable with `logging=False`.
|
|
161
|
+
|
|
162
|
+
## Project structure
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
src/devaci/
|
|
166
|
+
├── __init__.py # public API
|
|
167
|
+
├── config.py # typed DeployConfig
|
|
168
|
+
├── console.py # centralized logging + shared rich console
|
|
169
|
+
├── deploy.py # DeployClass orchestrator
|
|
170
|
+
├── exceptions.py
|
|
171
|
+
├── results.py # result dataclasses
|
|
172
|
+
├── _values.py # shared value predicates (nan/empty checks)
|
|
173
|
+
├── inputs/ # TemplateSource + DataLoader (xlsx/csv, filters)
|
|
174
|
+
├── rendering/ # JinjaRenderer, filters, non-coercing YAML loader
|
|
175
|
+
├── output/ # OutputWriter + RunLog
|
|
176
|
+
├── transport/ # ApicSession (login/commit)
|
|
177
|
+
└── cobra/ # CobraBuilder + BUILDERS mapping
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
uv run pytest # full test suite
|
|
184
|
+
uv run pytest --cov=devaci # with coverage (fails under 80%)
|
|
185
|
+
uv run ruff check . # lint
|
|
186
|
+
uv run mypy src # typecheck (strict)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Run order when verifying a change: `ruff check .` → `mypy src` → `pytest`.
|
|
190
|
+
|
|
191
|
+
## Publishing
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
uv build
|
|
195
|
+
uv publish
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Only a PyPI release bumps the version; commits do not. See `CONTRIBUTING.md`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devaci"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.0a3"
|
|
8
8
|
description = "Python library that generates Cisco ACI configuration and optionally pushes it to an APIC controller via Cisco's official Cobra SDK."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
"pandas>=2.3.3",
|
|
35
35
|
"pyyaml>=6.0.3",
|
|
36
36
|
"rich>=15.0.0",
|
|
37
|
+
"urllib3>=2.0",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
[dependency-groups]
|
|
@@ -52,11 +53,9 @@ Issues = "https://github.com/cocuni80/devaci/issues"
|
|
|
52
53
|
|
|
53
54
|
[tool.hatch.build.targets.wheel]
|
|
54
55
|
packages = ["src/devaci"]
|
|
55
|
-
exclude = ["src/devaci/_legacy"]
|
|
56
56
|
|
|
57
57
|
[tool.hatch.build.targets.sdist]
|
|
58
58
|
include = ["src/devaci", "tests", "README.md", "LICENSE"]
|
|
59
|
-
exclude = ["src/devaci/_legacy"]
|
|
60
59
|
|
|
61
60
|
[tool.pytest.ini_options]
|
|
62
61
|
testpaths = ["tests"]
|
|
@@ -64,29 +63,30 @@ addopts = "-ra"
|
|
|
64
63
|
|
|
65
64
|
[tool.coverage.run]
|
|
66
65
|
source = ["devaci"]
|
|
67
|
-
|
|
66
|
+
# builders.py is a large map of thin Cobra SDK wrappers, exercised through the
|
|
67
|
+
# end-to-end deploy path rather than unit tests; it is excluded from the gate.
|
|
68
|
+
omit = ["*/cobra/builders.py"]
|
|
68
69
|
|
|
69
70
|
[tool.coverage.report]
|
|
70
71
|
show_missing = true
|
|
72
|
+
fail_under = 80
|
|
71
73
|
|
|
72
74
|
[tool.ruff]
|
|
73
75
|
target-version = "py310"
|
|
74
76
|
line-length = 100
|
|
75
|
-
exclude = ["src/devaci/_legacy"]
|
|
76
77
|
|
|
77
78
|
[tool.ruff.lint]
|
|
78
79
|
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
|
|
79
80
|
|
|
80
81
|
[tool.ruff.lint.per-file-ignores]
|
|
81
82
|
# Cobra SDK convention uses PascalCase locals mirroring Mo class names,
|
|
82
|
-
# and the
|
|
83
|
+
# and the handlers use nested `if` guards (kept verbatim).
|
|
83
84
|
"src/devaci/cobra/builders.py" = ["N806", "SIM102"]
|
|
84
85
|
|
|
85
86
|
[tool.mypy]
|
|
86
87
|
python_version = "3.10"
|
|
87
88
|
strict = true
|
|
88
89
|
files = ["src"]
|
|
89
|
-
exclude = ["_legacy"]
|
|
90
90
|
|
|
91
91
|
[[tool.mypy.overrides]]
|
|
92
92
|
module = ["cobra", "cobra.*", "jinja2", "pandas"]
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
4
4
|
|
|
5
5
|
from devaci.cobra import CobraBuilder
|
|
6
|
+
from devaci.config import DeployConfig
|
|
7
|
+
from devaci.console import configure_logging
|
|
6
8
|
from devaci.deploy import DeployClass
|
|
7
9
|
from devaci.exceptions import CobraError, DataError, DeployError, DevaciError, JinjaError
|
|
8
|
-
from devaci.jinja import JinjaRenderer
|
|
10
|
+
from devaci.rendering.jinja import JinjaRenderer
|
|
9
11
|
|
|
10
12
|
try:
|
|
11
13
|
__version__ = version("devaci")
|
|
@@ -15,7 +17,9 @@ except PackageNotFoundError:
|
|
|
15
17
|
__all__ = [
|
|
16
18
|
"CobraBuilder",
|
|
17
19
|
"DeployClass",
|
|
20
|
+
"DeployConfig",
|
|
18
21
|
"JinjaRenderer",
|
|
22
|
+
"configure_logging",
|
|
19
23
|
"DevaciError",
|
|
20
24
|
"CobraError",
|
|
21
25
|
"JinjaError",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Internal value predicates shared across devaci layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isnan
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = ["is_invalid", "is_nan", "is_nan_text"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_nan_text(value: Any) -> bool:
|
|
12
|
+
"""Return True when ``value`` is the text ``nan`` (case-insensitive, stripped)."""
|
|
13
|
+
return isinstance(value, str) and value.strip().lower() == "nan"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_nan(value: Any) -> bool:
|
|
17
|
+
"""Return True for a float NaN or the text ``nan`` (case-insensitive)."""
|
|
18
|
+
if isinstance(value, float):
|
|
19
|
+
return isnan(value)
|
|
20
|
+
return is_nan_text(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_invalid(value: Any) -> bool:
|
|
24
|
+
"""Return True for None, empty/whitespace strings, text ``nan`` or float NaN."""
|
|
25
|
+
if value is None:
|
|
26
|
+
return True
|
|
27
|
+
if is_nan(value):
|
|
28
|
+
return True
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
return not value.strip()
|
|
31
|
+
return False
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
from typing import Any, cast
|
|
5
|
+
from typing import Any
|
|
7
6
|
|
|
8
7
|
import cobra.mit.request
|
|
9
8
|
import cobra.model.pol
|
|
10
9
|
|
|
11
|
-
from devaci.cobra.
|
|
12
|
-
from devaci.
|
|
10
|
+
from devaci.cobra.base import config_json, config_xml
|
|
11
|
+
from devaci.cobra.builders import BUILDERS
|
|
12
|
+
from devaci.console import get_logger
|
|
13
13
|
from devaci.results import CobraResult
|
|
14
14
|
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class CobraBuilder:
|
|
17
19
|
"""Build an ACI configuration (:class:`ConfigRequest`) from a rendered dict.
|
|
@@ -20,6 +22,12 @@ class CobraBuilder:
|
|
|
20
22
|
:mod:`devaci.cobra.builders`). Each handler receives the builder and the
|
|
21
23
|
list of objects and is responsible for adding Mo instances to
|
|
22
24
|
:attr:`config`.
|
|
25
|
+
|
|
26
|
+
A builder is **cumulative**: every object added by :meth:`render` stays in
|
|
27
|
+
the same :class:`ConfigRequest` rooted at :attr:`uni`, so several renders
|
|
28
|
+
(e.g. several templates) build one tree that is committed once. To start a
|
|
29
|
+
fresh tree, create a new :class:`CobraBuilder`; devaci never clears the
|
|
30
|
+
accumulated tree.
|
|
23
31
|
"""
|
|
24
32
|
|
|
25
33
|
def __init__(self) -> None:
|
|
@@ -40,13 +48,13 @@ class CobraBuilder:
|
|
|
40
48
|
|
|
41
49
|
@property
|
|
42
50
|
def xml(self) -> str | None:
|
|
43
|
-
"""Return the rendered XML payload."""
|
|
44
|
-
return
|
|
51
|
+
"""Return the rendered XML payload, or None when the config is empty."""
|
|
52
|
+
return config_xml(self.config)
|
|
45
53
|
|
|
46
54
|
@property
|
|
47
|
-
def json(self) -> Any:
|
|
48
|
-
"""Return the rendered JSON payload as a dict."""
|
|
49
|
-
return
|
|
55
|
+
def json(self) -> dict[str, Any] | None:
|
|
56
|
+
"""Return the rendered JSON payload as a dict, or None when the config is empty."""
|
|
57
|
+
return config_json(self.config)
|
|
50
58
|
|
|
51
59
|
def render(self, output: dict[str, Any]) -> CobraResult:
|
|
52
60
|
"""Render ``output`` through the registered handlers.
|
|
@@ -61,33 +69,30 @@ class CobraBuilder:
|
|
|
61
69
|
if value in (None, [], {}, ""):
|
|
62
70
|
continue
|
|
63
71
|
|
|
64
|
-
handler =
|
|
72
|
+
handler = BUILDERS.get(key)
|
|
65
73
|
if handler is None:
|
|
66
74
|
success = False
|
|
67
|
-
msg = f"
|
|
68
|
-
logger.warning(msg)
|
|
75
|
+
msg = f"Class {key} does not exist."
|
|
76
|
+
logger.warning("%s", msg)
|
|
69
77
|
logs.append(msg)
|
|
70
78
|
continue
|
|
71
79
|
|
|
72
80
|
try:
|
|
73
81
|
handler(self, value)
|
|
74
|
-
msg = f"
|
|
75
|
-
logger.info(msg)
|
|
82
|
+
msg = f"Class {key} rendered successfully."
|
|
83
|
+
logger.info("%s", msg)
|
|
76
84
|
logs.append(msg)
|
|
77
85
|
except Exception as exc:
|
|
78
86
|
success = False
|
|
79
|
-
msg = f"
|
|
80
|
-
logger.
|
|
87
|
+
msg = f"Class {key} failed: {exc}"
|
|
88
|
+
logger.exception("%s", msg)
|
|
81
89
|
logs.append(msg)
|
|
82
90
|
|
|
83
91
|
if not self.config.configMos:
|
|
84
92
|
success = False
|
|
85
|
-
msg = "
|
|
86
|
-
logger.warning(msg)
|
|
93
|
+
msg = "No object was found in configuration."
|
|
94
|
+
logger.warning("%s", msg)
|
|
87
95
|
logs.append(msg)
|
|
88
96
|
|
|
89
97
|
self.result = CobraResult(success=success, log=logs, config=self.config)
|
|
90
98
|
return self.result
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
from devaci.cobra import builders # noqa: E402, F401
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared helpers for Cobra object builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from devaci._values import is_invalid
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from devaci.cobra import CobraBuilder
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Handler(Protocol):
|
|
16
|
+
"""A registered handler for one top-level ACI object key."""
|
|
17
|
+
|
|
18
|
+
def __call__(self, builder: CobraBuilder, value: Any) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def not_nan_str(value: Mapping[str, Any], keys: Iterable[str]) -> bool:
|
|
22
|
+
"""Return True when none of the given keys hold an invalid value.
|
|
23
|
+
|
|
24
|
+
A value is considered invalid when it is ``None``, an empty/whitespace
|
|
25
|
+
string, the string ``nan`` (case-insensitive), or a float NaN. Missing
|
|
26
|
+
keys are ignored.
|
|
27
|
+
"""
|
|
28
|
+
return not any(is_invalid(value[k]) for k in keys if k in value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_objects(config: Any) -> bool:
|
|
32
|
+
return config is not None and bool(config.configMos)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_xml(config: Any) -> str | None:
|
|
36
|
+
"""Return the XML payload of a ``ConfigRequest``, or None when empty."""
|
|
37
|
+
if not _has_objects(config):
|
|
38
|
+
return None
|
|
39
|
+
return cast("str | None", config.xmldata)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_json(config: Any) -> dict[str, Any] | None:
|
|
43
|
+
"""Return the JSON-decoded payload of a ``ConfigRequest``, or None when empty."""
|
|
44
|
+
if not _has_objects(config):
|
|
45
|
+
return None
|
|
46
|
+
return cast("dict[str, Any]", json.loads(config.data))
|