netbox-super-cli 1.6.0__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.0 → netbox_super_cli-1.6.2}/CHANGELOG.md +47 -0
  2. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/PKG-INFO +1 -1
  3. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/interactive-tui.md +5 -3
  4. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/_version.py +1 -1
  5. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/builder/build.py +102 -1
  6. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cache/store.py +5 -1
  7. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/model/command_model.py +22 -0
  8. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/bulk.py +4 -2
  9. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/fk.py +123 -13
  10. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/forms.py +17 -4
  11. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/bulk_edit_form.py +46 -7
  12. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/detail.py +7 -1
  13. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/edit_form.py +18 -4
  14. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/filter.py +9 -2
  15. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/styles.tcss +4 -1
  16. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/pyproject.toml +1 -1
  17. netbox_super_cli-1.6.2/tests/builder/test_fk_metadata.py +148 -0
  18. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/test_store.py +12 -1
  19. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_completion_protocol_smoke.py +2 -0
  20. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_dynamic_completion.py +2 -0
  21. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_edit_form.py +135 -2
  22. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_edit_form.py +28 -1
  23. netbox_super_cli-1.6.2/tests/tui/test_fk_resolve.py +305 -0
  24. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_forms.py +15 -0
  25. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/uv.lock +1 -1
  26. netbox_super_cli-1.6.0/tests/tui/test_fk_resolve.py +0 -144
  27. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/docs.yml +0 -0
  30. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  31. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/dependabot.yml +0 -0
  32. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/pull_request_template.md +0 -0
  33. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/bench.yml +0 -0
  34. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/claude.yml +0 -0
  35. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/docs.yml +0 -0
  36. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/e2e.yml +0 -0
  37. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/lint.yml +0 -0
  38. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/release.yml +0 -0
  39. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/skill-convention-review.yml +0 -0
  40. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/test.yml +0 -0
  41. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.gitignore +0 -0
  42. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.pre-commit-config.yaml +0 -0
  43. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.python-version +0 -0
  44. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/AGENTS.md +0 -0
  45. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/CLAUDE.md +0 -0
  46. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/LICENSE +0 -0
  47. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/README.md +0 -0
  48. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/caching.md +0 -0
  49. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/command-generation.md +0 -0
  50. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/http-client.md +0 -0
  51. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/overview.md +0 -0
  52. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/schema-loading.md +0 -0
  53. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/detail.svg +0 -0
  54. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/filter.svg +0 -0
  55. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/list.svg +0 -0
  56. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/picker.svg +0 -0
  57. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/search.svg +0 -0
  58. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/adding-bundled-schemas.md +0 -0
  59. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/branching.md +0 -0
  60. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/development.md +0 -0
  61. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/release-process.md +0 -0
  62. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/concepts.md +0 -0
  63. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/first-run.md +0 -0
  64. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/install.md +0 -0
  65. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/audit-log.md +0 -0
  66. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/ci-and-automation.md +0 -0
  67. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/managing-profiles.md +0 -0
  68. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/output-formats.md +0 -0
  69. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/using-with-ai-agents.md +0 -0
  70. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/working-with-plugins.md +0 -0
  71. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/writes-and-safety.md +0 -0
  72. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/index.md +0 -0
  73. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/cli.md +0 -0
  74. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/config.md +0 -0
  75. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/exit-codes.md +0 -0
  76. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/schemas.md +0 -0
  77. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/justfile +0 -0
  78. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/mkdocs.yml +0 -0
  79. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/__init__.py +0 -0
  80. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/__main__.py +0 -0
  81. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/aliases/__init__.py +0 -0
  82. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/aliases/resolver.py +0 -0
  83. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/auth/__init__.py +0 -0
  84. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/auth/verify.py +0 -0
  85. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/builder/__init__.py +0 -0
  86. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cache/__init__.py +0 -0
  87. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/__init__.py +0 -0
  88. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/aliases_commands.py +0 -0
  89. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/app.py +0 -0
  90. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/cache_commands.py +0 -0
  91. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/commands_dump.py +0 -0
  92. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/config_commands.py +0 -0
  93. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/globals.py +0 -0
  94. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/handlers.py +0 -0
  95. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/init_commands.py +0 -0
  96. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/login_commands.py +0 -0
  97. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/profiles_commands.py +0 -0
  98. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/registration.py +0 -0
  99. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/runtime.py +0 -0
  100. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/skill_commands.py +0 -0
  101. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/tui_commands.py +0 -0
  102. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/__init__.py +0 -0
  103. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/apply.py +0 -0
  104. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/bulk.py +0 -0
  105. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/coercion.py +0 -0
  106. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/confirmation.py +0 -0
  107. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/input.py +0 -0
  108. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/preflight.py +0 -0
  109. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/__init__.py +0 -0
  110. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/cache_probe.py +0 -0
  111. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/callbacks.py +0 -0
  112. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/providers.py +0 -0
  113. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/__init__.py +0 -0
  114. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/loader.py +0 -0
  115. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/models.py +0 -0
  116. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/saved_searches.py +0 -0
  117. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/settings.py +0 -0
  118. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/writer.py +0 -0
  119. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/__init__.py +0 -0
  120. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/audit.py +0 -0
  121. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/client.py +0 -0
  122. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/errors.py +0 -0
  123. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/retry.py +0 -0
  124. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/model/__init__.py +0 -0
  125. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/__init__.py +0 -0
  126. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/_console.py +0 -0
  127. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/colors.py +0 -0
  128. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/csv_.py +0 -0
  129. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/errors.py +0 -0
  130. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/explain.py +0 -0
  131. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/flatten.py +0 -0
  132. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/headers.py +0 -0
  133. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/json_.py +0 -0
  134. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/jsonl.py +0 -0
  135. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/render.py +0 -0
  136. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/table.py +0 -0
  137. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/yaml_.py +0 -0
  138. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/__init__.py +0 -0
  139. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/custom_fields.py +0 -0
  140. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/objecttypes.py +0 -0
  141. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/params.py +0 -0
  142. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/store.py +0 -0
  143. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/tags.py +0 -0
  144. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/__init__.py +0 -0
  145. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/hashing.py +0 -0
  146. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/loader.py +0 -0
  147. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/models.py +0 -0
  148. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/source.py +0 -0
  149. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/__init__.py +0 -0
  150. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/__init__.py +0 -0
  151. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/manifest.yaml +0 -0
  152. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
  153. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
  154. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/skill/__init__.py +0 -0
  155. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/__init__.py +0 -0
  156. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/_bindings.py +0 -0
  157. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/app.py +0 -0
  158. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/catalog.py +0 -0
  159. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/columns.py +0 -0
  160. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/errors.py +0 -0
  161. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/filters.py +0 -0
  162. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/keymap.py +0 -0
  163. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/nav.py +0 -0
  164. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/relations.py +0 -0
  165. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/__init__.py +0 -0
  166. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/columns.py +0 -0
  167. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/global_search.py +0 -0
  168. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/list.py +0 -0
  169. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/picker.py +0 -0
  170. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/record_picker.py +0 -0
  171. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/saved_search_picker.py +0 -0
  172. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/search.py +0 -0
  173. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/selection.py +0 -0
  174. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/view.py +0 -0
  175. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/__init__.py +0 -0
  176. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/_modal.py +0 -0
  177. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_diff.py +0 -0
  178. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_summary.py +0 -0
  179. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/confirm.py +0 -0
  180. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/diff.py +0 -0
  181. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/help.py +0 -0
  182. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/nav_tree.py +0 -0
  183. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/scripts/gen_docs.py +0 -0
  184. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/scripts/sync_agents_md.py +0 -0
  185. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/skills/netbox-super-cli/SKILL.md +0 -0
  186. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/__init__.py +0 -0
  187. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/aliases/__init__.py +0 -0
  188. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/aliases/test_resolver.py +0 -0
  189. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/audit/__init__.py +0 -0
  190. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/audit/test_redaction_full.py +0 -0
  191. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/auth/__init__.py +0 -0
  192. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/auth/test_verify.py +0 -0
  193. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/benchmarks/__init__.py +0 -0
  194. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/benchmarks/test_startup.py +0 -0
  195. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/__init__.py +0 -0
  196. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_build.py +0 -0
  197. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_default_columns.py +0 -0
  198. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_redaction_marking.py +0 -0
  199. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_request_body_shape.py +0 -0
  200. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/__init__.py +0 -0
  201. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/test_prune.py +0 -0
  202. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/__init__.py +0 -0
  203. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_aliases_commands.py +0 -0
  204. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_app_smoke.py +0 -0
  205. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_cache_commands.py +0 -0
  206. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_commands_dump.py +0 -0
  207. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_completion_smoke.py +0 -0
  208. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_config_commands.py +0 -0
  209. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_explain.py +0 -0
  210. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_globals_color.py +0 -0
  211. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_handlers.py +0 -0
  212. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_init_commands.py +0 -0
  213. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_lazy_textual_import.py +0 -0
  214. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_login_commands.py +0 -0
  215. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
  216. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_ndjson_input.py +0 -0
  217. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_profiles_commands.py +0 -0
  218. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_registration.py +0 -0
  219. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_respx_integration.py +0 -0
  220. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_runtime.py +0 -0
  221. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_runtime_color.py +0 -0
  222. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_skill_commands.py +0 -0
  223. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_stdin_sniffer.py +0 -0
  224. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_tui_command.py +0 -0
  225. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_writes_respx.py +0 -0
  226. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/__init__.py +0 -0
  227. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_apply.py +0 -0
  228. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_bulk.py +0 -0
  229. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_confirmation.py +0 -0
  230. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handler_helpers.py +0 -0
  231. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handlers_audit.py +0 -0
  232. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_input.py +0 -0
  233. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_preflight.py +0 -0
  234. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/__init__.py +0 -0
  235. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_loader.py +0 -0
  236. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_models.py +0 -0
  237. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_saved_search_fallback.py +0 -0
  238. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_saved_searches.py +0 -0
  239. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_settings_private_dir.py +0 -0
  240. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_atomicity.py +0 -0
  241. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_dotted_paths.py +0 -0
  242. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_roundtrip.py +0 -0
  243. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/conftest.py +0 -0
  244. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/README.md +0 -0
  245. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/__init__.py +0 -0
  246. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/conftest.py +0 -0
  247. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/docker-compose.yml +0 -0
  248. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_audit_redaction.py +0 -0
  249. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_auth_error_envelope.py +0 -0
  250. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
  251. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_full_cycle.py +0 -0
  252. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_login.py +0 -0
  253. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_ndjson.py +0 -0
  254. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_preflight_blocks_apply.py +0 -0
  255. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
  256. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/wait_for_netbox.sh +0 -0
  257. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/profiles/single_profile.yaml +0 -0
  258. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/auth_401.json +0 -0
  259. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/circuits_providers_list.json +0 -0
  260. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_get.json +0 -0
  261. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
  262. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
  263. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/__init__.py +0 -0
  264. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit.py +0 -0
  265. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit_permissions.py +0 -0
  266. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit_redaction.py +0 -0
  267. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_client.py +0 -0
  268. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_client_redaction_threading.py +0 -0
  269. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_errors.py +0 -0
  270. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_retry.py +0 -0
  271. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/__init__.py +0 -0
  272. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/test_command_model.py +0 -0
  273. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/test_request_body_shape.py +0 -0
  274. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/__init__.py +0 -0
  275. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_colors.py +0 -0
  276. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_console.py +0 -0
  277. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_csv.py +0 -0
  278. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors.py +0 -0
  279. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors_aliases.py +0 -0
  280. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors_color.py +0 -0
  281. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_explain.py +0 -0
  282. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_explain_color.py +0 -0
  283. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_flatten.py +0 -0
  284. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_input_error_envelope.py +0 -0
  285. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_json.py +0 -0
  286. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_jsonl.py +0 -0
  287. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_render_dispatch.py +0 -0
  288. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_table.py +0 -0
  289. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_table_color.py +0 -0
  290. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_yaml.py +0 -0
  291. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/__init__.py +0 -0
  292. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_custom_fields.py +0 -0
  293. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_objecttypes.py +0 -0
  294. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_params.py +0 -0
  295. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_store.py +0 -0
  296. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_tags.py +0 -0
  297. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/__init__.py +0 -0
  298. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_hashing.py +0 -0
  299. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_loader.py +0 -0
  300. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_models.py +0 -0
  301. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_source.py +0 -0
  302. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_source_ttl.py +0 -0
  303. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/__init__.py +0 -0
  304. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/test_gen_docs.py +0 -0
  305. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/test_sync_agents_md.py +0 -0
  306. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/skill/__init__.py +0 -0
  307. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/skill/test_bundle.py +0 -0
  308. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/test_packaging.py +0 -0
  309. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/__init__.py +0 -0
  310. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_app.py +0 -0
  311. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bindings.py +0 -0
  312. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_apply.py +0 -0
  313. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff.py +0 -0
  314. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff_modal.py +0 -0
  315. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_import_isolation.py +0 -0
  316. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_summary.py +0 -0
  317. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_catalog.py +0 -0
  318. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_column_chooser.py +0 -0
  319. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_columns.py +0 -0
  320. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_detail_screen.py +0 -0
  321. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_diff.py +0 -0
  322. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_diff_modal.py +0 -0
  323. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_errors.py +0 -0
  324. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_filter_screen.py +0 -0
  325. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_filters.py +0 -0
  326. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_global_search.py +0 -0
  327. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_help_overlay.py +0 -0
  328. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_keymap.py +0 -0
  329. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_list_screen.py +0 -0
  330. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_picker.py +0 -0
  331. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_record_picker.py +0 -0
  332. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_relations.py +0 -0
  333. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_saved_filter_integration.py +0 -0
  334. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_search.py +0 -0
  335. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_selection.py +0 -0
  336. {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_view.py +0 -0
@@ -2,6 +2,53 @@
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
+
22
+ ## v1.6.1 — 2026-06-29
23
+
24
+ Patch release. Fixes the TUI bulk-edit form so custom-field edits are visible and
25
+ actually save.
26
+
27
+ ### Fixed
28
+
29
+ - **Bulk-edit include toggles were invisible, so edits silently didn't save**
30
+ ([#137]). The per-field include switch in the bulk-edit form was clipped to
31
+ zero width by its stylesheet and rendered blank — users couldn't see or flip
32
+ it, so no field was opted in and the apply produced no change with no error.
33
+ The toggle now sizes to fit its slider.
34
+ - **Edit forms showed the raw `custom_fields.<name>` key** ([#137]). The
35
+ single-edit and bulk-edit forms, and the change-preview diff, now show a custom
36
+ field's human label (e.g. "Site Contact") instead of the raw dotted key,
37
+ matching the list-column labels added in v1.6.0.
38
+ - **Opting in a custom-field select with an unresolved choice set could silently
39
+ null the field** ([#137]). A select whose choices couldn't be fetched rendered
40
+ empty; opting it in seeded a null and cleared a value the user never edited.
41
+ Opting in a blank select now contributes no change; only the explicit ∅ button
42
+ nulls.
43
+ - **Bulk-editing a shared custom field could overwrite it on opt-in** ([#137]).
44
+ Custom-field widgets didn't seed the value the selected records share, so a
45
+ shared boolean rendered as `False` and opting it in unchanged flipped every
46
+ record to `False` (and a shared multiselect cleared to empty). Custom-field
47
+ widgets now seed the shared value, so opting a field in without editing it is a
48
+ no-op.
49
+
50
+ [#137]: https://github.com/thomaschristory/netbox-super-cli/issues/137
51
+
5
52
  ## v1.6.0 — 2026-06-29
6
53
 
7
54
  Minor release. The interactive TUI gets human-readable custom-field columns,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-super-cli
3
- Version: 1.6.0
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/
@@ -144,9 +144,11 @@ endpoint can filter by this one. Switch tabs with <kbd>Tab</kbd> and press
144
144
  ## Bulk editing
145
145
 
146
146
  Select rows on a list with <kbd>v</kbd>/<kbd>Space</kbd>, then press
147
- <kbd>B</kbd>. Choose *which* fields to set (each has an include toggle) and one
148
- value each. Fields are **prepopulated** with the value the selected records
149
- share, so a small tweak doesn't mean retyping.
147
+ <kbd>B</kbd>. Each field is one row: flip its **include toggle** (the switch on
148
+ the left) to opt it into the change, then set its value — a field with the toggle
149
+ off is left untouched. Custom fields appear as individual rows under their human
150
+ label. Fields are **prepopulated** with the value the selected records share, so
151
+ a small tweak doesn't mean retyping.
150
152
 
151
153
  Press <kbd>p</kbd> to **preview** — a per-record diff of exactly what will
152
154
  change (records already matching are left untouched). Confirm to apply; a
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "1.6.0"
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."""
@@ -29,6 +29,7 @@ def bulk_diff(
29
29
  bulk_set: dict[str, object],
30
30
  sensitive_paths: tuple[str, ...],
31
31
  new_displays: dict[str, str] | None = None,
32
+ field_labels: dict[str, str] | None = None,
32
33
  ) -> list[RecordChange]:
33
34
  """Compute the per-record patch and diff rows for a uniform bulk ``set``.
34
35
 
@@ -36,12 +37,13 @@ def bulk_diff(
36
37
  record's current value contributes no patch entry and no row for that
37
38
  record, so heterogeneous current values yield different changed subsets.
38
39
  ``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.
40
+ the diff renders the name rather than the id. ``field_labels`` maps a field
41
+ key to its human label so ``custom_fields.<name>`` rows aren't shown raw.
40
42
  """
41
43
  changes: list[RecordChange] = []
42
44
  for record in selected:
43
45
  patch = compute_patch(record, bulk_set)
44
- rows = diff_rows(record, patch, sensitive_paths, new_displays)
46
+ rows = diff_rows(record, patch, sensitive_paths, new_displays, field_labels)
45
47
  changes.append(RecordChange(record_id=record.get("id"), patch=patch, rows=rows))
46
48
  return changes
47
49
 
@@ -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:
@@ -35,6 +35,9 @@ class WidgetSpec(BaseModel):
35
35
 
36
36
  kind: WidgetKind
37
37
  name: str
38
+ # Human-friendly row label; falls back to ``name`` when blank (regular fields
39
+ # keep their key as the label; custom fields carry their NetBox label here).
40
+ label: str = ""
38
41
  choices: tuple[str, ...] = ()
39
42
  nullable: bool = False
40
43
  sensitive: bool = False
@@ -83,15 +86,20 @@ def custom_field_widget(cf: CustomFieldDef) -> WidgetSpec:
83
86
  kind = _CF_KINDS.get(cf.type, "text")
84
87
  nullable = not cf.required
85
88
  if kind == "select":
86
- return WidgetSpec(kind="select", name=name, choices=cf.choices, nullable=nullable)
89
+ return WidgetSpec(
90
+ kind="select", name=name, label=cf.label, choices=cf.choices, nullable=nullable
91
+ )
87
92
  if kind == "multi_select":
88
93
  return WidgetSpec(
89
94
  kind="multi_select",
90
95
  name=name,
96
+ label=cf.label,
91
97
  options=tuple((c, c) for c in cf.choices),
92
98
  nullable=nullable,
93
99
  )
94
- return WidgetSpec(kind=kind, name=name, nullable=nullable, is_float=cf.type == "decimal")
100
+ return WidgetSpec(
101
+ kind=kind, name=name, label=cf.label, nullable=nullable, is_float=cf.type == "decimal"
102
+ )
95
103
 
96
104
 
97
105
  def expand_custom_fields(defs: dict[str, CustomFieldDef]) -> list[WidgetSpec]:
@@ -242,20 +250,25 @@ def diff_rows(
242
250
  patch: dict[str, object],
243
251
  sensitive_paths: tuple[str, ...],
244
252
  new_displays: dict[str, str] | None = None,
253
+ field_labels: dict[str, str] | None = None,
245
254
  ) -> list[DiffRow]:
246
255
  """Render ``patch`` as human-readable old -> new rows for the confirm modal.
247
256
 
248
257
  ``new_displays`` overrides a field's rendered *new* value — used for FK
249
258
  fields whose staged value is a bare id but whose chosen label is known
250
259
  (e.g. picked from the record chooser). The patch still carries the id.
260
+ ``field_labels`` overrides a field's *name* — used so a ``custom_fields.<name>``
261
+ key renders as its human label rather than the raw dotted key.
251
262
  """
252
263
  overrides = new_displays or {}
264
+ labels = field_labels or {}
253
265
  rows: list[DiffRow] = []
254
266
  for name, new_value in patch.items():
267
+ field = labels.get(name, name)
255
268
  if name in sensitive_paths:
256
- rows.append(DiffRow(field=name, old_display="****", new_display="****"))
269
+ rows.append(DiffRow(field=field, old_display="****", new_display="****"))
257
270
  continue
258
271
  old_display = fk_display(original[name]) if name in original else ""
259
272
  new_display = overrides.get(name, str(new_value))
260
- rows.append(DiffRow(field=name, old_display=old_display, new_display=new_display))
273
+ rows.append(DiffRow(field=field, old_display=old_display, new_display=new_display))
261
274
  return rows
@@ -100,9 +100,15 @@ class BulkEditForm(Screen[None]):
100
100
  self._fk_labels: dict[str, str] = {}
101
101
  body = update_op.request_body
102
102
  field_names = list(body.fields) if body is not None else []
103
+ # Custom fields are staged under flattened ``custom_fields.<name>`` keys,
104
+ # so seed their shared value from flattened records too — otherwise a
105
+ # widget defaults (e.g. a boolean to False) and opting it in unchanged
106
+ # would silently overwrite the records' shared value.
107
+ cf_names = [f"custom_fields.{cf.name}" for cf in (custom_field_defs or {}).values()]
108
+ flattened_records = [flatten_custom_fields(record) for record in selected_records]
103
109
  # Shared current value per field, to seed the widgets (does NOT opt the
104
110
  # field in — the include toggle still gates what gets set).
105
- self._shared = shared_values(selected_records, field_names)
111
+ self._shared = shared_values(flattened_records, field_names + cf_names)
106
112
  self.progress_total = 0
107
113
  self.progress_done = 0
108
114
  self.title = f"Bulk edit {len(selected_records)} {resource_name}"
@@ -111,6 +117,10 @@ class BulkEditForm(Screen[None]):
111
117
  def bulk_set(self) -> dict[str, Any]:
112
118
  return {name: self._values[name] for name in self._included if name in self._values}
113
119
 
120
+ def _field_labels(self) -> dict[str, str]:
121
+ """Human labels for fields that carry one (custom fields), for the diff."""
122
+ return {name: spec.label for name, spec in self._specs.items() if spec.label}
123
+
114
124
  def compose(self) -> ComposeResult:
115
125
  yield Header()
116
126
  with VerticalScroll(id="bulk-form-body"):
@@ -142,7 +152,7 @@ class BulkEditForm(Screen[None]):
142
152
  def _compose_field(self, name: str, spec: WidgetSpec) -> ComposeResult:
143
153
  with Horizontal(classes="bulk-field"):
144
154
  yield Switch(value=False, id=f"include-{encode_field_id(name)}", classes="bulk-include")
145
- yield Label(name, classes="bulk-label")
155
+ yield Label(spec.label or name, classes="bulk-label")
146
156
  yield from self._compose_widget(name, spec)
147
157
  if spec.nullable and spec.kind != "multi_select":
148
158
  yield Button("∅", id=f"setnull-{encode_field_id(name)}", classes="bulk-setnull")
@@ -173,7 +183,13 @@ class BulkEditForm(Screen[None]):
173
183
  return str(shared_id)
174
184
 
175
185
  def _compose_fk(self, name: str) -> ComposeResult:
176
- 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
+ )
177
193
  self._fk_kinds[name] = target.kind
178
194
  shared_id = self._shared.get(name)
179
195
  if target.kind == "raw_id":
@@ -191,8 +207,16 @@ class BulkEditForm(Screen[None]):
191
207
  def _compose_widget(self, name: str, spec: WidgetSpec) -> ComposeResult:
192
208
  wid = f"field-{encode_field_id(name)}"
193
209
  if spec.kind == "multi_select":
210
+ # Tags seed via spec.selected; a custom-field multiselect seeds its
211
+ # shared current list so opting it in unchanged isn't a destructive
212
+ # clear (tags are intentionally left blank — they have their own flow).
213
+ selected = set(spec.selected)
214
+ if not selected and name != "tags":
215
+ shared = self._shared.get(name)
216
+ if isinstance(shared, list):
217
+ selected = {str(v) for v in shared}
194
218
  yield SelectionList[str](
195
- *(Selection(label, val, val in spec.selected) for label, val in spec.options),
219
+ *(Selection(label, val, val in selected) for label, val in spec.options),
196
220
  id=wid,
197
221
  classes="bulk-multiselect",
198
222
  )
@@ -264,7 +288,10 @@ class BulkEditForm(Screen[None]):
264
288
  return self._multiselect_value(name, self.query_one(wid, SelectionList).selected)
265
289
  if spec is not None and spec.kind == "select":
266
290
  value = self.query_one(wid, Select).value
267
- return None if value is Select.NULL else value
291
+ # A blank select (no/unresolved choices, or a current value not in the
292
+ # options) must not seed a null on opt-in — that would silently clear a
293
+ # field the user never edited. Only the explicit ∅ button nulls.
294
+ return _NO_SEED if value is Select.NULL else value
268
295
  if spec is not None and spec.kind == "switch":
269
296
  return self.query_one(wid, Switch).value
270
297
  return self._coerce_input(name, self.query_one(wid, Input).value)
@@ -279,6 +306,10 @@ class BulkEditForm(Screen[None]):
279
306
  name = self._strip(event.select.id, "field-")
280
307
  if name is None:
281
308
  return
309
+ # A blank select on a field the user hasn't opted in must not stage a null
310
+ # (which would clear the field on apply). Only the explicit ∅ button nulls.
311
+ if event.value is Select.NULL and name not in self._included:
312
+ return
282
313
  self._values[name] = None if event.value is Select.NULL else event.value
283
314
 
284
315
  def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged[str]) -> None:
@@ -306,7 +337,13 @@ class BulkEditForm(Screen[None]):
306
337
  self._open_picker(fk)
307
338
 
308
339
  def _open_picker(self, name: str) -> None:
309
- 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
+ )
310
347
  if target.list_op is None:
311
348
  return
312
349
  from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
@@ -329,7 +366,9 @@ class BulkEditForm(Screen[None]):
329
366
  sensitive = body.sensitive_paths if body is not None else ()
330
367
  # Flatten custom_fields so each chosen custom_fields.<name> diffs per record.
331
368
  flattened = [flatten_custom_fields(record) for record in self._selected]
332
- changes = bulk_diff(flattened, self.bulk_set, sensitive, self._fk_labels)
369
+ changes = bulk_diff(
370
+ flattened, self.bulk_set, sensitive, self._fk_labels, self._field_labels()
371
+ )
333
372
 
334
373
  def _on_confirm(confirmed: bool | None) -> None:
335
374
  if confirmed: