spaceforge 0.1.0.dev0__py3-none-any.whl → 1.0.0__py3-none-any.whl

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 (35) hide show
  1. spaceforge/__init__.py +12 -4
  2. spaceforge/__main__.py +3 -3
  3. spaceforge/_version.py +0 -1
  4. spaceforge/_version_scm.py +34 -0
  5. spaceforge/cls.py +24 -14
  6. spaceforge/conftest.py +89 -0
  7. spaceforge/generator.py +129 -56
  8. spaceforge/plugin.py +199 -22
  9. spaceforge/runner.py +0 -12
  10. spaceforge/schema.json +45 -22
  11. spaceforge/templates/binary_install.sh.j2 +24 -0
  12. spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +24 -0
  13. spaceforge/{generator_test.py → test_generator.py} +265 -53
  14. spaceforge/test_generator_binaries.py +194 -0
  15. spaceforge/test_generator_core.py +180 -0
  16. spaceforge/test_generator_hooks.py +90 -0
  17. spaceforge/test_generator_parameters.py +59 -0
  18. spaceforge/test_plugin.py +357 -0
  19. spaceforge/test_plugin_file_operations.py +118 -0
  20. spaceforge/test_plugin_hooks.py +100 -0
  21. spaceforge/test_plugin_inheritance.py +102 -0
  22. spaceforge/{runner_test.py → test_runner.py} +2 -65
  23. spaceforge/test_runner_cli.py +69 -0
  24. spaceforge/test_runner_core.py +124 -0
  25. spaceforge/test_runner_execution.py +169 -0
  26. spaceforge-1.0.0.dist-info/METADATA +606 -0
  27. spaceforge-1.0.0.dist-info/RECORD +33 -0
  28. spaceforge/plugin_test.py +0 -621
  29. spaceforge-0.1.0.dev0.dist-info/METADATA +0 -163
  30. spaceforge-0.1.0.dev0.dist-info/RECORD +0 -19
  31. /spaceforge/{cls_test.py → test_cls.py} +0 -0
  32. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.0.dist-info}/WHEEL +0 -0
  33. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.0.dist-info}/entry_points.txt +0 -0
  34. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.0.dist-info}/licenses/LICENSE +0 -0
  35. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import os
2
2
  import tempfile
3
- from typing import Optional
3
+ from typing import Any, Dict, List
4
4
  from unittest.mock import Mock, mock_open, patch
5
5
 
6
6
  import pytest
@@ -55,7 +55,7 @@ class PluginExample(SpaceforgePlugin):
55
55
  Context(
56
56
  name_prefix="test_context",
57
57
  description="Test context",
58
- labels={"env": "test"},
58
+ labels=["env:test"],
59
59
  env=[Variable(key="TEST_VAR", value="test_value")],
60
60
  )
61
61
  ]
@@ -64,16 +64,17 @@ class PluginExample(SpaceforgePlugin):
64
64
  Webhook(
65
65
  name_prefix="test_webhook",
66
66
  endpoint="https://webhook.example.com",
67
- secrets=[Variable(key="SECRET_KEY", value_from_parameter="api_key")],
67
+ secretFromParameter="api_key",
68
+ labels=["type:notification"],
68
69
  )
69
70
  ]
70
71
 
71
72
  __policies__ = [
72
73
  Policy(
73
74
  name_prefix="test_policy",
74
- type="notification",
75
+ type="NOTIFICATION",
75
76
  body="package test",
76
- labels={"type": "security"},
77
+ labels=["type:security"],
77
78
  )
78
79
  ]
79
80
 
@@ -318,14 +319,32 @@ class NotAPlugin:
318
319
  """Test binary installation command generation."""
319
320
  generator = PluginGenerator()
320
321
  generator.plugin_class = PluginExample
322
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
323
+ generator.config = {
324
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
325
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
326
+ }
327
+
328
+ hooks: Dict[str, List[str]] = {"before_init": []}
329
+ mounted_files: List = []
330
+ generator._generate_binary_install_command(hooks, mounted_files)
331
+
332
+ # Should have added a script execution to hooks
333
+ assert len(hooks["before_init"]) == 1
334
+ command = hooks["before_init"][0]
321
335
 
