devaci 1.0.1__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.
Files changed (57) hide show
  1. devaci-2.0.0a3/PKG-INFO +228 -0
  2. devaci-2.0.0a3/README.md +198 -0
  3. {devaci-1.0.1 → devaci-2.0.0a3}/pyproject.toml +7 -7
  4. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/__init__.py +5 -1
  5. devaci-2.0.0a3/src/devaci/_values.py +31 -0
  6. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/cobra/__init__.py +24 -23
  7. devaci-2.0.0a3/src/devaci/cobra/base.py +46 -0
  8. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/cobra/builders.py +131 -126
  9. devaci-2.0.0a3/src/devaci/config.py +59 -0
  10. devaci-2.0.0a3/src/devaci/console.py +82 -0
  11. devaci-2.0.0a3/src/devaci/deploy.py +207 -0
  12. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/exceptions.py +2 -0
  13. devaci-2.0.0a3/src/devaci/inputs/__init__.py +14 -0
  14. devaci-2.0.0a3/src/devaci/inputs/datasets.py +149 -0
  15. devaci-2.0.0a3/src/devaci/inputs/templates.py +51 -0
  16. devaci-2.0.0a3/src/devaci/output/__init__.py +8 -0
  17. devaci-2.0.0a3/src/devaci/output/runlog.py +44 -0
  18. devaci-2.0.0a3/src/devaci/output/writer.py +62 -0
  19. devaci-2.0.0a3/src/devaci/rendering/__init__.py +16 -0
  20. devaci-2.0.0a3/src/devaci/rendering/filters.py +45 -0
  21. {devaci-1.0.1/src/devaci → devaci-2.0.0a3/src/devaci/rendering}/jinja.py +2 -1
  22. devaci-1.0.1/src/devaci/filters.py → devaci-2.0.0a3/src/devaci/rendering/yaml_loader.py +7 -30
  23. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/results.py +8 -9
  24. devaci-2.0.0a3/src/devaci/transport/__init__.py +7 -0
  25. devaci-2.0.0a3/src/devaci/transport/apic.py +68 -0
  26. devaci-2.0.0a3/tests/conftest.py +29 -0
  27. devaci-2.0.0a3/tests/test_cobra/__init__.py +0 -0
  28. {devaci-1.0.1/tests → devaci-2.0.0a3/tests/test_cobra}/test_cobra.py +28 -15
  29. devaci-2.0.0a3/tests/test_config.py +50 -0
  30. devaci-2.0.0a3/tests/test_console.py +59 -0
  31. devaci-2.0.0a3/tests/test_deploy.py +152 -0
  32. devaci-2.0.0a3/tests/test_inputs/__init__.py +0 -0
  33. devaci-2.0.0a3/tests/test_inputs/test_datasets.py +113 -0
  34. devaci-2.0.0a3/tests/test_inputs/test_templates.py +54 -0
  35. devaci-2.0.0a3/tests/test_output/__init__.py +0 -0
  36. devaci-2.0.0a3/tests/test_output/test_runlog.py +31 -0
  37. devaci-2.0.0a3/tests/test_output/test_writer.py +39 -0
  38. devaci-2.0.0a3/tests/test_rendering/__init__.py +0 -0
  39. {devaci-1.0.1/tests → devaci-2.0.0a3/tests/test_rendering}/test_filters.py +13 -8
  40. {devaci-1.0.1/tests → devaci-2.0.0a3/tests/test_rendering}/test_jinja.py +1 -1
  41. {devaci-1.0.1 → devaci-2.0.0a3}/tests/test_results.py +13 -0
  42. devaci-2.0.0a3/tests/test_transport/__init__.py +0 -0
  43. devaci-2.0.0a3/tests/test_transport/test_apic.py +59 -0
  44. devaci-1.0.1/PKG-INFO +0 -110
  45. devaci-1.0.1/README.md +0 -81
  46. devaci-1.0.1/src/devaci/cobra/base.py +0 -28
  47. devaci-1.0.1/src/devaci/cobra/registry.py +0 -24
  48. devaci-1.0.1/src/devaci/console.py +0 -34
  49. devaci-1.0.1/src/devaci/data.py +0 -81
  50. devaci-1.0.1/src/devaci/deploy.py +0 -320
  51. devaci-1.0.1/tests/test_data.py +0 -56
  52. devaci-1.0.1/tests/test_deploy.py +0 -98
  53. {devaci-1.0.1 → devaci-2.0.0a3}/.gitignore +0 -0
  54. {devaci-1.0.1 → devaci-2.0.0a3}/LICENSE +0 -0
  55. {devaci-1.0.1 → devaci-2.0.0a3}/src/devaci/py.typed +0 -0
  56. {devaci-1.0.1 → devaci-2.0.0a3}/tests/__init__.py +0 -0
  57. {devaci-1.0.1 → devaci-2.0.0a3}/tests/test_devaci.py +0 -0
@@ -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`.
@@ -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 = "1.0.1"
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
- omit = ["*/_legacy/*"]
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 legacy handlers use nested `if` guards (kept verbatim).
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 json
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.registry import REGISTRY
12
- from devaci.console import logger
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:
@@ -41,16 +49,12 @@ class CobraBuilder:
41
49
  @property
42
50
  def xml(self) -> str | None:
43
51
  """Return the rendered XML payload, or None when the config is empty."""
44
- if not self.config.configMos:
45
- return None
46
- return cast(str | None, self.config.xmldata)
52
+ return config_xml(self.config)
47
53
 
48
54
  @property
49
- def json(self) -> Any:
55
+ def json(self) -> dict[str, Any] | None:
50
56
  """Return the rendered JSON payload as a dict, or None when the config is empty."""
51
- if not self.config.configMos:
52
- return None
53
- return json.loads(self.config.data)
57
+ return config_json(self.config)
54
58
 
55
59
  def render(self, output: dict[str, Any]) -> CobraResult:
56
60
  """Render ``output`` through the registered handlers.
@@ -65,33 +69,30 @@ class CobraBuilder:
65
69
  if value in (None, [], {}, ""):
66
70
  continue
67
71
 
68
- handler = REGISTRY.get(key)
72
+ handler = BUILDERS.get(key)
69
73
  if handler is None:
70
74
  success = False
71
- msg = f"[Cobra] -> [ConfigError]: Class {key} does not exist."
72
- logger.warning(msg)
75
+ msg = f"Class {key} does not exist."
76
+ logger.warning("%s", msg)
73
77
  logs.append(msg)
74
78
  continue
75
79
 
76
80
  try:
77
81
  handler(self, value)
78
- msg = f"[Cobra]: Class {key} was rendered successfully."
79
- logger.info(msg)
82
+ msg = f"Class {key} rendered successfully."
83
+ logger.info("%s", msg)
80
84
  logs.append(msg)
81
85
  except Exception as exc:
82
86
  success = False
83
- msg = f"[Cobra] -> [{type(exc).__name__}]: Class {key} failed: {exc}"
84
- logger.error(msg)
87
+ msg = f"Class {key} failed: {exc}"
88
+ logger.exception("%s", msg)
85
89
  logs.append(msg)
86
90
 
87
91
  if not self.config.configMos:
88
92
  success = False
89
- msg = "[Cobra] -> [ConfigError]: No object was found in configuration."
90
- logger.warning(msg)
93
+ msg = "No object was found in configuration."
94
+ logger.warning("%s", msg)
91
95
  logs.append(msg)
92
96
 
93
97
  self.result = CobraResult(success=success, log=logs, config=self.config)
94
98
  return self.result
95
-
96
-
97
- 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))