netbox-super-cli 1.2.0__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/CHANGELOG.md +21 -0
  2. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/PKG-INFO +1 -1
  3. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/_version.py +1 -1
  4. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/bulk.py +4 -1
  5. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/fk.py +21 -0
  6. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/forms.py +22 -4
  7. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/bulk_edit_form.py +82 -5
  8. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/detail.py +14 -6
  9. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/edit_form.py +29 -7
  10. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/pyproject.toml +1 -1
  11. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_diff.py +9 -1
  12. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_edit_form.py +232 -1
  13. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_detail_screen.py +91 -1
  14. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_diff.py +3 -2
  15. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_edit_form.py +53 -0
  16. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_fk_resolve.py +14 -0
  17. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_forms.py +43 -1
  18. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/uv.lock +1 -1
  19. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/bench.yml +0 -0
  20. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/claude.yml +0 -0
  21. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/docs.yml +0 -0
  22. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/e2e.yml +0 -0
  23. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/lint.yml +0 -0
  24. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/release.yml +0 -0
  25. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/test.yml +0 -0
  26. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.gitignore +0 -0
  27. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.pre-commit-config.yaml +0 -0
  28. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.python-version +0 -0
  29. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/AGENTS.md +0 -0
  30. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/CLAUDE.md +0 -0
  31. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/LICENSE +0 -0
  32. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/README.md +0 -0
  33. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/caching.md +0 -0
  34. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/command-generation.md +0 -0
  35. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/http-client.md +0 -0
  36. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/overview.md +0 -0
  37. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/schema-loading.md +0 -0
  38. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/detail.svg +0 -0
  39. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/filter.svg +0 -0
  40. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/list.svg +0 -0
  41. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/picker.svg +0 -0
  42. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/search.svg +0 -0
  43. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/adding-bundled-schemas.md +0 -0
  44. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/branching.md +0 -0
  45. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/development.md +0 -0
  46. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/release-process.md +0 -0
  47. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/concepts.md +0 -0
  48. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/first-run.md +0 -0
  49. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/install.md +0 -0
  50. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/ci-and-automation.md +0 -0
  51. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/interactive-tui.md +0 -0
  52. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/managing-profiles.md +0 -0
  53. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/output-formats.md +0 -0
  54. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/using-with-ai-agents.md +0 -0
  55. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/working-with-plugins.md +0 -0
  56. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/writes-and-safety.md +0 -0
  57. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/index.md +0 -0
  58. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/cli.md +0 -0
  59. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/config.md +0 -0
  60. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/exit-codes.md +0 -0
  61. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/schemas.md +0 -0
  62. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/justfile +0 -0
  63. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/mkdocs.yml +0 -0
  64. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/__init__.py +0 -0
  65. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/__main__.py +0 -0
  66. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/aliases/__init__.py +0 -0
  67. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/aliases/resolver.py +0 -0
  68. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/auth/__init__.py +0 -0
  69. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/auth/verify.py +0 -0
  70. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/builder/__init__.py +0 -0
  71. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/builder/build.py +0 -0
  72. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cache/__init__.py +0 -0
  73. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cache/store.py +0 -0
  74. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/__init__.py +0 -0
  75. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/aliases_commands.py +0 -0
  76. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/app.py +0 -0
  77. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/cache_commands.py +0 -0
  78. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/commands_dump.py +0 -0
  79. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/config_commands.py +0 -0
  80. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/globals.py +0 -0
  81. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/handlers.py +0 -0
  82. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/init_commands.py +0 -0
  83. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/login_commands.py +0 -0
  84. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/profiles_commands.py +0 -0
  85. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/registration.py +0 -0
  86. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/runtime.py +0 -0
  87. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/skill_commands.py +0 -0
  88. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/tui_commands.py +0 -0
  89. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/__init__.py +0 -0
  90. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/apply.py +0 -0
  91. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/bulk.py +0 -0
  92. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/coercion.py +0 -0
  93. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/confirmation.py +0 -0
  94. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/input.py +0 -0
  95. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/preflight.py +0 -0
  96. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/__init__.py +0 -0
  97. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/loader.py +0 -0
  98. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/models.py +0 -0
  99. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/settings.py +0 -0
  100. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/writer.py +0 -0
  101. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/__init__.py +0 -0
  102. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/audit.py +0 -0
  103. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/client.py +0 -0
  104. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/errors.py +0 -0
  105. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/retry.py +0 -0
  106. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/model/__init__.py +0 -0
  107. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/model/command_model.py +0 -0
  108. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/__init__.py +0 -0
  109. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/_console.py +0 -0
  110. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/csv_.py +0 -0
  111. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/errors.py +0 -0
  112. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/explain.py +0 -0
  113. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/flatten.py +0 -0
  114. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/headers.py +0 -0
  115. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/json_.py +0 -0
  116. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/jsonl.py +0 -0
  117. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/render.py +0 -0
  118. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/table.py +0 -0
  119. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/yaml_.py +0 -0
  120. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/__init__.py +0 -0
  121. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/hashing.py +0 -0
  122. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/loader.py +0 -0
  123. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/models.py +0 -0
  124. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/source.py +0 -0
  125. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/__init__.py +0 -0
  126. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/__init__.py +0 -0
  127. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/manifest.yaml +0 -0
  128. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
  129. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
  130. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/skill/__init__.py +0 -0
  131. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/__init__.py +0 -0
  132. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/_bindings.py +0 -0
  133. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/app.py +0 -0
  134. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/catalog.py +0 -0
  135. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/columns.py +0 -0
  136. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/errors.py +0 -0
  137. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/filters.py +0 -0
  138. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/keymap.py +0 -0
  139. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/nav.py +0 -0
  140. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/relations.py +0 -0
  141. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/__init__.py +0 -0
  142. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/columns.py +0 -0
  143. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/filter.py +0 -0
  144. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/global_search.py +0 -0
  145. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/list.py +0 -0
  146. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/picker.py +0 -0
  147. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/record_picker.py +0 -0
  148. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/search.py +0 -0
  149. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/selection.py +0 -0
  150. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/styles.tcss +0 -0
  151. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/view.py +0 -0
  152. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/__init__.py +0 -0
  153. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/_modal.py +0 -0
  154. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/bulk_diff.py +0 -0
  155. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/bulk_summary.py +0 -0
  156. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/confirm.py +0 -0
  157. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/diff.py +0 -0
  158. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/help.py +0 -0
  159. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/nav_tree.py +0 -0
  160. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/scripts/gen_docs.py +0 -0
  161. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/scripts/sync_agents_md.py +0 -0
  162. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/skills/netbox-super-cli/SKILL.md +0 -0
  163. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/__init__.py +0 -0
  164. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/aliases/__init__.py +0 -0
  165. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/aliases/test_resolver.py +0 -0
  166. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/auth/__init__.py +0 -0
  167. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/auth/test_verify.py +0 -0
  168. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/benchmarks/__init__.py +0 -0
  169. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/benchmarks/test_startup.py +0 -0
  170. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/__init__.py +0 -0
  171. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_build.py +0 -0
  172. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_default_columns.py +0 -0
  173. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_redaction_marking.py +0 -0
  174. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_request_body_shape.py +0 -0
  175. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/__init__.py +0 -0
  176. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/test_prune.py +0 -0
  177. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/test_store.py +0 -0
  178. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/__init__.py +0 -0
  179. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_aliases_commands.py +0 -0
  180. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_app_smoke.py +0 -0
  181. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_cache_commands.py +0 -0
  182. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_commands_dump.py +0 -0
  183. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_completion_smoke.py +0 -0
  184. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_config_commands.py +0 -0
  185. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_explain.py +0 -0
  186. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_globals_color.py +0 -0
  187. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_handlers.py +0 -0
  188. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_init_commands.py +0 -0
  189. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_lazy_textual_import.py +0 -0
  190. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_login_commands.py +0 -0
  191. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
  192. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_ndjson_input.py +0 -0
  193. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_profiles_commands.py +0 -0
  194. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_registration.py +0 -0
  195. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_respx_integration.py +0 -0
  196. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_runtime.py +0 -0
  197. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_runtime_color.py +0 -0
  198. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_skill_commands.py +0 -0
  199. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_stdin_sniffer.py +0 -0
  200. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_tui_command.py +0 -0
  201. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_writes_respx.py +0 -0
  202. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/__init__.py +0 -0
  203. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_apply.py +0 -0
  204. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_bulk.py +0 -0
  205. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_confirmation.py +0 -0
  206. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_handler_helpers.py +0 -0
  207. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_handlers_audit.py +0 -0
  208. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_input.py +0 -0
  209. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_preflight.py +0 -0
  210. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/__init__.py +0 -0
  211. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_loader.py +0 -0
  212. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_models.py +0 -0
  213. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_atomicity.py +0 -0
  214. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_dotted_paths.py +0 -0
  215. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_roundtrip.py +0 -0
  216. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/conftest.py +0 -0
  217. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/README.md +0 -0
  218. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/__init__.py +0 -0
  219. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/conftest.py +0 -0
  220. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/docker-compose.yml +0 -0
  221. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_audit_redaction.py +0 -0
  222. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_auth_error_envelope.py +0 -0
  223. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
  224. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_full_cycle.py +0 -0
  225. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_login.py +0 -0
  226. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_ndjson.py +0 -0
  227. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_preflight_blocks_apply.py +0 -0
  228. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
  229. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/wait_for_netbox.sh +0 -0
  230. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/profiles/single_profile.yaml +0 -0
  231. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/auth_401.json +0 -0
  232. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/circuits_providers_list.json +0 -0
  233. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_get.json +0 -0
  234. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
  235. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
  236. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/__init__.py +0 -0
  237. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit.py +0 -0
  238. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit_permissions.py +0 -0
  239. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit_redaction.py +0 -0
  240. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_client.py +0 -0
  241. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_client_redaction_threading.py +0 -0
  242. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_errors.py +0 -0
  243. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_retry.py +0 -0
  244. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/__init__.py +0 -0
  245. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/test_command_model.py +0 -0
  246. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/test_request_body_shape.py +0 -0
  247. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/__init__.py +0 -0
  248. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_console.py +0 -0
  249. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_csv.py +0 -0
  250. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors.py +0 -0
  251. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors_aliases.py +0 -0
  252. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors_color.py +0 -0
  253. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_explain.py +0 -0
  254. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_explain_color.py +0 -0
  255. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_flatten.py +0 -0
  256. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_input_error_envelope.py +0 -0
  257. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_json.py +0 -0
  258. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_jsonl.py +0 -0
  259. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_render_dispatch.py +0 -0
  260. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_table.py +0 -0
  261. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_table_color.py +0 -0
  262. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_yaml.py +0 -0
  263. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/__init__.py +0 -0
  264. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_hashing.py +0 -0
  265. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_loader.py +0 -0
  266. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_models.py +0 -0
  267. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_source.py +0 -0
  268. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_source_ttl.py +0 -0
  269. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/__init__.py +0 -0
  270. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/test_gen_docs.py +0 -0
  271. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/test_sync_agents_md.py +0 -0
  272. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/skill/__init__.py +0 -0
  273. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/skill/test_bundle.py +0 -0
  274. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/test_packaging.py +0 -0
  275. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/__init__.py +0 -0
  276. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_app.py +0 -0
  277. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bindings.py +0 -0
  278. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_apply.py +0 -0
  279. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_diff_modal.py +0 -0
  280. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_import_isolation.py +0 -0
  281. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_summary.py +0 -0
  282. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_catalog.py +0 -0
  283. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_column_chooser.py +0 -0
  284. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_columns.py +0 -0
  285. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_diff_modal.py +0 -0
  286. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_errors.py +0 -0
  287. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_filter_screen.py +0 -0
  288. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_filters.py +0 -0
  289. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_global_search.py +0 -0
  290. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_help_overlay.py +0 -0
  291. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_keymap.py +0 -0
  292. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_list_screen.py +0 -0
  293. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_picker.py +0 -0
  294. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_record_picker.py +0 -0
  295. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_relations.py +0 -0
  296. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_search.py +0 -0
  297. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_selection.py +0 -0
  298. {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_view.py +0 -0
@@ -2,6 +2,27 @@
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.2.1 — 2026-06-25
6
+
7
+ Patch release. Fixes foreign-key handling in the interactive TUI.
8
+
9
+ ### Fixed
10
+
11
+ - **TUI foreign-key fields show names and a chooser, not raw IDs** ([#97]).
12
+ In the bulk-edit, single-edit and detail screens, FK fields (device `role`,
13
+ `site`, `tenant`, `device_type`, …) now render their human-readable
14
+ `display`/`name` in the confirmation diff and present a searchable
15
+ record-picker dropdown of valid related objects, instead of showing the
16
+ numeric id and requiring a raw id to be typed. The outgoing `PATCH` still
17
+ carries the id, so the wire format is unchanged. FK detection is now keyed on
18
+ the record's nested object rather than the widget kind, because NetBox's
19
+ writable schema types FK fields as `oneOf[integer, brief]` (no primitive
20
+ type), which previously fell through to a free-text widget. When a relation's
21
+ target resource has no list endpoint, the field gracefully falls back to
22
+ raw-id entry with a hint rather than an inert chooser button.
23
+
24
+ [#97]: https://github.com/thomaschristory/netbox-super-cli/issues/97
25
+
5
26
  ## v1.2.0 — 2026-06-24
6
27
 
7
28
  Minor release. Adds an **interactive terminal UI** — a keyboard-driven, fully
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-super-cli
3
- Version: 1.2.0
3
+ Version: 1.2.1
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: Issues, https://github.com/thomaschristory/netbox-super-cli/issues
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.2.1"
@@ -28,17 +28,20 @@ def bulk_diff(
28
28
  selected: list[dict[str, object]],
29
29
  bulk_set: dict[str, object],
30
30
  sensitive_paths: tuple[str, ...],
31
+ new_displays: dict[str, str] | None = None,
31
32
  ) -> list[RecordChange]:
32
33
  """Compute the per-record patch and diff rows for a uniform bulk ``set``.
33
34
 
34
35
  Order of ``selected`` is preserved. A field whose new value equals a
35
36
  record's current value contributes no patch entry and no row for that
36
37
  record, so heterogeneous current values yield different changed subsets.
38
+ ``new_displays`` maps a field to the human label of its chosen FK value so
39
+ the diff renders the name rather than the id.
37
40
  """
38
41
  changes: list[RecordChange] = []
39
42
  for record in selected:
40
43
  patch = compute_patch(record, bulk_set)
41
- rows = diff_rows(record, patch, sensitive_paths)
44
+ rows = diff_rows(record, patch, sensitive_paths, new_displays)
42
45
  changes.append(RecordChange(record_id=record.get("id"), patch=patch, rows=rows))
43
46
  return changes
44
47
 
@@ -28,6 +28,17 @@ FkKind = Literal["picker", "raw_id"]
28
28
  _MIN_URL_SEGMENTS = 2
29
29
 
30
30
 
31
+ def is_fk_value(value: Any) -> bool:
32
+ """True when ``value`` is a NetBox FK nested object (carries ``id``/``url``).
33
+
34
+ The writable schema types FK fields as ``oneOf[integer, brief-ref]`` with no
35
+ top-level ``type``, so the model has no FK signal; the reliable runtime cue
36
+ is the record's nested object. A bare ``custom_fields`` dict (no id/url) is
37
+ deliberately excluded.
38
+ """
39
+ return isinstance(value, dict) and ("id" in value or "url" in value)
40
+
41
+
31
42
  class FkTarget(BaseModel):
32
43
  model_config = ConfigDict(frozen=True, extra="forbid")
33
44
 
@@ -79,6 +90,16 @@ def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel)
79
90
  )
80
91
 
81
92
  tag, list_op = located
93
+ # A `picker` target must carry a usable list endpoint — otherwise the screen
94
+ # renders a chooser button that can't open anything. Resolvable but
95
+ # list-less resources fall back to raw-ID entry, like an unknown resource.
96
+ if list_op is None:
97
+ return FkTarget(
98
+ kind="raw_id",
99
+ field_name=field_name,
100
+ current_id=current_id,
101
+ hint=(f"'{resource_name}' has no list endpoint; enter the numeric ID."),
102
+ )
82
103
  return FkTarget(
83
104
  kind="picker",
84
105
  field_name=field_name,
@@ -87,8 +87,18 @@ def compute_patch(original: dict[str, object], staged: dict[str, object]) -> dic
87
87
  return patch
88
88
 
89
89
 
90
- def _display(value: object) -> str:
90
+ def fk_display(value: object) -> str:
91
+ """Human-readable label for a value, resolving FK nested objects.
92
+
93
+ NetBox FK objects carry ``display`` (and usually ``name``/``slug``); prefer
94
+ those over the bare ``id`` so the diff shows e.g. ``Top of Rack Switch``
95
+ rather than ``12``. Non-dict values render via ``str``.
96
+ """
91
97
  if isinstance(value, dict):
98
+ for key in ("display", "name", "slug"):
99
+ label = value.get(key)
100
+ if label:
101
+ return str(label)
92
102
  ident = value.get("id")
93
103
  if ident is not None:
94
104
  return str(ident)
@@ -99,13 +109,21 @@ def diff_rows(
99
109
  original: dict[str, object],
100
110
  patch: dict[str, object],
101
111
  sensitive_paths: tuple[str, ...],
112
+ new_displays: dict[str, str] | None = None,
102
113
  ) -> list[DiffRow]:
103
- """Render ``patch`` as human-readable old -> new rows for the confirm modal."""
114
+ """Render ``patch`` as human-readable old -> new rows for the confirm modal.
115
+
116
+ ``new_displays`` overrides a field's rendered *new* value — used for FK
117
+ fields whose staged value is a bare id but whose chosen label is known
118
+ (e.g. picked from the record chooser). The patch still carries the id.
119
+ """
120
+ overrides = new_displays or {}
104
121
  rows: list[DiffRow] = []
105
122
  for name, new_value in patch.items():
106
123
  if name in sensitive_paths:
107
124
  rows.append(DiffRow(field=name, old_display="****", new_display="****"))
108
125
  continue
109
- old_display = _display(original[name]) if name in original else ""
110
- rows.append(DiffRow(field=name, old_display=old_display, new_display=str(new_value)))
126
+ old_display = fk_display(original[name]) if name in original else ""
127
+ new_display = overrides.get(name, str(new_value))
128
+ rows.append(DiffRow(field=name, old_display=old_display, new_display=new_display))
111
129
  return rows
@@ -21,9 +21,14 @@ from textual.widgets import Button, Footer, Header, Input, Label, ProgressBar, S
21
21
  from nsc.model.command_model import CommandModel, Operation
22
22
  from nsc.tui._bindings import textual_bindings
23
23
  from nsc.tui.bulk import RecordChange, shared_values
24
- from nsc.tui.forms import SET_NULL, WidgetSpec, field_to_widget
24
+ from nsc.tui.fk import is_fk_value, resolve_fk_target
25
+ from nsc.tui.forms import SET_NULL, WidgetSpec, field_to_widget, fk_display
25
26
  from nsc.tui.view import detail_path
26
27
 
28
+ # Sentinel: a picker FK opted in with neither a pick nor a shared value
29
+ # contributes nothing, so it never nulls the relation.
30
+ _NO_SEED = object()
31
+
27
32
 
28
33
  class _Client(Protocol):
29
34
  def paginate(
@@ -62,6 +67,8 @@ class BulkEditForm(Screen[None]):
62
67
  self._specs: dict[str, WidgetSpec] = {}
63
68
  self._values: dict[str, Any] = {}
64
69
  self._included: set[str] = set()
70
+ self._fk_kinds: dict[str, str] = {}
71
+ self._fk_labels: dict[str, str] = {}
65
72
  body = update_op.request_body
66
73
  field_names = list(body.fields) if body is not None else []
67
74
  # Shared current value per field, to seed the widgets (does NOT opt the
@@ -99,7 +106,47 @@ class BulkEditForm(Screen[None]):
99
106
  if spec.nullable:
100
107
  yield Button("∅", id=f"setnull-{name}", classes="bulk-setnull")
101
108
 
109
+ def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
110
+ if spec.kind in ("select", "switch", "masked") or spec.is_float:
111
+ return False
112
+ return name.endswith("_id") or any(
113
+ is_fk_value(record.get(name)) for record in self._selected
114
+ )
115
+
116
+ def _fk_nested_value(self, name: str) -> Any:
117
+ """A representative nested FK object across the selection, for resolution."""
118
+ for record in self._selected:
119
+ value = record.get(name)
120
+ if is_fk_value(value):
121
+ return value
122
+ return None
123
+
124
+ def _fk_seed_label(self, name: str) -> str:
125
+ shared_id = self._shared.get(name)
126
+ if shared_id is None:
127
+ return ""
128
+ for record in self._selected:
129
+ value = record.get(name)
130
+ if isinstance(value, dict) and value.get("id") == shared_id:
131
+ return fk_display(value)
132
+ return str(shared_id)
133
+
134
+ def _compose_fk(self, name: str) -> ComposeResult:
135
+ target = resolve_fk_target(name, self._fk_nested_value(name), self._model)
136
+ self._fk_kinds[name] = target.kind
137
+ shared_id = self._shared.get(name)
138
+ if target.kind == "raw_id":
139
+ text = "" if shared_id is None else str(shared_id)
140
+ yield Input(value=text, id=f"field-{name}")
141
+ if target.hint:
142
+ yield Label(target.hint, classes="bulk-fk-hint")
143
+ return
144
+ yield Button(f"{name}: {self._fk_seed_label(name)}", id=f"fk-{name}", classes="bulk-fk")
145
+
102
146
  def _compose_widget(self, name: str, spec: WidgetSpec) -> ComposeResult:
147
+ if self._is_fk(name, spec):
148
+ yield from self._compose_fk(name)
149
+ return
103
150
  shared = self._shared.get(name)
104
151
  if spec.kind == "select":
105
152
  options = [(choice, choice) for choice in spec.choices]
@@ -126,12 +173,15 @@ class BulkEditForm(Screen[None]):
126
173
 
127
174
  def _coerce_input(self, name: str, raw: str) -> Any:
128
175
  spec = self._specs.get(name)
129
- if spec is None or spec.kind != "number":
176
+ numeric = (spec is not None and spec.kind == "number") or self._fk_kinds.get(
177
+ name
178
+ ) == "raw_id"
179
+ if not numeric:
130
180
  return raw
131
181
  if raw == "":
132
182
  return None
133
183
  try:
134
- return float(raw) if spec.is_float else int(raw)
184
+ return float(raw) if spec is not None and spec.is_float else int(raw)
135
185
  except ValueError:
136
186
  return raw
137
187
 
@@ -140,7 +190,10 @@ class BulkEditForm(Screen[None]):
140
190
  if include is not None:
141
191
  if event.value:
142
192
  self._included.add(include)
143
- self._values.setdefault(include, self._read_widget_value(include))
193
+ if include not in self._values:
194
+ seed = self._read_widget_value(include)
195
+ if seed is not _NO_SEED:
196
+ self._values[include] = seed
144
197
  else:
145
198
  self._included.discard(include)
146
199
  return
@@ -150,6 +203,9 @@ class BulkEditForm(Screen[None]):
150
203
 
151
204
  def _read_widget_value(self, name: str) -> Any:
152
205
  spec = self._specs.get(name)
206
+ if self._fk_kinds.get(name) == "picker":
207
+ shared_id = self._shared.get(name)
208
+ return shared_id if shared_id is not None else _NO_SEED
153
209
  if spec is not None and spec.kind == "select":
154
210
  value = self.query_one(f"#field-{name}", Select).value
155
211
  return None if value is Select.NULL else value
@@ -173,6 +229,27 @@ class BulkEditForm(Screen[None]):
173
229
  name = self._strip(ident, "setnull-")
174
230
  if name is not None:
175
231
  self._values[name] = SET_NULL
232
+ # Drop any picked label so the diff can't show a name while the
233
+ # patch nulls the relation.
234
+ self._fk_labels.pop(name, None)
235
+ return
236
+ fk = self._strip(ident, "fk-")
237
+ if fk is not None:
238
+ self._open_picker(fk)
239
+
240
+ def _open_picker(self, name: str) -> None:
241
+ target = resolve_fk_target(name, self._fk_nested_value(name), self._model)
242
+ if target.list_op is None:
243
+ return
244
+ from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
245
+
246
+ def _stage(result: tuple[int, str] | None) -> None:
247
+ if result is not None:
248
+ self._values[name] = result[0]
249
+ self._fk_labels[name] = result[1]
250
+ self.query_one(f"#fk-{name}", Button).label = f"{name}: {result[1]}"
251
+
252
+ self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
176
253
 
177
254
  def action_preview(self) -> None:
178
255
  from nsc.tui.bulk import bulk_diff # noqa: PLC0415
@@ -180,7 +257,7 @@ class BulkEditForm(Screen[None]):
180
257
 
181
258
  body = self._op.request_body
182
259
  sensitive = body.sensitive_paths if body is not None else ()
183
- changes = bulk_diff(self._selected, self.bulk_set, sensitive)
260
+ changes = bulk_diff(self._selected, self.bulk_set, sensitive, self._fk_labels)
184
261
 
185
262
  def _on_confirm(confirmed: bool | None) -> None:
186
263
  if confirmed:
@@ -23,7 +23,7 @@ from nsc.model.command_model import CommandModel, Resource
23
23
  from nsc.output.flatten import flatten
24
24
  from nsc.tui._bindings import textual_bindings
25
25
  from nsc.tui.errors import api_error_message
26
- from nsc.tui.fk import resolve_fk_target
26
+ from nsc.tui.fk import is_fk_value, resolve_fk_target
27
27
  from nsc.tui.forms import SET_NULL, WidgetSpec, compute_patch, diff_rows, field_to_widget
28
28
  from nsc.tui.relations import RelatedView, related_views
29
29
  from nsc.tui.view import detail_path
@@ -69,6 +69,7 @@ class DetailScreen(Screen[None]):
69
69
  self._specs: dict[str, WidgetSpec] = {}
70
70
  self._rows: list[_FieldRow] = []
71
71
  self.staged: dict[str, Any] = {}
72
+ self._fk_labels: dict[str, str] = {}
72
73
  self._editing: str | None = None
73
74
  self._relations: list[RelatedView] = related_views(model, resource_name)
74
75
  self.title = f"{resource_name} #{record.get('id', '?')}"
@@ -129,8 +130,11 @@ class DetailScreen(Screen[None]):
129
130
  staged = self.staged[row.name]
130
131
  if staged is SET_NULL:
131
132
  return "(null)"
132
- # Never echo a just-typed secret back into the on-screen table.
133
- return "****" if sensitive and staged != "" else str(staged)
133
+ # Never echo a just-typed secret; a picked FK stages a bare id, so
134
+ # show its chosen label rather than the number.
135
+ return (
136
+ "****" if sensitive and staged != "" else self._fk_labels.get(row.name, str(staged))
137
+ )
134
138
  if row.editable:
135
139
  value = self._record.get(row.name)
136
140
  if isinstance(value, dict):
@@ -199,6 +203,7 @@ class DetailScreen(Screen[None]):
199
203
  def _stage(result: tuple[int, str] | None) -> None:
200
204
  if result is not None:
201
205
  self.staged[name] = result[0]
206
+ self._fk_labels[name] = result[1]
202
207
  self._refresh_rows()
203
208
 
204
209
  self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
@@ -236,9 +241,12 @@ class DetailScreen(Screen[None]):
236
241
  self._table.focus()
237
242
 
238
243
  def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
239
- if spec.kind != "number" or spec.is_float or spec.sensitive:
244
+ # Writable FK fields type as oneOf[int, brief] -> UNKNOWN -> `text`, so a
245
+ # `number`-only gate would miss real relations. Key off the record's
246
+ # nested object instead; exclude enum/bool/secret/float.
247
+ if spec.kind in ("select", "switch", "masked") or spec.is_float:
240
248
  return False
241
- return name.endswith("_id") or isinstance(self._record.get(name), dict)
249
+ return name.endswith("_id") or is_fk_value(self._record.get(name))
242
250
 
243
251
  # --- save ------------------------------------------------------------
244
252
  def action_save_all(self) -> None:
@@ -254,7 +262,7 @@ class DetailScreen(Screen[None]):
254
262
  if confirmed:
255
263
  self._apply_patch(patch)
256
264
 
257
- rows = diff_rows(self._record, patch, self._sensitive)
265
+ rows = diff_rows(self._record, patch, self._sensitive, self._fk_labels)
258
266
  self.app.push_screen(DiffModal(rows), _on_confirm)
259
267
 
260
268
  def _apply_patch(self, patch: dict[str, Any]) -> None:
@@ -19,8 +19,15 @@ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
19
19
  from nsc.model.command_model import CommandModel, Operation
20
20
  from nsc.tui._bindings import textual_bindings
21
21
  from nsc.tui.errors import api_error_message
22
- from nsc.tui.fk import resolve_fk_target
23
- from nsc.tui.forms import SET_NULL, WidgetSpec, compute_patch, diff_rows, field_to_widget
22
+ from nsc.tui.fk import is_fk_value, resolve_fk_target
23
+ from nsc.tui.forms import (
24
+ SET_NULL,
25
+ WidgetSpec,
26
+ compute_patch,
27
+ diff_rows,
28
+ field_to_widget,
29
+ fk_display,
30
+ )
24
31
  from nsc.tui.view import detail_path
25
32
 
26
33
 
@@ -75,6 +82,7 @@ class EditForm(Screen[None]):
75
82
  self._op = operation
76
83
  self._record = record
77
84
  self._specs: dict[str, WidgetSpec] = {}
85
+ self._fk_labels: dict[str, str] = {}
78
86
  self.staged: dict[str, Any] = {}
79
87
  self._create_mode = operation.http_method.upper() == "POST"
80
88
  if self._create_mode:
@@ -119,9 +127,12 @@ class EditForm(Screen[None]):
119
127
  yield Input(value=text, password=spec.sensitive, id=f"field-{name}")
120
128
 
121
129
  def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
122
- if spec.kind != "number" or spec.is_float or spec.sensitive:
130
+ # Writable FK fields type as oneOf[int, brief] -> UNKNOWN -> `text`, so a
131
+ # `number`-only gate would miss real relations (role, site, tenant…). Key
132
+ # off the record's nested object instead; exclude enum/bool/secret/float.
133
+ if spec.kind in ("select", "switch", "masked") or spec.is_float:
123
134
  return False
124
- return name.endswith("_id") or isinstance(self._record.get(name), dict)
135
+ return name.endswith("_id") or is_fk_value(self._record.get(name))
125
136
 
126
137
  def _compose_fk(self, name: str, value: Any) -> ComposeResult:
127
138
  target = resolve_fk_target(name, self._record.get(name), self._model)
@@ -131,7 +142,12 @@ class EditForm(Screen[None]):
131
142
  if target.hint:
132
143
  yield Label(target.hint, classes="edit-fk-hint")
133
144
  return
134
- current = "" if value is None else str(value)
145
+ nested = self._record.get(name)
146
+ current = (
147
+ fk_display(nested)
148
+ if isinstance(nested, dict)
149
+ else ("" if value is None else str(value))
150
+ )
135
151
  yield Button(f"{name}: {current}", id=f"fk-{name}", classes="edit-fk")
136
152
 
137
153
  @staticmethod
@@ -181,7 +197,10 @@ class EditForm(Screen[None]):
181
197
  self.action_save()
182
198
  return
183
199
  if ident.startswith("setnull-"):
184
- self.staged[ident.removeprefix("setnull-")] = SET_NULL
200
+ field = ident.removeprefix("setnull-")
201
+ self.staged[field] = SET_NULL
202
+ # Drop any picked label so the diff can't show a name while nulling.
203
+ self._fk_labels.pop(field, None)
185
204
  return
186
205
  if ident.startswith("fk-"):
187
206
  self._open_picker(ident.removeprefix("fk-"))
@@ -195,6 +214,7 @@ class EditForm(Screen[None]):
195
214
  def _stage(result: tuple[int, str] | None) -> None:
196
215
  if result is not None:
197
216
  self.staged[name] = result[0]
217
+ self._fk_labels[name] = result[1]
198
218
  self.query_one(f"#fk-{name}", Button).label = f"{name}: {result[1]}"
199
219
 
200
220
  self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
@@ -212,7 +232,9 @@ class EditForm(Screen[None]):
212
232
  if confirmed:
213
233
  self._apply_patch(patch, sensitive)
214
234
 
215
- self.app.push_screen(DiffModal(diff_rows(self._record, patch, sensitive)), _on_confirm)
235
+ self.app.push_screen(
236
+ DiffModal(diff_rows(self._record, patch, sensitive, self._fk_labels)), _on_confirm
237
+ )
216
238
 
217
239
  def _apply_patch(self, patch: dict[str, Any], sensitive_paths: tuple[str, ...]) -> None:
218
240
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netbox-super-cli"
3
- version = "1.2.0"
3
+ version = "1.2.1"
4
4
  description = "Dynamic NetBox CLI generated from the live OpenAPI schema."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -78,7 +78,15 @@ def test_fk_nested_dict_by_id_changes_when_id_differs() -> None:
78
78
  selected = [{"id": 1, "site": {"id": 9, "name": "dc1"}}]
79
79
  result = bulk_diff(selected, {"site": 3}, ())
80
80
  assert result[0].patch == {"site": 3}
81
- assert result[0].rows == [DiffRow(field="site", old_display="9", new_display="3")]
81
+ # Old value renders the FK's human label, not its numeric id.
82
+ assert result[0].rows == [DiffRow(field="site", old_display="dc1", new_display="3")]
83
+
84
+
85
+ def test_fk_new_display_override_renders_chosen_label() -> None:
86
+ selected = [{"id": 1, "site": {"id": 9, "name": "dc1"}}]
87
+ result = bulk_diff(selected, {"site": 3}, (), {"site": "dc2"})
88
+ assert result[0].patch == {"site": 3}
89
+ assert result[0].rows == [DiffRow(field="site", old_display="dc1", new_display="dc2")]
82
90
 
83
91
 
84
92
  def test_set_null_on_already_null_record_yields_no_row() -> None: