openc3 7.2.1 → 7.4.0

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 (502) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Guardfile +0 -0
  4. data/LICENSE.md +0 -0
  5. data/README.md +0 -0
  6. data/Rakefile +0 -0
  7. data/bin/openc3cli +235 -6
  8. data/bin/pipinstall +5 -4
  9. data/bin/pipuninstall +5 -1
  10. data/bin/uvinstall +156 -0
  11. data/data/config/_array_params.yaml +0 -0
  12. data/data/config/_canvas_values.yaml +0 -0
  13. data/data/config/_graph_params.yaml +0 -0
  14. data/data/config/_id_items.yaml +0 -0
  15. data/data/config/_id_params.yaml +0 -0
  16. data/data/config/_interfaces.yaml +0 -0
  17. data/data/config/_items.yaml +0 -0
  18. data/data/config/_params.yaml +0 -0
  19. data/data/config/command.yaml +0 -0
  20. data/data/config/command_modifiers.yaml +2 -0
  21. data/data/config/command_telemetry.yaml +0 -0
  22. data/data/config/conversions.yaml +0 -0
  23. data/data/config/graph_settings.yaml +0 -0
  24. data/data/config/interface_modifiers.yaml +103 -1
  25. data/data/config/item_modifiers.yaml +2 -1
  26. data/data/config/microservice.yaml +9 -2
  27. data/data/config/param_item_modifiers.yaml +0 -0
  28. data/data/config/parameter_modifiers.yaml +0 -0
  29. data/data/config/plugins.yaml +0 -0
  30. data/data/config/processors.yaml +0 -0
  31. data/data/config/protocols.yaml +0 -0
  32. data/data/config/screen.yaml +2 -1
  33. data/data/config/settings.yaml +0 -0
  34. data/data/config/table_manager.yaml +0 -0
  35. data/data/config/table_parameter_modifiers.yaml +0 -0
  36. data/data/config/target.yaml +21 -0
  37. data/data/config/target_config.yaml +0 -0
  38. data/data/config/telemetry.yaml +0 -0
  39. data/data/config/telemetry_modifiers.yaml +2 -0
  40. data/data/config/tool.yaml +3 -1
  41. data/data/config/unknown.yaml +0 -0
  42. data/data/config/widgets.yaml +22 -3
  43. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  44. data/data/xtce_schemas/xml.xsd +116 -0
  45. data/ext/openc3/ext/array/array.c +0 -0
  46. data/ext/openc3/ext/array/extconf.rb +0 -0
  47. data/ext/openc3/ext/buffered_file/buffered_file.c +0 -0
  48. data/ext/openc3/ext/buffered_file/extconf.rb +0 -0
  49. data/ext/openc3/ext/burst_protocol/burst_protocol.c +6 -0
  50. data/ext/openc3/ext/burst_protocol/extconf.rb +0 -0
  51. data/ext/openc3/ext/config_parser/config_parser.c +0 -0
  52. data/ext/openc3/ext/config_parser/extconf.rb +0 -0
  53. data/ext/openc3/ext/crc/crc.c +0 -0
  54. data/ext/openc3/ext/crc/extconf.rb +0 -0
  55. data/ext/openc3/ext/openc3_io/extconf.rb +0 -0
  56. data/ext/openc3/ext/openc3_io/openc3_io.c +0 -0
  57. data/ext/openc3/ext/packet/extconf.rb +0 -0
  58. data/ext/openc3/ext/packet/packet.c +0 -0
  59. data/ext/openc3/ext/platform/extconf.rb +0 -0
  60. data/ext/openc3/ext/platform/platform.c +4 -1
  61. data/ext/openc3/ext/polynomial_conversion/extconf.rb +0 -0
  62. data/ext/openc3/ext/polynomial_conversion/polynomial_conversion.c +0 -0
  63. data/ext/openc3/ext/string/extconf.rb +0 -0
  64. data/ext/openc3/ext/string/string.c +0 -0
  65. data/ext/openc3/ext/structure/structure.c +49 -17
  66. data/ext/openc3/ext/tabbed_plots_config/extconf.rb +0 -0
  67. data/ext/openc3/ext/tabbed_plots_config/tabbed_plots_config.c +0 -0
  68. data/ext/openc3/ext/telemetry/extconf.rb +0 -0
  69. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  70. data/lib/cosmos.rb +0 -0
  71. data/lib/cosmosc2.rb +0 -0
  72. data/lib/openc3/accessors/accessor.rb +0 -0
  73. data/lib/openc3/accessors/binary_accessor.rb +63 -9
  74. data/lib/openc3/accessors/cbor_accessor.rb +0 -0
  75. data/lib/openc3/accessors/form_accessor.rb +0 -0
  76. data/lib/openc3/accessors/html_accessor.rb +0 -0
  77. data/lib/openc3/accessors/http_accessor.rb +0 -0
  78. data/lib/openc3/accessors/json_accessor.rb +0 -0
  79. data/lib/openc3/accessors/template_accessor.rb +2 -2
  80. data/lib/openc3/accessors/xml_accessor.rb +0 -0
  81. data/lib/openc3/accessors.rb +0 -0
  82. data/lib/openc3/api/README.md +0 -0
  83. data/lib/openc3/api/api.rb +0 -0
  84. data/lib/openc3/api/authorized_api.rb +0 -0
  85. data/lib/openc3/api/calendar_api.rb +0 -0
  86. data/lib/openc3/api/cmd_api.rb +1 -1
  87. data/lib/openc3/api/config_api.rb +0 -0
  88. data/lib/openc3/api/interface_api.rb +2 -2
  89. data/lib/openc3/api/limits_api.rb +27 -13
  90. data/lib/openc3/api/metrics_api.rb +1 -1
  91. data/lib/openc3/api/offline_access_api.rb +1 -1
  92. data/lib/openc3/api/router_api.rb +3 -3
  93. data/lib/openc3/api/settings_api.rb +22 -18
  94. data/lib/openc3/api/stash_api.rb +1 -1
  95. data/lib/openc3/api/target_api.rb +0 -0
  96. data/lib/openc3/api/tlm_api.rb +5 -4
  97. data/lib/openc3/bridge/bridge.rb +0 -0
  98. data/lib/openc3/bridge/bridge_config.rb +0 -0
  99. data/lib/openc3/bridge/bridge_interface_thread.rb +0 -0
  100. data/lib/openc3/bridge/bridge_router_thread.rb +0 -0
  101. data/lib/openc3/ccsds/ccsds_packet.rb +0 -0
  102. data/lib/openc3/ccsds/ccsds_parser.rb +0 -0
  103. data/lib/openc3/config/config_parser.rb +78 -17
  104. data/lib/openc3/config/meta_config_parser.rb +0 -0
  105. data/lib/openc3/conversions/bit_reverse_conversion.rb +0 -0
  106. data/lib/openc3/conversions/conversion.rb +0 -0
  107. data/lib/openc3/conversions/generic_conversion.rb +0 -0
  108. data/lib/openc3/conversions/ip_read_conversion.rb +0 -0
  109. data/lib/openc3/conversions/ip_write_conversion.rb +0 -0
  110. data/lib/openc3/conversions/object_read_conversion.rb +0 -0
  111. data/lib/openc3/conversions/object_write_conversion.rb +0 -0
  112. data/lib/openc3/conversions/packet_time_formatted_conversion.rb +0 -0
  113. data/lib/openc3/conversions/packet_time_seconds_conversion.rb +0 -0
  114. data/lib/openc3/conversions/polynomial_conversion.rb +0 -0
  115. data/lib/openc3/conversions/processor_conversion.rb +0 -0
  116. data/lib/openc3/conversions/received_count_conversion.rb +0 -0
  117. data/lib/openc3/conversions/received_time_formatted_conversion.rb +0 -0
  118. data/lib/openc3/conversions/received_time_seconds_conversion.rb +0 -0
  119. data/lib/openc3/conversions/segmented_polynomial_conversion.rb +0 -0
  120. data/lib/openc3/conversions/unix_time_conversion.rb +0 -0
  121. data/lib/openc3/conversions/unix_time_formatted_conversion.rb +0 -0
  122. data/lib/openc3/conversions/unix_time_seconds_conversion.rb +0 -0
  123. data/lib/openc3/conversions.rb +0 -0
  124. data/lib/openc3/core_ext/array.rb +0 -0
  125. data/lib/openc3/core_ext/binding.rb +0 -0
  126. data/lib/openc3/core_ext/class.rb +0 -0
  127. data/lib/openc3/core_ext/exception.rb +0 -0
  128. data/lib/openc3/core_ext/faraday.rb +0 -0
  129. data/lib/openc3/core_ext/file.rb +0 -0
  130. data/lib/openc3/core_ext/io.rb +0 -0
  131. data/lib/openc3/core_ext/kernel.rb +0 -0
  132. data/lib/openc3/core_ext/math.rb +0 -0
  133. data/lib/openc3/core_ext/matrix.rb +0 -0
  134. data/lib/openc3/core_ext/objectspace.rb +0 -0
  135. data/lib/openc3/core_ext/openc3_io.rb +0 -0
  136. data/lib/openc3/core_ext/range.rb +0 -0
  137. data/lib/openc3/core_ext/socket.rb +0 -0
  138. data/lib/openc3/core_ext/string.rb +0 -0
  139. data/lib/openc3/core_ext/stringio.rb +0 -0
  140. data/lib/openc3/core_ext/tempfile.rb +0 -0
  141. data/lib/openc3/core_ext/time.rb +0 -0
  142. data/lib/openc3/core_ext.rb +0 -0
  143. data/lib/openc3/ext/.keep +0 -0
  144. data/lib/openc3/interfaces/file_interface.rb +0 -0
  145. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  146. data/lib/openc3/interfaces/http_server_interface.rb +0 -0
  147. data/lib/openc3/interfaces/interface.rb +5 -4
  148. data/lib/openc3/interfaces/mqtt_interface.rb +0 -0
  149. data/lib/openc3/interfaces/mqtt_stream_interface.rb +2 -0
  150. data/lib/openc3/interfaces/protocols/burst_protocol.rb +27 -0
  151. data/lib/openc3/interfaces/protocols/cmd_response_protocol.rb +0 -0
  152. data/lib/openc3/interfaces/protocols/cobs_protocol.rb +0 -0
  153. data/lib/openc3/interfaces/protocols/crc_protocol.rb +0 -0
  154. data/lib/openc3/interfaces/protocols/fixed_protocol.rb +0 -0
  155. data/lib/openc3/interfaces/protocols/ignore_packet_protocol.rb +0 -0
  156. data/lib/openc3/interfaces/protocols/length_protocol.rb +7 -0
  157. data/lib/openc3/interfaces/protocols/preidentified_protocol.rb +7 -0
  158. data/lib/openc3/interfaces/protocols/protocol.rb +4 -4
  159. data/lib/openc3/interfaces/protocols/slip_protocol.rb +0 -0
  160. data/lib/openc3/interfaces/protocols/template_protocol.rb +2 -2
  161. data/lib/openc3/interfaces/protocols/terminated_protocol.rb +0 -0
  162. data/lib/openc3/interfaces/serial_interface.rb +2 -0
  163. data/lib/openc3/interfaces/simulated_target_interface.rb +0 -0
  164. data/lib/openc3/interfaces/stream_interface.rb +0 -0
  165. data/lib/openc3/interfaces/tcpip_client_interface.rb +2 -0
  166. data/lib/openc3/interfaces/tcpip_server_interface.rb +0 -0
  167. data/lib/openc3/interfaces/udp_interface.rb +16 -5
  168. data/lib/openc3/interfaces.rb +0 -0
  169. data/lib/openc3/io/buffered_file.rb +0 -0
  170. data/lib/openc3/io/io_multiplexer.rb +0 -0
  171. data/lib/openc3/io/json_api.rb +3 -2
  172. data/lib/openc3/io/json_api_object.rb +0 -0
  173. data/lib/openc3/io/json_drb.rb +44 -12
  174. data/lib/openc3/io/json_drb_object.rb +0 -0
  175. data/lib/openc3/io/json_drb_rack.rb +0 -0
  176. data/lib/openc3/io/json_rpc.rb +0 -2
  177. data/lib/openc3/io/posix_serial_driver.rb +0 -0
  178. data/lib/openc3/io/serial_driver.rb +0 -0
  179. data/lib/openc3/io/stderr.rb +0 -0
  180. data/lib/openc3/io/stdout.rb +0 -0
  181. data/lib/openc3/io/udp_sockets.rb +38 -8
  182. data/lib/openc3/io/win32_serial_driver.rb +1 -0
  183. data/lib/openc3/logs/buffered_packet_log_reader.rb +0 -0
  184. data/lib/openc3/logs/buffered_packet_log_writer.rb +0 -0
  185. data/lib/openc3/logs/log_writer.rb +43 -7
  186. data/lib/openc3/logs/packet_log_constants.rb +0 -0
  187. data/lib/openc3/logs/packet_log_reader.rb +23 -2
  188. data/lib/openc3/logs/packet_log_writer.rb +6 -2
  189. data/lib/openc3/logs/stream_log.rb +1 -1
  190. data/lib/openc3/logs/stream_log_pair.rb +0 -0
  191. data/lib/openc3/logs/text_log_writer.rb +0 -0
  192. data/lib/openc3/logs.rb +0 -0
  193. data/lib/openc3/microservices/cleanup_microservice.rb +0 -0
  194. data/lib/openc3/microservices/decom_common.rb +0 -0
  195. data/lib/openc3/microservices/decom_microservice.rb +0 -0
  196. data/lib/openc3/microservices/interface_decom_common.rb +2 -1
  197. data/lib/openc3/microservices/interface_microservice.rb +73 -26
  198. data/lib/openc3/microservices/log_microservice.rb +0 -0
  199. data/lib/openc3/microservices/microservice.rb +3 -3
  200. data/lib/openc3/microservices/multi_microservice.rb +1 -1
  201. data/lib/openc3/microservices/periodic_microservice.rb +2 -2
  202. data/lib/openc3/microservices/plugin_microservice.rb +0 -0
  203. data/lib/openc3/microservices/queue_microservice.rb +0 -0
  204. data/lib/openc3/microservices/router_microservice.rb +0 -0
  205. data/lib/openc3/microservices/scope_cleanup_microservice.rb +0 -0
  206. data/lib/openc3/microservices/text_log_microservice.rb +0 -0
  207. data/lib/openc3/migrations/20220420190000_log_stuff.rb +0 -0
  208. data/lib/openc3/migrations/20221202214600_add_target_names.rb +0 -0
  209. data/lib/openc3/migrations/20221210174900_convert_to_multi.rb +0 -0
  210. data/lib/openc3/migrations/20230915000002_no_scope_log_messages.rb +0 -0
  211. data/lib/openc3/migrations/20231022000000_tlm_viewer_config.rb +0 -0
  212. data/lib/openc3/migrations/20241208080000_no_critical_cmd.rb +0 -0
  213. data/lib/openc3/migrations/20241208080001_no_trigger_group.rb +0 -0
  214. data/lib/openc3/migrations/20250108060000_news_feed.rb +0 -0
  215. data/lib/openc3/migrations/20250402000000_periodic_only_default.rb +0 -0
  216. data/lib/openc3/migrations/20260203000000_remove_store_id.rb +0 -0
  217. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +0 -0
  218. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +0 -0
  219. data/lib/openc3/migrations/20260704000000_default_bridge.rb +38 -0
  220. data/lib/openc3/migrations/20260731000000_system_health_json.rb +52 -0
  221. data/lib/openc3/models/activity_model.rb +0 -0
  222. data/lib/openc3/models/auth_model.rb +35 -7
  223. data/lib/openc3/models/bridge_model.rb +133 -0
  224. data/lib/openc3/models/config_keyword_model.rb +63 -0
  225. data/lib/openc3/models/cvt_model.rb +5 -5
  226. data/lib/openc3/models/db_sharded_model.rb +0 -0
  227. data/lib/openc3/models/environment_model.rb +0 -0
  228. data/lib/openc3/models/gem_model.rb +13 -10
  229. data/lib/openc3/models/host_interface_microservice_model.rb +114 -0
  230. data/lib/openc3/models/info_model.rb +0 -0
  231. data/lib/openc3/models/interface_model.rb +179 -20
  232. data/lib/openc3/models/interface_status_model.rb +0 -0
  233. data/lib/openc3/models/metadata_model.rb +0 -0
  234. data/lib/openc3/models/metric_model.rb +0 -0
  235. data/lib/openc3/models/microservice_model.rb +26 -18
  236. data/lib/openc3/models/microservice_status_model.rb +0 -0
  237. data/lib/openc3/models/migration_model.rb +0 -0
  238. data/lib/openc3/models/model.rb +0 -0
  239. data/lib/openc3/models/news_model.rb +0 -0
  240. data/lib/openc3/models/note_model.rb +0 -0
  241. data/lib/openc3/models/offline_access_model.rb +0 -0
  242. data/lib/openc3/models/ping_model.rb +0 -0
  243. data/lib/openc3/models/plugin_model.rb +211 -50
  244. data/lib/openc3/models/plugin_store_model.rb +0 -0
  245. data/lib/openc3/models/process_status_model.rb +0 -0
  246. data/lib/openc3/models/python_package_model.rb +211 -15
  247. data/lib/openc3/models/queue_model.rb +0 -0
  248. data/lib/openc3/models/reaction_model.rb +0 -0
  249. data/lib/openc3/models/reingest_job_model.rb +0 -0
  250. data/lib/openc3/models/router_model.rb +0 -0
  251. data/lib/openc3/models/router_status_model.rb +0 -0
  252. data/lib/openc3/models/scope_model.rb +85 -56
  253. data/lib/openc3/models/script_engine_model.rb +0 -0
  254. data/lib/openc3/models/script_status_model.rb +0 -0
  255. data/lib/openc3/models/secret_model.rb +0 -0
  256. data/lib/openc3/models/setting_model.rb +546 -3
  257. data/lib/openc3/models/sorted_model.rb +0 -0
  258. data/lib/openc3/models/stash_model.rb +0 -0
  259. data/lib/openc3/models/target_model.rb +210 -31
  260. data/lib/openc3/models/timeline_model.rb +0 -0
  261. data/lib/openc3/models/tool_config_model.rb +0 -0
  262. data/lib/openc3/models/tool_model.rb +34 -20
  263. data/lib/openc3/models/trigger_group_model.rb +0 -0
  264. data/lib/openc3/models/trigger_model.rb +20 -0
  265. data/lib/openc3/models/widget_model.rb +0 -0
  266. data/lib/openc3/operators/microservice_operator.rb +79 -8
  267. data/lib/openc3/operators/operator.rb +4 -4
  268. data/lib/openc3/packets/binary_accessor.rb +0 -0
  269. data/lib/openc3/packets/command_validator.rb +0 -0
  270. data/lib/openc3/packets/commands.rb +4 -4
  271. data/lib/openc3/packets/json_packet.rb +0 -0
  272. data/lib/openc3/packets/limits.rb +15 -7
  273. data/lib/openc3/packets/limits_response.rb +0 -0
  274. data/lib/openc3/packets/packet.rb +5 -1
  275. data/lib/openc3/packets/packet_config.rb +0 -0
  276. data/lib/openc3/packets/packet_item.rb +0 -0
  277. data/lib/openc3/packets/packet_item_limits.rb +0 -0
  278. data/lib/openc3/packets/parsers/format_string_parser.rb +0 -0
  279. data/lib/openc3/packets/parsers/limits_parser.rb +0 -0
  280. data/lib/openc3/packets/parsers/limits_response_parser.rb +0 -0
  281. data/lib/openc3/packets/parsers/packet_item_parser.rb +0 -0
  282. data/lib/openc3/packets/parsers/packet_parser.rb +0 -0
  283. data/lib/openc3/packets/parsers/processor_parser.rb +0 -0
  284. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  285. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  286. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  287. data/lib/openc3/packets/structure.rb +9 -3
  288. data/lib/openc3/packets/structure_item.rb +0 -0
  289. data/lib/openc3/packets/telemetry.rb +3 -3
  290. data/lib/openc3/processors/processor.rb +2 -0
  291. data/lib/openc3/processors/statistics_processor.rb +0 -0
  292. data/lib/openc3/processors/watermark_processor.rb +0 -0
  293. data/lib/openc3/processors.rb +0 -0
  294. data/lib/openc3/script/api_shared.rb +0 -0
  295. data/lib/openc3/script/autonomic.rb +12 -12
  296. data/lib/openc3/script/calendar.rb +0 -0
  297. data/lib/openc3/script/commands.rb +1 -1
  298. data/lib/openc3/script/critical_cmd.rb +0 -0
  299. data/lib/openc3/script/exceptions.rb +0 -0
  300. data/lib/openc3/script/extract.rb +20 -25
  301. data/lib/openc3/script/limits.rb +0 -0
  302. data/lib/openc3/script/metadata.rb +0 -0
  303. data/lib/openc3/script/packages.rb +0 -0
  304. data/lib/openc3/script/plugins.rb +0 -0
  305. data/lib/openc3/script/queue.rb +0 -0
  306. data/lib/openc3/script/screen.rb +0 -0
  307. data/lib/openc3/script/script.rb +2 -2
  308. data/lib/openc3/script/script_runner.rb +0 -0
  309. data/lib/openc3/script/storage.rb +4 -4
  310. data/lib/openc3/script/suite.rb +0 -0
  311. data/lib/openc3/script/suite_results.rb +0 -0
  312. data/lib/openc3/script/suite_runner.rb +26 -0
  313. data/lib/openc3/script/tables.rb +0 -0
  314. data/lib/openc3/script/telemetry.rb +9 -3
  315. data/lib/openc3/script/web_socket_api.rb +247 -134
  316. data/lib/openc3/script.rb +0 -0
  317. data/lib/openc3/script_engines/script_engine.rb +0 -0
  318. data/lib/openc3/streams/mqtt_stream.rb +0 -0
  319. data/lib/openc3/streams/serial_stream.rb +0 -0
  320. data/lib/openc3/streams/stream.rb +0 -0
  321. data/lib/openc3/streams/tcpip_client_stream.rb +0 -0
  322. data/lib/openc3/streams/tcpip_socket_stream.rb +0 -0
  323. data/lib/openc3/streams/web_socket_client_stream.rb +0 -0
  324. data/lib/openc3/subpacketizers/subpacketizer.rb +0 -0
  325. data/lib/openc3/system/system.rb +0 -0
  326. data/lib/openc3/system/target.rb +0 -0
  327. data/lib/openc3/system.rb +0 -0
  328. data/lib/openc3/tools/cmd_tlm_server/api.rb +0 -0
  329. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +7 -0
  330. data/lib/openc3/tools/table_manager/table.rb +0 -0
  331. data/lib/openc3/tools/table_manager/table_config.rb +0 -0
  332. data/lib/openc3/tools/table_manager/table_item.rb +0 -0
  333. data/lib/openc3/tools/table_manager/table_item_parser.rb +0 -0
  334. data/lib/openc3/tools/table_manager/table_manager_core.rb +0 -0
  335. data/lib/openc3/tools/table_manager/table_parser.rb +0 -0
  336. data/lib/openc3/tools/test_runner/test.rb +0 -0
  337. data/lib/openc3/top_level.rb +1 -1
  338. data/lib/openc3/topics/autonomic_topic.rb +0 -0
  339. data/lib/openc3/topics/calendar_topic.rb +0 -0
  340. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  341. data/lib/openc3/topics/command_topic.rb +0 -0
  342. data/lib/openc3/topics/config_topic.rb +0 -0
  343. data/lib/openc3/topics/decom_interface_topic.rb +2 -1
  344. data/lib/openc3/topics/interface_topic.rb +8 -2
  345. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  346. data/lib/openc3/topics/notebook_topic.rb +0 -0
  347. data/lib/openc3/topics/queue_topic.rb +0 -0
  348. data/lib/openc3/topics/router_topic.rb +6 -1
  349. data/lib/openc3/topics/system_events_topic.rb +0 -0
  350. data/lib/openc3/topics/telemetry_decom_topic.rb +0 -0
  351. data/lib/openc3/topics/telemetry_topic.rb +0 -0
  352. data/lib/openc3/topics/timeline_topic.rb +0 -0
  353. data/lib/openc3/topics/topic.rb +0 -0
  354. data/lib/openc3/utilities/authentication.rb +4 -4
  355. data/lib/openc3/utilities/authorization.rb +0 -0
  356. data/lib/openc3/utilities/aws_bucket.rb +12 -4
  357. data/lib/openc3/utilities/bucket.rb +1 -1
  358. data/lib/openc3/utilities/bucket_file_cache.rb +55 -22
  359. data/lib/openc3/utilities/bucket_require.rb +0 -0
  360. data/lib/openc3/utilities/bucket_utilities.rb +3 -4
  361. data/lib/openc3/utilities/cli_generator.rb +0 -0
  362. data/lib/openc3/utilities/cmd_log.rb +0 -0
  363. data/lib/openc3/utilities/config_overlay.rb +75 -0
  364. data/lib/openc3/utilities/cosmos_rails_formatter.rb +0 -0
  365. data/lib/openc3/utilities/crc.rb +59 -32
  366. data/lib/openc3/utilities/csv.rb +5 -5
  367. data/lib/openc3/utilities/ctrf.rb +0 -0
  368. data/lib/openc3/utilities/env_helper.rb +0 -0
  369. data/lib/openc3/utilities/local_bucket.rb +0 -0
  370. data/lib/openc3/utilities/local_mode.rb +36 -7
  371. data/lib/openc3/utilities/logger.rb +30 -8
  372. data/lib/openc3/utilities/message_log.rb +0 -0
  373. data/lib/openc3/utilities/metric.rb +0 -0
  374. data/lib/openc3/utilities/migration.rb +0 -0
  375. data/lib/openc3/utilities/open_telemetry.rb +0 -0
  376. data/lib/openc3/utilities/process_manager.rb +0 -0
  377. data/lib/openc3/utilities/pypi_url.rb +0 -0
  378. data/lib/openc3/utilities/python_proxy.rb +0 -0
  379. data/lib/openc3/utilities/python_venv.rb +135 -0
  380. data/lib/openc3/utilities/quaternion.rb +0 -0
  381. data/lib/openc3/utilities/questdb_client.rb +0 -0
  382. data/lib/openc3/utilities/redis_secrets.rb +0 -0
  383. data/lib/openc3/utilities/reingest_job.rb +0 -0
  384. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  385. data/lib/openc3/utilities/rubygems_url.rb +39 -0
  386. data/lib/openc3/utilities/running_script.rb +119 -95
  387. data/lib/openc3/utilities/s3_autoload.rb +5 -5
  388. data/lib/openc3/utilities/script.rb +52 -12
  389. data/lib/openc3/utilities/secrets.rb +48 -3
  390. data/lib/openc3/utilities/simulated_target.rb +0 -0
  391. data/lib/openc3/utilities/sleeper.rb +0 -0
  392. data/lib/openc3/utilities/store.rb +0 -0
  393. data/lib/openc3/utilities/store_autoload.rb +2 -2
  394. data/lib/openc3/utilities/store_queued.rb +0 -0
  395. data/lib/openc3/utilities/target_file.rb +18 -1
  396. data/lib/openc3/utilities/thread_manager.rb +0 -0
  397. data/lib/openc3/utilities/throttle.rb +0 -0
  398. data/lib/openc3/utilities/zip.rb +0 -0
  399. data/lib/openc3/utilities.rb +0 -0
  400. data/lib/openc3/version.rb +6 -6
  401. data/lib/openc3/win32/excel.rb +0 -0
  402. data/lib/openc3/win32/win32.rb +0 -0
  403. data/lib/openc3/win32/win32_main.rb +0 -0
  404. data/lib/openc3.rb +2 -2
  405. data/tasks/gemfile_stats.rake +0 -0
  406. data/tasks/spec.rake +0 -0
  407. data/templates/command_validator/command_validator.py +0 -0
  408. data/templates/command_validator/command_validator.rb +0 -0
  409. data/templates/conversion/conversion.py +0 -0
  410. data/templates/conversion/conversion.rb +0 -0
  411. data/templates/limits_response/response.py +0 -0
  412. data/templates/limits_response/response.rb +0 -0
  413. data/templates/microservice/microservices/TEMPLATE/microservice.py +0 -0
  414. data/templates/microservice/microservices/TEMPLATE/microservice.rb +0 -0
  415. data/templates/plugin/.gitignore +0 -0
  416. data/templates/plugin/LICENSE.md +0 -0
  417. data/templates/plugin/README.md +0 -0
  418. data/templates/plugin/Rakefile +0 -0
  419. data/templates/plugin/plugin.gemspec +1 -1
  420. data/templates/plugin/plugin.txt +0 -0
  421. data/templates/processor/processor.py +1 -1
  422. data/templates/processor/processor.rb +0 -0
  423. data/templates/target/targets/TARGET/cmd_tlm/cmd.txt +0 -0
  424. data/templates/target/targets/TARGET/cmd_tlm/tlm.txt +0 -0
  425. data/templates/target/targets/TARGET/lib/target.py +0 -0
  426. data/templates/target/targets/TARGET/lib/target.rb +0 -0
  427. data/templates/target/targets/TARGET/procedures/procedure.py +0 -0
  428. data/templates/target/targets/TARGET/procedures/procedure.rb +0 -0
  429. data/templates/target/targets/TARGET/public/README.txt +0 -0
  430. data/templates/target/targets/TARGET/screens/status.txt +0 -0
  431. data/templates/target/targets/TARGET/target.txt +0 -0
  432. data/templates/tool_angular/.editorconfig +0 -0
  433. data/templates/tool_angular/.gitignore +0 -0
  434. data/templates/tool_angular/.vscode/extensions.json +0 -0
  435. data/templates/tool_angular/.vscode/launch.json +0 -0
  436. data/templates/tool_angular/.vscode/tasks.json +0 -0
  437. data/templates/tool_angular/angular.json +0 -0
  438. data/templates/tool_angular/extra-webpack.config.js +0 -0
  439. data/templates/tool_angular/package.json +2 -2
  440. data/templates/tool_angular/src/app/app-routing.module.ts +0 -0
  441. data/templates/tool_angular/src/app/app.component.html +0 -0
  442. data/templates/tool_angular/src/app/app.component.scss +0 -0
  443. data/templates/tool_angular/src/app/app.component.spec.ts +0 -0
  444. data/templates/tool_angular/src/app/app.component.ts +0 -0
  445. data/templates/tool_angular/src/app/app.module.ts +0 -0
  446. data/templates/tool_angular/src/app/custom-overlay-container.ts +0 -0
  447. data/templates/tool_angular/src/app/empty-route/empty-route.component.ts +0 -0
  448. data/templates/tool_angular/src/app/openc3-api.d.ts +0 -0
  449. data/templates/tool_angular/src/assets/.gitkeep +0 -0
  450. data/templates/tool_angular/src/environments/environment.prod.ts +0 -0
  451. data/templates/tool_angular/src/environments/environment.ts +0 -0
  452. data/templates/tool_angular/src/favicon.ico +0 -0
  453. data/templates/tool_angular/src/index.html +0 -0
  454. data/templates/tool_angular/src/main.single-spa.ts +0 -0
  455. data/templates/tool_angular/src/single-spa/asset-url.ts +0 -0
  456. data/templates/tool_angular/src/single-spa/single-spa-props.ts +0 -0
  457. data/templates/tool_angular/src/styles.scss +0 -0
  458. data/templates/tool_angular/tsconfig.app.json +0 -0
  459. data/templates/tool_angular/tsconfig.json +0 -0
  460. data/templates/tool_angular/tsconfig.spec.json +0 -0
  461. data/templates/tool_react/.eslintrc +0 -0
  462. data/templates/tool_react/.gitignore +0 -0
  463. data/templates/tool_react/.prettierignore +0 -0
  464. data/templates/tool_react/babel.config.json +0 -0
  465. data/templates/tool_react/jest.config.js +0 -0
  466. data/templates/tool_react/package.json +1 -1
  467. data/templates/tool_react/src/openc3-tool_name.js +0 -0
  468. data/templates/tool_react/src/root.component.js +0 -0
  469. data/templates/tool_react/src/root.component.test.js +0 -0
  470. data/templates/tool_react/webpack.config.js +0 -0
  471. data/templates/tool_svelte/.gitignore +0 -0
  472. data/templates/tool_svelte/.prettierignore +0 -0
  473. data/templates/tool_svelte/.prettierrc.js +0 -0
  474. data/templates/tool_svelte/babel.config.js +0 -0
  475. data/templates/tool_svelte/build/smui.css +0 -0
  476. data/templates/tool_svelte/jest.config.js +0 -0
  477. data/templates/tool_svelte/package.json +1 -1
  478. data/templates/tool_svelte/rollup.config.js +0 -0
  479. data/templates/tool_svelte/src/App.svelte +0 -0
  480. data/templates/tool_svelte/src/App.test.js +0 -0
  481. data/templates/tool_svelte/src/theme/_smui-theme.scss +0 -0
  482. data/templates/tool_svelte/src/tool_name.js +0 -0
  483. data/templates/tool_vue/.env.standalone +0 -0
  484. data/templates/tool_vue/.gitignore +0 -0
  485. data/templates/tool_vue/.prettierrc.cjs +0 -0
  486. data/templates/tool_vue/eslint.config.mjs +0 -0
  487. data/templates/tool_vue/jsconfig.json +0 -0
  488. data/templates/tool_vue/package.json +3 -3
  489. data/templates/tool_vue/src/App.vue +0 -0
  490. data/templates/tool_vue/src/main.js +0 -0
  491. data/templates/tool_vue/src/router.js +0 -0
  492. data/templates/tool_vue/src/tools/tool_name/tool_name.vue +0 -0
  493. data/templates/tool_vue/vite.config.js +1 -1
  494. data/templates/widget/.prettierrc.cjs +0 -0
  495. data/templates/widget/LICENSE.md +0 -0
  496. data/templates/widget/Rakefile +0 -0
  497. data/templates/widget/package.json +2 -2
  498. data/templates/widget/src/Widget.vue +0 -0
  499. data/templates/widget/vite.config.js +0 -0
  500. metadata +40 -11
  501. data/templates/tool_vue/.nycrc +0 -3
  502. data/templates/widget/.nycrc +0 -3
