openc3 7.2.0 → 7.3.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +108 -29
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/interface_modifiers.yaml +15 -0
  7. data/data/config/item_modifiers.yaml +2 -1
  8. data/data/config/microservice.yaml +5 -1
  9. data/data/config/screen.yaml +2 -1
  10. data/data/config/target.yaml +38 -0
  11. data/data/config/tool.yaml +3 -1
  12. data/data/config/widgets.yaml +43 -3
  13. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  14. data/data/xtce_schemas/xml.xsd +116 -0
  15. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  16. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  17. data/lib/openc3/accessors/template_accessor.rb +2 -2
  18. data/lib/openc3/api/cmd_api.rb +5 -1
  19. data/lib/openc3/api/interface_api.rb +2 -2
  20. data/lib/openc3/api/limits_api.rb +89 -3
  21. data/lib/openc3/api/router_api.rb +3 -3
  22. data/lib/openc3/api/tlm_api.rb +1 -1
  23. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  24. data/lib/openc3/io/json_drb.rb +24 -8
  25. data/lib/openc3/io/json_rpc.rb +0 -2
  26. data/lib/openc3/logs/log_writer.rb +43 -7
  27. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  28. data/lib/openc3/microservices/decom_common.rb +15 -4
  29. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  30. data/lib/openc3/microservices/interface_microservice.rb +26 -12
  31. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  32. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  33. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  34. data/lib/openc3/models/cvt_model.rb +7 -7
  35. data/lib/openc3/models/db_sharded_model.rb +2 -2
  36. data/lib/openc3/models/gem_model.rb +1 -1
  37. data/lib/openc3/models/interface_model.rb +17 -11
  38. data/lib/openc3/models/interface_status_model.rb +2 -2
  39. data/lib/openc3/models/metric_model.rb +2 -2
  40. data/lib/openc3/models/microservice_model.rb +21 -0
  41. data/lib/openc3/models/microservice_status_model.rb +2 -2
  42. data/lib/openc3/models/model.rb +2 -6
  43. data/lib/openc3/models/plugin_model.rb +204 -49
  44. data/lib/openc3/models/python_package_model.rb +210 -12
  45. data/lib/openc3/models/reingest_job_model.rb +1 -1
  46. data/lib/openc3/models/scope_model.rb +34 -30
  47. data/lib/openc3/models/script_status_model.rb +73 -38
  48. data/lib/openc3/models/target_model.rb +193 -6
  49. data/lib/openc3/models/tool_model.rb +33 -20
  50. data/lib/openc3/operators/microservice_operator.rb +33 -4
  51. data/lib/openc3/operators/operator.rb +15 -5
  52. data/lib/openc3/packets/commands.rb +7 -7
  53. data/lib/openc3/packets/limits.rb +33 -2
  54. data/lib/openc3/packets/packet.rb +6 -4
  55. data/lib/openc3/packets/packet_config.rb +5 -1
  56. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  57. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  58. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  59. data/lib/openc3/packets/structure.rb +16 -12
  60. data/lib/openc3/packets/telemetry.rb +3 -3
  61. data/lib/openc3/script/commands.rb +1 -1
  62. data/lib/openc3/script/extract.rb +20 -25
  63. data/lib/openc3/script/limits.rb +1 -1
  64. data/lib/openc3/script/suite_runner.rb +43 -9
  65. data/lib/openc3/script/web_socket_api.rb +45 -5
  66. data/lib/openc3/system/target.rb +4 -1
  67. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  68. data/lib/openc3/top_level.rb +1 -1
  69. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  70. data/lib/openc3/topics/limits_event_topic.rb +63 -4
  71. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  72. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  73. data/lib/openc3/utilities/cli_generator.rb +5 -1
  74. data/lib/openc3/utilities/csv.rb +5 -5
  75. data/lib/openc3/utilities/env_helper.rb +13 -1
  76. data/lib/openc3/utilities/local_mode.rb +4 -1
  77. data/lib/openc3/utilities/pypi_url.rb +39 -0
  78. data/lib/openc3/utilities/reingest_job.rb +1 -1
  79. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  80. data/lib/openc3/utilities/running_script.rb +160 -100
  81. data/lib/openc3/utilities/script.rb +60 -13
  82. data/lib/openc3/utilities/store_autoload.rb +39 -1
  83. data/lib/openc3/utilities/store_queued.rb +5 -1
  84. data/lib/openc3/version.rb +5 -5
  85. data/lib/openc3.rb +6 -1
  86. data/templates/plugin/plugin.gemspec +1 -1
  87. data/templates/tool_angular/package.json +2 -2
  88. data/templates/tool_react/package.json +1 -1
  89. data/templates/tool_svelte/package.json +1 -1
  90. data/templates/tool_vue/package.json +3 -3
  91. data/templates/widget/package.json +2 -2
  92. metadata +7 -3
  93. data/templates/tool_vue/.nycrc +0 -3
  94. data/templates/widget/.nycrc +0 -3
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2022 Ball Aerospace & Technologies Corp.
4
4
  # All Rights Reserved.
@@ -31,9 +31,11 @@ require 'openc3/models/tool_model'
31
31
  require 'openc3/models/widget_model'
32
32
  require 'openc3/models/microservice_model'
33
33
  require 'openc3/api/api'
34
+ require 'openc3/utilities/pypi_url'
34
35
  require 'tmpdir'
35
36
  require 'tempfile'
36
37
  require 'fileutils'
38
+ require 'open3'
37
39
 
38
40
  module OpenC3
39
41
  class EmptyGemFileError < StandardError; end
@@ -183,11 +185,24 @@ module OpenC3
183
185
  # Called by the PluginsController to create the plugin
184
186
  # Because this uses ERB it must be run in a separate process from the API to
185
187
  # prevent corruption and single require problems in the current process
186
- def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false)
188
+ # diff_only: dry run that renders the plugin's target files and returns the
189
+ # list of modified files ("TARGET/path") whose live content differs from
190
+ # what this plugin would deploy — used to warn before an upgrade which user
191
+ # modifications it would supersede. Implies validate_only (no side effects).
192
+ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false, diff_only: false)
193
+ # diff_only implies a dry run; derive a local flag instead of mutating
194
+ # the validate_only parameter.
195
+ dry_run = validate_only || diff_only
187
196
  # Register plugin to aid in uninstall if install fails
188
197
  plugin_hash.delete("existing_plugin_txt_lines")
198
+ # Version History upgrade hints (threaded from the admin install through
199
+ # update_plugin). Extracted before the splat below because PluginModel
200
+ # has no such attributes. version_history_files lists modified files the
201
+ # user chose to take from the plugin; username attributes the upgrade.
202
+ upgrade_username = plugin_hash.delete("username")
203
+ upgrade_version_files = plugin_hash.delete("version_history_files")
189
204
  plugin_model = PluginModel.new(**(plugin_hash.transform_keys(&:to_sym)), scope: scope)
190
- plugin_model.create unless validate_only
205
+ plugin_model.create unless dry_run
191
206
 
192
207
  temp_dir = Dir.mktmpdir
193
208
  begin
@@ -204,10 +219,11 @@ module OpenC3
204
219
  # Attempt to remove all older versions of this same plugin before install to prevent version conflicts
205
220
  # Especially on downgrades
206
221
  # Leave the same version if it already exists
207
- OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path))
222
+ # Skipped for dry_run/diff_only: a dry run must not mutate gems.
223
+ OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path)) unless dry_run
208
224
 
209
225
  # Actually install the gem now (slow)
210
- OpenC3::GemModel.install(gem_file_path, scope: scope) unless validate_only
226
+ OpenC3::GemModel.install(gem_file_path, scope: scope) unless dry_run
211
227
 
212
228
  # Extract gem contents
213
229
  gem_path = File.join(temp_dir, "gem")
@@ -222,7 +238,7 @@ module OpenC3
222
238
 
223
239
  plugin_model.minimum_cosmos_version = pkg.spec.metadata['openc3_cosmos_minimum_version']
224
240
 
