sanka-cli 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/PKG-INFO +1 -1
  2. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/pyproject.toml +1 -1
  3. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/cli/__init__.py +45 -3
  4. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/cli/_output.py +18 -0
  5. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/store.py +31 -24
  6. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/__init__.py +1 -1
  7. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_cli.py +68 -1
  8. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_extension_store.py +76 -0
  9. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_mcp.py +2 -2
  10. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_sanka_facade.py +1 -1
  11. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/.gitignore +0 -0
  12. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/LICENSES/AGPL-3.0-only.txt +0 -0
  13. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/LICENSES/Apache-2.0.txt +0 -0
  14. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/NOTICE +0 -0
  15. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/README.md +0 -0
  16. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/__init__.py +0 -0
  17. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/_client.py +0 -0
  18. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/cli/_research.py +0 -0
  19. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/cli/py.typed +0 -0
  20. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/connector/__init__.py +0 -0
  21. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/connector/py.typed +0 -0
  22. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/__about__.py +0 -0
  23. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/__init__.py +0 -0
  24. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/concurrency.py +0 -0
  25. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/connector_client.py +0 -0
  26. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/connector_host.py +0 -0
  27. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/engine.py +0 -0
  28. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/__init__.py +0 -0
  29. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/continuous.py +0 -0
  30. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/errors.py +0 -0
  31. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/local.py +0 -0
  32. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/model.py +0 -0
  33. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/report_codec.py +0 -0
  34. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/routes.py +0 -0
  35. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/scope.py +0 -0
  36. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/state.py +0 -0
  37. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/execution/validate.py +0 -0
  38. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/__init__.py +0 -0
  39. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/discovery.py +0 -0
  40. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/lifecycle.py +0 -0
  41. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/model.py +0 -0
  42. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/runner.py +0 -0
  43. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/hashing.py +0 -0
  44. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/__init__.py +0 -0
  45. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/errors.py +0 -0
  46. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  47. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/model.py +0 -0
  48. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  49. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  50. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  51. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/planner.py +0 -0
  52. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/private_sqlite.py +0 -0
  53. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/py.typed +0 -0
  54. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/registry.py +0 -0
  55. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/safe_local_io.py +0 -0
  56. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/spec.py +0 -0
  57. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka/runtime/state.py +0 -0
  58. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/__main__.py +0 -0
  59. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/bundle.py +0 -0
  60. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/client.py +0 -0
  61. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/__init__.py +0 -0
  62. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/ai.py +0 -0
  63. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/auth.py +0 -0
  64. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  65. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/code.py +0 -0
  66. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/migrate.py +0 -0
  67. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/profiles.py +0 -0
  68. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/resources.py +0 -0
  69. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/skill.py +0 -0
  70. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/commands/workflows.py +0 -0
  71. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/config.py +0 -0
  72. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/main.py +0 -0
  73. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/mcp/__init__.py +0 -0
  74. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/mcp/client.py +0 -0
  75. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/mcp/render.py +0 -0
  76. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/mcp/server.py +0 -0
  77. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/output.py +0 -0
  78. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/runtime.py +0 -0
  79. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -0
  80. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_cli/state.py +0 -0
  81. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/__about__.py +0 -0
  82. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/__init__.py +0 -0
  83. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/credentials.py +0 -0
  84. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/errors.py +0 -0
  85. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/protocols.py +0 -0
  86. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/provisioning.py +0 -0
  87. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/py.typed +0 -0
  88. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/records.py +0 -0
  89. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/registration.py +0 -0
  90. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/src/sanka_connector/schema.py +0 -0
  91. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/conftest.py +0 -0
  92. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  93. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  94. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  95. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  96. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  97. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  98. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/manage.py +0 -0
  99. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  100. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  101. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_cli_developer_experience.py +0 -0
  102. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_code_bundle.py +0 -0
  103. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_code_commands.py +0 -0
  104. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_concurrency.py +0 -0
  105. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_connector_client.py +0 -0
  106. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_connector_host.py +0 -0
  107. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_credential_provider.py +0 -0
  108. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_engine_e2e.py +0 -0
  109. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_engine_execution_adoption.py +0 -0
  110. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  111. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_engine_postgres_e2e.py +0 -0
  112. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_continuous.py +0 -0
  113. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_exact_scope.py +0 -0
  114. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_local_store.py +0 -0
  115. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_report_codec.py +0 -0
  116. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_routes.py +0 -0
  117. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_scope.py +0 -0
  118. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_seam.py +0 -0
  119. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_execution_validate.py +0 -0
  120. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_extension_discovery.py +0 -0
  121. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_extension_lifecycle.py +0 -0
  122. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_extension_runner.py +0 -0
  123. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_extension_wheel_acceptance.py +0 -0
  124. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_hashing.py +0 -0
  125. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_hosted_cli.py +0 -0
  126. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_mapping_candidates.py +0 -0
  127. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_mapping_owner_mapping.py +0 -0
  128. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_mapping_pending_relationships.py +0 -0
  129. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_mapping_record_mapping.py +0 -0
  130. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_planner.py +0 -0
  131. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_registry.py +0 -0
  132. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_safe_local_io.py +0 -0
  133. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_skill_commands.py +0 -0
  134. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_spec.py +0 -0
  135. {sanka_cli-0.2.2 → sanka_cli-0.2.3}/tests/test_state_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sanka-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Sanka command-line interface and migration runtime