@@ -19,9 +19,11 @@ require 'rubygems'
19
19
  require 'rubygems/package'
20
20
  require 'openc3'
21
21
  require 'openc3/utilities/bucket'
22
+ require 'openc3/utilities/python_venv'
22
23
  require 'openc3/utilities/store'
23
24
  require 'openc3/config/config_parser'
24
25
  require 'openc3/models/model'
26
+ require 'openc3/models/scope_model'
25
27
  require 'openc3/models/gem_model'
26
28
  require 'openc3/models/target_model'
27
29
  require 'openc3/models/interface_model'
@@ -185,11 +187,24 @@ module OpenC3
185
187
  # Called by the PluginsController to create the plugin
186
188
  # Because this uses ERB it must be run in a separate process from the API to
187
189
  # prevent corruption and single require problems in the current process
188
- def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false)
190
+ # diff_only: dry run that renders the plugin's target files and returns the
191
+ # list of modified files ("TARGET/path") whose live content differs from
192
+ # what this plugin would deploy — used to warn before an upgrade which user
193
+ # modifications it would supersede. Implies validate_only (no side effects).
194
+ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false, diff_only: false)
195
+ # diff_only implies a dry run; derive a local flag instead of mutating
196
+ # the validate_only parameter.
197
+ dry_run = validate_only || diff_only
189
198
  # Register plugin to aid in uninstall if install fails