322
- command = generator.generate_binary_install_command()
336
+ # Should have added a mounted file with script content
337
+ assert len(mounted_files) == 1
338
+ script_content = mounted_files[0].content
323
339
 
324
- assert "mkdir -p /mnt/workspace/plugins/plugin_binaries" in command
325
- assert "curl https://example.com/test-cli-amd64" in command
326
- assert "curl https://example.com/test-cli-arm64" in command
327
- assert "arch" in command
328
- assert "x86_64" in command
340
+ # Check that hook command runs the script
341
+ assert "chmod +x" in command
342
+ assert "binary_install_test-cli.sh" in command
343
+
344
+ # Check script content contains binary installation logic
345
+ assert "test-cli" in script_content
346
+ assert "https://example.com/test-cli-amd64" in script_content
347
+ assert "https://example.com/test-cli-arm64" in script_content
329
348
 
330
349
  def test_generate_binary_install_command_no_binaries(self) -> None:
331
350
  """Test binary command generation when no binaries."""
@@ -335,9 +354,18 @@ class NotAPlugin:
335
354
 
336
355
  generator = PluginGenerator()
337
356
  generator.plugin_class = NoBinariesPlugin
338
-
339
- command = generator.generate_binary_install_command()
340
- assert command == ""
357
+ generator.plugin_working_directory = "/mnt/workspace/plugins/nobinaries"
358
+ generator.config = {
359
+ "setup_virtual_env": "cd /mnt/workspace/plugins/nobinaries && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
360
+ "plugin_mounted_path": "/mnt/workspace/plugins/nobinaries/plugin.py",
361
+ }
362
+
363
+ hooks: Dict[str, List[str]] = {"before_init": []}
364
+ mounted_files: List = []
365
+ generator._generate_binary_install_command(hooks, mounted_files)
366
+ # No binaries should mean no new commands or files added
367
+ assert len(hooks["before_init"]) == 0
368
+ assert len(mounted_files) == 0
341
369
 
342
370
  def test_generate_binary_install_command_missing_urls(self) -> None:
343
371
  """Test binary command generation with missing URLs."""
@@ -347,9 +375,16 @@ class NotAPlugin:
347
375
 
348
376
  generator = PluginGenerator()
349
377
  generator.plugin_class = InvalidBinaryPlugin
350
-
378
+ generator.plugin_working_directory = "/mnt/workspace/plugins/invalidbinary"
379
+ generator.config = {
380
+ "setup_virtual_env": "cd /mnt/workspace/plugins/invalidbinary && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
381
+ "plugin_mounted_path": "/mnt/workspace/plugins/invalidbinary/plugin.py",
382
+ }
383
+
384
+ hooks: Dict[str, List[str]] = {"before_init": []}
385
+ mounted_files: List = []
351
386
  with pytest.raises(ValueError, match="must have at least one download URL"):
352
- generator.generate_binary_install_command()
387
+ generator._generate_binary_install_command(hooks, mounted_files)
353
388
 
354
389
  def test_get_plugin_policies(self) -> None:
355
390
  """Test policy extraction."""
@@ -361,7 +396,7 @@ class NotAPlugin:
361
396
  assert policies is not None
362
397
  assert len(policies) == 1
363
398
  assert policies[0].name_prefix == "test_policy"
364
- assert policies[0].type == "notification"
399
+ assert policies[0].type == "NOTIFICATION"
365
400
  assert policies[0].body == "package test"
366
401
 
367
402
  def test_get_plugin_webhooks(self) -> None:
@@ -377,33 +412,50 @@ class NotAPlugin:
377
412
  assert webhooks[0].endpoint == "https://webhook.example.com"
378
413
 
379
414
  @patch("os.path.exists")
380
- @patch("builtins.open", new_callable=mock_open, read_data="requirements content")
381
- def test_get_plugin_contexts_with_requirements(
382
- self, mock_file: Mock, mock_exists: Mock
383
- ) -> None:
415
+ def test_get_plugin_contexts_with_requirements(self, mock_exists: Mock) -> None:
384
416
  """Test context generation with requirements.txt."""
385
417
  mock_exists.side_effect = (
386
418
  lambda path: path == "requirements.txt" or "plugin.py" in path
387
419
  )