5
5
  Project-URL: Repository, https://github.com/sankaHQ/sanka
6
6
  Project-URL: Issues, https://github.com/sankaHQ/sanka/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -669,9 +669,51 @@ def _extension_result(
669
669
  data = {"operation": operation, "records": records}
670
670
  if args.json:
671
671
  _print_json(_json_result("extension", data, migration_state="not_started"))
672
- else:
673
- for record in records:
674
- print(json.dumps(record, ensure_ascii=False, sort_keys=True))
672
+ return 0
673
+
674
+ terminal = _terminal(args)
675
+ if operation == "list":
676
+ terminal.heading("Extensions")
677
+ terminal.table(
678
+ ("Extension", "Kind", "Version", "Status", "Marketplace"),
679
+ [
680
+ (
681
+ str(record.get("id", "")),
682
+ str(record.get("kind", "")),
683
+ str(record.get("version", "")),
684
+ ", ".join(map(str, record.get("status", []))),
685
+ str(record.get("marketplace", "")),
686
+ )
687
+ for record in records
688
+ ],
689
+ )
690
+ elif operation == "marketplace_list":
691
+ terminal.heading("Marketplaces")
692
+ terminal.table(
693
+ ("Marketplace", "Source", "Commit", "Trusted"),
694
+ [
695
+ (
696
+ str(record.get("name", "")),
697
+ str(record.get("source", "")),
698
+ str(record.get("resolved_commit") or "-")[:12],
699
+ "yes" if record.get("trusted") else "no",
700
+ )
701
+ for record in records
702
+ ],
703
+ )
704
+ elif operation == "add":
705
+ record = records[0]
706
+ terminal.success(f"Installed {record['id']} {record['version']}")
707
+ elif operation == "remove":
708
+ terminal.success(f"Removed {records[0]['id']}")
709
+ elif operation == "marketplace_add":
710
+ terminal.success(f"Added marketplace {records[0]['name']}")
711
+ elif operation == "marketplace_upgrade":
712
+ terminal.success(
713
+ "Updated marketplace " + ", ".join(str(record["name"]) for record in records)
714
+ )
715
+ elif operation == "marketplace_remove":
716
+ terminal.success(f"Removed marketplace {records[0]['name']}")
675
717
  return 0
676
718
 
677
719
 
@@ -57,6 +57,24 @@ class TerminalOutput:
57
57
  if not self.quiet:
58
58
  print(self.style(value, "cyan"), file=self.stdout)
59
59
 