190
199
  plugin_hash.delete("existing_plugin_txt_lines")
200
+ # Version History upgrade hints (threaded from the admin install through
201
+ # update_plugin). Extracted before the splat below because PluginModel
202
+ # has no such attributes. version_history_files lists modified files the
203
+ # user chose to take from the plugin; username attributes the upgrade.
204
+ upgrade_username = plugin_hash.delete("username")
205
+ upgrade_version_files = plugin_hash.delete("version_history_files")
191
206
  plugin_model = PluginModel.new(**(plugin_hash.transform_keys(&:to_sym)), scope: scope)
192
- plugin_model.create unless validate_only
207
+ plugin_model.create unless dry_run
193
208
 
194
209
  temp_dir = Dir.mktmpdir
195
210
  begin
@@ -206,10 +221,11 @@ module OpenC3
206
221
  # Attempt to remove all older versions of this same plugin before install to prevent version conflicts
207
222
  # Especially on downgrades
208
223
  # Leave the same version if it already exists
209
- OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path))
224
+ # Skipped for dry_run/diff_only: a dry run must not mutate gems.
225
+ OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path)) unless dry_run
210
226
 
211
227
  # Actually install the gem now (slow)
212
- OpenC3::GemModel.install(gem_file_path, scope: scope) unless validate_only
228
+ OpenC3::GemModel.install(gem_file_path, scope: scope) unless dry_run
213
229
 
