firestarter 3.2.7b1__tar.gz → 3.2.7b2__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 (212) hide show
  1. {firestarter-3.2.7b1/firestarter.egg-info → firestarter-3.2.7b2}/PKG-INFO +1 -1
  2. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/_version.py +3 -3
  3. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/cli_handlers.py +36 -6
  4. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/eprom_operations.py +15 -1
  5. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/exceptions.py +3 -0
  6. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/firmware.py +3 -0
  7. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/fw_release_gate.py +46 -4
  8. {firestarter-3.2.7b1 → firestarter-3.2.7b2/firestarter.egg-info}/PKG-INFO +1 -1
  9. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/scm_version.json +2 -2
  10. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/__snapshots__/test_characterization.ambr +5 -8
  11. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_characterization.py +33 -16
  12. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_cli_handlers.py +8 -0
  13. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_eprom_operations.py +14 -1
  14. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_port_targeting_and_blind_install.py +7 -0
  15. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_release_gate.py +111 -1
  16. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_update_path_gate.py +7 -0
  17. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_py32_channel_gating.py +51 -0
  18. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_py32_dfu.py +7 -0
  19. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/LICENSE +0 -0
  20. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/MANIFEST.in +0 -0
  21. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/README.md +0 -0
  22. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/__init__.py +0 -0
  23. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/address_parser.py +0 -0
  24. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/avr_tool.py +0 -0
  25. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/channel.py +0 -0
  26. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/chip_resolver.py +0 -0
  27. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/chip_test.py +0 -0
  28. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/codec.py +0 -0
  29. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/compare.py +0 -0
  30. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/config.py +0 -0
  31. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/constants.py +0 -0
  32. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/data/chip_database.json +0 -0
  33. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/data/pinouts.json +0 -0
  34. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/database.py +0 -0
  35. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/diagnostic_report.py +0 -0
  36. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/dip24_2816_erase_gate.py +0 -0
  37. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/eprom_info.py +0 -0
  38. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/erase_support.py +0 -0
  39. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/flash4_erase_gate.py +0 -0
  40. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/frame_parser.py +0 -0
  41. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/hardware.py +0 -0
  42. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/ic_layout.py +0 -0
  43. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/jp5_gate.py +0 -0
  44. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/jumper_table.py +0 -0
  45. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/lock_status.py +0 -0
  46. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/log_capture.py +0 -0
  47. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/logging_utils.py +0 -0
  48. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/main.py +0 -0
  49. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/messages.py +0 -0
  50. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/page_size_gate.py +0 -0
  51. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/protection_readability.py +0 -0
  52. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/py32_dfu.py +0 -0
  53. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/sdp_capability.py +0 -0
  54. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/sdp_honesty.py +0 -0
  55. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/serial_comm.py +0 -0
  56. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/submit.py +0 -0
  57. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/timestamps.py +0 -0
  58. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/transport_counters.py +0 -0
  59. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/utils.py +0 -0
  60. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/vpp_display.py +0 -0
  61. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter/write_blank_guard.py +0 -0
  62. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/SOURCES.txt +0 -0
  63. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/dependency_links.txt +0 -0
  64. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/entry_points.txt +0 -0
  65. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/requires.txt +0 -0
  66. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/scm_file_list.json +0 -0
  67. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/firestarter.egg-info/top_level.txt +0 -0
  68. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/pyproject.toml +0 -0
  69. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/setup.cfg +0 -0
  70. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/__init__.py +0 -0
  71. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/conftest.py +0 -0
  72. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fake_chip.py +0 -0
  73. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/part_number_delta.json +0 -0
  74. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/report_shapes.py +0 -0
  75. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/at28c256-full-all-ok-sdp.json +0 -0
  76. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/attr01-status-axis-transport-fault.json +0 -0
  77. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/gh20-at28c256-fail.json +0 -0
  78. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/gh23-w27e257-fail.json +0 -0
  79. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/gh28-m27c512-fail.json +0 -0
  80. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/gh47-sst27sf512-pass.json +0 -0
  81. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/m27c512-full-all-ok.json +0 -0
  82. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/m27c512-full-blank-check-bad.json +0 -0
  83. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/m27c512-full-canonical-name.json +0 -0
  84. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/m27c512-full-comma-joined-name.json +0 -0
  85. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/m27c512-full-runs-1.json +0 -0
  86. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/prune03-synthesized-fingerprint-match.json +0 -0
  87. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/sst27sf512-full-all-ok.json +0 -0
  88. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/sst27sf512-six-step-readback-gated.json +0 -0
  89. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/sst27sf512-six-step.json +0 -0
  90. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/synthetic-arm4-empty-results.json +0 -0
  91. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/synthetic-arm4-no-ok.json +0 -0
  92. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/uv-slot-write-pass.json +0 -0
  93. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/reports/w27e257-full-all-ok.json +0 -0
  94. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/shape_ids.json +0 -0
  95. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/fixtures/synthetic_nonzero_chip_id.py +0 -0
  96. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/golden/build_db_part_specific_constants.json +0 -0
  97. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/golden/chip_database_field_inventory.json +0 -0
  98. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/golden/wire_dict.json +0 -0
  99. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/plan_corpus.py +0 -0
  100. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_address_parser.py +0 -0
  101. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_b15_page_size_corroboration.py +0 -0
  102. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_blast_radius_invariance.py +0 -0
  103. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_boot_block_hint.py +0 -0
  104. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_budget_failure_render.py +0 -0
  105. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_bug_characterization.py +0 -0
  106. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_build_db_constant_census.py +0 -0
  107. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_build_db_inclusion.py +0 -0
  108. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_build_db_interpret_timing.py +0 -0
  109. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_build_db_pinout_fork.py +0 -0
  110. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_build_db_sram_non_volatile.py +0 -0
  111. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_canonical_part_number.py +0 -0
  112. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_database_field_inventory.py +0 -0
  113. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_name_resolution.py +0 -0
  114. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_resolver.py +0 -0
  115. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test.py +0 -0
  116. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test_blank_check_order.py +0 -0
  117. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test_cycle.py +0 -0
  118. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test_sdp_leg.py +0 -0
  119. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test_timing.py +0 -0
  120. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_chip_test_uv_slot_write.py +0 -0
  121. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_click_group_gate_hook.py +0 -0
  122. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_cobs.py +0 -0
  123. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_codec.py +0 -0
  124. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_codec_format_message.py +0 -0
  125. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_compare.py +0 -0
  126. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_config.py +0 -0
  127. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_config_band_refusal.py +0 -0
  128. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_connect_cost_harness.py +0 -0
  129. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_consistency_check.py +0 -0
  130. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_coverage_floor_v18.py +0 -0
  131. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_database_conversion.py +0 -0
  132. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_datasheet_overrides.py +0 -0
  133. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_decoder.py +0 -0
  134. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_derive_plan_no_drop_sweep.py +0 -0
  135. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_derive_plan_structural_sentinel.py +0 -0
  136. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_dev_group_channel_gating.py +0 -0
  137. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_dev_test_cmd.py +0 -0
  138. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_dev_tools_channel_gate.py +0 -0
  139. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_devtest_firmware_error_propagation.py +0 -0
  140. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_dfu_opcode_anchors.py +0 -0
  141. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_diagnostic_report.py +0 -0
  142. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_dip24_2816_erase_gate.py +0 -0
  143. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_endpoint_constants.py +0 -0
  144. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_eprom_database.py +0 -0
  145. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_eprom_info.py +0 -0
  146. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_erase_blank_step_nonregression.py +0 -0
  147. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_erase_flag_invariants.py +0 -0
  148. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_error_code_seam.py +0 -0
  149. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_even_block.py +0 -0
  150. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_extra_chips_supplement.py +0 -0
  151. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_firmware_install.py +0 -0
  152. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_flash4_erase_gate.py +0 -0
  153. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_list_failure_vs_empty.py +0 -0
  154. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_update_dead_endpoint.py +0 -0
  155. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fw_version_guard.py +0 -0
  156. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_fwguard.py +0 -0
  157. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_hardware.py +0 -0
  158. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_hw_revision_gate.py +0 -0
  159. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_ic_layout.py +0 -0
  160. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_info_vpp_and_erase.py +0 -0
  161. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_jp5_gate.py +0 -0
  162. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_jumper_table.py +0 -0
  163. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_lock_status_class_partition.py +0 -0
  164. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_lock_status_cli.py +0 -0
  165. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_lock_status_wire.py +0 -0
  166. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_log_capture.py +0 -0
  167. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_logging_utils.py +0 -0
  168. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_matrix_artifact.py +0 -0
  169. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_page_size_alignment_refusal.py +0 -0
  170. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_page_size_invariants.py +0 -0
  171. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_page_size_write_refusal.py +0 -0
  172. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_parse_devtest_issue.py +0 -0
  173. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_probe_spurious_setup_ack.py +0 -0
  174. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_programming_vcc_census.py +0 -0
  175. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protect_flags_doc_measurements.py +0 -0
  176. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protection_resolution.py +0 -0
  177. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protection_status_catalog.py +0 -0
  178. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protection_table_citations.py +0 -0
  179. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protocol_not_implemented.py +0 -0
  180. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_protocol_not_implemented_production_path.py +0 -0
  181. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_provenance.py +0 -0
  182. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_pulse_us_override.py +0 -0
  183. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_py32_flash_map_host.py +0 -0
  184. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_py32_pyusb_absent.py +0 -0
  185. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_pyusb_api_surface.py +0 -0
  186. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_sdp_capability.py +0 -0
  187. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_sdp_db_invariant.py +0 -0
  188. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_sdp_honesty.py +0 -0
  189. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_serial_characterization.py +0 -0
  190. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_serial_comm.py +0 -0
  191. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_session_lease.py +0 -0
  192. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_setup_refusals.py +0 -0
  193. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_submit.py +0 -0
  194. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_timestamps.py +0 -0
  195. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_transport_counters.py +0 -0
  196. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_utils.py +0 -0
  197. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_uv_mask.py +0 -0
  198. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_validate_family_cmd.py +0 -0
  199. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_validate_oracle.py +0 -0
  200. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_validated_parts_regression_surface.py +0 -0
  201. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_vcc_margin_rail.py +0 -0
  202. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_vpp_decode_table.py +0 -0
  203. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_vpp_rail_classification.py +0 -0
  204. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_wire_dict_equivalence.py +0 -0
  205. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_blank_guard.py +0 -0
  206. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_blank_guard_pinning.py +0 -0
  207. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_guard_port_pinning.py +0 -0
  208. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_progress.py +0 -0
  209. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_response_budget.py +0 -0
  210. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_skip_erase_0x0d.py +0 -0
  211. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_skip_sdp_unlock.py +0 -0
  212. {firestarter-3.2.7b1 → firestarter-3.2.7b2}/tests/test_write_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firestarter
