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.
Files changed (336) hide show
  1. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/CHANGELOG.md +17 -0
  2. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/PKG-INFO +1 -1
  3. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/_version.py +1 -1
  4. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/builder/build.py +102 -1
  5. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cache/store.py +5 -1
  6. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/model/command_model.py +22 -0
  7. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/fk.py +123 -13
  8. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/bulk_edit_form.py +14 -2
  9. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/detail.py +7 -1
  10. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/edit_form.py +14 -2
  11. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/filter.py +9 -2
  12. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/pyproject.toml +1 -1
  13. netbox_super_cli-1.6.2/tests/builder/test_fk_metadata.py +148 -0
  14. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/test_store.py +12 -1
  15. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_completion_protocol_smoke.py +2 -0
  16. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_dynamic_completion.py +2 -0
  17. netbox_super_cli-1.6.2/tests/tui/test_fk_resolve.py +305 -0
  18. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/uv.lock +1 -1
  19. netbox_super_cli-1.6.1/tests/tui/test_fk_resolve.py +0 -144
  20. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  21. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  22. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/docs.yml +0 -0
  23. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/dependabot.yml +0 -0
  25. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/pull_request_template.md +0 -0
  26. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/bench.yml +0 -0
  27. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/claude.yml +0 -0
  28. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/docs.yml +0 -0
  29. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/e2e.yml +0 -0
  30. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/lint.yml +0 -0
  31. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/release.yml +0 -0
  32. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/skill-convention-review.yml +0 -0
  33. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.github/workflows/test.yml +0 -0
  34. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.gitignore +0 -0
  35. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.pre-commit-config.yaml +0 -0
  36. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/.python-version +0 -0
  37. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/AGENTS.md +0 -0
  38. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/CLAUDE.md +0 -0
  39. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/LICENSE +0 -0
  40. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/README.md +0 -0
  41. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/caching.md +0 -0
  42. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/command-generation.md +0 -0
  43. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/http-client.md +0 -0
  44. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/overview.md +0 -0
  45. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/architecture/schema-loading.md +0 -0
  46. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/detail.svg +0 -0
  47. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/filter.svg +0 -0
  48. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/list.svg +0 -0
  49. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/picker.svg +0 -0
  50. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/assets/tui/search.svg +0 -0
  51. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/adding-bundled-schemas.md +0 -0
  52. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/branching.md +0 -0
  53. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/development.md +0 -0
  54. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/contributing/release-process.md +0 -0
  55. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/concepts.md +0 -0
  56. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/first-run.md +0 -0
  57. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/getting-started/install.md +0 -0
  58. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/audit-log.md +0 -0
  59. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/ci-and-automation.md +0 -0
  60. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/interactive-tui.md +0 -0
  61. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/managing-profiles.md +0 -0
  62. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/output-formats.md +0 -0
  63. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/using-with-ai-agents.md +0 -0
  64. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/working-with-plugins.md +0 -0
  65. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/guides/writes-and-safety.md +0 -0
  66. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/index.md +0 -0
  67. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/cli.md +0 -0
  68. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/config.md +0 -0
  69. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/exit-codes.md +0 -0
  70. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/docs/reference/schemas.md +0 -0
  71. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/justfile +0 -0
  72. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/mkdocs.yml +0 -0
  73. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/__init__.py +0 -0
  74. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/__main__.py +0 -0
  75. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/aliases/__init__.py +0 -0
  76. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/aliases/resolver.py +0 -0
  77. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/auth/__init__.py +0 -0
  78. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/auth/verify.py +0 -0
  79. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/builder/__init__.py +0 -0
  80. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cache/__init__.py +0 -0
  81. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/__init__.py +0 -0
  82. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/aliases_commands.py +0 -0
  83. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/app.py +0 -0
  84. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/cache_commands.py +0 -0
  85. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/commands_dump.py +0 -0
  86. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/config_commands.py +0 -0
  87. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/globals.py +0 -0
  88. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/handlers.py +0 -0
  89. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/init_commands.py +0 -0
  90. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/login_commands.py +0 -0
  91. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/profiles_commands.py +0 -0
  92. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/registration.py +0 -0
  93. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/runtime.py +0 -0
  94. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/skill_commands.py +0 -0
  95. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/tui_commands.py +0 -0
  96. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/__init__.py +0 -0
  97. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/apply.py +0 -0
  98. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/bulk.py +0 -0
  99. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/coercion.py +0 -0
  100. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/confirmation.py +0 -0
  101. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/input.py +0 -0
  102. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/cli/writes/preflight.py +0 -0
  103. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/__init__.py +0 -0
  104. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/cache_probe.py +0 -0
  105. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/callbacks.py +0 -0
  106. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/completion/providers.py +0 -0
  107. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/__init__.py +0 -0
  108. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/loader.py +0 -0
  109. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/models.py +0 -0
  110. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/saved_searches.py +0 -0
  111. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/settings.py +0 -0
  112. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/config/writer.py +0 -0
  113. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/__init__.py +0 -0
  114. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/audit.py +0 -0
  115. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/client.py +0 -0
  116. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/errors.py +0 -0
  117. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/http/retry.py +0 -0
  118. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/model/__init__.py +0 -0
  119. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/__init__.py +0 -0
  120. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/_console.py +0 -0
  121. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/colors.py +0 -0
  122. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/csv_.py +0 -0
  123. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/errors.py +0 -0
  124. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/explain.py +0 -0
  125. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/flatten.py +0 -0
  126. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/headers.py +0 -0
  127. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/json_.py +0 -0
  128. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/jsonl.py +0 -0
  129. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/render.py +0 -0
  130. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/table.py +0 -0
  131. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/output/yaml_.py +0 -0
  132. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/__init__.py +0 -0
  133. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/custom_fields.py +0 -0
  134. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/objecttypes.py +0 -0
  135. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/params.py +0 -0
  136. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/store.py +0 -0
  137. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/savedfilters/tags.py +0 -0
  138. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/__init__.py +0 -0
  139. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/hashing.py +0 -0
  140. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/loader.py +0 -0
  141. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/models.py +0 -0
  142. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schema/source.py +0 -0
  143. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/__init__.py +0 -0
  144. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/__init__.py +0 -0
  145. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/manifest.yaml +0 -0
  146. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
  147. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
  148. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/skill/__init__.py +0 -0
  149. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/__init__.py +0 -0
  150. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/_bindings.py +0 -0
  151. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/app.py +0 -0
  152. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/bulk.py +0 -0
  153. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/catalog.py +0 -0
  154. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/columns.py +0 -0
  155. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/errors.py +0 -0
  156. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/filters.py +0 -0
  157. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/forms.py +0 -0
  158. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/keymap.py +0 -0
  159. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/nav.py +0 -0
  160. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/relations.py +0 -0
  161. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/__init__.py +0 -0
  162. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/columns.py +0 -0
  163. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/global_search.py +0 -0
  164. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/list.py +0 -0
  165. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/picker.py +0 -0
  166. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/record_picker.py +0 -0
  167. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/screens/saved_search_picker.py +0 -0
  168. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/search.py +0 -0
  169. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/selection.py +0 -0
  170. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/styles.tcss +0 -0
  171. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/view.py +0 -0
  172. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/__init__.py +0 -0
  173. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/_modal.py +0 -0
  174. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_diff.py +0 -0
  175. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_summary.py +0 -0
  176. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/confirm.py +0 -0
  177. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/diff.py +0 -0
  178. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/help.py +0 -0
  179. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/nsc/tui/widgets/nav_tree.py +0 -0
  180. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/scripts/gen_docs.py +0 -0
  181. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/scripts/sync_agents_md.py +0 -0
  182. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/skills/netbox-super-cli/SKILL.md +0 -0
  183. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/__init__.py +0 -0
  184. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/aliases/__init__.py +0 -0
  185. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/aliases/test_resolver.py +0 -0
  186. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/audit/__init__.py +0 -0
  187. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/audit/test_redaction_full.py +0 -0
  188. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/auth/__init__.py +0 -0
  189. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/auth/test_verify.py +0 -0
  190. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/benchmarks/__init__.py +0 -0
  191. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/benchmarks/test_startup.py +0 -0
  192. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/__init__.py +0 -0
  193. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_build.py +0 -0
  194. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_default_columns.py +0 -0
  195. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_redaction_marking.py +0 -0
  196. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/builder/test_request_body_shape.py +0 -0
  197. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/__init__.py +0 -0
  198. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cache/test_prune.py +0 -0
  199. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/__init__.py +0 -0
  200. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_aliases_commands.py +0 -0
  201. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_app_smoke.py +0 -0
  202. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_cache_commands.py +0 -0
  203. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_commands_dump.py +0 -0
  204. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_completion_smoke.py +0 -0
  205. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_config_commands.py +0 -0
  206. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_explain.py +0 -0
  207. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_globals_color.py +0 -0
  208. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_handlers.py +0 -0
  209. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_init_commands.py +0 -0
  210. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_lazy_textual_import.py +0 -0
  211. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_login_commands.py +0 -0
  212. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
  213. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_ndjson_input.py +0 -0
  214. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_profiles_commands.py +0 -0
  215. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_registration.py +0 -0
  216. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_respx_integration.py +0 -0
  217. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_runtime.py +0 -0
  218. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_runtime_color.py +0 -0
  219. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_skill_commands.py +0 -0
  220. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_stdin_sniffer.py +0 -0
  221. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_tui_command.py +0 -0
  222. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/test_writes_respx.py +0 -0
  223. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/__init__.py +0 -0
  224. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_apply.py +0 -0
  225. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_bulk.py +0 -0
  226. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_confirmation.py +0 -0
  227. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handler_helpers.py +0 -0
  228. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handlers_audit.py +0 -0
  229. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_input.py +0 -0
  230. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/cli/writes/test_preflight.py +0 -0
  231. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/__init__.py +0 -0
  232. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_loader.py +0 -0
  233. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_models.py +0 -0
  234. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_saved_search_fallback.py +0 -0
  235. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_saved_searches.py +0 -0
  236. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_settings_private_dir.py +0 -0
  237. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_atomicity.py +0 -0
  238. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_dotted_paths.py +0 -0
  239. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/config/test_writer_roundtrip.py +0 -0
  240. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/conftest.py +0 -0
  241. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/README.md +0 -0
  242. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/__init__.py +0 -0
  243. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/conftest.py +0 -0
  244. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/docker-compose.yml +0 -0
  245. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_audit_redaction.py +0 -0
  246. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_auth_error_envelope.py +0 -0
  247. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
  248. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_full_cycle.py +0 -0
  249. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_login.py +0 -0
  250. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_ndjson.py +0 -0
  251. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_preflight_blocks_apply.py +0 -0
  252. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
  253. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/e2e/wait_for_netbox.sh +0 -0
  254. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/profiles/single_profile.yaml +0 -0
  255. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/auth_401.json +0 -0
  256. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/circuits_providers_list.json +0 -0
  257. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_get.json +0 -0
  258. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
  259. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
  260. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/__init__.py +0 -0
  261. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit.py +0 -0
  262. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit_permissions.py +0 -0
  263. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_audit_redaction.py +0 -0
  264. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_client.py +0 -0
  265. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_client_redaction_threading.py +0 -0
  266. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_errors.py +0 -0
  267. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/http/test_retry.py +0 -0
  268. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/__init__.py +0 -0
  269. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/test_command_model.py +0 -0
  270. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/model/test_request_body_shape.py +0 -0
  271. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/__init__.py +0 -0
  272. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_colors.py +0 -0
  273. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_console.py +0 -0
  274. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_csv.py +0 -0
  275. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors.py +0 -0
  276. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors_aliases.py +0 -0
  277. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_errors_color.py +0 -0
  278. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_explain.py +0 -0
  279. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_explain_color.py +0 -0
  280. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_flatten.py +0 -0
  281. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_input_error_envelope.py +0 -0
  282. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_json.py +0 -0
  283. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_jsonl.py +0 -0
  284. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_render_dispatch.py +0 -0
  285. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_table.py +0 -0
  286. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_table_color.py +0 -0
  287. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/output/test_yaml.py +0 -0
  288. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/__init__.py +0 -0
  289. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_custom_fields.py +0 -0
  290. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_objecttypes.py +0 -0
  291. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_params.py +0 -0
  292. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_store.py +0 -0
  293. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/savedfilters/test_tags.py +0 -0
  294. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/__init__.py +0 -0
  295. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_hashing.py +0 -0
  296. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_loader.py +0 -0
  297. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_models.py +0 -0
  298. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_source.py +0 -0
  299. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/schema/test_source_ttl.py +0 -0
  300. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/__init__.py +0 -0
  301. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/test_gen_docs.py +0 -0
  302. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/scripts/test_sync_agents_md.py +0 -0
  303. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/skill/__init__.py +0 -0
  304. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/skill/test_bundle.py +0 -0
  305. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/test_packaging.py +0 -0
  306. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/__init__.py +0 -0
  307. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_app.py +0 -0
  308. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bindings.py +0 -0
  309. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_apply.py +0 -0
  310. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff.py +0 -0
  311. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff_modal.py +0 -0
  312. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_edit_form.py +0 -0
  313. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_import_isolation.py +0 -0
  314. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_summary.py +0 -0
  315. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_catalog.py +0 -0
  316. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_column_chooser.py +0 -0
  317. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_columns.py +0 -0
  318. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_detail_screen.py +0 -0
  319. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_diff.py +0 -0
  320. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_diff_modal.py +0 -0
  321. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_edit_form.py +0 -0
  322. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_errors.py +0 -0
  323. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_filter_screen.py +0 -0
  324. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_filters.py +0 -0
  325. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_forms.py +0 -0
  326. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_global_search.py +0 -0
  327. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_help_overlay.py +0 -0
  328. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_keymap.py +0 -0
  329. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_list_screen.py +0 -0
  330. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_picker.py +0 -0
  331. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_record_picker.py +0 -0
  332. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_relations.py +0 -0
  333. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_saved_filter_integration.py +0 -0
  334. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_search.py +0 -0
  335. {netbox_super_cli-1.6.1 → netbox_super_cli-1.6.2}/tests/tui/test_selection.py +0 -0
  336. {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.1
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/
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "1.6.1"
3
+ __version__ = "1.6.2"
@@ -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(primitive=primitive, enum=enum, nullable=resolved.nullable)
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(field_name: str, current_value: Any, model: CommandModel) -> FkTarget:
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
- by_name = _resolve_by_field_name(field_name, model)
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(field_name: str, model: CommandModel) -> FkTarget | None:
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
- for tag_name in sorted(model.tags):
117
- tag = model.tags[tag_name]
118
- for resource_name in sorted(tag.resources):
119
- if singularize(resource_name) != wanted:
120
- continue
121
- resource = tag.resources[resource_name]
122
- if resource.list_op is None:
123
- continue
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=resource.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(name, self._fk_nested_value(name), self._model)
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(name, self._fk_nested_value(name), self._model)
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(name, self._record.get(name), self._model)
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(name, self._record.get(name), self._model)
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(name, self._record.get(name), self._model)
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 resolve_fk_target(name, None, self._model).kind == "picker"
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(name, None, self._model)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netbox-super-cli"
3
- version = "1.6.1"
3
+ version = "1.6.2"
4
4
  description = "Dynamic NetBox CLI generated from the live OpenAPI schema."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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