214
230
  # Extract gem contents
215
231
  gem_path = File.join(temp_dir, "gem")
@@ -224,7 +240,7 @@ module OpenC3
224
240
 
225
241
  plugin_model.minimum_cosmos_version = pkg.spec.metadata['openc3_cosmos_minimum_version']
226
242
 
227
- # Process app store metadata
243
+ # Process OpenC3 Store metadata
228
244
  plugin_model.title = pkg.spec.metadata['openc3_store_title'] || pkg.spec.summary.strip
229
245
  plugin_model.description = pkg.spec.metadata['openc3_store_description'] || pkg.spec.description.strip
230
246
  plugin_model.licenses = pkg.spec.licenses
@@ -239,8 +255,19 @@ module OpenC3
239
255
  img_path = pkg.spec.metadata['openc3_store_image'] || 'public/store_img.png'
240
256
  img_path = nil unless File.exist?(File.join(gem_path, img_path))
241
257
  package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
258
+ # Build the upgrade context once the gem version is known; TargetModel
259
+ # deploys use it either to collect a modified-file diff (diff_only) or
260
+ # to version modified files taken from the plugin.
261
+ upgrade_context = nil
262
+ if diff_only
263
+ upgrade_context = { diff_collector: [] }
264
+ elsif upgrade_version_files && !upgrade_version_files.empty?
265
+ upgrade_context = { username: upgrade_username,
266
+ plugin: "#{pkg.spec.name} #{pkg.spec.version}",
267
+ version_files: upgrade_version_files }
268
+ end
242
269
  plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
