netbox-super-cli 1.6.1__tar.gz → 1.6.2__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.
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/CHANGELOG.md +17 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/PKG-INFO +1 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/_version.py +1 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/builder/build.py +102 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cache/store.py +5 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/model/command_model.py +22 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/fk.py +123 -13
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/bulk_edit_form.py +14 -2
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/detail.py +7 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/edit_form.py +14 -2
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/filter.py +9 -2
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/pyproject.toml +1 -1
- netbox_super_cli-1.6.2/tests/builder/test_fk_metadata.py +148 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/test_store.py +12 -1
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_completion_protocol_smoke.py +2 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_dynamic_completion.py +2 -0
- netbox_super_cli-1.6.2/tests/tui/test_fk_resolve.py +305 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/uv.lock +1 -1
- netbox_super_cli-1.6.1/tests/tui/test_fk_resolve.py +0 -144
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/docs.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/dependabot.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/pull_request_template.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/bench.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/claude.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/docs.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/e2e.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/lint.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/release.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/skill-convention-review.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/test.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.gitignore +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.pre-commit-config.yaml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.python-version +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/AGENTS.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/CLAUDE.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/LICENSE +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/README.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/caching.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/command-generation.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/http-client.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/overview.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/schema-loading.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/detail.svg +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/filter.svg +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/list.svg +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/picker.svg +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/search.svg +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/adding-bundled-schemas.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/branching.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/development.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/release-process.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/concepts.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/first-run.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/install.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/audit-log.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/ci-and-automation.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/interactive-tui.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/managing-profiles.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/output-formats.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/using-with-ai-agents.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/working-with-plugins.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/writes-and-safety.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/index.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/cli.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/config.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/exit-codes.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/schemas.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/justfile +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/mkdocs.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/__main__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/aliases/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/aliases/resolver.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/auth/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/auth/verify.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/builder/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cache/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/aliases_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/app.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/cache_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/commands_dump.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/config_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/globals.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/handlers.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/init_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/login_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/profiles_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/registration.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/runtime.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/skill_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/tui_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/apply.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/bulk.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/coercion.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/confirmation.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/input.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/preflight.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/cache_probe.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/callbacks.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/providers.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/loader.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/models.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/saved_searches.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/settings.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/writer.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/audit.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/client.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/retry.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/model/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/_console.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/colors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/csv_.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/explain.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/flatten.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/headers.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/json_.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/jsonl.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/render.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/table.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/yaml_.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/custom_fields.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/objecttypes.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/params.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/store.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/tags.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/hashing.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/loader.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/models.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/source.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/manifest.yaml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/skill/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/_bindings.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/app.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/bulk.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/catalog.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/columns.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/filters.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/forms.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/keymap.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/nav.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/relations.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/columns.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/global_search.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/list.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/picker.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/record_picker.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/saved_search_picker.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/search.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/selection.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/styles.tcss +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/view.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/_modal.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_diff.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_summary.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/confirm.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/diff.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/help.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/nav_tree.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/scripts/gen_docs.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/scripts/sync_agents_md.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/skills/netbox-super-cli/SKILL.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/aliases/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/aliases/test_resolver.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/audit/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/audit/test_redaction_full.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/auth/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/auth/test_verify.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/benchmarks/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/benchmarks/test_startup.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_build.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_default_columns.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_redaction_marking.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/test_prune.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_aliases_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_app_smoke.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_cache_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_commands_dump.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_completion_smoke.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_config_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_explain.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_globals_color.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_handlers.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_init_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_lazy_textual_import.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_login_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_ndjson_input.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_profiles_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_registration.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_respx_integration.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_runtime.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_runtime_color.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_skill_commands.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_stdin_sniffer.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_tui_command.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_writes_respx.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_apply.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_bulk.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_confirmation.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handler_helpers.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handlers_audit.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_input.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_preflight.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_loader.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_models.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_saved_search_fallback.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_saved_searches.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_settings_private_dir.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_atomicity.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_dotted_paths.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_roundtrip.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/conftest.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/README.md +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/conftest.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/docker-compose.yml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_auth_error_envelope.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_full_cycle.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_login.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_ndjson.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_preflight_blocks_apply.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/wait_for_netbox.sh +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/profiles/single_profile.yaml +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/auth_401.json +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/circuits_providers_list.json +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_get.json +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit_permissions.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_client.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_client_redaction_threading.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_retry.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/test_command_model.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_colors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_console.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_csv.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors_aliases.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors_color.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_explain.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_explain_color.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_flatten.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_input_error_envelope.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_json.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_jsonl.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_render_dispatch.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_table.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_table_color.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_yaml.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_custom_fields.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_objecttypes.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_params.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_store.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_tags.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_hashing.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_loader.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_models.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_source.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_source_ttl.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/test_gen_docs.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/test_sync_agents_md.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/skill/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/skill/test_bundle.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/test_packaging.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/__init__.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_app.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bindings.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_apply.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff_modal.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_edit_form.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_import_isolation.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_summary.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_catalog.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_column_chooser.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_columns.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_detail_screen.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_diff.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_diff_modal.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_edit_form.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_errors.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_filter_screen.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_filters.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_forms.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_global_search.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_help_overlay.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_keymap.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_list_screen.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_picker.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_record_picker.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_relations.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_saved_filter_integration.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_search.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_selection.py +0 -0
- {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_view.py +0 -0
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`.
|
|
4
4
|
|
|
5
|
+
## v1.6.2 — 2026-06-30
|
|
6
|
+
|
|
7
|
+
Patch release. Fixes TUI foreign-key pickers resolving to the wrong resource.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Filtering/editing by `role` (and other FKs) opened the wrong picker**
|
|
12
|
+
([#139]). Choosing a `role` value when filtering devices listed IPAM roles
|
|
13
|
+
instead of device roles, because the picker matched the bare field name across
|
|
14
|
+
apps. Foreign-key pickers now resolve to the field's true target read from the
|
|
15
|
+
OpenAPI schema (a device/VM `role` → dcim device-roles, a prefix `role` → ipam
|
|
16
|
+
roles, a contact-assignment `role` → tenancy contact-roles), and fall back to a
|
|
17
|
+
context-qualified name match. This also corrects `group`, `type`, and similar
|
|
18
|
+
cross-app FK fields. Cached schemas rebuild once to pick up the new metadata.
|
|
19
|
+
|
|
20
|
+
[#139]: https://github.com/thomaschristory/netbox-super-cli/issues/139
|
|
21
|
+
|
|
5
22
|
## v1.6.1 — 2026-06-29
|
|
6
23
|
|
|
7
24
|
Patch release. Fixes the TUI bulk-edit form so custom-field edits are visible and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-super-cli
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: Dynamic NetBox CLI generated from the live OpenAPI schema.
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomaschristory/netbox-super-cli
|
|
6
6
|
Project-URL: Documentation, https://thomaschristory.github.io/netbox-super-cli/
|
|
@@ -10,8 +10,10 @@ import re
|
|
|
10
10
|
from typing import Literal
|
|
11
11
|
|
|
12
12
|
from nsc.model.command_model import (
|
|
13
|
+
MODEL_FORMAT_VERSION,
|
|
13
14
|
CommandModel,
|
|
14
15
|
FieldShape,
|
|
16
|
+
FkResourceRef,
|
|
15
17
|
HttpMethod,
|
|
16
18
|
Operation,
|
|
17
19
|
Parameter,
|
|
@@ -77,6 +79,8 @@ def build_command_model(loaded: LoadedSchema) -> CommandModel:
|
|
|
77
79
|
info_version=doc.info.version,
|
|
78
80
|
schema_hash=loaded.hash,
|
|
79
81
|
tags=final_tags,
|
|
82
|
+
fk_resources=_build_fk_resources(doc),
|
|
83
|
+
format_version=MODEL_FORMAT_VERSION,
|
|
80
84
|
)
|
|
81
85
|
|
|
82
86
|
|
|
@@ -346,10 +350,107 @@ def _flat_fields(schema: SchemaObject, doc: OpenAPIDocument) -> dict[str, FieldS
|
|
|
346
350
|
resolved = _resolve_ref(prop, doc) or prop
|
|
347
351
|
primitive = _primitive(resolved)
|
|
348
352
|
enum = [str(v) for v in resolved.enum] if resolved.enum else None
|
|
349
|
-
out[name] = FieldShape(
|
|
353
|
+
out[name] = FieldShape(
|
|
354
|
+
primitive=primitive,
|
|
355
|
+
enum=enum,
|
|
356
|
+
nullable=resolved.nullable,
|
|
357
|
+
fk_target=_fk_target_component(prop),
|
|
358
|
+
)
|
|
350
359
|
return out
|
|
351
360
|
|
|
352
361
|
|
|
362
|
+
def _ref_component_name(ref: str | None) -> str | None:
|
|
363
|
+
prefix = "#/components/schemas/"
|
|
364
|
+
if isinstance(ref, str) and ref.startswith(prefix):
|
|
365
|
+
return ref[len(prefix) :]
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _normalize_serializer(name: str) -> str:
|
|
370
|
+
"""Reduce a serializer name to a key shared by a field's FK brief and the
|
|
371
|
+
target resource's list serializer: ``BriefDeviceRoleRequest`` → ``DeviceRole``
|
|
372
|
+
and ``DeviceWithConfigContext`` → ``Device`` both land on the same base."""
|
|
373
|
+
return name.removeprefix("Brief").removesuffix("Request").removesuffix("WithConfigContext")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _branch_ref(raw: dict[str, object]) -> str | None:
|
|
377
|
+
"""The `$ref` of a oneOf/anyOf branch, unwrapping a nullable `allOf` wrapper."""
|
|
378
|
+
ref = raw.get("$ref")
|
|
379
|
+
if isinstance(ref, str):
|
|
380
|
+
return ref
|
|
381
|
+
inner = raw.get("allOf")
|
|
382
|
+
if isinstance(inner, list):
|
|
383
|
+
for sub in inner:
|
|
384
|
+
if isinstance(sub, dict) and isinstance(sub.get("$ref"), str):
|
|
385
|
+
return str(sub["$ref"])
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _fk_target_component(prop: SchemaObject) -> str | None:
|
|
390
|
+
"""Target serializer name for a writable FK (`oneOf[integer, Brief<X>Request]`).
|
|
391
|
+
|
|
392
|
+
A `oneOf`/`anyOf` mixing an integer branch with a brief-ref branch is NetBox's
|
|
393
|
+
way of saying "an id or an object" — i.e. a foreign key. Returns `<X>`.
|
|
394
|
+
"""
|
|
395
|
+
extras = prop.model_extra or {}
|
|
396
|
+
branches = extras.get("oneOf") or extras.get("anyOf") or []
|
|
397
|
+
if not isinstance(branches, list):
|
|
398
|
+
return None
|
|
399
|
+
has_integer = False
|
|
400
|
+
ref_name: str | None = None
|
|
401
|
+
for raw in branches:
|
|
402
|
+
if not isinstance(raw, dict):
|
|
403
|
+
continue
|
|
404
|
+
if raw.get("type") == "integer":
|
|
405
|
+
has_integer = True
|
|
406
|
+
name = _ref_component_name(_branch_ref(raw))
|
|
407
|
+
if name is not None:
|
|
408
|
+
ref_name = name
|
|
409
|
+
if has_integer and ref_name is not None:
|
|
410
|
+
return _normalize_serializer(ref_name)
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _list_serializer_name(get_op: SchemaOperation, doc: OpenAPIDocument) -> str | None:
|
|
415
|
+
"""The item serializer name a collection GET returns (Paginated<X>List → <X>)."""
|
|
416
|
+
response = get_op.responses.get("200")
|
|
417
|
+
if response is None:
|
|
418
|
+
return None
|
|
419
|
+
media = response.content.get("application/json")
|
|
420
|
+
if media is None or media.schema_ is None:
|
|
421
|
+
return None
|
|
422
|
+
paginated_name = _ref_component_name(media.schema_.ref)
|
|
423
|
+
if paginated_name is None:
|
|
424
|
+
return None
|
|
425
|
+
paginated = doc.components.schemas.get(paginated_name)
|
|
426
|
+
if paginated is None or not paginated.properties:
|
|
427
|
+
return None
|
|
428
|
+
results = paginated.properties.get("results")
|
|
429
|
+
if results is None or results.items is None:
|
|
430
|
+
return None
|
|
431
|
+
return _ref_component_name(results.items.ref)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _build_fk_resources(doc: OpenAPIDocument) -> dict[str, FkResourceRef]:
|
|
435
|
+
"""Index each list endpoint's item serializer to its owning resource."""
|
|
436
|
+
index: dict[str, FkResourceRef] = {}
|
|
437
|
+
for path in sorted(doc.paths):
|
|
438
|
+
get_op = doc.paths[path].get
|
|
439
|
+
if get_op is None or not get_op.tags:
|
|
440
|
+
continue
|
|
441
|
+
tag_name = get_op.tags[0]
|
|
442
|
+
resource_name, is_collection = _resource_from_path(path, tag_name)
|
|
443
|
+
if resource_name is None or not is_collection:
|
|
444
|
+
continue
|
|
445
|
+
serializer = _list_serializer_name(get_op, doc)
|
|
446
|
+
if serializer is None:
|
|
447
|
+
continue
|
|
448
|
+
index.setdefault(
|
|
449
|
+
_normalize_serializer(serializer), FkResourceRef(tag=tag_name, resource=resource_name)
|
|
450
|
+
)
|
|
451
|
+
return index
|
|
452
|
+
|
|
453
|
+
|
|
353
454
|
def _primitive(schema: SchemaObject) -> PrimitiveType:
|
|
354
455
|
if schema.type is None:
|
|
355
456
|
return PrimitiveType.UNKNOWN
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
from nsc.config.models import Config, Profile
|
|
17
|
-
from nsc.model.command_model import CommandModel
|
|
17
|
+
from nsc.model.command_model import MODEL_FORMAT_VERSION, CommandModel
|
|
18
18
|
|
|
19
19
|
_LOG = logging.getLogger(__name__)
|
|
20
20
|
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
@@ -76,6 +76,10 @@ class CacheStore:
|
|
|
76
76
|
if model.schema_hash != schema_hash:
|
|
77
77
|
_LOG.warning("cache: hash mismatch for %s (file says %s)", target, model.schema_hash)
|
|
78
78
|
return None
|
|
79
|
+
if model.format_version != MODEL_FORMAT_VERSION:
|
|
80
|
+
# Built by an older nsc whose model lacks current metadata; rebuild.
|
|
81
|
+
_LOG.info("cache: stale model format for %s; rebuilding", target)
|
|
82
|
+
return None
|
|
79
83
|
return model
|
|
80
84
|
|
|
81
85
|
def save(self, profile: str, model: CommandModel) -> Path:
|
|
@@ -43,6 +43,12 @@ class PrimitiveType(StrEnum):
|
|
|
43
43
|
UNKNOWN = "unknown"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
# Bumped whenever the builder changes the shape of a CommandModel in a way that
|
|
47
|
+
# makes older cached models incomplete (not just a schema-body change). The cache
|
|
48
|
+
# rebuilds entries stamped with an older version. See nsc/cache/store.py.
|
|
49
|
+
MODEL_FORMAT_VERSION = 1
|
|
50
|
+
|
|
51
|
+
|
|
46
52
|
class _Frozen(BaseModel):
|
|
47
53
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
48
54
|
|
|
@@ -60,6 +66,10 @@ class FieldShape(_Frozen):
|
|
|
60
66
|
primitive: PrimitiveType = PrimitiveType.UNKNOWN
|
|
61
67
|
enum: list[str] | None = None
|
|
62
68
|
nullable: bool = False
|
|
69
|
+
# For a writable foreign key (`oneOf[integer, Brief<X>Request]`), the target
|
|
70
|
+
# serializer name `<X>` (e.g. "DeviceRole"); None for non-FK fields. Pairs
|
|
71
|
+
# with CommandModel.fk_resources to find the target resource.
|
|
72
|
+
fk_target: str | None = None
|
|
63
73
|
|
|
64
74
|
|
|
65
75
|
class RequestBodyShape(_Frozen):
|
|
@@ -97,6 +107,13 @@ class Tag(_Frozen):
|
|
|
97
107
|
resources: dict[str, Resource] = Field(default_factory=dict)
|
|
98
108
|
|
|
99
109
|
|
|
110
|
+
class FkResourceRef(_Frozen):
|
|
111
|
+
"""Where a foreign-key serializer lives: which `tag` and `resource` serve it."""
|
|
112
|
+
|
|
113
|
+
tag: str
|
|
114
|
+
resource: str
|
|
115
|
+
|
|
116
|
+
|
|
100
117
|
class CommandModel(_Frozen):
|
|
101
118
|
"""The full normalized tree: tags → resources → operations."""
|
|
102
119
|
|
|
@@ -104,6 +121,11 @@ class CommandModel(_Frozen):
|
|
|
104
121
|
info_version: str
|
|
105
122
|
schema_hash: str
|
|
106
123
|
tags: dict[str, Tag] = Field(default_factory=dict)
|
|
124
|
+
# serializer name (e.g. "DeviceRole") → the resource whose list endpoint
|
|
125
|
+
# serves it. Lets the TUI resolve an FK picker to its true target.
|
|
126
|
+
fk_resources: dict[str, FkResourceRef] = Field(default_factory=dict)
|
|
127
|
+
# Defaults to 0 so models written before versioning read as stale and rebuild.
|
|
128
|
+
format_version: int = 0
|
|
107
129
|
|
|
108
130
|
def iter_operations(self) -> Iterator[tuple[str, str, Operation]]:
|
|
109
131
|
"""Yield `(tag, resource, operation)` triples in deterministic order."""
|
|
@@ -20,7 +20,7 @@ from urllib.parse import urlsplit
|
|
|
20
20
|
|
|
21
21
|
from pydantic import BaseModel, ConfigDict
|
|
22
22
|
|
|
23
|
-
from nsc.model.command_model import CommandModel, Operation
|
|
23
|
+
from nsc.model.command_model import CommandModel, Operation, Resource
|
|
24
24
|
from nsc.tui.relations import singularize
|
|
25
25
|
|
|
26
26
|
FkKind = Literal["picker", "raw_id"]
|
|
@@ -51,12 +51,27 @@ class FkTarget(BaseModel):
|
|
|
51
51
|
hint: str | None = None
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def resolve_fk_target(
|
|
54
|
+
def resolve_fk_target(
|
|
55
|
+
field_name: str,
|
|
56
|
+
current_value: Any,
|
|
57
|
+
model: CommandModel,
|
|
58
|
+
*,
|
|
59
|
+
context_tag: str | None = None,
|
|
60
|
+
context_resource: str | None = None,
|
|
61
|
+
) -> FkTarget:
|
|
55
62
|
from_url = _resolve_from_url(field_name, current_value, model)
|
|
56
63
|
if from_url is not None:
|
|
57
64
|
return from_url
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
from_schema = _resolve_from_schema_fk(
|
|
67
|
+
field_name, model, context_tag=context_tag, context_resource=context_resource
|
|
68
|
+
)
|
|
69
|
+
if from_schema is not None:
|
|
70
|
+
return from_schema
|
|
71
|
+
|
|
72
|
+
by_name = _resolve_by_field_name(
|
|
73
|
+
field_name, model, context_tag=context_tag, context_resource=context_resource
|
|
74
|
+
)
|
|
60
75
|
if by_name is not None:
|
|
61
76
|
return by_name
|
|
62
77
|
|
|
@@ -67,6 +82,67 @@ def resolve_fk_target(field_name: str, current_value: Any, model: CommandModel)
|
|
|
67
82
|
)
|
|
68
83
|
|
|
69
84
|
|
|
85
|
+
def _resolve_from_schema_fk(
|
|
86
|
+
field_name: str,
|
|
87
|
+
model: CommandModel,
|
|
88
|
+
*,
|
|
89
|
+
context_tag: str | None,
|
|
90
|
+
context_resource: str | None,
|
|
91
|
+
) -> FkTarget | None:
|
|
92
|
+
"""Resolve via the schema-declared FK target of the owning resource's field.
|
|
93
|
+
|
|
94
|
+
The owning resource's create/update body types the field as a FK to a named
|
|
95
|
+
serializer (`fk_target`); `model.fk_resources` maps that serializer to the
|
|
96
|
+
resource that serves it. This resolves even when the field and resource names
|
|
97
|
+
diverge (a virtual-machine's `role` targets dcim `device-roles`).
|
|
98
|
+
"""
|
|
99
|
+
if context_resource is None:
|
|
100
|
+
return None
|
|
101
|
+
resource = _find_resource(model, context_resource, prefer_tag=context_tag)
|
|
102
|
+
if resource is None:
|
|
103
|
+
return None
|
|
104
|
+
base = field_name[:-3] if field_name.endswith("_id") else field_name
|
|
105
|
+
component = _field_fk_target(resource, base)
|
|
106
|
+
if component is None:
|
|
107
|
+
return None
|
|
108
|
+
ref = model.fk_resources.get(component)
|
|
109
|
+
if ref is None:
|
|
110
|
+
return None
|
|
111
|
+
tag = model.tags.get(ref.tag)
|
|
112
|
+
target = tag.resources.get(ref.resource) if tag is not None else None
|
|
113
|
+
if target is None or target.list_op is None:
|
|
114
|
+
return None
|
|
115
|
+
return FkTarget(
|
|
116
|
+
kind="picker",
|
|
117
|
+
field_name=field_name,
|
|
118
|
+
tag=ref.tag,
|
|
119
|
+
resource_name=ref.resource,
|
|
120
|
+
list_op=target.list_op,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _field_fk_target(resource: Resource, base: str) -> str | None:
|
|
125
|
+
for op in (resource.create_op, resource.update_op, resource.replace_op):
|
|
126
|
+
if op is None or op.request_body is None:
|
|
127
|
+
continue
|
|
128
|
+
shape = op.request_body.fields.get(base)
|
|
129
|
+
if shape is not None and shape.fk_target is not None:
|
|
130
|
+
return shape.fk_target
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _find_resource(model: CommandModel, name: str, *, prefer_tag: str | None) -> Resource | None:
|
|
135
|
+
if prefer_tag is not None and prefer_tag in model.tags:
|
|
136
|
+
found = model.tags[prefer_tag].resources.get(name)
|
|
137
|
+
if found is not None:
|
|
138
|
+
return found
|
|
139
|
+
for tag_name in sorted(model.tags):
|
|
140
|
+
found = model.tags[tag_name].resources.get(name)
|
|
141
|
+
if found is not None:
|
|
142
|
+
return found
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
70
146
|
def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel) -> FkTarget | None:
|
|
71
147
|
if not isinstance(current_value, dict):
|
|
72
148
|
return None
|
|
@@ -110,27 +186,61 @@ def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel)
|
|
|
110
186
|
)
|
|
111
187
|
|
|
112
188
|
|
|
113
|
-
def _resolve_by_field_name(
|
|
189
|
+
def _resolve_by_field_name(
|
|
190
|
+
field_name: str,
|
|
191
|
+
model: CommandModel,
|
|
192
|
+
*,
|
|
193
|
+
context_tag: str | None = None,
|
|
194
|
+
context_resource: str | None = None,
|
|
195
|
+
) -> FkTarget | None:
|
|
114
196
|
base = field_name[:-3] if field_name.endswith("_id") else field_name
|
|
115
197
|
wanted = singularize(base)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
198
|
+
|
|
199
|
+
# NetBox reuses bare FK names across apps: a device's `role` targets dcim
|
|
200
|
+
# `device-roles`, an ipam prefix's `role` targets ipam `roles`. The bare name
|
|
201
|
+
# can't disambiguate (`device-roles` does not singularize to `role`), so when
|
|
202
|
+
# the owning resource is known we first try the qualified target name
|
|
203
|
+
# `<owner>-<field>` (e.g. `device-role`), preferring the owner's own tag,
|
|
204
|
+
# before falling back to the bare name's global scan.
|
|
205
|
+
candidates: list[str] = []
|
|
206
|
+
if context_resource is not None:
|
|
207
|
+
candidates.append(f"{singularize(context_resource)}-{wanted}")
|
|
208
|
+
candidates.append(wanted)
|
|
209
|
+
|
|
210
|
+
for candidate in candidates:
|
|
211
|
+
located = _match_resource_by_singular(model, candidate, prefer_tag=context_tag)
|
|
212
|
+
if located is not None:
|
|
213
|
+
tag_name, resource_name, list_op = located
|
|
124
214
|
return FkTarget(
|
|
125
215
|
kind="picker",
|
|
126
216
|
field_name=field_name,
|
|
127
217
|
tag=tag_name,
|
|
128
218
|
resource_name=resource_name,
|
|
129
|
-
list_op=
|
|
219
|
+
list_op=list_op,
|
|
130
220
|
)
|
|
131
221
|
return None
|
|
132
222
|
|
|
133
223
|
|
|
224
|
+
def _match_resource_by_singular(
|
|
225
|
+
model: CommandModel, wanted: str, *, prefer_tag: str | None
|
|
226
|
+
) -> tuple[str, str, Operation] | None:
|
|
227
|
+
for tag_name in _tag_search_order(model, prefer_tag):
|
|
228
|
+
tag = model.tags[tag_name]
|
|
229
|
+
for resource_name in sorted(tag.resources):
|
|
230
|
+
resource = tag.resources[resource_name]
|
|
231
|
+
if singularize(resource_name) != wanted or resource.list_op is None:
|
|
232
|
+
continue
|
|
233
|
+
return (tag_name, resource_name, resource.list_op)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _tag_search_order(model: CommandModel, prefer_tag: str | None) -> list[str]:
|
|
238
|
+
rest = sorted(model.tags)
|
|
239
|
+
if prefer_tag is not None and prefer_tag in model.tags:
|
|
240
|
+
return [prefer_tag, *(t for t in rest if t != prefer_tag)]
|
|
241
|
+
return rest
|
|
242
|
+
|
|
243
|
+
|
|
134
244
|
def _locate_resource(
|
|
135
245
|
resource_name: str, model: CommandModel
|
|
136
246
|
) -> tuple[str, Operation | None] | None:
|
|
@@ -183,7 +183,13 @@ class BulkEditForm(Screen[None]):
|
|
|
183
183
|
return str(shared_id)
|
|
184
184
|
|
|
185
185
|
def _compose_fk(self, name: str) -> ComposeResult:
|
|
186
|
-
target = resolve_fk_target(
|
|
186
|
+
target = resolve_fk_target(
|
|
187
|
+
name,
|
|
188
|
+
self._fk_nested_value(name),
|
|
189
|
+
self._model,
|
|
190
|
+
context_tag=self._tag,
|
|
191
|
+
context_resource=self._resource_name,
|
|
192
|
+
)
|
|
187
193
|
self._fk_kinds[name] = target.kind
|
|
188
194
|
shared_id = self._shared.get(name)
|
|
189
195
|
if target.kind == "raw_id":
|
|
@@ -331,7 +337,13 @@ class BulkEditForm(Screen[None]):
|
|
|
331
337
|
self._open_picker(fk)
|
|
332
338
|
|
|
333
339
|
def _open_picker(self, name: str) -> None:
|
|
334
|
-
target = resolve_fk_target(
|
|
340
|
+
target = resolve_fk_target(
|
|
341
|
+
name,
|
|
342
|
+
self._fk_nested_value(name),
|
|
343
|
+
self._model,
|
|
344
|
+
context_tag=self._tag,
|
|
345
|
+
context_resource=self._resource_name,
|
|
346
|
+
)
|
|
335
347
|
if target.list_op is None:
|
|
336
348
|
return
|
|
337
349
|
from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
|
|
@@ -214,7 +214,13 @@ class DetailScreen(Screen[None]):
|
|
|
214
214
|
editor.focus()
|
|
215
215
|
|
|
216
216
|
def _edit_fk(self, name: str) -> None:
|
|
217
|
-
target = resolve_fk_target(
|
|
217
|
+
target = resolve_fk_target(
|
|
218
|
+
name,
|
|
219
|
+
self._record.get(name),
|
|
220
|
+
self._model,
|
|
221
|
+
context_tag=self._tag,
|
|
222
|
+
context_resource=self._resource_name,
|
|
223
|
+
)
|
|
218
224
|
if target.kind == "raw_id" or target.list_op is None:
|
|
219
225
|
spec = self._specs[name]
|
|
220
226
|
self._open_input(name, spec)
|
|
@@ -174,7 +174,13 @@ class EditForm(Screen[None]):
|
|
|
174
174
|
return name.endswith("_id") or is_fk_value(self._record.get(name))
|
|
175
175
|
|
|
176
176
|
def _compose_fk(self, name: str, value: Any) -> ComposeResult:
|
|
177
|
-
target = resolve_fk_target(
|
|
177
|
+
target = resolve_fk_target(
|
|
178
|
+
name,
|
|
179
|
+
self._record.get(name),
|
|
180
|
+
self._model,
|
|
181
|
+
context_tag=self._tag,
|
|
182
|
+
context_resource=self._resource_name,
|
|
183
|
+
)
|
|
178
184
|
if target.kind == "raw_id":
|
|
179
185
|
text = "" if value is None else str(value)
|
|
180
186
|
yield Input(value=text, id=f"field-{name}")
|
|
@@ -266,7 +272,13 @@ class EditForm(Screen[None]):
|
|
|
266
272
|
self.staged[name] = list(selected)
|
|
267
273
|
|
|
268
274
|
def _open_picker(self, name: str) -> None:
|
|
269
|
-
target = resolve_fk_target(
|
|
275
|
+
target = resolve_fk_target(
|
|
276
|
+
name,
|
|
277
|
+
self._record.get(name),
|
|
278
|
+
self._model,
|
|
279
|
+
context_tag=self._tag,
|
|
280
|
+
context_resource=self._resource_name,
|
|
281
|
+
)
|
|
270
282
|
if target.list_op is None:
|
|
271
283
|
return
|
|
272
284
|
from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
|
|
@@ -67,7 +67,12 @@ class FilterScreen(ModalScreen[dict[str, str]]):
|
|
|
67
67
|
self._syncing = False
|
|
68
68
|
|
|
69
69
|
def _is_fk(self, name: str) -> bool:
|
|
70
|
-
return
|
|
70
|
+
return (
|
|
71
|
+
resolve_fk_target(
|
|
72
|
+
name, None, self._model, context_tag=self._tag, context_resource=self._resource
|
|
73
|
+
).kind
|
|
74
|
+
== "picker"
|
|
75
|
+
)
|
|
71
76
|
|
|
72
77
|
@staticmethod
|
|
73
78
|
def _apply_key(name: str) -> str:
|
|
@@ -187,7 +192,9 @@ class FilterScreen(ModalScreen[dict[str, str]]):
|
|
|
187
192
|
raw.focus()
|
|
188
193
|
|
|
189
194
|
def _open_fk_picker(self, name: str) -> None:
|
|
190
|
-
target = resolve_fk_target(
|
|
195
|
+
target = resolve_fk_target(
|
|
196
|
+
name, None, self._model, context_tag=self._tag, context_resource=self._resource
|
|
197
|
+
)
|
|
191
198
|
if target.list_op is None:
|
|
192
199
|
return
|
|
193
200
|
from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Schema-driven foreign-key target metadata.
|
|
2
|
+
|
|
3
|
+
NetBox types a writable FK as ``oneOf[integer, Brief<X>Request]``. The builder
|
|
4
|
+
records the target serializer name (``<X>``) on the field and indexes which
|
|
5
|
+
resource serves objects of each serializer, so the TUI can resolve an FK picker
|
|
6
|
+
to the true target even when the field name does not match the resource name
|
|
7
|
+
(e.g. a virtual-machine's ``role`` targets dcim ``device-roles``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from nsc.builder.build import build_command_model
|
|
18
|
+
from nsc.model.command_model import MODEL_FORMAT_VERSION, FkResourceRef
|
|
19
|
+
from nsc.schema.loader import LoadedSchema, load_schema
|
|
20
|
+
from nsc.schema.models import OpenAPIDocument
|
|
21
|
+
|
|
22
|
+
_BUNDLED = Path("nsc/schemas/bundled/netbox-4.6.0.json.gz")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="module")
|
|
26
|
+
def bundled_model():
|
|
27
|
+
return build_command_model(load_schema(str(_BUNDLED)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_fk_resources_index_maps_serializer_to_resource(bundled_model) -> None:
|
|
31
|
+
index = bundled_model.fk_resources
|
|
32
|
+
assert index["DeviceRole"] == FkResourceRef(tag="dcim", resource="device-roles")
|
|
33
|
+
assert index["ContactRole"] == FkResourceRef(tag="tenancy", resource="contact-roles")
|
|
34
|
+
assert index["InventoryItemRole"] == FkResourceRef(tag="dcim", resource="inventory-item-roles")
|
|
35
|
+
assert index["Role"] == FkResourceRef(tag="ipam", resource="roles")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_field_fk_target_records_serializer_name(bundled_model) -> None:
|
|
39
|
+
devices = bundled_model.tags["dcim"].resources["devices"]
|
|
40
|
+
assert devices.create_op is not None
|
|
41
|
+
assert devices.create_op.request_body is not None
|
|
42
|
+
assert devices.create_op.request_body.fields["role"].fk_target == "DeviceRole"
|
|
43
|
+
|
|
44
|
+
vms = bundled_model.tags["virtualization"].resources["virtual-machines"]
|
|
45
|
+
assert vms.create_op is not None
|
|
46
|
+
assert vms.create_op.request_body is not None
|
|
47
|
+
# A VM's role is a device-role, despite the field/resource name mismatch.
|
|
48
|
+
assert vms.create_op.request_body.fields["role"].fk_target == "DeviceRole"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_every_fk_target_resolves_in_index(bundled_model) -> None:
|
|
52
|
+
# Invariant: a field's declared FK target must exist in the index, or the TUI
|
|
53
|
+
# picker silently degrades to raw-ID entry. Catches list serializers whose
|
|
54
|
+
# name (e.g. DeviceWithConfigContext) doesn't normalize to the brief base.
|
|
55
|
+
index = bundled_model.fk_resources
|
|
56
|
+
missing: set[str] = set()
|
|
57
|
+
for _tag, _resource, op in bundled_model.iter_operations():
|
|
58
|
+
if op.request_body is None:
|
|
59
|
+
continue
|
|
60
|
+
for shape in op.request_body.fields.values():
|
|
61
|
+
if shape.fk_target is not None and shape.fk_target not in index:
|
|
62
|
+
missing.add(shape.fk_target)
|
|
63
|
+
assert not missing, f"FK targets with no resource in the index: {sorted(missing)}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_with_config_context_targets_resolve(bundled_model) -> None:
|
|
67
|
+
# Device/VirtualMachine list serializers carry a WithConfigContext suffix;
|
|
68
|
+
# the index must still key them under the brief base name.
|
|
69
|
+
index = bundled_model.fk_resources
|
|
70
|
+
assert index["Device"] == FkResourceRef(tag="dcim", resource="devices")
|
|
71
|
+
assert index["VirtualMachine"] == FkResourceRef(
|
|
72
|
+
tag="virtualization", resource="virtual-machines"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_non_fk_field_has_no_fk_target(bundled_model) -> None:
|
|
77
|
+
devices = bundled_model.tags["dcim"].resources["devices"]
|
|
78
|
+
assert devices.create_op is not None
|
|
79
|
+
assert devices.create_op.request_body is not None
|
|
80
|
+
assert devices.create_op.request_body.fields["name"].fk_target is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_builder_stamps_current_format_version(bundled_model) -> None:
|
|
84
|
+
assert bundled_model.format_version == MODEL_FORMAT_VERSION
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _loaded(doc: OpenAPIDocument) -> LoadedSchema:
|
|
88
|
+
return LoadedSchema(source="test", body=b"", hash="sha256:test", document=doc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _doc(body_role: dict[str, Any]) -> OpenAPIDocument:
|
|
92
|
+
return OpenAPIDocument.model_validate(
|
|
93
|
+
{
|
|
94
|
+
"openapi": "3.0.0",
|
|
95
|
+
"info": {"title": "T", "version": "0"},
|
|
96
|
+
"paths": {
|
|
97
|
+
"/api/t/widgets/": {
|
|
98
|
+
"post": {
|
|
99
|
+
"operationId": "create",
|
|
100
|
+
"tags": ["t"],
|
|
101
|
+
"requestBody": {
|
|
102
|
+
"content": {
|
|
103
|
+
"application/json": {
|
|
104
|
+
"schema": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"properties": {"role": body_role},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"responses": {"200": {"description": "ok"}},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"tags": [{"name": "t"}],
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_fk_target_extracted_from_plain_oneof() -> None:
|
|
121
|
+
doc = _doc({"oneOf": [{"type": "integer"}, {"$ref": "#/components/schemas/BriefThingRequest"}]})
|
|
122
|
+
model = build_command_model(_loaded(doc))
|
|
123
|
+
field = model.tags["t"].resources["widgets"].create_op.request_body.fields["role"]
|
|
124
|
+
assert field.fk_target == "Thing"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_fk_target_extracted_from_nullable_allof_wrapper() -> None:
|
|
128
|
+
# NetBox wraps a nullable FK as oneOf[integer, {allOf:[$ref], nullable}].
|
|
129
|
+
doc = _doc(
|
|
130
|
+
{
|
|
131
|
+
"oneOf": [
|
|
132
|
+
{"type": "integer"},
|
|
133
|
+
{"allOf": [{"$ref": "#/components/schemas/BriefThingRequest"}], "nullable": True},
|
|
134
|
+
],
|
|
135
|
+
"nullable": True,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
model = build_command_model(_loaded(doc))
|
|
139
|
+
field = model.tags["t"].resources["widgets"].create_op.request_body.fields["role"]
|
|
140
|
+
assert field.fk_target == "Thing"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_string_enum_role_is_not_an_fk() -> None:
|
|
144
|
+
# Some `role` fields (e.g. ip-addresses) are plain string enums, not FKs.
|
|
145
|
+
doc = _doc({"type": "string", "enum": ["loopback", "secondary"]})
|
|
146
|
+
model = build_command_model(_loaded(doc))
|
|
147
|
+
field = model.tags["t"].resources["widgets"].create_op.request_body.fields["role"]
|
|
148
|
+
assert field.fk_target is None
|