225
- # Process app store metadata
241
+ # Process OpenC3 Store metadata
226
242
  plugin_model.title = pkg.spec.metadata['openc3_store_title'] || pkg.spec.summary.strip
227
243
  plugin_model.description = pkg.spec.metadata['openc3_store_description'] || pkg.spec.description.strip
228
244
  plugin_model.licenses = pkg.spec.licenses
@@ -237,8 +253,19 @@ module OpenC3
237
253
  img_path = pkg.spec.metadata['openc3_store_image'] || 'public/store_img.png'
238
254
  img_path = nil unless File.exist?(File.join(gem_path, img_path))
239
255
  package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
256
+ # Build the upgrade context once the gem version is known; TargetModel
257
+ # deploys use it either to collect a modified-file diff (diff_only) or
258
+ # to version modified files taken from the plugin.
259
+ upgrade_context = nil
260
+ if diff_only
261
+ upgrade_context = { diff_collector: [] }
262
+ elsif upgrade_version_files && !upgrade_version_files.empty?
263
+ upgrade_context = { username: upgrade_username,
264
+ plugin: "#{pkg.spec.name} #{pkg.spec.version}",
265
+ version_files: upgrade_version_files }
266
+ end
240
267
  plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
241
- plugin_model.update() unless validate_only
268
+ plugin_model.update() unless dry_run
242
269
 
243
270
  needs_dependencies = pkg.spec.runtime_dependencies.length > 0
244
271
  needs_dependencies = true if Dir.exist?(File.join(gem_path, 'lib'))
@@ -248,48 +275,47 @@ module OpenC3
248
275
  requirements_path = File.join(gem_path, 'requirements.txt')
249
276
 
250
277
  if File.exist?(pyproject_path) || File.exist?(requirements_path)
251
- begin
252
- pypi_url = get_setting('pypi_url', scope: scope)
253
- if pypi_url
254
- pypi_url += '/simple'
255
- end
256
- rescue => e
257
- Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
258
- ensure
259
- if pypi_url.nil?
260
- # If Redis isn't running try the ENV, then simply pypi.org/simple
261
- pypi_url = ENV['PYPI_URL']
262
- if pypi_url
263
- pypi_url += '/simple'
278
+ pypi_url = resolve_pypi_url(scope: scope)
279
+ unless validate_only
280
+ pypi_args = build_pypi_args(pypi_url)
281
+
282
+ # Install Python dependencies into an isolated per-plugin venv when UV
283
+ # is available. Each plugin gets its own venv at /gems/plugin_venvs/<name>/.venv
284
+ # so that plugins with conflicting Python dependency versions don't interfere.
285
+ # If UV is unavailable or the install fails, fall back to the shared pipinstall
286
+ # which installs into PYTHONUSERBASE (the legacy shared environment).
287
+ uv_installed = ENV['OPENC3_USE_UV'] != 'false' && system('which uv > /dev/null 2>&1')
288
+ if uv_installed
289
+ plugin_venv_name = plugin_venv_name(scope: scope, plugin_name: plugin_model.name)
290
+ Logger.info "Installing python packages into per-plugin venv '#{plugin_venv_name}' with pypi_url=#{pypi_url}"
291
+ uv_args = [plugin_venv_name, gem_path] + pypi_args
292
+ output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
293
+ puts output
294
+ unless status.success?
295
+ Logger.warn "UV per-plugin install failed, falling back to shared pipinstall"
296
+ uv_installed = false
264
297
  end
265
- pypi_url ||= 'https://pypi.org/simple'
266
298
  end
267
- end
268
- unless validate_only
269
- if File.exist?(pyproject_path)
270
- Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
271
- if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
272
- pip_args = "-i #{pypi_url} #{gem_path}"
299
+
300
+ unless uv_installed
301
+ pip_args = pypi_args.dup
302
+ if File.exist?(pyproject_path)
303
+ Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
304
+ pip_args << gem_path
273
305
  else
274
- pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} #{gem_path}"
306
+ Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
307
+ pip_args += ["-r", requirements_path]
275
308
  end