60
+ def table(self, headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> None:
61
+ if self.quiet:
62
+ return
63
+ widths = [
64
+ max(len(header), *(len(row[index]) for row in rows))
65
+ for index, header in enumerate(headers)
66
+ ]
67
+ print(
68
+ " ".join(header.ljust(width) for header, width in zip(headers, widths, strict=True)),
69
+ file=self.stdout,
70
+ )
71
+ print(" ".join("-" * width for width in widths), file=self.stdout)
72
+ for row in rows:
73
+ print(
74
+ " ".join(value.ljust(width) for value, width in zip(row, widths, strict=True)),
75
+ file=self.stdout,
76
+ )
77
+
60
78
  def success(self, value: str) -> None:
61
79
  print(self.style(f"✓ OK {value}", "green"), file=self.stdout)
62
80
 
@@ -529,6 +529,13 @@ def _create_venv(
529
529
  )
530
530
 
531
531
 
532
+ def _venv_launcher_names() -> tuple[str, ...]:
533
+ names = ("python", "python3", f"python{sys.version_info.major}.{sys.version_info.minor}")
534
+ if sys.version_info[:2] == (3, 14) and sys.getfilesystemencoding() == "utf-8":
535
+ names += ("𝜋thon",)
536
+ return names
537
+
538
+
532
539
  def _normalize_venv_launchers(environment_descriptor: int) -> None:
533
540
  if os.name == "nt":
534
541
  return
@@ -543,10 +550,9 @@ def _normalize_venv_launchers(environment_descriptor: int) -> None:
543
550
  )
544
551
  os.unlink("lib64", dir_fd=environment_descriptor)
545
552
  interpreter = Path(sys.executable).resolve()
546
- names = ("python", "python3", f"python{sys.version_info.major}.{sys.version_info.minor}")
547
553
  with _directory_at(environment_descriptor, "bin", Path("bin")) as bin_descriptor:
548
554
  assert bin_descriptor is not None
549
- for name in names:
555
+ for name in _venv_launcher_names():
550
556
  with suppress(FileNotFoundError):
551
557
  status = os.stat(name, dir_fd=bin_descriptor, follow_symlinks=False)
552
558
  if not (stat.S_ISLNK(status.st_mode) or stat.S_ISREG(status.st_mode)):
@@ -2830,24 +2836,29 @@ class ExtensionStore:
2830
2836
  missing_ok=True,
2831
2837
  ) as existing:
2832
2838
  if existing is not None:
