Client contract (rewrite pack) — python-neutronclient — branch stable/2026.2
Branch
stable/2026.2@596a9d99b188(14.0.0+ 2 maintenance commits) — repository https://opendev.org/openstack/python-neutronclient — analysed on 2026-11-01
This pack states what a Neutron replacement must do so that these keep working unmodified:
- the 34 commands of the plugin;
- the projects that import
neutronclient.v2_0.client.Client.
No replay against a real Neutron was possible. The client ran against a local stub. See § 6 for what was proved and what was not.
1. Contents of _rewrite/
| File | Content | Use |
|---|---|---|
[cli-commands_stable-2026.2.json](_rewrite/cli-commands_stable-2026.2.json) | the 34 commands: group, class, purpose, the 94 arguments (names, type, default, choices, required, exclusive groups, help), client calls with file:line, error sites | generate a parser; check argument names |
[client-contract_stable-2026.2.json](_rewrite/client-contract_stable-2026.2.json) | per command: stack, client methods, observed requests, static requests, sample bodies, vectors, exit codes per stub mode | requirements per command |
[api-surface_stable-2026.2.md](_rewrite/api-surface_stable-2026.2.md) | part 1: the 48 routes seen from commands; part 2: the 372 methods of Client, with path, definitions match and callers | gap analysis of a Neutron service |
[python-api_stable-2026.2.json](_rewrite/python-api_stable-2026.2.json) | the same 372 methods with signatures, plus the path constants, EXTED_PLURALS, and the consumers' commits and imports | Python API reference |
[unit-vectors_stable-2026.2.json](_rewrite/unit-vectors_stable-2026.2.json) | 90 vectors: argv → ordered client calls with arguments, UUIDs normalised | test argument mapping without Python |
[wire-probe-404_stable-2026.2.json](_rewrite/wire-probe-404_stable-2026.2.json) | each command against a stub answering 404 and NeutronError | requests up to the first error, then lookups |
[wire-probe-200_stable-2026.2.json](_rewrite/wire-probe-200_stable-2026.2.json) | each command against a stub answering 200 {} | reaction to an empty object |
[wire-probe-rich_stable-2026.2.json](_rewrite/wire-probe-rich_stable-2026.2.json) | each command against a stub with canned resources, stdout included | last routes of each command |
[wire-probe-rich-all-options_stable-2026.2.json](_rewrite/wire-probe-rich-all-options_stable-2026.2.json) | same, with every optional argument | request bodies |
[defect-repros_stable-2026.2.json](_rewrite/defect-repros_stable-2026.2.json) | 96 runs of the real CLI and class, with chosen responses | evidence for 02 and § 2 |
| Script | Role |
|---|---|
[extract_plugin_stable-2026.2.py](_rewrite/extract_plugin_stable-2026.2.py) | imports the entry points, dumps parsers, walks the code with ast (unchanged copy) |
[probe_plugin_stable-2026.2.py](_rewrite/probe_plugin_stable-2026.2.py) | local stub and real shell. Adapted: Neutron error body, mode rich, --all-options, headers recorded, /v2.0 kept in routes |
[capture_plugin_stable-2026.2.py](_rewrite/capture_plugin_stable-2026.2.py) | unit tests → vectors (unchanged copy) |
[repro_defects_stable-2026.2.py](_rewrite/repro_defects_stable-2026.2.py) | subprocess of the real shell for each chosen response |
[sweep_client_stable-2026.2.py](_rewrite/sweep_client_stable-2026.2.py) | calls each public method with a recorder, sending nothing; searches consumers by name |
[build_contract_stable-2026.2.py](_rewrite/build_contract_stable-2026.2.py) | merges everything |
Versions: python-neutronclient 14.0.1.dev2 (the venv's copy is identical to the clone), python-openstackclient 10.3.1, osc-lib 4.7.0, cliff 4.16.0, openstacksdk 4.20.0, keystoneauth1 5.17.0, python-keystoneclient 6.0.0, Python 3.13.
To regenerate, with HOME set to an empty directory and no OS_* variable:
python -I -B _rewrite/extract_plugin_stable-2026.2.py <repo> python-neutronclient neutronclient _rewrite
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-404> --mode 404
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-200> --mode 200
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-rich> --mode rich
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-all> --mode rich --all-options
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-1>/c.json <repo>/neutronclient/tests/unit <repo>
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-2>/c.json <repo>/neutronclient/tests/unit <repo>
python -I -B _rewrite/sweep_client_stable-2026.2.py <repo> <dir-of-clones> _rewrite/python-api_stable-2026.2.json
python -I -B _rewrite/repro_defects_stable-2026.2.py _rewrite <repro.json>
python -I -B _rewrite/build_contract_stable-2026.2.py . <gen-dir> <dir-404>/wire-probe_stable-2026.2.json <dir-200>/wire-probe_stable-2026.2.json <dir-rich>/wire-probe_stable-2026.2.json <dir-all>/wire-probe_stable-2026.2.json <run-1>/c.json <run-2>/c.json <repro.json>
2. What a Neutron service must do
| # | Requirement | Why | Source |
|---|---|---|---|
| 1 | The catalog URL of type network must not end in /v2.0 | the class appends it, giving /v2.0/v2.0/…; the SDK copes with both | [observed catalog-v2-log], neutronclient/v2_0/client.py:260 |
| 2 | All routes live under /v2.0 with these exact spellings: /log/logs, /log/loggable-resources, /subnetpools/{id}/onboard_network_subnets, /sfc/flow_classifiers, /sfc/port_pairs, /sfc/port_pair_groups, /sfc/port_chains, /sfc/service_graphs | hard-coded; hyphen in one, underscores in the others | neutronclient/v2_0/client.py:530-539, neutronclient/v2_0/client.py:679-681, openstacksdk:openstack/network/v2/sfc_port_pair_group.py:19 |
| 3 | Responses use 200, 201, 202 or 204 for success | others take the error path | neutronclient/v2_0/client.py:291-294 |
| 4 | An error is {"NeutronError": {"type": T, "message": M, "detail": D}}, with all three keys | a missing detail displays a Python dict and loses the type | [observed error-no-detail], neutronclient/v2_0/client.py:64-72 |
| 5 | type keeps the original names of Neutron exceptions, at least the 14 in 02 § 2 | <type>Client decides the class caught by Nova, Heat, Manila, Zun | neutronclient/v2_0/client.py:70, § 4 |
| 6 | message is present at the top of the error object | the SDK takes it from any top-level object | openstacksdk:openstack/exceptions.py:197 |
| 7 | Error bodies are JSON objects, never lists | [observed error-json-list] | neutronclient/v2_0/client.py:56 |
| 8 | Lists always hold the collection key, also when empty; they are never {} | otherwise tuple indices… or 'logs' | [observed log-list-empty-object, sdk-list-no-key], neutronclient/v2_0/client.py:376 |
| 9 | Single resources are wrapped in the singular key; POST and PUT return them | ['log'], and resource_key of the SDK | [observed log-show-no-object], neutronclient/osc/v2/logging/network_log.py:152 |
| 10 | GET /x?id=<uuid> filters by ID, and GET /x?name=<text> by exact name | the class's lookup; a filter ignored by the server gives Multiple … matches | [observed log-show-two-names], neutronclient/v2_0/client.py:430, neutronclient/v2_0/client.py:452 |
| 11 | GET /x/{id} answers 404 for a name, never 400 | the SDK then tries ?name= | [observed ppg-show-missing-control], openstacksdk:openstack/resource.py:2492 |
| 12 | fields=id is honoured or harmlessly ignored, keeping id | find_resourceid_by_name_or_id reads ['id'] | neutronclient/neutron/v2_0/__init__.py:53-58 |
| 13 | A log always holds event, target_id, resource_type and resource_id, with event not null | § 4 of the defects in 02 | [observed log-list-event-none-first, log-list-no-target-key] |
| 14 | A flow classifier always holds l7_parameters as an object; a port chain holds flow_classifiers and port_pair_groups as lists, and a port pair group holds port_pairs | client-side join and list operations | [observed fc-list-no-l7], neutronclient/osc/v2/sfc/sfc_port_chain.py:216 |
| 15 | PUT {"log": {}} is accepted with no change | network log set x sends it | [observed log-set-no-option] |
| 16 | String values are coerced: "symmetric": "true", "weight": "2" | the client does not convert them | [observed pc-create-params, pp-create-params], neutronclient/osc/v2/sfc/sfc_port_chain.py:380 |
| 17 | Service graphs: a real cycle check, since the client's misses longer cycles | [observed graph-cycle-3-missed] | neutronclient/osc/v2/sfc/sfc_service_graph.py:233 |
| 18 | <collection>_links with rel = next, and href whose query alone is reused | pagination; the host and path of the link are ignored by the class | [observed log-list-pagination], neutronclient/v2_0/client.py:396 |
| 19 | GET /quotas/tenant and GET /quotas/{id}/details.json | hard-coded in the class; Neutron keeps tenant as a special case for this client | neutronclient/v2_0/client.py:770, neutronclient/v2_0/client.py:514, neutron:neutron/pecan_wsgi/controllers/quota.py:137 |
| 20 | If-Match: revision_number=N is honoured on updates | methods given revision_number | neutronclient/v2_0/client.py:2546 |
| 21 | A 401 for an invalid token; a new token then works | keystoneauth retries once | [observed error-401-once], keystoneauth1:keystoneauth1/session.py:1130 |
| 22 | X-OpenStack-Request-ID is returned | stored in request_ids, shown in errors | neutronclient/v2_0/client.py:121, neutronclient/common/exceptions.py:80 |
Which requests a command sends
Read observed_requests in the contract first, then static_requests. The {} mode is excluded from observed_requests: there the SDK uses the name as ID, which gives routes such as DELETE /sfc/port_chains/x that a real service would not see.
3. Coverage of OpenKeel
Read in openkeel-neutron:internal/api/api.go:72-77, not run:
log/logsandlog/loggable_resourcesare stubs: list empty, other calls 501. Sonetwork log listwould print an empty table, andnetwork log createwould fail.- No SFC route was found by search, and neither was
onboard_network_subnets, so the 27sfccommands andnetwork onboard subnetswould get what an unknown route answers [inferred]. - The OSC spec's lab run gave 404 for extension commands (OSC 07 § 3). This plugin was not run on the lab.
Which matters for OpenKeel: the Python class matters more than these 34 commands, because Nova and Heat depend on it unless replaced too. Requirements 4, 5, 8 and 10 to 12 carry the weight.
4. Consumers of the library
Name search in non-test Python code, at these commits. A method is counted when a call with its name exists, so false positives are possible [inferred], and no consumer was run.
| Project | Commit | Branch | Methods by name | Imports | Key usage |
|---|---|---|---|---|---|
| Heat | 5872396e548c | stable/2026.2 | 112 | Client, exceptions, neutronclient.neutron.v2_0 | heat:heat/engine/clients/os/neutron/__init__.py:15, heat:heat/engine/clients/os/neutron/__init__.py:113; is_not_found, is_conflict, is_no_unique read the classes (heat:heat/engine/clients/os/neutron/__init__.py:71-90) |
| ec2-api | 32740781f605 | pre-retirement | 46 | Client, exceptions | ec2-api:ec2api/clients.py:77 |
| Nova | 8df1922926dd | stable/2026.2 | 30 | Client, exceptions, neutronclient.neutron.v2_0 | nova:nova/network/neutron.py:30; Unauthorized translation (nova:nova/network/neutron.py:189); security groups by name (nova:nova/network/security_group_api.py:67) |
| Manila | 4fbc4b4f3688 | stable/2026.2 | 28 | Client, exceptions | manila:manila/network/neutron/api.py:20; adds its own exception class (manila:manila/network/neutron/api.py:80) |
| Zun | 34135a563699 | stable/2026.2 | 17 | Client, exceptions, neutronclient.neutron.v2_0 | zun:zun/common/clients.py:93, zun:zun/network/neutron.py:271 |
| Skyline | 4af72bfd4577 | stable/2026.2 | 3 | Client, private _GeneratorWithMeta | skyline-apiserver:skyline_apiserver/client/utils.py:195, skyline-apiserver:skyline_apiserver/client/openstack/neutron.py:23 |
| heat-tempest-plugin | ceb78843237c | master | 1 | Client | heat-tempest-plugin:heat_tempest_plugin/services/clients.py:152 |
Not consumers at these commits: python-openstackclient (in doc/requirements.txt only, openstackclient:doc/requirements.txt:22, plus IGNORED_MODULES), and Horizon and Octavia, which only mention it in comments (horizon:openstack_dashboard/api/neutron.py:1032, octavia:octavia/common/keystone.py:63). openstacksdk keeps a comment about the hard-coded /v2.0 (openstacksdk:openstack/config/cloud_region.py:1210). In Neutron it is a test dependency only (neutron:test-requirements.txt:11). requirements:upper-constraints.txt:370 pins 14.0.0.
Exceptions caught by Nova, by count: NeutronClientException 18, PortNotFoundClient 4, NetworkNotFoundClient 4, Unauthorized 3, Conflict 3 and others. These names and their inheritance are a Python API that a drop-in replacement of this package must keep.
The Python surface, by path family
Counts come from the surface. "Matched" is a text search in neutron-lib definitions and neutron extensions, so it is a hint: a path can match and not be served.
| Family | Methods | With a caller by name | Notes |
|---|---|---|---|
/networks, /subnets, /ports, /subnetpools, /routers, /floatingips, /security-groups, /security-group-rules | 58 | 48 | core; what the consumers really need |
/qos, /trunks, /segments, /rbac-policies, /address-scopes, /quotas, /extensions, /agents, /tags, /flavors, /service_profiles, /metering, and others | 109 | 45 | extensions; 23 have no match |
/lbaas, /lb | 63 | 1 | LBaaS; 47 have no match; show_loadbalancer matches Heat by name only |
/fw, /fwaas, /vpn, /bgpvpn, /bgp-speakers, /bgp-peers | 95 | 24 | the matching OSC commands now use the SDK; callers are by name; /fwaas has no caller, apart from the lookup in network log create |
/network-gateways, /gateway-devices, /qos-queues | 16 | 5 | no match in definitions; callers are Heat, by name |
/sfc, /log | 31 | 0 | the commands use /log, by Client; /sfc methods are not on any command's path |
5. For a rewrite of the commands
| Topic | Rule | Source |
|---|---|---|
| Names | sfc_port_chain_create → sfc port chain create; scripts depend on these and on argument names | setup.cfg:40 |
| Exit codes | 0, 1, 2 as in 02; do not copy its § 4 defects | cliff:cliff/command.py:196 |
| Case | --resource-type lower-cased, --event upper-cased before the check | neutronclient/osc/v2/logging/network_log.py:123 |
| Display | Summary columns in network log list and sfc flow classifier list are built on the client | neutronclient/osc/v2/logging/network_log.py:243, neutronclient/osc/v2/sfc/sfc_flow_classifier.py:209 |
| Column names | Branching Points for port_chains, Tap Enabled for is_tap_enabled | neutronclient/osc/v2/sfc/sfc_service_graph.py:40, neutronclient/osc/v2/sfc/sfc_port_pair_group.py:47 |
Recommendation: keep the plugin as it is, and fix the service side first. The surface is small, but it runs inside OSC, and the class's users are Python services that OpenKeel may replace on its own schedule.
6. Conformance: what was proved
| Evidence | Result | What it proves | What it does not prove |
|---|---|---|---|
| Parser dump | 34 of 34 classes imported, 0 failures | the catalogue is complete for the declared entry points | |
Real shell, command list | exactly the 34 | they load with the 8 plugins present, and none is ignored | another plugin set |
| Unit tests with recorder | 154 of 154 pass, 90 vectors for 34 of 34 classes | argv → client calls | anything about a service: the clients are mocks |
| Comparison of the two runs | 90 of 90 have the same test, call sequence and status | method sequences are deterministic | arguments holding random IDs were not compared |
| Wire probes | 34 of 34 reach a request in each mode; exit 0: 404 mode 0, {} mode 22, rich 34, all options 34; 54, 49, 54 and 79 requests | real routes, headers and bodies | acceptance by a server |
| Chosen responses | 96 runs | every defect and trap of 02 | that a real Neutron ever answers this way |
| Sweep of the class | 372 methods, 0 raised | path templates are well-formed | those routes exist on a server |
Replay against a real service was not done, and the unit vectors were not replayed against a rewrite, since none exists. The usual "replay is mandatory" bar is not met for service behaviour. The mocks hide real defects: items 1 to 7 of 02 § 4 all pass the unit tests.
Unit tests exercise no 3-node cycle and no empty event: neutronclient/tests/unit/osc/v2/sfc/test_service_graph.py:1 has no test named for a cycle, and the fake log event is always set (neutronclient/tests/unit/osc/v2/logging/fakes.py:77).
To close the gap: run probe_plugin_stable-2026.2.py against a devstack with networking-sfc and logging enabled, and compare it with wire-probe-rich_stable-2026.2.json.
7. Gaps found while building the pack
- The extractor gives no REST route for
neutronclient.*calls, because it resolves only SDK methods; the sweep fills that in the contract'sstatic_requests.find_resourcedepends on its argument, so only the observed routes show it. - 64 of the 154 tests record no
check_parsercall, and so give no vector. --project-domainand--l7-parameterswere left out of the all-options run;--l7-parametershas its own run, and--project-domainwas not run.- The stub never served a real
tap_enableddefault,chain_idorgroup_id.
8. Non-functional requirements
- Cost per command: authentication once per process; an SFC update costs up to 7
GETbefore thePUT[observedpc-set-append]. - Client startup: every
Clientscans theneutronclient.extensionentry points (neutronclient/v2_0/client.py:2544,neutronclient/common/extension.py:22). - No timeout is set by the plugin, so a stuck service can block a command [inferred].
9. Checklist for an OpenKeel Neutron
- [ ] The catalog URL of type
networkhas no version suffix. - [ ] Errors hold
NeutronError.type,messageanddetail;typekeeps the original names. - [ ] Lists always hold the collection key; single objects are wrapped.
- [ ]
?id=and?name=filter exactly;GET /x/{name}gives 404. - [ ] Each operation of
api-surface_stable-2026.2.mdpart 1 is implemented, or its absence is documented. - [ ] Each method that Nova, Heat, Manila and Zun call is served, checked against part 2.
- [ ]
/quotas/tenantand/quotas/{id}/details.jsonrespond, if the library stays in use. - [ ] The cycle check of service graphs is done on the server, if SFC is implemented.
- [ ] The probe, pointed at the lab, gives the same routes, and exit 0 with real resources.
Source: reverse-spec-python-neutronclient/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.