276
- else
277
- Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
278
- if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
279
- pip_args = "-i #{pypi_url} -r #{requirements_path}"
280
- else
281
- pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
309
+ # Capture output and check exit code so failures surface as a warning
310
+ # rather than silently succeeding. pipinstall is non-fatal: the plugin
311
+ # continues to install even if Python packages fail so that non-Python
312
+ # functionality still works.
313
+ output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
314
+ puts output
315
+ unless status.success?
316
+ Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
282
317
  end
283
318
  end
284
- # Capture output and check exit code so failures surface as a warning
285
- # rather than silently succeeding. pipinstall is non-fatal: the plugin
286
- # continues to install even if Python packages fail so that non-Python
287
- # functionality still works.
288
- output = `/openc3/bin/pipinstall #{pip_args}`
289
- puts output
290
- unless $?.success?
291
- Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
292
- end
293
319
  end
294
320
  needs_dependencies = true
295
321
  end
@@ -303,7 +329,7 @@ module OpenC3
303
329
  end
304
330
  if needs_dependencies
305
331
  plugin_model.needs_dependencies = true
306
- plugin_model.update unless validate_only
332
+ plugin_model.update unless dry_run
307
333
  end
308
334
 
309
335
  # Temporarily add all lib folders from the gem to the end of the load path
@@ -342,8 +368,12 @@ module OpenC3
342
368
  when 'TARGET', 'INTERFACE', 'ROUTER', 'MICROSERVICE', 'TOOL', 'WIDGET', 'SCRIPT_ENGINE'
343
369
  begin
344
370
  if current_model
345
- current_model.create unless validate_only
346
- current_model.deploy(gem_path, erb_variables, validate_only: validate_only)
371
+ current_model.create unless dry_run
372
+ if current_model.is_a?(OpenC3::TargetModel)
373
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
374
+ else
375
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
376
+ end
347
377
  end
348
378
  # If something goes wrong in create, or more likely in deploy,
349
379
  # we want to clear the current_model and try to instantiate the next
@@ -362,8 +392,12 @@ module OpenC3
362
392
  end
363
393
  end
364
394
  if current_model
365
- current_model.create unless validate_only
366
- current_model.deploy(gem_path, erb_variables, validate_only: validate_only)
395
+ current_model.create unless dry_run
396
+ if current_model.is_a?(OpenC3::TargetModel)
397
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
398
+ else
399
+ current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
400
+ end
367
401
  current_model = nil
368
402
  end
369
403
  end
@@ -374,15 +408,26 @@ module OpenC3
374
408
  end
375
409
  rescue => e
376
410
  # Install failed - need to cleanup
377
- plugin_model.destroy unless validate_only
411
+ plugin_model.destroy unless dry_run
378
412
  raise e
379
413
  ensure
380
414
  FileUtils.remove_entry_secure(temp_dir, true)
381
415
  tf.unlink if tf
382
416
  end
417
+ return upgrade_context[:diff_collector].uniq if diff_only
383
418
  return plugin_model.as_json()
384
419
  end
385
420
 