388
420
 
389
- generator = PluginGenerator(self.test_plugin_path)
390
- generator.plugin_class = PluginExample
391
- generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
421
+ # Mock specific file contents with a custom open function
422
+ original_open = open
423
+
424
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
425
+ if filename == "requirements.txt":
426
+ from io import StringIO
392
427
 
393
- contexts = generator.get_plugin_contexts()
428
+ return StringIO("requirements content")
429
+ elif "plugin.py" in filename:
430
+ from io import StringIO
431
+
432
+ return StringIO("plugin content")
433
+ else:
434
+ return original_open(filename, *args, **kwargs)
435
+
436
+ with patch("builtins.open", side_effect=mock_open_func):
437
+ generator = PluginGenerator(self.test_plugin_path)
438
+ generator.plugin_class = PluginExample
439
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
440
+ generator.config = {
441
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
442
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
443
+ }
444
+
445
+ contexts = generator.get_plugin_contexts()
394
446
 
395
447
  assert len(contexts) == 1
396
448
  context = contexts[0]
397
449
 
398
- # Should have before_init hooks for venv setup
450
+ # Should have before_init hooks with mkdir command
399
451
  assert context.hooks is not None
400
452
  assert "before_init" in context.hooks
401
- venv_command = None
453
+ mkdir_command = None
402
454
  for cmd in context.hooks["before_init"]:
403
- if "python -m venv" in cmd:
404
- venv_command = cmd
455
+ if "mkdir -p" in cmd:
456
+ mkdir_command = cmd
405
457
  break
406
- assert venv_command is not None
458
+ assert mkdir_command is not None
407
459
 
408
460
  # Should have requirements.txt as mounted file
409
461
  assert context.mounted_files is not None
@@ -415,22 +467,44 @@ class NotAPlugin:
415
467
  assert req_file is not None
416
468
  assert req_file.content == "requirements content"
417
469
 
470
+ # Should have plugin.py as mounted file
471
+ plugin_file = None
472
+ for mf in context.mounted_files:
473
+ if "plugin.py" in mf.path:
474
+ plugin_file = mf
475
+ break
476
+ assert plugin_file is not None
477
+ assert plugin_file.content == "plugin content"
478
+
418
479
  @patch("os.path.exists")
419
- @patch("builtins.open", new_callable=mock_open, read_data="plugin content")
420
- def test_get_plugin_contexts_basic(
421
- self, mock_file: Mock, mock_exists: Mock
422
- ) -> None:
480
+ def test_get_plugin_contexts_basic(self, mock_exists: Mock) -> None:
423
481
  """Test basic context generation."""
424
482
  mock_exists.side_effect = lambda path: "plugin.py" in path
425
483
 
426
- generator = PluginGenerator(self.test_plugin_path)
427
- generator.plugin_class = PluginExample
428
- generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
484
+ # Mock specific file contents with a custom open function
485
+ original_open = open
429
486
 
430
- with patch.object(
431
- generator, "get_available_hooks", return_value=["after_plan"]
432
- ):
433
- contexts = generator.get_plugin_contexts()
487
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
488
+ if "plugin.py" in filename:
489
+ from io import StringIO
490
+
491
+ return StringIO("plugin content")
492
+ else:
493
+ return original_open(filename, *args, **kwargs)
494
+
495
+ with patch("builtins.open", side_effect=mock_open_func):
496
+ generator = PluginGenerator(self.test_plugin_path)
497
+ generator.plugin_class = PluginExample
498
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
499
+ generator.config = {
500
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
501
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
502
+ }
503
+
504
+ with patch.object(
505
+ generator, "get_available_hooks", return_value=["after_plan"]
506
+ ):
507
+ contexts = generator.get_plugin_contexts()
434
508
 
435
509
  assert len(contexts) == 1
436
510
  context = contexts[0]
@@ -444,19 +518,36 @@ class NotAPlugin:
444
518
  break
445
519
  assert plugin_file is not None
446
520
 
447
- # Should have spacepy runner hooks
521
+ # Should have hooks for after_plan (the hook we mocked as available)
448
522
  assert context.hooks is not None
449
523
  assert "after_plan" in context.hooks