3
- Version: 3.2.7b1
3
+ Version: 3.2.7b2
4
4
  Summary: EPROM programmer software for the Relatively Universal ROM Programmer
5
5
  Author-email: Henrik Olsson <henols@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/henols/firestarter_app
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '3.2.7b1'
22
- __version_tuple__ = version_tuple = (3, 2, 7, 'b1')
21
+ __version__ = version = '3.2.7b2'
22
+ __version_tuple__ = version_tuple = (3, 2, 7, 'b2')
23
23
 
24
- __commit_id__ = commit_id = 'g0159d48'
24
+ __commit_id__ = commit_id = 'g30e9def'
@@ -37,6 +37,7 @@ from firestarter.channel import (
37
37
  available_boards,
38
38
  dev_command_gate_message,
39
39
  is_dev_tools_enabled,
40
+ is_prerelease_build,
40
41
  )
41
42
  from firestarter.chip_resolver import (
42
43
  describe_ambiguity,
@@ -186,6 +187,27 @@ def _complete_eprom(
186
187
  _ALL_BOARDS: tuple[str, ...] = ("uno", "uno328pb", "leonardo", "py32f071")
187
188
  _BOARD_CHOICES: list[str] = available_boards(_ALL_BOARDS)
188
189
  _PY32_ENABLED: bool = "py32f071" in _BOARD_CHOICES
190
+ # A stable build never offers pre-release firmware: `fw --pre` is refused and
191
+ # `fw --list` shows stable releases only. fw_release_gate refuses the install.
192
+ _PRE_CHANNEL_ENABLED: bool = is_prerelease_build()
193
+
194
+
195
+ def _reject_gated_option(name: str, given: bool, enabled: bool) -> None:
196
+ """The one refusal for a CLI option that this build's channel does not offer.
197
+
198
+ It reads like Click's own unknown-option error, because on this build the
199
+ option does not exist.
200
+ """
201
+ if given and not enabled:
202
+ raise click.UsageError(f"no such option: {name}")
203
+
204
+
205
+ def _reject_pre_only_option(name: str, given: bool) -> None:
206
+ """Refuse a pre-release-only CLI option on a stable build.
207
+
208
+ Reads `_PRE_CHANNEL_ENABLED` at CALL time, like `_reject_py32_only_option`.
209
+ """
210
+ _reject_gated_option(name, given, _PRE_CHANNEL_ENABLED)
189
211
 
190
212
 
191
213
  def _reject_py32_only_option(name: str, given: bool) -> None:
@@ -204,8 +226,7 @@ def _reject_py32_only_option(name: str, given: bool) -> None:
204
226
  default -- which is what makes it monkeypatchable while the Click surface
205
227
  stays frozen at import time.
206
228
  """
207
- if given and not _PY32_ENABLED:
208
- raise click.UsageError(f"no such option: {name}")
229
+ _reject_gated_option(name, given, _PY32_ENABLED)
209
230
 
210
231
 
211
232
  def map_typed_errors(f: Callable[..., Any]) -> Callable[..., Any]:
@@ -1764,6 +1785,7 @@ def _maybe_auto_route_to_pre_click(
1764
1785
  @click.option(
1765
1786
  "--pre",
1766
1787
  is_flag=True,
1788
+ hidden=not _PRE_CHANNEL_ENABLED,
1767
1789
  help="Fetch latest pre-release firmware (mirrors pip install --pre).",
1768
1790
  )
1769
1791
  @click.option(
@@ -1772,7 +1794,11 @@ def _maybe_auto_route_to_pre_click(
1772
1794
  type=_FirmwareVersionType(),
1773
1795
  default=None,
1774
1796
  metavar="VERSION",
1775
- help="Pin exact firmware version (e.g. 3.1.0, 3.1.0b2, 3.1.0rc1).",
1797
+ help=(
1798
+ "Pin exact firmware version (e.g. 3.1.0, 3.1.0b2, 3.1.0rc1)."
1799
+ if _PRE_CHANNEL_ENABLED
1800
+ else "Pin exact firmware version (e.g. 3.1.0)."
1801
+ ),
1776
1802
  )
1777
1803
  @click.option(
1778
1804
  "--stable",
@@ -1861,11 +1887,15 @@ def fw(
1861
1887
  ) -> None:
1862
1888
  """Firmware version.
1863
1889
 
1864
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
1865
- passing more than one is refused before anything is installed.
1890
+ The release channel options are mutually exclusive; passing more than one
1891
+ is refused before anything is installed.
1866
1892
  """
1867
1893
  app: AppContext = ctx.obj
1868
1894
 
1895
+ # Before the mutex check, so a stable build never names --pre in an error
1896
+ # other than this one.
1897
+ _reject_pre_only_option("--pre", pre)
1898
+
1869
1899
  # 3-way --pre / --firmware-version / --stable mutex enforced once,
1870
1900
  # post-parse, with a deterministic error message that doesn't depend on
1871
1901
  # the user's option ordering. Raises click.UsageError → exit-2, matching
@@ -1914,7 +1944,7 @@ def fw(
1914
1944
  channel_filter: Literal["all", "pre", "stable"]
1915
1945
  if pre:
1916
1946
  channel_filter = "pre"
1917
- elif stable:
1947
+ elif stable or not _PRE_CHANNEL_ENABLED:
1918
1948
  channel_filter = "stable"
1919
1949
  else:
1920
1950
  channel_filter = "all"
@@ -362,6 +362,20 @@ def _blank_expected_bytes(_offset: int, length: int) -> bytes:
362
362
  return b"\xff" * length
363
363
 
364
364
 
365
+ def _default_read_path(eprom_name: str) -> str:
366
+ """The output file name `read` uses when the user gives none:
367
+ `<NAME>.bin`, or `<NAME>-<n>.bin` with the lowest free `n` when that
368
+ file exists, so a second read never overwrites the first.
369
+ """
370
+ base = eprom_name.upper()
371
+ path = f"{base}.bin"
372
+ n = 1
373
+ while os.path.exists(path):
374
+ path = f"{base}-{n}.bin"
375
+ n += 1
376
+ return path
377
+
378
+
365
379
  def _write_blank_guard_refusal_message(
366
380
  eprom_name: str, region_start: int, result: CompareResult
367
381
  ) -> str:
@@ -1298,7 +1312,7 @@ class EpromOperator:
1298
1312
  if not cmd_data:
1299
1313
  return False
1300
1314
 
1301
- actual_output_file = output_file or f"{eprom_name.upper()}.bin"
1315
+ actual_output_file = output_file or _default_read_path(eprom_name)
1302
1316
  logger.info(
1303
1317
  f"Reading EPROM {eprom_name.upper()}, saving to {actual_output_file}"
1304
1318
  )
@@ -181,6 +181,9 @@ class FirmwareReleaseRefusedError(FirmwareOperationError):
181
181
  waives it; `--force` does not, because `--force` means "reinstall the same
182
182
  version" and is also a wire flag (FLAG_FORCE).
183
183
 
184
+ Also raised when this CLI is a stable release and the firmware release is a
185
+ pre-release. Nothing waives that refusal.
186
+
184
187
  A FirmwareOperationError, so cli_handlers.map_typed_errors renders it
185
188
  verbatim through the existing FirmwareOperationError arm. No new arm.
186
189
  """
@@ -866,6 +866,9 @@ class FirmwareManager:
866
866
  # onto the pre channel, which is the channel most likely to be ahead of
867
867
  # it. That raises how often this refusal fires. Do not "fix" the auto
868
868
  # route to avoid the refusal -- the refusal is the point.
869
+ #
870
+ # The same call refuses a pre-release on a stable CLI. That covers a
871
+ # library caller that asks a stable CLI for channel="pre".
869
872
  if app_version is None:
870
873
  # Dereferenced at call time, not bound at import, so a test can
871
874
  # monkeypatch firestarter.__version__ without reloading this module.
@@ -16,6 +16,11 @@ The rule is the FEATURE version, (major, minor). The patch number is ignored,
16
16
  and so is the pre-release suffix: 3.1.0b2 and 3.1.0b5 are one feature version.
17
17
  A release is refused when its feature version is ABOVE this CLI's.
18
18
 
19
+ A stable CLI also refuses every pre-release firmware. A stable CLI is a final
20
+ PEP 440 release; a pre-release firmware is a b/rc/dev tag. This rule ignores the
21
+ feature version, and nothing waives it: a stable user gets stable firmware only.
22
+ A pre-release CLI (a b/rc tag, or a dev checkout) installs either.
23
+
19
24
  The reverse pairing -- a CLI newer than the firmware -- is NOT gated here.
20
25
  `_maybe_auto_route_to_pre` in cli_handlers.py steers channel selection away from
21
26
  it. The two checks cover opposite directions and neither replaces the other.
@@ -23,7 +28,7 @@ it. The two checks cover opposite directions and neither replaces the other.
23
28
  Polarity: fail closed. A false pass flashes a board this host cannot drive, and
24
29
  the board must then be recovered through the bootloader. A false refusal only
25
30
  makes the install unavailable, and `firestarter fw --list` still enumerates
26
- every release. A version that cannot be read on either side is refused exactly
31
+ the releases. A version that cannot be read on either side is refused exactly
27
32
  like a version that is too new.
28
33
 
29
34
  One deliberate exception: a release version of None is NOT a refusal. With no
@@ -31,7 +36,8 @@ release there is nothing to flash, and `manage_firmware_update` already reports
31
36
  the failed fetch. Turning a GitHub outage into a version-mismatch refusal would
32
37
  be a lie. This is the one place the gate looks fail-open and is not.
33
38
 
34
- Escape: `--allow-newer-firmware`. Never an environment variable.
39
+ Escape for the feature-version refusal: `--allow-newer-firmware`. Never an
40
+ environment variable. The pre-release refusal has no escape.
35
41
 
36
42
  Unlike the pre-serial gates, the authoritative call runs AFTER the release
37
43
  metadata fetch, because the release tag is only known then. It still runs before
@@ -75,6 +81,15 @@ _UNREADABLE_FORMAT = (
75
81
  "--allow-newer-firmware. This pairing is not tested."
76
82
  )
77
83
 
84
+ _PRERELEASE_ON_STABLE_FORMAT = (
85
+ "Firmware {release} is a pre-release. This CLI is {app}, a stable release. "
86
+ "A stable CLI installs stable firmware only. "
87
+ "Refusing to install firmware {release}.\n"
88
+ "Run 'firestarter fw --install' to install the latest stable firmware.\n"
89
+ "To use pre-release firmware, install a pre-release CLI. "
90
+ "Run 'pip install --upgrade --pre firestarter'."
91
+ )
92
+
78
93
  _UNREADABLE_TEXT = "an unreadable version"
79
94
 
80
95
 
@@ -94,6 +109,20 @@ def feature_version(raw: str | None) -> tuple[int, int] | None:
94
109
  return (parsed.major, parsed.minor)
95
110
 
96
111
 
112
+ def is_prerelease_version(raw: str | None) -> bool | None:
113
+ """True for a PEP 440 pre-release (b, rc or dev), False for a final release.
114
+
115
+ None when the version cannot be read. The caller decides what that means;
116
+ the feature-version check already refuses an unreadable version.
117
+ """
118
+ if not raw:
119
+ return None
120
+ try:
121
+ return Version(raw).is_prerelease
122
+ except InvalidVersion:
123
+ return None
124
+
125
+
97
126
  def is_refused(app_version: str | None, release_version: str | None) -> bool:
98
127
  """True when this CLI must not flash this release.
99
128
 
@@ -117,9 +146,22 @@ def require_installable_release(
117
146
  ) -> None:
118
147
  """Raise FirmwareReleaseRefusedError when this CLI must not flash this release.
119
148
 
120
- Returns None on pass. `allow_newer` waives the refusal; it defaults to False
121
- so a library caller that does not ask for the waiver does not get it.
149
+ Returns None on pass. `allow_newer` waives the feature-version refusal; it
150
+ defaults to False so a library caller that does not ask for the waiver does
151
+ not get it. Nothing waives the pre-release refusal on a stable CLI.
152
+
153
+ An unreadable version on either side is not judged by the pre-release rule.
154
+ The feature-version check refuses it.
122
155
  """
156
+ if (
157
+ is_prerelease_version(app_version) is False
158
+ and is_prerelease_version(release_version) is True
159
+ ):
160
+ raise FirmwareReleaseRefusedError(
161
+ _PRERELEASE_ON_STABLE_FORMAT.format(
162
+ app=app_version, release=release_version
163
+ )
164
+ )
123
165
  if allow_newer or not is_refused(app_version, release_version):
124
166
  return
125
167
  app = feature_version(app_version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firestarter
3
- Version: 3.2.7b1
3
+ Version: 3.2.7b2
4
4
  Summary: EPROM programmer software for the Relatively Universal ROM Programmer
5
5
  Author-email: Henrik Olsson <henols@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/henols/firestarter_app
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "3.2.7b1",
2
+ "tag": "3.2.7b2",
3
3
  "distance": 0,
4
- "node": "g0159d48",
4
+ "node": "g30e9def",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-28"
@@ -217,8 +217,8 @@
217
217
 
218
218
  Firmware version.
219
219
 
220
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
221
- passing more than one is refused before anything is installed.
220
+ The release channel options are mutually exclusive; passing more than one is
221
+ refused before anything is installed.
222
222
 
223
223
  Options:
224
224
  -i, --install Try to install the latest firmware.
@@ -257,15 +257,12 @@
257
257
 
258
258
  Firmware version.
259
259
 
260
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
261
- passing more than one is refused before anything is installed.
260
+ The release channel options are mutually exclusive; passing more than one is
261
+ refused before anything is installed.
262
262
 
263
263
  Options:
264
264
  -i, --install Try to install the latest firmware.
265
- --pre Fetch latest pre-release firmware (mirrors
266
- pip install --pre).
267
- --firmware-version VERSION Pin exact firmware version (e.g. 3.1.0,
268
- 3.1.0b2, 3.1.0rc1).
265
+ --firmware-version VERSION Pin exact firmware version (e.g. 3.1.0).
269
266
  --stable Explicitly select stable channel. With
270
267
  --list, filters to stable releases only.
271
268
  --list List available firmware releases for the
@@ -239,29 +239,36 @@ def _run_fw_help_at_version(version: str) -> str:
239
239
  firestarter fw [OPTIONS]` header `run_firestarter()`'s real subprocess
240
240
  produces, rather than Click's own default derived from `sys.argv[0]`.
241
241
  """
242
+ stdout, stderr, rc = _run_firestarter_at_version(version, "fw", "--help")
243
+ assert rc == 0, stderr
244
+ return stdout
245
+
246
+
247
+ def _run_firestarter_at_version(version: str, *args: str) -> tuple[str, str, int]:
248
+ """Run `firestarter <args>` in a subprocess simulating `version`.
249
+
250
+ The mechanism of `_run_fw_help_at_version`, for any arguments. Returns
251
+ (normalized_stdout, normalized_stderr, exit_code).
252
+ """
242
253
  program = (
243
- "import sys, io, contextlib\n"
254
+ "import sys\n"
244
255
  "import firestarter\n"
245
256
  f"firestarter.__version__ = {version!r}\n"
246
257
  "from firestarter import cli_handlers\n"
247
- "buf = io.StringIO()\n"
248
- "with contextlib.redirect_stdout(buf):\n"
249
- " try:\n"
250
- " cli_handlers.cli.main(\n"
251
- " args=['fw', '--help'], prog_name='firestarter'\n"
252
- " )\n"
253
- " except SystemExit:\n"
254
- " pass\n"
255
- "sys.stdout.write(buf.getvalue())\n"
258
+ f"cli_handlers.cli.main(args={list(args)!r}, prog_name='firestarter')\n"
256
259
  )
257
260
  result = subprocess.run(
258
261
  [sys.executable, "-c", program],
259
262
  capture_output=True,
260
263
  text=True,
261
264
  timeout=15,
265
+ env={**os.environ, "FIRESTARTER_CONFIG_DIR": _CLEAN_CONFIG_DIR},
266
+ )
267
+ return (
268
+ normalize_output(result.stdout),
269
+ normalize_output(result.stderr),
270
+ result.returncode,
262
271
  )
263
- assert result.returncode == 0, result.stderr
264
- return normalize_output(result.stdout)
265
272
 
266
273
 
267
274
  def test_help_fw(snapshot):
@@ -434,15 +441,25 @@ def test_error_write_missing_args(snapshot):
434
441
 
435
442
 
436
443
  def test_error_fw_pre_stable_mutex(snapshot):
437
- """fw --pre --stable → mutually exclusive, exit 2."""
438
- stdout, stderr, rc = run_firestarter("fw", "--pre", "--stable")
444
+ """fw --pre --stable → mutually exclusive, exit 2.
445
+
446
+ On a pre-release CLI: a stable CLI refuses --pre itself.
447
+ """
448
+ stdout, stderr, rc = _run_firestarter_at_version(
449
+ "3.0.0b1", "fw", "--pre", "--stable"
450
+ )
439
451
  assert rc == 2
440
452
  assert stderr == snapshot
441
453
 
442
454
 
443
455
  def test_error_fw_pre_firmware_version_mutex(snapshot):
444
- """fw --pre --firmware-version → mutually exclusive, exit 2."""
445
- stdout, stderr, rc = run_firestarter("fw", "--pre", "--firmware-version", "3.0.0")
456
+ """fw --pre --firmware-version → mutually exclusive, exit 2.
457
+
458
+ On a pre-release CLI: a stable CLI refuses --pre itself.
459
+ """
460
+ stdout, stderr, rc = _run_firestarter_at_version(
461
+ "3.0.0b1", "fw", "--pre", "--firmware-version", "3.0.0"
462
+ )
446
463
  assert rc == 2
447
464
  assert stderr == snapshot
448
465
 
@@ -1296,6 +1296,13 @@ def test_fw_install_returns_false(runner: CliRunner) -> None:
1296
1296
  assert result.exit_code == 1
1297
1297
 
1298
1298
 
1299
+ @pytest.fixture
1300
+ def _pre_channel_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
1301
+ """A pre-release build: a stable build refuses --pre before the mutex check."""
1302
+ monkeypatch.setattr(cli_handlers_mod, "_PRE_CHANNEL_ENABLED", True)
1303
+
1304
+
1305
+ @pytest.mark.usefixtures("_pre_channel_enabled")
1299
1306
  def test_fw_mutex_pre_and_firmware_version(runner: CliRunner) -> None:
1300
1307
  """TRAP #4 / D-13.4: --pre + --firmware-version exits 2 (mutually exclusive).
1301
1308
 
@@ -1312,6 +1319,7 @@ def test_fw_mutex_pre_and_firmware_version(runner: CliRunner) -> None:
1312
1319
  assert "mutually exclusive" in result.output.lower()
1313
1320
 
1314
1321
 
1322
+ @pytest.mark.usefixtures("_pre_channel_enabled")
1315
1323
  def test_fw_mutex_stable_and_pre(runner: CliRunner) -> None:
1316
1324
  """TRAP #4 / D-13.4: --stable + --pre exits 2 (mutually exclusive)."""
1317
1325
  fw_mgr = Mock(spec=FirmwareManager)
@@ -24,7 +24,7 @@ from unittest.mock import patch
24
24
 
25
25
  from firestarter.compare import CompareAccumulator
26
26
  from firestarter.config import ConfigManager
27
- from firestarter.eprom_operations import EpromOperator
27
+ from firestarter.eprom_operations import EpromOperator, _default_read_path
28
28
  from firestarter.messages import (
29
29
  MSG_DATA_CHUNK,
30
30
  MSG_END_DONE,
@@ -2729,3 +2729,16 @@ def test_seen_message_ids_records_decoded_ids_and_stays_bounded(
2729
2729
  assert unknown_id not in comm.seen_message_ids
2730
2730
  assert isinstance(comm.seen_message_ids, set)
2731
2731
  assert all(isinstance(i, int) for i in comm.seen_message_ids)
2732
+
2733
+
2734
+ def test_default_read_path_adds_lowest_free_suffix(tmp_path, monkeypatch):
2735
+ """`read` with no file name never overwrites an earlier read: the
2736
+ default `<NAME>.bin` gets `-1`, `-2`, ... when that name is taken."""
2737
+ monkeypatch.chdir(tmp_path)
2738
+ assert _default_read_path("w27c512") == "W27C512.bin"
2739
+
2740
+ (tmp_path / "W27C512.bin").write_bytes(b"")
2741
+ assert _default_read_path("w27c512") == "W27C512-1.bin"
2742
+
2743
+ (tmp_path / "W27C512-1.bin").write_bytes(b"")
2744
+ assert _default_read_path("w27c512") == "W27C512-2.bin"
@@ -229,6 +229,13 @@ def _manager_that_cannot_identify(monkeypatch):
229
229
 
230
230
 
231
231
  class TestBlindInstallRequiresExplicitBoard:
232
+ @pytest.fixture(autouse=True)
233
+ def _prerelease_cli(self, monkeypatch):
234
+ """A pre-release CLI: a stable CLI refuses the pre-release tags below."""
235
+ import firestarter as _pkg
236
+
237
+ monkeypatch.setattr(_pkg, "__version__", "3.0.0b19")
238
+
232
239
  @pytest.mark.parametrize(
233
240
  ("install_flag", "flags", "label"),
234
241
  [(True, 0, "--install"), (False, FLAG_FORCE, "--force")],
@@ -15,7 +15,7 @@ version explicitly or monkeypatches the package attribute, so a release bump
15
15
  cannot redden this file.
16
16
  """
17
17
 
18
- from unittest.mock import Mock
18
+ from unittest.mock import MagicMock, Mock
19
19
 
20
20
  import pytest
21
21
  from click.testing import CliRunner
@@ -28,9 +28,11 @@ from firestarter.exceptions import (
28
28
  )
29
29
  from firestarter.firmware import FirmwareManager
30
30
  from firestarter.fw_release_gate import (
31
+ _PRERELEASE_ON_STABLE_FORMAT,
31
32
  _REFUSAL_FORMAT,
32
33
  _UNREADABLE_FORMAT,
33
34
  feature_version,
35
+ is_prerelease_version,
34
36
  is_refused,
35
37
  require_installable_release,
36
38
  )
@@ -261,3 +263,111 @@ def test_list_still_enumerates_a_too_new_release(
261
263
  )
262
264
  assert result.exit_code == 0
263
265
  assert "3.2.0" in result.output
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # G. A stable CLI refuses every pre-release firmware
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ @pytest.mark.parametrize(
274
+ ("raw", "expected"),
275
+ [
276
+ ("3.2.6", False),
277
+ ("v3.2.6", False),
278
+ ("3.2.7b1", True),
279
+ ("3.2.0rc1", True),
280
+ ("3.2.7.dev3+gabc1234", True),
281
+ ("not-a-version", None),
282
+ ("", None),
283
+ (None, None),
284
+ ],
285
+ )
286
+ def test_is_prerelease_version(raw, expected) -> None:
287
+ assert is_prerelease_version(raw) is expected
288
+
289
+
290
+ @pytest.mark.parametrize("release_version", ["3.2.7b1", "3.2.0rc1", "3.3.0.dev1"])
291
+ @pytest.mark.parametrize("allow_newer", [False, True])
292
+ def test_stable_cli_refuses_a_prerelease(release_version, allow_newer) -> None:
293
+ """No waiver: --allow-newer-firmware does not open this refusal."""
294
+ with pytest.raises(FirmwareReleaseRefusedError) as exc:
295
+ require_installable_release("3.2.6", release_version, allow_newer=allow_newer)
296
+ assert str(exc.value) == _PRERELEASE_ON_STABLE_FORMAT.format(
297
+ app="3.2.6", release=release_version
298
+ )
299
+
300
+
301
+ def test_stable_cli_refuses_an_older_prerelease() -> None:
302
+ """The rule ignores the feature version: an older pre-release is refused too."""
303
+ with pytest.raises(FirmwareReleaseRefusedError):
304
+ require_installable_release("3.2.6", "3.1.0b2")
305
+
306
+
307
+ @pytest.mark.parametrize(
308
+ ("app_version", "release_version"),
309
+ [
310
+ ("3.2.6", "3.2.6"), # stable on stable
311
+ ("3.2.6", None), # nothing to flash
312
+ ("3.2.7b1", "3.2.7b2"), # pre-release CLI
313
+ ("3.2.7.dev3+gabc1234", "3.2.7b1"), # dev checkout
314
+ ("3.2.7b1", "3.2.6"), # pre-release CLI, stable firmware
315
+ ],
316
+ )
317
+ def test_prerelease_rule_passes(app_version, release_version) -> None:
318
+ require_installable_release(app_version, release_version)
319
+
320
+
321
+ def test_stable_cli_unreadable_release_keeps_the_unreadable_refusal() -> None:
322
+ with pytest.raises(FirmwareReleaseRefusedError) as exc:
323
+ require_installable_release("3.2.6", "garbage")
324
+ assert "is a pre-release" not in str(exc.value)
325
+ assert "not readable" in str(exc.value)
326
+
327
+
328
+ def test_pinned_prerelease_on_stable_cli_refuses_before_any_port_or_request(
329
+ runner, fw_manager, monkeypatch
330
+ ) -> None:
331
+ monkeypatch.setattr(_pkg, "__version__", "3.2.6")
332
+ result = runner.invoke(
333
+ cli,
334
+ ["fw", "--install", "--firmware-version", "3.2.7b1"],
335
+ obj=make_app_context(firmware_manager=fw_manager),
336
+ )
337
+ assert result.exit_code == 1
338
+ assert "is a pre-release" in result.output
339
+ fw_manager.check_current_firmware.assert_not_called()
340
+ fw_manager.manage_firmware_update.assert_not_called()
341
+
342
+
343
+ def test_pre_channel_on_stable_library_call_refuses_before_download(
344
+ monkeypatch,
345
+ ) -> None:
346
+ """A library caller that asks a stable CLI for channel='pre' is refused."""
347
+ fm = FirmwareManager(config_manager=MagicMock())
348
+ monkeypatch.setattr(
349
+ fm,
350
+ "check_current_firmware",
351
+ lambda **_kw: ("/dev/ttyACM0", "3.2.6", "uno"),
352
+ )
353
+ monkeypatch.setattr(
354
+ fm,
355
+ "fetch_release_info",
356
+ lambda channel="stable", version=None, board="uno": (
357
+ "3.2.7b1",
358
+ "https://example.invalid/uno.hex",
359
+ ),
360
+ )
361
+ monkeypatch.setattr(
362
+ fm, "_download_firmware_file", lambda url: pytest.fail("must not download")
363
+ )
364
+ monkeypatch.setattr(
365
+ fm, "_install_firmware", lambda **kw: pytest.fail("must not flash")
366
+ )
367
+ with pytest.raises(FirmwareReleaseRefusedError):
368
+ fm.manage_firmware_update(
369
+ install_flag=True,
370
+ channel="pre",
371
+ app_version="3.2.6",
372
+ allow_newer_firmware=True,
373
+ )
@@ -203,6 +203,13 @@ class TestWaiverPlumbing:
203
203
  class TestUpdateDecisionReachedOnPreCap02Firmware:
204
204
  """The end-to-end regression: the update decision must be REACHED."""
205
205
 
206
+ @pytest.fixture(autouse=True)
207
+ def _prerelease_cli(self, monkeypatch):
208
+ """A pre-release CLI: a stable CLI refuses the pre-release tags below."""
209
+ import firestarter as _pkg
210
+
211
+ monkeypatch.setattr(_pkg, "__version__", "3.0.0b19")
212
+
206
213
  def test_manage_firmware_update_reaches_the_download_boundary(self, monkeypatch):
207
214
  """No identity in the ack, version from the legacy text ack, then install.
208
215