421
+ # Dry run: which modified files would this plugin's install supersede?
422
+ # Returns a list of "TARGET/path" names whose live (modified) content
423
+ # differs from the rendered plugin content. Read-only; no side effects.
424
+ def self.modified_diff(plugin_hash, scope:)
425
+ install_phase2(plugin_hash, scope: scope, diff_only: true)
426
+ rescue => e
427
+ Logger.warn("PluginModel.modified_diff failed: #{e.message}")
428
+ []
429
+ end
430
+
386
431
  def initialize(
387
432
  name:,
388
433
  variables: {},
@@ -481,6 +526,19 @@ module OpenC3
481
526
  errors << e
482
527
  end
483
528
  end
529
+ # Remove the per-plugin UV virtual environment directory that was created
530
+ # during install_phase2. This cleans up the .venv, pyproject.toml, uv.lock,
531
+ # and .uv_managed marker so disk space is reclaimed on plugin uninstall.
532
+ begin
533
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
534
+ plugin_venv_path = File.join('/gems', 'plugin_venvs', plugin_venv_name)
535
+ if File.directory?(plugin_venv_path)
536
+ Logger.info("Removing per-plugin Python venv: #{plugin_venv_path}")
537
+ FileUtils.rm_rf(plugin_venv_path)
538
+ end
539
+ rescue Exception => e
540
+ errors << e
541
+ end
484
542
  # Raise all the errors at once
485
543
  if errors.length > 0
486
544
  message = ''
@@ -527,6 +585,103 @@ module OpenC3
527
585
  return result.sort
528
586
  end
529
587
 
588
+ # Resolve the PyPI URL from settings, environment, or default.
589
+ # Used by both install_phase2 and migrate_to_uv! to avoid duplication.
590
+ def self.resolve_pypi_url(scope:)
591
+ pypi_url = nil
592
+ begin
593
+ pypi_url = get_setting('pypi_url', scope: scope)
594
+ pypi_url += '/simple' if pypi_url
595
+ rescue => e
596
+ Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
597
+ ensure
598
+ if pypi_url.nil?
599
+ pypi_url = ENV['PYPI_URL']
600
+ pypi_url += '/simple' if pypi_url
601
+ pypi_url ||= PypiUrl::DEFAULT
602
+ end
603
+ end
604
+ PypiUrl.validate(pypi_url)
605
+ end
606
+
607
+ # Build the argv array for pypi index and trusted-host arguments.
608
+ def self.build_pypi_args(pypi_url)
609
+ args = ["-i", pypi_url]
610
+ args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
611
+ args
612
+ end
613
+
614
+ # Build a sanitized venv directory name from scope and plugin name.
615
+ # Replaces characters that are not alphanumeric, underscore, or hyphen with underscores.
616
+ def self.plugin_venv_name(scope:, plugin_name:)
617
+ "#{scope}__#{plugin_name}".tr('^a-zA-Z0-9_-', '_')
618
+ end
619
+
620
+ # Check if this plugin needs migration to a per-plugin UV virtual environment.
621
+ # Returns true if the plugin has Python dependencies but no .uv_managed marker exists.
622
+ def needs_uv_migration?
623
+ return false unless @needs_dependencies
624
+
625
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
626
+ marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
627
+ !File.exist?(marker_path)
628
+ end
629
+
630
+ # Migrate this plugin from the shared Python venv to a per-plugin UV virtual environment.
631
+ # Non-fatal: logs a warning on failure, plugin continues using the shared venv.
632
+ # Returns true on success, false on failure.
633
+ def migrate_to_uv!(scope:)
634
+ plugin_venv_name = self.class.plugin_venv_name(scope: scope, plugin_name: @name)
635
+ marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
636
+ if File.exist?(marker_path)
637
+ Logger.info("Plugin '#{@name}' is already migrated to a per-plugin UV venv")
638
+ return true
639
+ end
640
+
641
+ gem_name = @name.split("__")[0]
642
+ gem_file_path = OpenC3::GemModel.get(gem_name)
643
+
644
+ temp_dir = Dir.mktmpdir
645
+ begin
646
+ # Extract gem contents (same pattern as install_phase2)
647
+ gem_path = File.join(temp_dir, "gem")
648
+ FileUtils.mkdir_p(gem_path)
649
+ pkg = Gem::Package.new(gem_file_path)
650
+ pkg.extract_files(gem_path)
651
+
652
+ # Check for Python dependency files
653
+ pyproject_path = File.join(gem_path, 'pyproject.toml')
654
+ requirements_path = File.join(gem_path, 'requirements.txt')
655
+
656
+ unless File.exist?(pyproject_path) || File.exist?(requirements_path)
657
+ Logger.info("Plugin #{@name} has no Python dependencies to migrate")
658
+ return true
659
+ end
660
+
661
+ pypi_url = self.class.resolve_pypi_url(scope: scope)
662
+ pypi_args = self.class.build_pypi_args(pypi_url)
663
+
664
+ # Run uvinstall for this plugin
665
+ plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
666
+ Logger.info("Migrating plugin '#{@name}' to per-plugin UV venv '#{plugin_venv_name}'")
667
+ uv_args = [plugin_venv_name, gem_path] + pypi_args
668
+ output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
669
+ puts output
670
+ if status.success?
671
+ Logger.info("Successfully migrated plugin '#{@name}' to per-plugin UV venv")
672
+ return true
673
+ else
674
+ Logger.warn("UV migration failed for plugin '#{@name}'. Plugin will continue using shared venv.")
675
+ return false
676
+ end
677
+ rescue => e
678
+ Logger.warn("UV migration failed for plugin '#{@name}': #{e.message}. Plugin will continue using shared venv.")
679
+ return false
680
+ ensure
681
+ FileUtils.remove_entry_secure(temp_dir, true)
682
+ end
683
+ end
684
+
530
685
  # Remove the backing gem for an unloaded plugin so it disappears from
531
686
  # GemModel.names (and the admin Packages tab). Skips the removal if any
532
687
  # other PluginModel (any scope, any counter) still references the gem,
@@ -12,9 +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'
18
+ require 'openc3/utilities/pypi_url'
16
19
  require 'openc3/api/api'
17
20
  require 'pathname'
21
+ require 'json'
18
22
 
19
23
  module OpenC3
20
24
  # This class acts like a Model but doesn't inherit from Model because it doesn't
@@ -25,14 +29,162 @@ module OpenC3
25
29
  extend Api
26
30
 
27
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
28
74
 
29
75
  def self.names
30
- paths = Dir.glob("#{ENV['PYTHONUSERBASE']}/lib/*")
31
- 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/*")
32
178
  paths.each do |path|
33
- 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
34
186
  end
35
- return results.sort
187
+ packages
36
188
  end
37
189
 
38
190
  def self.get(name)
@@ -42,17 +194,26 @@ module OpenC3
42
194
  if result.length > 0
43
195
  return result[0] if File.exist?(result[0])
44
196
  end
45
- raise "Package #{name} not found"
197
+ raise "Package '#{name}' not found"
46
198
  end
47
199
 
48
- def self.put(package_file_path, package_install: true, scope:)
200
+ def self.put(package_file_path, package_install: true, scope:, plugin: nil)
49
201
  if File.file?(package_file_path)
50
202
  package_filename = File.basename(package_file_path)
51
203
  FileUtils.mkdir_p("#{ENV['PYTHONUSERBASE']}/cache") unless Dir.exist?("#{ENV['PYTHONUSERBASE']}/cache")
52
204
  cache_path = "#{ENV['PYTHONUSERBASE']}/cache/#{File.basename(package_file_path)}"
53
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
+
54
215
  if package_install
55
- return self.install(cache_path, scope: scope)
216
+ return self.install(cache_path, scope: scope, plugin: plugin)
56
217
  end
57
218
  else
58
219
  message = "Package file #{package_file_path} does not exist!"
@@ -62,7 +223,11 @@ module OpenC3
62
223
  return nil
63
224
  end
64
225
 
65
- 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)
66
231
  if File.exist?(name_or_path)
67
232
  package_file_path = name_or_path
68
233
  else
@@ -83,27 +248,60 @@ module OpenC3
83
248
  if pypi_url
84
249
  pypi_url += '/simple'
85
250
  end
86
- pypi_url ||= 'https://pypi.org/simple'
251
+ pypi_url ||= PypiUrl::DEFAULT
87
252
  end
88
253
  end
254
+ pypi_url = PypiUrl.validate(pypi_url)
89
255
  Logger.info "Installing python package: #{name_or_path}"
90
256
  if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
91
257
  pip_args = ["-i", pypi_url, package_file_path]
92
258
  else
93
259
  pip_args = ["-i", pypi_url, "--trusted-host", URI.parse(pypi_url).host, package_file_path]
94
260
  end
95
- 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)
96
267
  return result.name
97
268
  end
98
269
 
99
- 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)
100
273
  package_name, version = self.extract_name_and_version(name)
101
274
  Logger.info "Uninstalling package: #{name}"
102
275
  pip_args = [package_name]
103
- 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)
104
282
  return result.name
105
283
  end
106
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
+
107
305
  def self.extract_name_and_version(name)
108
306
  split_name = name.split('-')
109
307
  if split_name.length > 1
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.