a4i 0.0.1__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. a4i-0.0.1/.github/dependabot.yml +7 -0
  2. a4i-0.0.1/.github/workflows/publish.yml +36 -0
  3. a4i-0.0.1/.github/workflows/test.yml +65 -0
  4. a4i-0.0.1/.gitignore +15 -0
  5. a4i-0.0.1/LICENSE +21 -0
  6. a4i-0.0.1/PKG-INFO +247 -0
  7. a4i-0.0.1/README.md +210 -0
  8. a4i-0.0.1/pyproject.toml +60 -0
  9. a4i-0.0.1/src/a4i/__init__.py +84 -0
  10. a4i-0.0.1/src/a4i/__main__.py +8 -0
  11. a4i-0.0.1/src/a4i/cli.py +836 -0
  12. a4i-0.0.1/src/a4i/client.py +593 -0
  13. a4i-0.0.1/src/a4i/completion.py +310 -0
  14. a4i-0.0.1/src/a4i/config.py +78 -0
  15. a4i-0.0.1/src/a4i/daemon.py +252 -0
  16. a4i-0.0.1/src/a4i/diff.py +250 -0
  17. a4i-0.0.1/src/a4i/dry_run.py +141 -0
  18. a4i-0.0.1/src/a4i/errors.py +123 -0
  19. a4i-0.0.1/src/a4i/ipc.py +328 -0
  20. a4i-0.0.1/src/a4i/mcp/__init__.py +21 -0
  21. a4i-0.0.1/src/a4i/mcp/guides.py +308 -0
  22. a4i-0.0.1/src/a4i/mcp/server.py +230 -0
  23. a4i-0.0.1/src/a4i/mcp/tools.py +562 -0
  24. a4i-0.0.1/src/a4i/merge.py +224 -0
  25. a4i-0.0.1/src/a4i/metadata/__init__.py +170 -0
  26. a4i-0.0.1/src/a4i/metadata/classes.txt +15637 -0
  27. a4i-0.0.1/src/a4i/metadata/model.idx +17864 -0
  28. a4i-0.0.1/src/a4i/metadata/model.jsonl +17864 -0
  29. a4i-0.0.1/src/a4i/metadata/rn_formats.txt +2806 -0
  30. a4i-0.0.1/src/a4i/metadata/search.txt +17864 -0
  31. a4i-0.0.1/src/a4i/mo.py +394 -0
  32. a4i-0.0.1/src/a4i/output.py +490 -0
  33. a4i-0.0.1/src/a4i/query.py +182 -0
  34. a4i-0.0.1/src/a4i/session.py +590 -0
  35. a4i-0.0.1/src/a4i/transport.py +100 -0
  36. a4i-0.0.1/tests/apic_mock.py +300 -0
  37. a4i-0.0.1/tests/conftest.py +16 -0
  38. a4i-0.0.1/tests/test_async_client.py +123 -0
  39. a4i-0.0.1/tests/test_async_session.py +120 -0
  40. a4i-0.0.1/tests/test_awaited.py +165 -0
  41. a4i-0.0.1/tests/test_cli.py +865 -0
  42. a4i-0.0.1/tests/test_client.py +539 -0
  43. a4i-0.0.1/tests/test_completion.py +327 -0
  44. a4i-0.0.1/tests/test_config.py +122 -0
  45. a4i-0.0.1/tests/test_daemon.py +227 -0
  46. a4i-0.0.1/tests/test_diff.py +757 -0
  47. a4i-0.0.1/tests/test_dry_run.py +300 -0
  48. a4i-0.0.1/tests/test_errors.py +119 -0
  49. a4i-0.0.1/tests/test_ipc.py +188 -0
  50. a4i-0.0.1/tests/test_mcp.py +624 -0
  51. a4i-0.0.1/tests/test_merge.py +333 -0
  52. a4i-0.0.1/tests/test_mo.py +136 -0
  53. a4i-0.0.1/tests/test_output.py +210 -0
  54. a4i-0.0.1/tests/test_query.py +31 -0
  55. a4i-0.0.1/tests/test_session.py +419 -0
  56. a4i-0.0.1/tools/gen_metadata.py +491 -0
  57. a4i-0.0.1/uv.lock +269 -0
@@ -0,0 +1,7 @@
1
+ version: 2
2
+ updates:
3
+ # Actions are pinned to commit SHAs, so updates have to come from a bot.
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: weekly
@@ -0,0 +1,36 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ # Only release tags, so a stray working tag never reaches PyPI.
6
+ tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ name: publish to PyPI
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ permissions:
17
+ contents: read
18
+ # Required for PyPI Trusted Publishing (OIDC) and PEP 740 attestations.
19
+ id-token: write
20
+ steps:
21
+ - name: Check out
22
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ with:
24
+ # hatch-vcs reads the version from git tags, which a shallow clone lacks.
25
+ fetch-depth: 0
26
+
27
+ - name: Set up uv
28
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
29
+ with:
30
+ python-version: "3.13"
31
+
32
+ - name: Build sdist and wheel
33
+ run: uv build
34
+
35
+ - name: Publish
36
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,65 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags-ignore: ["v[0-9]+.[0-9]+.[0-9]+*"]
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ # Supersede an in-flight run when the same ref is pushed again.
13
+ concurrency:
14
+ group: ${{ github.workflow }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ lint:
19
+ name: lint
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Check out
23
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
+
25
+ - name: Set up uv
26
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
27
+ with:
28
+ python-version: "3.13"
29
+ enable-cache: true
30
+
31
+ - name: Install dependencies
32
+ run: uv sync --locked --all-groups
33
+
34
+ - name: ruff check
35
+ run: uv run ruff check
36
+
37
+ - name: ruff format
38
+ run: uv run ruff format --check
39
+
40
+ - name: ty check
41
+ run: uv run ty check
42
+
43
+ test:
44
+ name: test (${{ matrix.python-version }})
45
+ runs-on: ubuntu-latest
46
+ strategy:
47
+ # One failing interpreter should not hide the result of the others.
48
+ fail-fast: false
49
+ matrix:
50
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
51
+ steps:
52
+ - name: Check out
53
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54
+
55
+ - name: Set up uv
56
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
57
+ with:
58
+ python-version: ${{ matrix.python-version }}
59
+ enable-cache: true
60
+
61
+ - name: Install dependencies
62
+ run: uv sync --locked --all-groups
63
+
64
+ - name: pytest
65
+ run: uv run pytest
a4i-0.0.1/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+
9
+ # Tooling caches
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .ty_cache/
13
+
14
+ # Raw MIM Reference responses cached by tools/gen_metadata.py (some gigabytes)
15
+ .metadata-cache/
a4i-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 minefuto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
a4i-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.5
2
+ Name: a4i
3
+ Version: 0.0.1
4
+ Summary: CLI/MCP/Python Library for the Cisco ACI REST API
5
+ Author-email: minefuto <46558834+minefuto@users.noreply.github.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 minefuto
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Classifier: Programming Language :: Python :: 3
29
+ Classifier: Programming Language :: Python :: 3.11
30
+ Classifier: Programming Language :: Python :: 3.12
31
+ Classifier: Programming Language :: Python :: 3.13
32
+ Classifier: Programming Language :: Python :: 3.14
33
+ Requires-Python: >=3.11
34
+ Requires-Dist: httpx2>=2.9.1
35
+ Requires-Dist: rich>=15.0.0
36
+ Description-Content-Type: text/markdown
37
+
38
+ # aciapi(a4i)
39
+
40
+ [![test](https://github.com/minefuto/a4i/actions/workflows/test.yml/badge.svg)](https://github.com/minefuto/a4i/actions/workflows/test.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/a4i.svg)](https://pypi.org/project/a4i/)
42
+
43
+ CLI/MCP/Python Library for the Cisco ACI REST API.
44
+
45
+ - **The token is never written to disk.** `login` hands it to a small per-user
46
+ daemon that holds it in memory, behind a Unix domain socket, so it survives
47
+ across short-lived CLI invocations without touching the filesystem.
48
+ - **The ACI object model ships with it.** `search` and `describe` answer what a
49
+ class is called and what a body may set on it, without an APIC and without a
50
+ login.
51
+ - **`merge` and `diff` compare a fabric against an intended configuration**,
52
+ reporting both what the configuration asks for and the fabric lacks, and what
53
+ the fabric carries and the configuration never mentions.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ pip install a4i
59
+ ```
60
+
61
+ Shell completion is printed to standard output; add one line to your shell's
62
+ startup file:
63
+
64
+ ```sh
65
+ eval "$(a4i generate-shell-completion zsh)" # ~/.zshrc, after compinit
66
+ eval "$(a4i generate-shell-completion bash)" # ~/.bashrc
67
+ a4i generate-shell-completion fish | source # ~/.config/fish/config.fish
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ```sh
73
+ a4i login apic1.example.com -u admin # prompts for password; -k if self-signed
74
+ a4i get class fvTenant # class query
75
+ a4i get mo uni/tn-common # MO query, by DN
76
+ a4i get class fvTenant --query-target subtree --rsp-subtree full
77
+ a4i get class l1PhysIf --node leaf101.example.com # query a switch with the same token
78
+ echo '{"fvTenant":{"attributes":{"name":"demo"}}}' | a4i post mo uni/tn-demo
79
+ a4i logout # drop the in-memory session
80
+ a4i daemon status # is a token held, and for how long
81
+ a4i login apic1.example.com -u admin --read-only # a session that will refuse every POST
82
+ a4i mcp # serve MCP on stdio for an LLM client
83
+ ```
84
+
85
+ `get`, `post` and `list` each take a `class` or an `mo` subcommand, so a DN
86
+ needs no leading `/` and a class name is never mistaken for one. Every `get`
87
+ option is named after the ACI query parameter it sets, so a parameter read in
88
+ the APIC REST API documentation can be typed as-is -- `a4i get class --help`
89
+ lists them.
90
+
91
+ ### Reading the model
92
+
93
+ ```sh
94
+ a4i search 'bridge domain' # which class is that, by name
95
+ a4i describe fvBD # what a body may set on it
96
+ a4i list class fvT # class names starting with fvT
97
+ a4i list mo uni/tn-common # the MOs one level under that DN
98
+ ```
99
+
100
+ `search`, `describe` and `list class` read the bundled dictionary, so they need
101
+ neither a login nor a daemon. `list mo` asks the APIC for one level of children,
102
+ so it needs a session.
103
+
104
+ ```
105
+ $ a4i describe fvCtx
106
+ fvCtx VRF
107
+ The private layer 3 network context that belongs to a specific tenant or is
108
+ shared.
109
+
110
+ rn ctx-{name}
111
+ dn uni/tn-{name}/ctx-{name}
112
+ in fvTenant
113
+
114
+ properties (13 settable, 14 read-only hidden)
115
+ descr string:Basic (0-128) Specifies a descriptio…
116
+ ipDataPlaneLearning disabled|enabled = enabled
117
+ name* string:Basic (1-64) A name for the network…
118
+ pcEnfDir egress|ingress|mixed = ingress Policy Control Enforce…
119
+ pcEnfPref enforced|unenforced = enforced
120
+
121
+ children (42) --children to list them
122
+ ```
123
+
124
+ A `*` marks a naming property -- the one the RN is built from. The middle column
125
+ is what the property accepts, with the default after `=`. `-a` spells out the
126
+ read-only properties, `--children` the classes that may hang under this one, and
127
+ `--json` prints the underlying record instead of the layout.
128
+
129
+ ### Comparing a configuration
130
+
131
+ `merge` folds a configuration written across several files into the one body
132
+ that `diff` and `post` each take, later files winning attribute by attribute.
133
+ `diff` then compares that configuration against everything the fabric has under
134
+ `uni`.
135
+
136
+ ```sh
137
+ a4i merge ./configs/ -o merged.json # every *.json, in path order
138
+ a4i merge ./configs/ | a4i diff
139
+ a4i merge ./configs/ | a4i diff --exclude uni/tn-common --exclude uni/infra
140
+ a4i post mo uni/tn-demo --dry-run '{"fvTenant":{"attributes":{"descr":"prod"}}}'
141
+ ```
142
+
143
+ ```
144
+ - fvTenant uni/tn-common (extra: 2 child MOs)
145
+ - descr: ""
146
+ - name: "common"
147
+
148
+ ~ fvBD uni/tn-demo/BD-bd1
149
+ ~ mtu: "1500" -> "9000"
150
+
151
+ + fvTenant uni/tn-new (missing: 2 child MOs)
152
+ + descr: "added"
153
+ + name: "new"
154
+
155
+ 1 missing, 1 modified, 1 extra
156
+ ```
157
+
158
+ `+` is an MO the configuration asks for and the fabric does not have, `-` one
159
+ the fabric has and the configuration does not mention, and `~` one whose
160
+ attributes differ. A wholly missing or wholly extra subtree is reported as its
161
+ top MO with the MOs below it counted; `--expand` lists every one of them.
162
+
163
+ The configuration is taken to describe the whole of `uni`, so everything it
164
+ leaves out is `extra` -- including `tn-common`, `tn-infra`, `tn-mgmt` and the
165
+ policies the APIC creates for itself. `--exclude` is how the rest is quietened:
166
+ it takes a DN, or a pattern whose `*` matches within one RN, and is repeatable.
167
+
168
+ `post --dry-run` reads the same way over a single POST: it fetches the subtree
169
+ the body targets, prints what would change, and sends nothing. Both commands say
170
+ in their exit code whether anything would change:
171
+
172
+ | Code | Meaning |
173
+ | --- | --- |
174
+ | `0` | the fabric matches / posting this body would change nothing |
175
+ | `2` | it differs / the body would change something |
176
+ | `1` | the command itself failed (not logged in, bad JSON, unknown DN) |
177
+
178
+ ## MCP server
179
+
180
+ `a4i mcp` speaks the Model Context Protocol on stdin and stdout, so an LLM
181
+ client can read and write the fabric through the session you already logged in.
182
+ Register it with the client, then log in from a terminal as usual -- there is no
183
+ login tool, because this server never handles a password.
184
+
185
+ ```json
186
+ {"mcpServers": {"a4i": {"command": "a4i", "args": ["mcp"]}}}
187
+ ```
188
+
189
+ | Tool | What it does |
190
+ | --- | --- |
191
+ | `search` | find a class by what it is called |
192
+ | `describe` | one class from the bundled model, as a JSON record |
193
+ | `list` | class names by prefix, or the DNs one level under a DN |
194
+ | `get` | a class or MO query, with every query option under its own name |
195
+ | `dry_run` | what a POST would change, sending nothing |
196
+ | `post` | POST a body |
197
+ | `merge` | several bodies or paths folded into one |
198
+ | `diff` | the fabric compared against one configuration |
199
+
200
+ | Resource | Contents |
201
+ | --- | --- |
202
+ | `a4i://guide/post-body` | how an ACI body nests, how a child MO gets its DN, what `status` does |
203
+ | `a4i://guide/query` | class against MO queries, the two subtree controls, keeping a response small |
204
+ | `a4i://guide/workflow` | the order: search or list, describe, get, dry run, post |
205
+ | `a4i://guide/limits` | where the bundled model, the dry run and the diff each stop short |
206
+
207
+ A `get` whose response would exceed 64 KB is refused, with the total count and
208
+ the ways to narrow it; `A4I_MCP_MAX_BYTES` raises or lowers that. On a
209
+ `--read-only` session, `post` is not offered at all.
210
+
211
+ ## Python library
212
+
213
+ A `Client` holds its own session, so no daemon is involved: it logs in itself
214
+ and keeps the token in memory for as long as it lives.
215
+
216
+ ```python
217
+ import a4i
218
+ from a4i.merge import merge
219
+
220
+ with a4i.Client("apic1.example.com", verify=False) as client:
221
+ client.login("admin", password)
222
+
223
+ data = client.get("fvTenant", kind="class", query_target="subtree", rsp_subtree="full")
224
+ client.post("uni/tn-demo", {"fvTenant": {"attributes": {"name": "demo"}}}, kind="mo")
225
+ changes = client.diff(merge(base, override))
226
+ ```
227
+
228
+ `kind` is the subcommand the CLI takes, and it is required: `"class"` for a
229
+ class name, `"mo"` for a DN. Every other keyword argument is the CLI option of
230
+ the same name with underscores. `verify` is what `-k/--insecure` and `--ca`
231
+ express, `timeout` is `login --timeout`, and `dry_run()`, `diff()` and
232
+ `a4i.merge.merge()` are the commands of those names.
233
+
234
+ `AsyncClient` is `Client` awaited: the same arguments, the same return values
235
+ and the same exceptions, sending the same requests in the same order.
236
+
237
+ ```python
238
+ async with a4i.AsyncClient("apic1.example.com", verify=False) as client:
239
+ await client.login("admin", password)
240
+ data = await client.get("fvTenant", kind="class")
241
+ ```
242
+
243
+ A value ACI does not define raises `ValueError` before anything is sent. A
244
+ failed request raises `a4i.ApicError`, `a4i.NotLoggedInError` or
245
+ `a4i.SessionExpiredError`, all of them `a4i.A4iError`. The token refreshes
246
+ itself once half its lifetime has elapsed, so a long-running script needs
247
+ nothing of its own.
a4i-0.0.1/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # aciapi(a4i)
2
+
3
+ [![test](https://github.com/minefuto/a4i/actions/workflows/test.yml/badge.svg)](https://github.com/minefuto/a4i/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/a4i.svg)](https://pypi.org/project/a4i/)
5
+
6
+ CLI/MCP/Python Library for the Cisco ACI REST API.
7
+
8
+ - **The token is never written to disk.** `login` hands it to a small per-user
9
+ daemon that holds it in memory, behind a Unix domain socket, so it survives
10
+ across short-lived CLI invocations without touching the filesystem.
11
+ - **The ACI object model ships with it.** `search` and `describe` answer what a
12
+ class is called and what a body may set on it, without an APIC and without a
13
+ login.
14
+ - **`merge` and `diff` compare a fabric against an intended configuration**,
15
+ reporting both what the configuration asks for and the fabric lacks, and what
16
+ the fabric carries and the configuration never mentions.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ pip install a4i
22
+ ```
23
+
24
+ Shell completion is printed to standard output; add one line to your shell's
25
+ startup file:
26
+
27
+ ```sh
28
+ eval "$(a4i generate-shell-completion zsh)" # ~/.zshrc, after compinit
29
+ eval "$(a4i generate-shell-completion bash)" # ~/.bashrc
30
+ a4i generate-shell-completion fish | source # ~/.config/fish/config.fish
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```sh
36
+ a4i login apic1.example.com -u admin # prompts for password; -k if self-signed
37
+ a4i get class fvTenant # class query
38
+ a4i get mo uni/tn-common # MO query, by DN
39
+ a4i get class fvTenant --query-target subtree --rsp-subtree full
40
+ a4i get class l1PhysIf --node leaf101.example.com # query a switch with the same token
41
+ echo '{"fvTenant":{"attributes":{"name":"demo"}}}' | a4i post mo uni/tn-demo
42
+ a4i logout # drop the in-memory session
43
+ a4i daemon status # is a token held, and for how long
44
+ a4i login apic1.example.com -u admin --read-only # a session that will refuse every POST
45
+ a4i mcp # serve MCP on stdio for an LLM client
46
+ ```
47
+
48
+ `get`, `post` and `list` each take a `class` or an `mo` subcommand, so a DN
49
+ needs no leading `/` and a class name is never mistaken for one. Every `get`
50
+ option is named after the ACI query parameter it sets, so a parameter read in
51
+ the APIC REST API documentation can be typed as-is -- `a4i get class --help`
52
+ lists them.
53
+
54
+ ### Reading the model
55
+
56
+ ```sh
57
+ a4i search 'bridge domain' # which class is that, by name
58
+ a4i describe fvBD # what a body may set on it
59
+ a4i list class fvT # class names starting with fvT
60
+ a4i list mo uni/tn-common # the MOs one level under that DN
61
+ ```
62
+
63
+ `search`, `describe` and `list class` read the bundled dictionary, so they need
64
+ neither a login nor a daemon. `list mo` asks the APIC for one level of children,
65
+ so it needs a session.
66
+
67
+ ```
68
+ $ a4i describe fvCtx
69
+ fvCtx VRF
70
+ The private layer 3 network context that belongs to a specific tenant or is
71
+ shared.
72
+
73
+ rn ctx-{name}
74
+ dn uni/tn-{name}/ctx-{name}
75
+ in fvTenant
76
+
77
+ properties (13 settable, 14 read-only hidden)
78
+ descr string:Basic (0-128) Specifies a descriptio…
79
+ ipDataPlaneLearning disabled|enabled = enabled
80
+ name* string:Basic (1-64) A name for the network…
81
+ pcEnfDir egress|ingress|mixed = ingress Policy Control Enforce…
82
+ pcEnfPref enforced|unenforced = enforced
83
+
84
+ children (42) --children to list them
85
+ ```
86
+
87
+ A `*` marks a naming property -- the one the RN is built from. The middle column
88
+ is what the property accepts, with the default after `=`. `-a` spells out the
89
+ read-only properties, `--children` the classes that may hang under this one, and
90
+ `--json` prints the underlying record instead of the layout.
91
+
92
+ ### Comparing a configuration
93
+
94
+ `merge` folds a configuration written across several files into the one body
95
+ that `diff` and `post` each take, later files winning attribute by attribute.
96
+ `diff` then compares that configuration against everything the fabric has under
97
+ `uni`.
98
+
99
+ ```sh
100
+ a4i merge ./configs/ -o merged.json # every *.json, in path order
101
+ a4i merge ./configs/ | a4i diff
102
+ a4i merge ./configs/ | a4i diff --exclude uni/tn-common --exclude uni/infra
103
+ a4i post mo uni/tn-demo --dry-run '{"fvTenant":{"attributes":{"descr":"prod"}}}'
104
+ ```
105
+
106
+ ```
107
+ - fvTenant uni/tn-common (extra: 2 child MOs)
108
+ - descr: ""
109
+ - name: "common"
110
+
111
+ ~ fvBD uni/tn-demo/BD-bd1
112
+ ~ mtu: "1500" -> "9000"
113
+
114
+ + fvTenant uni/tn-new (missing: 2 child MOs)
115
+ + descr: "added"
116
+ + name: "new"
117
+
118
+ 1 missing, 1 modified, 1 extra
119
+ ```
120
+
121
+ `+` is an MO the configuration asks for and the fabric does not have, `-` one
122
+ the fabric has and the configuration does not mention, and `~` one whose
123
+ attributes differ. A wholly missing or wholly extra subtree is reported as its
124
+ top MO with the MOs below it counted; `--expand` lists every one of them.
125
+
126
+ The configuration is taken to describe the whole of `uni`, so everything it
127
+ leaves out is `extra` -- including `tn-common`, `tn-infra`, `tn-mgmt` and the
128
+ policies the APIC creates for itself. `--exclude` is how the rest is quietened:
129
+ it takes a DN, or a pattern whose `*` matches within one RN, and is repeatable.
130
+
131
+ `post --dry-run` reads the same way over a single POST: it fetches the subtree
132
+ the body targets, prints what would change, and sends nothing. Both commands say
133
+ in their exit code whether anything would change:
134
+
135
+ | Code | Meaning |
136
+ | --- | --- |
137
+ | `0` | the fabric matches / posting this body would change nothing |
138
+ | `2` | it differs / the body would change something |
139
+ | `1` | the command itself failed (not logged in, bad JSON, unknown DN) |
140
+
141
+ ## MCP server
142
+
143
+ `a4i mcp` speaks the Model Context Protocol on stdin and stdout, so an LLM
144
+ client can read and write the fabric through the session you already logged in.
145
+ Register it with the client, then log in from a terminal as usual -- there is no
146
+ login tool, because this server never handles a password.
147
+
148
+ ```json
149
+ {"mcpServers": {"a4i": {"command": "a4i", "args": ["mcp"]}}}
150
+ ```
151
+
152
+ | Tool | What it does |
153
+ | --- | --- |
154
+ | `search` | find a class by what it is called |
155
+ | `describe` | one class from the bundled model, as a JSON record |
156
+ | `list` | class names by prefix, or the DNs one level under a DN |
157
+ | `get` | a class or MO query, with every query option under its own name |
158
+ | `dry_run` | what a POST would change, sending nothing |
159
+ | `post` | POST a body |
160
+ | `merge` | several bodies or paths folded into one |
161
+ | `diff` | the fabric compared against one configuration |
162
+
163
+ | Resource | Contents |
164
+ | --- | --- |
165
+ | `a4i://guide/post-body` | how an ACI body nests, how a child MO gets its DN, what `status` does |
166
+ | `a4i://guide/query` | class against MO queries, the two subtree controls, keeping a response small |
167
+ | `a4i://guide/workflow` | the order: search or list, describe, get, dry run, post |
168
+ | `a4i://guide/limits` | where the bundled model, the dry run and the diff each stop short |
169
+
170
+ A `get` whose response would exceed 64 KB is refused, with the total count and
171
+ the ways to narrow it; `A4I_MCP_MAX_BYTES` raises or lowers that. On a
172
+ `--read-only` session, `post` is not offered at all.
173
+
174
+ ## Python library
175
+
176
+ A `Client` holds its own session, so no daemon is involved: it logs in itself
177
+ and keeps the token in memory for as long as it lives.
178
+
179
+ ```python
180
+ import a4i
181
+ from a4i.merge import merge
182
+
183
+ with a4i.Client("apic1.example.com", verify=False) as client:
184
+ client.login("admin", password)
185
+
186
+ data = client.get("fvTenant", kind="class", query_target="subtree", rsp_subtree="full")
187
+ client.post("uni/tn-demo", {"fvTenant": {"attributes": {"name": "demo"}}}, kind="mo")
188
+ changes = client.diff(merge(base, override))
189
+ ```
190
+
191
+ `kind` is the subcommand the CLI takes, and it is required: `"class"` for a
192
+ class name, `"mo"` for a DN. Every other keyword argument is the CLI option of
193
+ the same name with underscores. `verify` is what `-k/--insecure` and `--ca`
194
+ express, `timeout` is `login --timeout`, and `dry_run()`, `diff()` and
195
+ `a4i.merge.merge()` are the commands of those names.
196
+
197
+ `AsyncClient` is `Client` awaited: the same arguments, the same return values
198
+ and the same exceptions, sending the same requests in the same order.
199
+
200
+ ```python
201
+ async with a4i.AsyncClient("apic1.example.com", verify=False) as client:
202
+ await client.login("admin", password)
203
+ data = await client.get("fvTenant", kind="class")
204
+ ```
205
+
206
+ A value ACI does not define raises `ValueError` before anything is sent. A
207
+ failed request raises `a4i.ApicError`, `a4i.NotLoggedInError` or
208
+ `a4i.SessionExpiredError`, all of them `a4i.A4iError`. The token refreshes
209
+ itself once half its lifetime has elapsed, so a long-running script needs
210
+ nothing of its own.
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "a4i"
3
+ dynamic = ["version"]
4
+ description = "CLI/MCP/Python Library for the Cisco ACI REST API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "minefuto", email = "46558834+minefuto@users.noreply.github.com" },
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "httpx2>=2.9.1",
12
+ "rich>=15.0.0",
13
+ ]
14
+ license = { file = "LICENSE" }
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+
23
+ [project.scripts]
24
+ a4i = "a4i.cli:main"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "pytest-asyncio>=1.2.0",
30
+ "ruff>=0.16.1",
31
+ "ty>=0.0.65",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling>=1.31.0", "hatch-vcs>=0.5.0"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.version]
39
+ source = "vcs"
40
+ fallback-version = "0.0.1"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/a4i"]
44
+
45
+ [tool.ruff]
46
+ src = ["src", "tests"]
47
+ line-length = 100
48
+ # 3.11 is the floor: enum.StrEnum, which the query options are built on, landed
49
+ # there.
50
+ target-version = "py311"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B", "SIM", "N", "W"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ pythonpath = ["src"]
58
+ # The awaited tests are told apart by "async def" and nothing else, so they are
59
+ # written the way the synchronous ones are, one marker lighter.
60
+ asyncio_mode = "auto"