2833
- digest = content_hash(
2834
- _tree_records(
2835
- existing,
2836
- error_code="SANKA_EXTENSION_PATH",
2837
- subject="Extension environment",
2838
- allowed_symlinks=self._environment_symlinks(),
2839
- )
2840
- )
2841
- if digest == expected_digest:
2842
- try:
2843
- self._verify_environment_artifacts(
2844
- root, executable, wheels, providers=providers
2839
+ try:
2840
+ digest = content_hash(
2841
+ _tree_records(
2842
+ existing,
2843
+ error_code="SANKA_EXTENSION_PATH",
2844
+ subject="Extension environment",
2845
+ allowed_symlinks=self._environment_symlinks(),
2845
2846
  )
2846
- except ExtensionError as error:
2847
- if error.code != "SANKA_EXTENSION_HASH_MISMATCH":
2848
- raise
2849
- else:
2850
- return root
2847
+ )
2848
+ except ExtensionError as error:
2849
+ if error.code != "SANKA_EXTENSION_PATH" or expected_digest is not None:
2850
+ raise
2851
+ else:
2852
+ if digest == expected_digest:
2853
+ try:
2854
+ self._verify_environment_artifacts(
2855
+ root, executable, wheels, providers=providers
2856
+ )
2857
+ except ExtensionError as error:
2858
+ if error.code != "SANKA_EXTENSION_HASH_MISMATCH":
2859
+ raise
2860
+ else:
2861
+ return root
2851
2862
  replace_existing = True
2852
2863
  if replace_existing:
2853
2864
  shutil.rmtree(environment_name, dir_fd=environments)
@@ -3212,11 +3223,7 @@ class ExtensionStore:
3212
3223
  def _environment_symlinks() -> dict[str, Path]:
3213
3224
  interpreter = Path(sys.executable).resolve()
3214
3225
  return dict.fromkeys(
3215
- (
3216
- "bin/python",
3217
- "bin/python3",
3218
- f"bin/python{sys.version_info.major}.{sys.version_info.minor}",
3219
- ),
3226
+ (f"bin/{name}" for name in _venv_launcher_names()),
3220
3227
  interpreter,
3221
3228
  )
3222
3229
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  __all__ = ["__version__"]
3
3
 
4
- __version__ = "0.2.2"
4
+ __version__ = "0.2.3"
@@ -163,6 +163,73 @@ def test_extension_management_uses_stable_json_envelopes(
163
163
  ]
164
164
 
165
165
 
166
+ def test_extension_lists_use_human_readable_tables(
167
+ tmp_path: Path,
168
+ monkeypatch: pytest.MonkeyPatch,
169
+ capsys: pytest.CaptureFixture[str],
170
+ ) -> None:
171
+ class Record:
172
+ def __init__(self, data: dict[str, object]) -> None:
173
+ self.data = data
174
+
175
+ def to_dict(self) -> dict[str, object]:
176
+ return self.data
177
+
178
+ class Store:
179
+ def __init__(self, project_root: Path) -> None:
180
+ assert project_root == tmp_path
181
+
182
+ def list_extensions(self) -> tuple[Record, ...]:
183
+ return (
184
+ Record(
185
+ {
186
+ "id": "sanka/drf-to-fastapi",
187
+ "kind": "migration",
188
+ "version": "0.1.0a3",
189
+ "status": ["installed", "enabled"],
190
+ "marketplace": "official",
191
+ "wheels": [{"sha256": "secret-noise"}],
192
+ }
193
+ ),
194
+ )
195
+
196
+ def marketplaces(self) -> tuple[Record, ...]:
197
+ return (
198
+ Record(
199
+ {
200
+ "name": "official",
201
+ "source": "https://github.com/sankaHQ/extensions.git",
202
+ "resolved_commit": "a44ab1794924d45e8eeaeec456ce5c28c1c17d56",
203
+ "trusted": True,
204
+ "snapshot_root": "/private/cache/noise",
205
+ }
206
+ ),
207
+ )
208
+
209
+ monkeypatch.chdir(tmp_path)
210
+ monkeypatch.setattr(cli, "ExtensionStore", Store)
211
+
212
+ assert main(["extension", "list", "--no-color"]) == 0
213
+ extensions = capsys.readouterr().out
214
+ assert "Extension" in extensions
215
+ assert "sanka/drf-to-fastapi" in extensions
216
+ assert "installed, enabled" in extensions
217
+ assert "wheels" not in extensions
218
+ assert "secret-noise" not in extensions
219
+ assert not extensions.lstrip().startswith("{")
220
+
221
+ assert main(["extension", "marketplace", "list", "--no-color"]) == 0
222
+ marketplaces = capsys.readouterr().out
223
+ assert "Marketplace" in marketplaces
224
+ assert "official" in marketplaces
225
+ assert "github.com/sankaHQ/extensions" in marketplaces
226
+ assert "a44ab179492" in marketplaces
227
+ assert "yes" in marketplaces
228
+ assert "snapshot_root" not in marketplaces
229
+ assert "private/cache/noise" not in marketplaces
230
+ assert not marketplaces.lstrip().startswith("{")
231
+
232
+
166
233
  def test_extension_errors_preserve_stable_code_and_details(
167
234
  tmp_path: Path,
168
235
  monkeypatch: pytest.MonkeyPatch,
@@ -550,7 +617,7 @@ def test_research_client_uses_public_branded_base_and_unwraps_data() -> None:
550
617
  "after": ["2027-01"],
551
618
  "locale": ["ja"],
552
619
  }
553
- assert request.get_header("User-agent") == "sanka-cli/0.2.2"
620
+ assert request.get_header("User-agent") == "sanka-cli/0.2.3"
554
621
  assert timeout == 10.0
555
622
 
556
623
 
@@ -1748,6 +1748,82 @@ def test_venv_normalization_rejects_a_nonstandard_lib64_alias(tmp_path: Path) ->
1748
1748
  os.close(descriptor)
1749
1749
 
1750
1750
 
1751
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1752
+ def test_venv_normalization_accepts_python_314_unicode_launcher(
1753
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1754
+ ) -> None:
1755
+ environment = tmp_path / "environment"
1756
+ (environment / "bin").mkdir(parents=True)
1757
+ (environment / "bin" / "𝜋thon").symlink_to("python3.14")
1758
+
1759
+ class VersionInfo(tuple[object, ...]):
1760
+ major = 3
1761
+ minor = 14
1762
+
1763
+ version_info = VersionInfo((3, 14, 0, "final", 0))
1764
+ monkeypatch.setattr(sys, "version_info", version_info)
1765
+ monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8")
1766
+
1767
+ descriptor = os.open(environment, os.O_RDONLY)
1768
+ try:
1769
+ extension_store._normalize_venv_launchers(descriptor)
1770
+ extension_store._tree_records(
1771
+ descriptor,
1772
+ error_code="SANKA_EXTENSION_PATH",
1773
+ subject="Extension environment",
1774
+ allowed_symlinks=ExtensionStore._environment_symlinks(),
1775
+ )
1776
+ finally:
1777
+ os.close(descriptor)
1778
+
1779
+ launcher = environment / "bin" / "𝜋thon"
1780
+ assert launcher.is_symlink()
1781
+ assert Path(os.readlink(launcher)) == Path(sys.executable).resolve()
1782
+
1783
+
1784
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1785
+ def test_add_rebuilds_an_orphaned_environment_with_an_unverified_symlink(
1786
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1787
+ ) -> None:
1788
+ source, wheel = _marketplace(tmp_path / "source")
1789
+ store = ExtensionStore(tmp_path / "project", user_root=tmp_path / "home")
1790
+ store.add_marketplace(source, name="fixtures", trust=True)
1791
+ manifest = load_marketplace(store.marketplaces()[0].snapshot_root)[0]
1792
+ _responses(monkeypatch, {manifest.wheels[0].name: wheel})
1793
+ environment = store.user_root / "environments" / store._manifest_artifact_digest(manifest)
1794
+ (environment / "bin").mkdir(parents=True)
1795
+ stale_launcher = environment / "bin" / "stale-python"
1796
+ stale_launcher.symlink_to("python")
1797
+
1798
+ lock = store.add_extension("example/demo")
1799
+
1800
+ assert lock.artifact_digest == environment.name
1801
+ assert not stale_launcher.exists() and not stale_launcher.is_symlink()
1802
+ assert store.resolve_locked("example/demo") == lock
1803
+
1804
+
1805
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1806
+ def test_add_rejects_an_unverified_symlink_in_a_recorded_environment(
1807
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1808
+ ) -> None:
1809
+ source, wheel = _marketplace(tmp_path / "source")
1810
+ store = ExtensionStore(tmp_path / "project", user_root=tmp_path / "home")
1811
+ store.add_marketplace(source, name="fixtures", trust=True)
1812
+ manifest = load_marketplace(store.marketplaces()[0].snapshot_root)[0]
1813
+ _responses(monkeypatch, {manifest.wheels[0].name: wheel})
1814
+ lock = store.add_extension("example/demo")
1815
+ stale_launcher = (
1816
+ store.user_root / "environments" / lock.artifact_digest / "bin" / "stale-python"
1817
+ )
1818
+ stale_launcher.symlink_to("python")
1819
+
1820
+ with pytest.raises(ExtensionError) as raised:
1821
+ store.add_extension("example/demo")
1822
+
1823
+ assert raised.value.code == "SANKA_EXTENSION_PATH"
1824
+ assert stale_launcher.is_symlink()
1825
+
1826
+
1751
1827
  @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1752
1828
  def test_real_posix_materializer_installs_without_a_venv_test_hook(
1753
1829
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -21,7 +21,7 @@ def test_root_version_uses_unified_public_version(runner: CliRunner) -> None:
21
21
  result = runner.invoke(cli, ["--version"])
22
22
 
23
23
  assert result.exit_code == 0
24
- assert result.output == "sanka, version 0.2.2\n"
24
+ assert result.output == "sanka, version 0.2.3\n"
25
25
 
26
26
 
27
27
  def test_mcp_client_uses_the_cli_version_in_its_user_agent() -> None:
@@ -29,7 +29,7 @@ def test_mcp_client_uses_the_cli_version_in_its_user_agent() -> None:
29
29
 
30
30
  client = SankaMigrateApiClient()
31
31
 
32
- assert client._client.headers["User-Agent"] == "sanka-cli/0.2.2"
32
+ assert client._client.headers["User-Agent"] == "sanka-cli/0.2.3"
33
33
 
34
34
 
35
35
  def test_mcp_without_extra_has_install_hint(
@@ -35,7 +35,7 @@ def test_public_facade_version_matches_unified_distribution() -> None:
35
35
  == sanka_cli.__version__
36
36
  == sanka_cli.mcp.__version__
37
37
  == distribution_version("sanka-cli")
38
- == "0.2.2"
38
+ == "0.2.3"
39
39
  )
40
40
 
41
41
 
File without changes
File without changes
File without changes
File without changes
File without changes