243
- plugin_model.update() unless validate_only
270
+ plugin_model.update() unless dry_run
244
271
 
245
272
  needs_dependencies = pkg.spec.runtime_dependencies.length > 0
246
273
  needs_dependencies = true if Dir.exist?(File.join(gem_path, 'lib'))
@@ -250,46 +277,51 @@ module OpenC3
250
277
  requirements_path = File.join(gem_path, 'requirements.txt')
251
278
 
252
279
  if File.exist?(pyproject_path) || File.exist?(requirements_path)
253
- begin
254
- pypi_url = get_setting('pypi_url', scope: scope)
255
- if pypi_url
256
- pypi_url += '/simple'
257
- end
258
- rescue => e
259
- Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
260
- ensure
261
- if pypi_url.nil?
262
- # If Redis isn't running try the ENV, then simply pypi.org/simple
263
- pypi_url = ENV['PYPI_URL']
264
- if pypi_url
265
- pypi_url += '/simple'
266
- end
267
- pypi_url ||= PypiUrl::DEFAULT
268
- end
269
- end
270
- pypi_url = PypiUrl.validate(pypi_url)
280
+ pypi_url = resolve_pypi_url(scope: scope)
271
281
  unless validate_only
272
- # Build the pip arguments as an argv array and run pipinstall without a
273
- # shell. pypi_url comes from a user-writable setting, so interpolating it
274
- # into a shell command line allowed OS command injection. An argv array
275
- # passes each argument verbatim with no shell metacharacter interpretation.
276
- pip_args = ["-i", pypi_url]
277
- pip_args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
278
- if File.exist?(pyproject_path)
279
- Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
280
- pip_args << gem_path
281
- else
282
- Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
283
- pip_args += ["-r", requirements_path]
282
+ pypi_args = build_pypi_args(pypi_url)
283
+
284
+ # Install Python dependencies into an isolated per-plugin venv when UV
285
+ # is available. Each plugin gets its own venv at /gems/plugin_venvs/<name>/.venv
286
+ # so that plugins with conflicting Python dependency versions don't interfere.
287
+ # If UV is unavailable or the install fails, fall back to the shared pipinstall
288
+ # which installs into PYTHONUSERBASE (the legacy shared environment).
289
+ uv_installed = ENV['OPENC3_USE_UV'] != 'false' && system('which uv > /dev/null 2>&1')
290
+ if uv_installed
291
+ plugin_venv_name = plugin_venv_name(scope: scope, plugin_name: plugin_model.name)
292
+ Logger.info "Installing python packages into per-plugin venv '#{plugin_venv_name}' with pypi_url=#{pypi_url}"
293
+ uv_args = [plugin_venv_name, gem_path] + pypi_args
294
+ output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
295
+ puts output
296
+ if status.success?
297
+ # A plugin that declares openc3 itself would shadow the system
298
+ # library once its site-packages goes on PYTHONPATH, so drop it
299
+ # here rather than let the wrong client reach a running script.
300
+ PythonVenv.purge_reserved_packages(File.join('/gems', 'plugin_venvs', plugin_venv_name, '.venv'))
301
+ else
302
+ Logger.warn "UV per-plugin install failed, falling back to shared pipinstall"
303
+ uv_installed = false
304
+ end
284
305
  end