450
- runner_command = context.hooks["after_plan"][0]
451
- assert "python -m spaceforge runner" in runner_command
452
- assert "after_plan" in runner_command
524
+
525
+ # Should have a script execution command
526
+ hook_command = context.hooks["after_plan"][0]
527
+ assert "chmod +x" in hook_command
528
+ assert "after_plan.sh" in hook_command
529
+
530
+ # Should have a mounted script file for after_plan
531
+ after_plan_script = None
532
+ for mf in context.mounted_files:
533
+ if "after_plan.sh" in mf.path:
534
+ after_plan_script = mf
535
+ break
536
+ assert after_plan_script is not None
537
+ assert "spaceforge runner" in after_plan_script.content
538
+ assert "after_plan" in after_plan_script.content
453
539
 
454
540
  def test_generate_manifest(self) -> None:
455
541
  """Test complete manifest generation."""
456
542
  generator = PluginGenerator(self.test_plugin_path)
543
+ generator.plugin_class = PluginExample
544
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
545
+ generator.config = {
546
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
547
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
548
+ }
457
549
 
458
550
  with patch.object(generator, "load_plugin"):
459
- generator.plugin_class = PluginExample
460
551
  manifest = generator.generate_manifest()
461
552
 
462
553
  assert isinstance(manifest, PluginManifest)
@@ -474,7 +565,7 @@ class NotAPlugin:
474
565
  """Test YAML writing functionality."""
475
566
  generator = PluginGenerator(output_path=self.test_output_path)