285
- # Capture output and check exit code so failures surface as a warning
286
- # rather than silently succeeding. pipinstall is non-fatal: the plugin
287
- # continues to install even if Python packages fail so that non-Python
288
- # functionality still works.
289
- output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
290
- puts output
291
- unless status.success?
292
- Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
306
+
307
+ unless uv_installed
308
+ pip_args = pypi_args.dup
309
+ if File.exist?(pyproject_path)
310
+ Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
311
+ pip_args << gem_path
312
+ else
313
+ Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
314
+ pip_args += ["-r", requirements_path]
315
+ end
316
+ # Capture output and check exit code so failures surface as a warning
317
+ # rather than silently succeeding. pipinstall is non-fatal: the plugin
318
+ # continues to install even if Python packages fail so that non-Python
319
+ # functionality still works.
320
+ output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
321
+ puts output
322
+ unless status.success?
323
+ Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
324
+ end
293
325
  end
294
326
  end
295
327
  needs_dependencies = true
@@ -304,7 +336,7 @@ module OpenC3
304
336
  end
305
337
  if needs_dependencies
306
338
  plugin_model.needs_dependencies = true
307
- plugin_model.update unless validate_only
339
+ plugin_model.update unless dry_run
308
340
  end
309
341
 
310
342
  # Temporarily add all lib folders from the gem to the end of the load path
@@ -343,8 +375,12 @@ module OpenC3
343
375
  when 'TARGET', 'INTERFACE', 'ROUTER', 'MICROSERVICE', 'TOOL', 'WIDGET', 'SCRIPT_ENGINE'
344
376
  begin
345
377
  if current_model
346
- current_model.create unless validate_only
347
- current_model.deploy(gem_path, erb_variables, validate_only: validate_only)
378
+ current_model.create unless dry_run
379
+ if current_model.is_a?(OpenC3::TargetModel)
380
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
381
+ else
382
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
383
+ end
348
384
  end
349
385
  # If something goes wrong in create, or more likely in deploy,
350
386
  # we want to clear the current_model and try to instantiate the next
@@ -363,8 +399,12 @@ module OpenC3
363
399
  end
364
400
  end
365
401
  if current_model
366
- current_model.create unless validate_only
367
- current_model.deploy(gem_path, erb_variables, validate_only: validate_only)
402
+ current_model.create unless dry_run
403
+ if current_model.is_a?(OpenC3::TargetModel)
404
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
405
+ else
406
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
407
+ end
368
408
  current_model = nil
369
409
  end
370
410
  end
@@ -375,15 +415,26 @@ module OpenC3
375
415
  end
376
416
  rescue => e
377
417
  # Install failed - need to cleanup
378
- plugin_model.destroy unless validate_only
418
+ plugin_model.destroy unless dry_run
379
419
  raise e
380
420
  ensure
381
421
  FileUtils.remove_entry_secure(temp_dir, true)
382
422
  tf.unlink if tf
383
423
  end
424
+ return upgrade_context[:diff_collector].uniq if diff_only
384
425
  return plugin_model.as_json()
385
426
  end
386
427
 
428
+ # Dry run: which modified files would this plugin's install supersede?
429
+ # Returns a list of "TARGET/path" names whose live (modified) content
430
+ # differs from the rendered plugin content. Read-only; no side effects.
431
+ def self.modified_diff(plugin_hash, scope:)
432
+ install_phase2(plugin_hash, scope: scope, diff_only: true)
433
+ rescue => e
434
+ Logger.warn("PluginModel.modified_diff failed: #{e.message}")
435
+ []
436
+ end
437
+
387
438
  def initialize(
388
439
  name:,
389
440
  variables: {},
@@ -482,6 +533,19 @@ module OpenC3
482
533
  errors << e
483
534
  end
484
535
  end
536
+ # Remove the per-plugin UV virtual environment directory that was created
537
+ # during install_phase2. This cleans up the .venv, pyproject.toml, uv.lock,
538
+ # and .uv_managed marker so disk space is reclaimed on plugin uninstall.
539
+ begin
540
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
541
+ plugin_venv_path = File.join('/gems', 'plugin_venvs', plugin_venv_name)
542
+ if File.directory?(plugin_venv_path)
543
+ Logger.info("Removing per-plugin Python venv: #{plugin_venv_path}")
544
+ FileUtils.rm_rf(plugin_venv_path)
545
+ end
546
+ rescue Exception => e
547
+ errors << e
548
+ end
485
549
  # Raise all the errors at once
486
550
  if errors.length > 0
487
551
  message = ''
@@ -528,6 +592,103 @@ module OpenC3
528
592
  return result.sort
529
593
  end
530
594
 
595
+ # Resolve the PyPI URL from settings, environment, or default.
596
+ # Used by both install_phase2 and migrate_to_uv! to avoid duplication.
597
+ def self.resolve_pypi_url(scope:)
598
+ pypi_url = nil
599
+ begin
600
+ pypi_url = get_setting('pypi_url', scope: scope)
601
+ pypi_url += '/simple' if pypi_url
602
+ rescue => e
603
+ Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
604
+ ensure
605
+ if pypi_url.nil?
606
+ pypi_url = ENV.fetch('PYPI_URL', nil)
607
+ pypi_url += '/simple' if pypi_url
608
+ pypi_url ||= PypiUrl::DEFAULT
609
+ end
610
+ end
611
+ PypiUrl.validate(pypi_url)
612
+ end
613
+
614
+ # Build the argv array for pypi index and trusted-host arguments.
615
+ def self.build_pypi_args(pypi_url)
616
+ args = ["-i", pypi_url]
617
+ args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
618
+ args
619
+ end
620
+
621
+ # Build a sanitized venv directory name from scope and plugin name.
622
+ # Replaces characters that are not alphanumeric, underscore, or hyphen with underscores.
623
+ def self.plugin_venv_name(scope:, plugin_name:)
624
+ "#{scope}__#{plugin_name}".tr('^a-zA-Z0-9_-', '_')
625
+ end
626
+
627
+ # Check if this plugin needs migration to a per-plugin UV virtual environment.
628
+ # Returns true if the plugin has Python dependencies but no .uv_managed marker exists.
629
+ def needs_uv_migration?
630
+ return false unless @needs_dependencies
631
+
632
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
633
+ marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
634
+ !File.exist?(marker_path)
635
+ end
636
+
637
+ # Migrate this plugin from the shared Python venv to a per-plugin UV virtual environment.
638
+ # Non-fatal: logs a warning on failure, plugin continues using the shared venv.
639
+ # Returns true on success, false on failure.
640
+ def migrate_to_uv!(scope:)
641
+ plugin_venv_name = self.class.plugin_venv_name(scope: scope, plugin_name: @name)
642
+ marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
643
+ if File.exist?(marker_path)
644
+ Logger.info("Plugin '#{@name}' is already migrated to a per-plugin UV venv")
645
+ return true
646
+ end
647
+
648
+ gem_name = @name.split("__")[0]
649
+ gem_file_path = OpenC3::GemModel.get(gem_name)
650
+
651
+ temp_dir = Dir.mktmpdir
652
+ begin
653
+ # Extract gem contents (same pattern as install_phase2)
654
+ gem_path = File.join(temp_dir, "gem")
655
+ FileUtils.mkdir_p(gem_path)
656
+ pkg = Gem::Package.new(gem_file_path)
657
+ pkg.extract_files(gem_path)
658
+
659
+ # Check for Python dependency files
660
+ pyproject_path = File.join(gem_path, 'pyproject.toml')
661
+ requirements_path = File.join(gem_path, 'requirements.txt')
662
+
663
+ unless File.exist?(pyproject_path) || File.exist?(requirements_path)
664
+ Logger.info("Plugin #{@name} has no Python dependencies to migrate")
665
+ return true
666
+ end
667
+
668
+ pypi_url = self.class.resolve_pypi_url(scope: scope)
669
+ pypi_args = self.class.build_pypi_args(pypi_url)
670
+
671
+ # Run uvinstall for this plugin
672
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
673
+ Logger.info("Migrating plugin '#{@name}' to per-plugin UV venv '#{plugin_venv_name}'")
674
+ uv_args = [plugin_venv_name, gem_path] + pypi_args
675
+ output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
676
+ puts output
677
+ if status.success?
678
+ Logger.info("Successfully migrated plugin '#{@name}' to per-plugin UV venv")
679
+ return true
680
+ else
681
+ Logger.warn("UV migration failed for plugin '#{@name}'. Plugin will continue using shared venv.")
682
+ return false
683
+ end
684
+ rescue => e
685
+ Logger.warn("UV migration failed for plugin '#{@name}': #{e.message}. Plugin will continue using shared venv.")
686
+ return false
687
+ ensure
688
+ FileUtils.remove_entry_secure(temp_dir, true)
689
+ end
690
+ end
691
+
531
692
  # Remove the backing gem for an unloaded plugin so it disappears from
532
693
  # GemModel.names (and the admin Packages tab). Skips the removal if any
533
694
  # other PluginModel (any scope, any counter) still references the gem,
File without changes
File without changes
@@ -12,10 +12,13 @@
12
12
  # if purchased from OpenC3, Inc.
13
13
 
14
14
  require 'fileutils'
15
+ require 'open3'
16
+ require 'set'
15
17
  require 'openc3/utilities/process_manager'
16
18
  require 'openc3/utilities/pypi_url'
17
19
  require 'openc3/api/api'
18
20
  require 'pathname'
21
+ require 'json'
19
22
 
20
23
  module OpenC3
21
24
  # This class acts like a Model but doesn't inherit from Model because it doesn't
@@ -26,34 +29,191 @@ module OpenC3
26
29
  extend Api
27
30
 
28
31
  DIST_INFO = '.dist-info'
32
+ PLUGIN_VENVS_DIR = '/gems/plugin_venvs' # Per-plugin isolated venvs created by uvinstall
33
+ SYSTEM_VENV_DIR = '/openc3/python/.venv' # Core openc3 Python library venv (read-only)
34
+ DEFAULT_UV_CACHE_DIR = '/gems/uv' # UV wheel cache, seeded from the Docker image at init
35
+ UPLOADS_DIR_NAME = 'uploads' # Subdirectory under UV cache for uploaded .whl files
36
+
37
+ # Normalize a package name to lowercase with hyphens (PEP 503 canonical form)
38
+ def self.normalize_pkg_name(name)
39
+ name.tr('_', '-').downcase
40
+ end
41
+
42
+ # Parse a PEP 427 wheel filename into [normalized_name, version].
43
+ # Handles browser-appended duplicate suffixes like "(1)" on the stem.
44
+ # Returns nil for non-wheel files or malformed names.
45
+ # Example: "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl" => ["numpy", "2.4.6"]
46
+ def self.parse_wheel_filename(filename)
47
+ # Strip browser duplicate suffixes like " (1)" before the .whl extension
48
+ # Two steps to avoid any space quantifier in the regex:
49
+ # 1. Remove "(N).whl" at end, replacing with just ".whl"
50
+ # 2. Clean up leftover spaces before .whl with rstrip
51
+ clean = filename.sub(/\(\d+\)\.whl\z/i, '.whl')
52
+ clean = "#{clean.chomp('.whl').rstrip}.whl" if clean.end_with?('.whl')
53
+ return nil unless clean.end_with?('.whl')
54
+
55
+ # PEP 427: {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl
56
+ # We need at least name-version-python-abi-platform (5 segments)
57
+ stem = clean.chomp('.whl')
58
+ parts = stem.split('-')
59
+ return nil if parts.length < 5
60
+
61
+ # The version is always the second segment; everything before it is the name
62
+ # (some packages have hyphens in their name that become underscores in the wheel)
63
+ # PEP 427 guarantees: last 3 segments are python-abi-platform,
64
+ # optional build tag before those, then version, then name (may have multiple segments)
65
+ # Simplest reliable approach: version is always at index 1
66
+ name = normalize_pkg_name(parts[0])
67
+ version = parts[1]
68
+
69
+ # Sanity check: version should start with a digit
70
+ return nil unless version.match?(/\A\d/)
71
+
72
+ [name, version]
73
+ end
29
74
 
30
75
  def self.names
31
- paths = Dir.glob("#{ENV['PYTHONUSERBASE']}/lib/*")
32
- results = []
76
+ result = {}
77
+
78
+ # Collect all packages available in the UV download cache
79
+ # This includes system packages (seeded from the Docker image) plus
80
+ # any additional packages downloaded during plugin installs
81
+ cached = cached_packages
82
+ result['cached'] = cached.sort unless cached.empty?
83
+
84
+ # Collect packages from per-plugin venvs
85
+ if File.directory?(PLUGIN_VENVS_DIR)
86
+ Dir.glob("#{PLUGIN_VENVS_DIR}/*/").each do |plugin_dir|
87
+ plugin_name = File.basename(plugin_dir)
88
+ venv_dir = File.join(plugin_dir, '.venv')
89
+ next unless File.directory?(venv_dir)
90
+
91
+ # Always include plugin venvs even if empty so they remain visible
92
+ # in the Admin UI and selectable as install targets
93
+ packages = packages_in_venv(venv_dir)
94
+ result[plugin_name] = packages.sort
95
+ end
96
+ end
97
+
98
+ # Also collect packages from the shared venv for backwards compatibility
99
+ shared_packages = shared_venv_packages
100
+ result['shared'] = shared_packages.sort unless shared_packages.empty?
101
+
102
+ return result
103
+ end
104
+
105
+ # List packages in a specific venv by scanning dist-info directories.
106
+ # Returns normalized names (lowercase, hyphens) for consistent display.
107
+ def self.packages_in_venv(venv_dir)
108
+ packages = []
109
+ # Look for site-packages in the venv's lib directory
110
+ Dir.glob("#{venv_dir}/lib/*/site-packages").each do |site_packages|
111
+ next unless File.directory?(site_packages)
112
+ Pathname.new(site_packages).children.each do |child|
113
+ if child.directory? && File.extname(child) == DIST_INFO
114
+ raw_name = File.basename(child, DIST_INFO)
115
+ # Normalize: split name from version, normalize name, rejoin
116
+ match = raw_name.match(/\A(.+?)-(\d.*)/)
117
+ if match
118
+ packages << "#{normalize_pkg_name(match[1])}-#{match[2]}"
119
+ else
120
+ packages << normalize_pkg_name(raw_name)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ packages
126
+ end
127
+
128
+ # List packages in the system venv (/openc3/python/.venv)
129
+ def self.system_venv_packages
130
+ packages_in_venv(SYSTEM_VENV_DIR)
131
+ end
132
+
133
+ # List unique packages in the UV download cache.
134
+ # UV cache structure: wheels-v<N>/<registry>/<package-name>/<version-pytag-abitag-platform>
135
+ # e.g. wheels-v6/pypi/numpy/2.4.6-cp312-cp312-musllinux_1_2_aarch64
136
+ # Also scans the uploads/ subdirectory for user-uploaded .whl files.
137
+ def self.cached_packages
138
+ cache_dir = ENV.fetch('UV_CACHE_DIR', DEFAULT_UV_CACHE_DIR)
139
+ return [] unless File.directory?(cache_dir)
140
+
141
+ packages = Set.new
142
+ # Glob 4 levels deep: wheels-v<N>/<registry>/<package-name>/<version-entry>
143
+ Dir.glob("#{cache_dir}/wheels-v*/*/*/*").each do |entry|
144
+ basename = File.basename(entry)
145
+ # Skip UV metadata sidecar files (.http, .msgpack)
146
+ next if basename.end_with?('.http', '.msgpack')
147
+
148
+ # Version entries start with a digit
149
+ match = basename.match(/\A(\d[^-]*)/)
150
+ next unless match
151
+
152
+ # Parent directory name is the package name
153
+ pkg_name = File.basename(File.dirname(entry)).tr('_', '-').downcase
154
+ packages.add("#{pkg_name}-#{match[1]}")
155
+ end
156
+
157
+ # Scan uploaded wheels stored at <cache_dir>/uploads/*.whl
158
+ uploads_dir = File.join(cache_dir, UPLOADS_DIR_NAME)
159
+ if File.directory?(uploads_dir)
160
+ Dir.glob("#{uploads_dir}/*.whl").each do |whl_path|
161
+ parsed = parse_wheel_filename(File.basename(whl_path))
162
+ next unless parsed
163
+
164
+ packages.add("#{parsed[0]}-#{parsed[1]}")
165
+ end
166
+ end
167
+
168
+ packages.to_a
169
+ end
170
+
171
+ # List packages in the shared venv (backwards compatibility)
172
+ def self.shared_venv_packages
173
+ packages = []
174
+ pythonuserbase = ENV.fetch('PYTHONUSERBASE', nil)
175
+ return packages unless pythonuserbase
176
+
177
+ paths = Dir.glob("#{pythonuserbase}/lib/*")
33
178
  paths.each do |path|
34
- results.concat(Pathname.new(File.join(path, 'site-packages')).children.select { |c| c.directory? and File.extname(c) == DIST_INFO }.collect { |p| File.basename(p, DIST_INFO) })
179
+ site_packages = File.join(path, 'site-packages')
180
+ next unless File.directory?(site_packages)
181
+ Pathname.new(site_packages).children.each do |child|
182
+ if child.directory? && File.extname(child) == DIST_INFO
183
+ packages << File.basename(child, DIST_INFO)
184
+ end
185
+ end
35
186
  end
36
- return results.sort
187
+ packages
37
188
  end
38
189
 
39
190
  def self.get(name)
40
- path = "#{ENV['PYTHONUSERBASE']}/cache"
191
+ path = "#{ENV.fetch('PYTHONUSERBASE', nil)}/cache"
41
192
  FileUtils.mkdir_p(path) unless Dir.exist?(path)
42
193
  result = Pathname.new(path).children.select { |c| c.file? and File.basename(c, File.extname(c)) == name }
43
194
  if result.length > 0
44
195
  return result[0] if File.exist?(result[0])
45
196
  end
46
- raise "Package #{name} not found"
197
+ raise "Package '#{name}' not found"
47
198
  end
48
199
 
49
- def self.put(package_file_path, package_install: true, scope:)
200
+ def self.put(package_file_path, package_install: true, scope:, plugin: nil)
50
201
  if File.file?(package_file_path)
51
202
  package_filename = File.basename(package_file_path)
52
- FileUtils.mkdir_p("#{ENV['PYTHONUSERBASE']}/cache") unless Dir.exist?("#{ENV['PYTHONUSERBASE']}/cache")
53
- cache_path = "#{ENV['PYTHONUSERBASE']}/cache/#{File.basename(package_file_path)}"
203
+ FileUtils.mkdir_p("#{ENV.fetch('PYTHONUSERBASE')}/cache") unless Dir.exist?("#{ENV.fetch('PYTHONUSERBASE')}/cache")
204
+ cache_path = "#{ENV.fetch('PYTHONUSERBASE')}/cache/#{File.basename(package_file_path)}"
54
205
  FileUtils.cp(package_file_path, cache_path)
206
+
207
+ # Copy uploaded .whl files to the UV uploads directory so they appear
208
+ # in the "Cached" section of the Admin Packages UI
209
+ if package_filename.end_with?('.whl')
210
+ uploads_dir = File.join(ENV.fetch('UV_CACHE_DIR', DEFAULT_UV_CACHE_DIR), UPLOADS_DIR_NAME)
211
+ FileUtils.mkdir_p(uploads_dir)
212
+ FileUtils.cp(package_file_path, File.join(uploads_dir, package_filename))
213
+ end
214
+
55
215
  if package_install
56
- return self.install(cache_path, scope: scope)
216
+ return self.install(cache_path, scope: scope, plugin: plugin)
57
217
  end
58
218
  else
59
219
  message = "Package file #{package_file_path} does not exist!"
@@ -63,7 +223,11 @@ module OpenC3
63
223
  return nil
64
224
  end
65
225
 
66
- def self.install(name_or_path, scope:)
226
+ # Install a Python package via pipinstall. When +plugin+ is provided, the
227
+ # package is installed into that plugin's per-plugin venv instead of the
228
+ # shared PYTHONUSERBASE. This is used by the Admin Packages tab when a user
229
+ # selects a specific plugin venv as the install target.
230
+ def self.install(name_or_path, scope:, plugin: nil)
67
231
  if File.exist?(name_or_path)
68
232
  package_file_path = name_or_path
69
233
  else
@@ -80,7 +244,7 @@ module OpenC3
80
244
  ensure
81
245
  if pypi_url.nil?
82
246
  # If Redis isn't running try the ENV, then simply pypi.org/simple
83
- pypi_url = ENV['PYPI_URL']
247
+ pypi_url = ENV.fetch('PYPI_URL', nil)
84
248
  if pypi_url
85
249
  pypi_url += '/simple'
86
250
  end
@@ -94,18 +258,50 @@ module OpenC3
94
258
  else
95
259
  pip_args = ["-i", pypi_url, "--trusted-host", URI.parse(pypi_url).host, package_file_path]
96
260
  end
97
- result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipinstall"] + pip_args, "package_install", package_filename, Time.now + 3600.0, scope: scope)
261
+ spawn_env = {}
262
+ if plugin
263
+ venv_path = "#{PLUGIN_VENVS_DIR}/#{plugin}/.venv"
264
+ spawn_env['PIPINSTALL_VENV'] = venv_path
265
+ end
266
+ result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipinstall"] + pip_args, "package_install", package_filename, Time.now + 3600.0, scope: scope, env: spawn_env)
98
267
  return result.name
99
268
  end
100
269
 
101
- def self.destroy(name, scope:)
270
+ # Uninstall a Python package. When +plugin+ is provided, the package is
271
+ # removed from that plugin's per-plugin venv; otherwise from the shared venv.
272
+ def self.destroy(name, scope:, plugin: nil)
102
273
  package_name, version = self.extract_name_and_version(name)
103
274
  Logger.info "Uninstalling package: #{name}"
104
275
  pip_args = [package_name]
105
- result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipuninstall"] + pip_args, "package_uninstall", name, Time.now + 3600.0, scope: scope)
276
+ spawn_env = {}
277
+ if plugin
278
+ venv_path = "#{PLUGIN_VENVS_DIR}/#{plugin}/.venv"
279
+ spawn_env['PIPINSTALL_VENV'] = venv_path
280
+ end
281
+ result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipuninstall"] + pip_args, "package_uninstall", name, Time.now + 3600.0, scope: scope, env: spawn_env)
106
282
  return result.name
107
283
  end
108
284
 
285
+ # Returns a hash of plugin_name => "uv pip list" text output for each plugin venv.
286
+ def self.trees
287
+ result = {}
288
+
289
+ if File.directory?(PLUGIN_VENVS_DIR)
290
+ Dir.glob("#{PLUGIN_VENVS_DIR}/*/").each do |plugin_dir|
291
+ plugin_name = File.basename(plugin_dir)
292
+ venv_dir = File.join(plugin_dir, '.venv')
293
+ next unless File.directory?(venv_dir)
294
+
295
+ stdout, status = Open3.capture2('uv', 'pip', 'list', '--python', venv_dir)
296
+ if status.success? && stdout.lines.length > 2
297
+ result[plugin_name] = stdout.rstrip
298
+ end
299
+ end
300
+ end
301
+
302
+ result
303
+ end
304
+
109
305
  def self.extract_name_and_version(name)
110
306
  split_name = name.split('-')
111
307
  if split_name.length > 1
File without changes
File without changes
File without changes
File without changes
File without changes