476
567
  manifest = PluginManifest(
477
- name_prefix="test",
568
+ name="test",
478
569
  version="1.0.0",
479
570
  description="Test",
480
571
  author="Test Author",
@@ -500,7 +591,7 @@ class NotAPlugin:
500
591
  """Test complete generate method."""
501
592
  generator = PluginGenerator()
502
593
  mock_manifest = PluginManifest(
503
- name_prefix="test", version="1.0.0", description="Test", author="Test"
594
+ name="test", version="1.0.0", description="Test", author="Test"
504
595
  )
505
596
  mock_generate_manifest.return_value = mock_manifest
506
597
 
@@ -606,11 +697,30 @@ class TestPluginGeneratorEdgeCases:
606
697
 
607
698
  generator = PluginGenerator()
608
699
  generator.plugin_class = SingleArchPlugin
700
+ generator.plugin_working_directory = "/mnt/workspace/plugins/singlearch"
701
+ generator.config = {
702
+ "setup_virtual_env": "cd /mnt/workspace/plugins/singlearch && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
703
+ "plugin_mounted_path": "/mnt/workspace/plugins/singlearch/plugin.py",
704
+ }
705
+
706
+ hooks: Dict[str, List[str]] = {"before_init": []}
707
+ mounted_files: List = []
708
+ generator._generate_binary_install_command(hooks, mounted_files)
709
+
710
+ # Should have added a script execution to hooks
711
+ assert len(hooks["before_init"]) == 1
712
+ command = hooks["before_init"][0]
609
713
 
610
- command = generator.generate_binary_install_command()
714
+ # Should have added a mounted file with script content
715
+ assert len(mounted_files) == 1
716
+ script_content = mounted_files[0].content
611
717
 
612
- assert "https://example.com/binary-amd64" in command
613
- assert "arm64 binary not available" in command
718
+ # Check that hook command runs the script
719
+ assert "chmod +x" in command
720
+ assert "binary_install_single-arch.sh" in command
721
+
722
+ # Check script content contains the binary URL
723
+ assert "https://example.com/binary-amd64" in script_content
614
724
 
615
725
  def test_context_merging_with_existing(self) -> None:
616
726
  """Test that generated hooks are merged with existing context hooks."""
@@ -638,11 +748,26 @@ class TestPluginGeneratorEdgeCases:
638
748
  generator = PluginGenerator("/fake/path")
639
749
  generator.plugin_class = ExistingHooksPlugin
640
750
  generator.plugin_working_directory = "/mnt/workspace/plugins/existing_hooks"
751
+ generator.config = {
752
+ "setup_virtual_env": "cd /mnt/workspace/plugins/existing_hooks && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
753
+ "plugin_mounted_path": "/mnt/workspace/plugins/existing_hooks/plugin.py",
754
+ }
755
+
756
+ # Mock specific file contents with a custom open function
757
+ original_open = open
758
+
759
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
760
+ if filename == "/fake/path":
761
+ from io import StringIO
762
+
763
+ return StringIO("fake content")
764
+ else:
765
+ return original_open(filename, *args, **kwargs)
641
766
 
642
767
  with patch("os.path.exists") as mock_exists:
643
768
  # Only plugin file exists, not requirements.txt
644
769
  mock_exists.side_effect = lambda path: path == "/fake/path"
645
- with patch("builtins.open", mock_open(read_data="fake content")):
770
+ with patch("builtins.open", side_effect=mock_open_func):
646
771
  contexts = generator.get_plugin_contexts()
647
772
 
648
773
  context = contexts[0]
@@ -658,14 +783,101 @@ class TestPluginGeneratorEdgeCases:
658
783
  assert len(existing_files) == 1
659
784
 
660
785
  # Plugin file should be mounted (path contains the working directory)
661
- plugin_files = [
786
+ # The new implementation creates both plugin.py and hook scripts
787
+ working_dir_files = [
662
788
  mf
663
789
  for mf in context.mounted_files
664
790
  if "/mnt/workspace/plugins/existing_hooks" in mf.path
665
791
  ]
792
+ assert (
793
+ len(working_dir_files) >= 1
794
+ ) # At least plugin.py, possibly hook scripts too
795
+
796
+ # Specifically check for plugin.py
797
+ plugin_files = [
798
+ mf for mf in context.mounted_files if mf.path.endswith("plugin.py")
799
+ ]
666
800
  assert len(plugin_files) == 1
667
801
 
668
802
  # Should have existing env vars
669
803
  assert context.env is not None
670
804
  existing_vars = [var for var in context.env if var.key == "EXISTING"]
671
805
  assert len(existing_vars) == 1
806
+
807
+ def test_parameters_and_variables_id_generation(self) -> None:
808
+ class ParametersAndVariablesPlugin(SpaceforgePlugin):
809
+ __plugin_name__ = "parameters_and_variables"
810
+
811
+ __parameters__ = [
812
+ Parameter(
813
+ name="api key",
814
+ description="API key for authentication",
815
+ required=True,
816
+ sensitive=True,
817
+ ),
818
+ Parameter(
819
+ name="endpoint",
820
+ description="API endpoint URL",
821
+ required=False,
822
+ default="https://api.example.com",
823
+ ),
824
+ ]
825
+
826
+ __contexts__ = [
827
+ Context(
828
+ name_prefix="existing",
829
+ description="Existing context",
830
+ hooks={"before_init": ["echo 'existing hook'"]},
831
+ mounted_files=[
832
+ MountedFile(
833
+ path="/existing", content="existing", sensitive=False
834
+ )
835
+ ],
836
+ env=[
837
+ Variable(
838
+ key="API_KEY",
839
+ value_from_parameter="api key",
840
+ sensitive=True,
841
+ ),
842
+ Variable(key="ENDPOINT", value_from_parameter="endpoint"),
843
+ ],
844
+ )
845
+ ]
846
+
847
+ def after_plan(self) -> None:
848
+ pass
849
+
850
+ generator = PluginGenerator("/fake/path")
851
+ generator.plugin_class = ParametersAndVariablesPlugin
852
+ generator.plugin_working_directory = (
853
+ "/mnt/workspace/plugins/parametersandvariables"
854
+ )
855
+ generator.config = {
856
+ "setup_virtual_env": "cd /mnt/workspace/plugins/parametersandvariables && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
857
+ "plugin_mounted_path": "/mnt/workspace/plugins/parametersandvariables/plugin.py",
858
+ }
859
+
860
+ # Mock specific file contents with a custom open function
861
+ original_open = open
862
+
863
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
864
+ if filename == "/fake/path":
865
+ from io import StringIO
866
+
867
+ return StringIO("fake content")
868
+ else:
869
+ return original_open(filename, *args, **kwargs)
870
+
871
+ with patch("os.path.exists") as mock_exists:
872
+ # Only plugin file exists, not requirements.txt
873
+ mock_exists.side_effect = lambda path: path == "/fake/path"
874
+ with patch("builtins.open", side_effect=mock_open_func):
875
+ contexts = generator.get_plugin_contexts()
876
+ parameters = generator.get_plugin_parameters()
877
+
878
+ assert contexts[0].env is not None
879
+ assert parameters is not None
880
+ assert parameters[0].id is not None
881
+ assert parameters[1].id is not None
882
+ assert parameters[0].id == contexts[0].env[0].value_from_parameter
883
+ assert parameters[1].id == contexts[0].env[1].value_from_parameter
@@ -0,0 +1,194 @@
1
+ """Tests for PluginGenerator binary handling."""
2
+
3
+ from typing import Dict, List
4
+
5
+ import pytest
6
+
7
+ from spaceforge.cls import Binary
8
+ from spaceforge.generator import PluginGenerator
9
+ from spaceforge.plugin import SpaceforgePlugin
10
+
11
+
12
+ class TestPluginGeneratorBinaries:
13
+ """Test binary extraction and installation command generation."""
14
+
15
+ def test_should_extract_binaries_when_defined(self) -> None:
16
+ """Should extract and return binary list when plugin defines them."""
17
+
18
+ # Arrange
19
+ class BinaryPlugin(SpaceforgePlugin):
20
+ __binaries__ = [
21
+ Binary(
22
+ name="test-cli",
23
+ download_urls={
24
+ "amd64": "https://example.com/test-cli-amd64",
25
+ "arm64": "https://example.com/test-cli-arm64",
26
+ },
27
+ )
28
+ ]
29
+
30
+ generator = PluginGenerator()
31
+ generator.plugin_class = BinaryPlugin
32
+
33
+ # Act
34
+ binaries = generator.get_plugin_binaries()
35
+
36
+ # Assert
37
+ assert binaries is not None
38
+ assert len(binaries) == 1
39
+ assert binaries[0].name == "test-cli"
40
+ assert "amd64" in binaries[0].download_urls
41
+ assert "arm64" in binaries[0].download_urls
42
+
43
+ def test_should_return_none_when_no_binaries_defined(self) -> None:
44
+ """Should return None when plugin has no binaries."""
45
+
46
+ # Arrange
47
+ class NoBinariesPlugin(SpaceforgePlugin):
48
+ pass
49
+
50
+ generator = PluginGenerator()
51
+ generator.plugin_class = NoBinariesPlugin
52
+
53
+ # Act
54
+ binaries = generator.get_plugin_binaries()
55
+
56
+ # Assert
57
+ assert binaries is None
58
+
59
+ def test_should_generate_binary_install_command_for_multi_arch(self) -> None:
60
+ """Should generate installation commands for multiple architectures."""
61
+
62
+ # Arrange
63
+ class MultiArchPlugin(SpaceforgePlugin):
64
+ __binaries__ = [
65
+ Binary(
66
+ name="multi-cli",
67
+ download_urls={
68
+ "amd64": "https://example.com/multi-cli-amd64",
69
+ "arm64": "https://example.com/multi-cli-arm64",
70
+ },
71
+ )
72
+ ]
73
+
74
+ generator = PluginGenerator()
75
+ generator.plugin_class = MultiArchPlugin
76
+ generator.plugin_working_directory = "/mnt/workspace/plugins/multiarch"
77
+ generator.config = {
78
+ "setup_virtual_env": "cd /mnt/workspace/plugins/multiarch && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
79
+ "plugin_mounted_path": "/mnt/workspace/plugins/multiarch/plugin.py",
80
+ }
81
+
82
+ hooks: Dict[str, List[str]] = {"before_init": []}
83
+ mounted_files: List = []
84
+
85
+ # Act
86
+ generator._generate_binary_install_command(hooks, mounted_files)
87
+
88
+ # Assert
89
+ # Should have added a script execution to hooks
90
+ assert len(hooks["before_init"]) == 1
91
+ command = hooks["before_init"][0]
92
+
93
+ # Should have added a mounted file with script content
94
+ assert len(mounted_files) == 1
95
+ script_content = mounted_files[0].content
96
+
97
+ # Check that hook command runs the script
98
+ assert "chmod +x" in command
99
+ assert "binary_install_multi-cli.sh" in command
100
+
101
+ # Check script content contains binary installation logic
102
+ assert "multi-cli" in script_content
103
+ assert "https://example.com/multi-cli-amd64" in script_content
104
+ assert "https://example.com/multi-cli-arm64" in script_content
105
+
106
+ def test_should_not_add_commands_when_no_binaries(self) -> None:
107
+ """Should not add installation commands when plugin has no binaries."""
108
+
109
+ # Arrange
110
+ class NoBinariesPlugin(SpaceforgePlugin):
111
+ pass
112
+
113
+ generator = PluginGenerator()
114
+ generator.plugin_class = NoBinariesPlugin
115
+ generator.plugin_working_directory = "/mnt/workspace/plugins/nobinaries"
116
+ generator.config = {
117
+ "setup_virtual_env": "cd /mnt/workspace/plugins/nobinaries && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
118
+ "plugin_mounted_path": "/mnt/workspace/plugins/nobinaries/plugin.py",
119
+ }
120
+
121
+ hooks: Dict[str, List[str]] = {"before_init": []}
122
+ mounted_files: List = []
123
+
124
+ # Act
125
+ generator._generate_binary_install_command(hooks, mounted_files)
126
+
127
+ # Assert
128
+ # No binaries should mean no new commands or files added
129
+ assert len(hooks["before_init"]) == 0
130
+ assert len(mounted_files) == 0
131
+
132
+ def test_should_raise_error_when_binary_has_no_download_urls(self) -> None:
133
+ """Should raise ValueError when binary has empty download URLs."""
134
+
135
+ # Arrange
136
+ class InvalidBinaryPlugin(SpaceforgePlugin):
137
+ __binaries__ = [Binary(name="invalid", download_urls={})]
138
+
139
+ generator = PluginGenerator()
140
+ generator.plugin_class = InvalidBinaryPlugin
141
+ generator.plugin_working_directory = "/mnt/workspace/plugins/invalidbinary"
142
+ generator.config = {
143
+ "setup_virtual_env": "cd /mnt/workspace/plugins/invalidbinary && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
144
+ "plugin_mounted_path": "/mnt/workspace/plugins/invalidbinary/plugin.py",
145
+ }
146
+
147
+ hooks: Dict[str, List[str]] = {"before_init": []}
148
+ mounted_files: List = []
149
+
150
+ # Act & Assert
151
+ with pytest.raises(ValueError, match="must have at least one download URL"):
152
+ generator._generate_binary_install_command(hooks, mounted_files)
153
+
154
+ def test_should_handle_single_architecture_binary(self) -> None:
155
+ """Should generate appropriate commands for single architecture binary."""
156
+
157
+ # Arrange
158
+ class SingleArchPlugin(SpaceforgePlugin):
159
+ __binaries__ = [
160
+ Binary(
161
+ name="single-arch",
162
+ download_urls={"amd64": "https://example.com/binary-amd64"},
163
+ )
164
+ ]
165
+
166
+ generator = PluginGenerator()
167
+ generator.plugin_class = SingleArchPlugin
168
+ generator.plugin_working_directory = "/mnt/workspace/plugins/singlearch"
169
+ generator.config = {
170
+ "setup_virtual_env": "cd /mnt/workspace/plugins/singlearch && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
171
+ "plugin_mounted_path": "/mnt/workspace/plugins/singlearch/plugin.py",
172
+ }
173
+
174
+ hooks: Dict[str, List[str]] = {"before_init": []}
175
+ mounted_files: List = []
176
+
177
+ # Act
178
+ generator._generate_binary_install_command(hooks, mounted_files)
179
+
180
+ # Assert
181
+ # Should have added a script execution to hooks
182
+ assert len(hooks["before_init"]) == 1
183
+ command = hooks["before_init"][0]
184
+
185
+ # Should have added a mounted file with script content
186
+ assert len(mounted_files) == 1
187
+ script_content = mounted_files[0].content
188
+
189
+ # Check that hook command runs the script
190
+ assert "chmod +x" in command
191
+ assert "binary_install_single-arch.sh" in command
192
+
193
+ # Check script content contains the binary URL
194
+ assert "https://example.com/binary-amd64" in script_content