sanka-cli 0.2.2__tar.gz → 0.2.4__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 (138) hide show
  1. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/PKG-INFO +20 -1
  2. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/README.md +19 -0
  3. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/pyproject.toml +1 -1
  4. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/cli/__init__.py +89 -26
  5. sanka_cli-0.2.4/src/sanka/cli/_compact.py +50 -0
  6. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/cli/_output.py +18 -0
  7. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/lifecycle.py +1 -2
  8. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/store.py +31 -24
  9. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/__init__.py +1 -1
  10. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/client.py +28 -1
  11. sanka_cli-0.2.4/src/sanka_cli/commands/cloud.py +240 -0
  12. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/main.py +2 -0
  13. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/runtime.py +8 -1
  14. sanka_cli-0.2.4/src/sanka_cli/skills/sanka-cli/SKILL.md +48 -0
  15. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_cli.py +68 -1
  16. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_cli_developer_experience.py +66 -1
  17. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_extension_lifecycle.py +1 -0
  18. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_extension_store.py +76 -0
  19. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_mcp.py +2 -2
  20. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_sanka_facade.py +1 -1
  21. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_skill_commands.py +7 -1
  22. sanka_cli-0.2.2/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -62
  23. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/.gitignore +0 -0
  24. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/LICENSES/AGPL-3.0-only.txt +0 -0
  25. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/LICENSES/Apache-2.0.txt +0 -0
  26. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/NOTICE +0 -0
  27. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/__init__.py +0 -0
  28. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/_client.py +0 -0
  29. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/cli/_research.py +0 -0
  30. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/cli/py.typed +0 -0
  31. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/connector/__init__.py +0 -0
  32. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/connector/py.typed +0 -0
  33. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/__about__.py +0 -0
  34. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/__init__.py +0 -0
  35. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/concurrency.py +0 -0
  36. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/connector_client.py +0 -0
  37. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/connector_host.py +0 -0
  38. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/engine.py +0 -0
  39. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/__init__.py +0 -0
  40. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/continuous.py +0 -0
  41. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/errors.py +0 -0
  42. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/local.py +0 -0
  43. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/model.py +0 -0
  44. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/report_codec.py +0 -0
  45. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/routes.py +0 -0
  46. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/scope.py +0 -0
  47. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/state.py +0 -0
  48. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/execution/validate.py +0 -0
  49. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/__init__.py +0 -0
  50. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/discovery.py +0 -0
  51. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/model.py +0 -0
  52. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/runner.py +0 -0
  53. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/hashing.py +0 -0
  54. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/__init__.py +0 -0
  55. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/errors.py +0 -0
  56. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  57. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/model.py +0 -0
  58. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  59. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  60. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  61. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/planner.py +0 -0
  62. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/private_sqlite.py +0 -0
  63. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/py.typed +0 -0
  64. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/registry.py +0 -0
  65. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/safe_local_io.py +0 -0
  66. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/spec.py +0 -0
  67. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka/runtime/state.py +0 -0
  68. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/__main__.py +0 -0
  69. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/bundle.py +0 -0
  70. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/__init__.py +0 -0
  71. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/ai.py +0 -0
  72. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/auth.py +0 -0
  73. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  74. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/code.py +0 -0
  75. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/migrate.py +0 -0
  76. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/profiles.py +0 -0
  77. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/resources.py +0 -0
  78. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/skill.py +0 -0
  79. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/commands/workflows.py +0 -0
  80. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/config.py +0 -0
  81. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/mcp/__init__.py +0 -0
  82. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/mcp/client.py +0 -0
  83. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/mcp/render.py +0 -0
  84. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/mcp/server.py +0 -0
  85. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/output.py +0 -0
  86. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_cli/state.py +0 -0
  87. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/__about__.py +0 -0
  88. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/__init__.py +0 -0
  89. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/credentials.py +0 -0
  90. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/errors.py +0 -0
  91. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/protocols.py +0 -0
  92. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/provisioning.py +0 -0
  93. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/py.typed +0 -0
  94. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/records.py +0 -0
  95. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/registration.py +0 -0
  96. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/src/sanka_connector/schema.py +0 -0
  97. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/conftest.py +0 -0
  98. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  99. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  100. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  101. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  102. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  103. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  104. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/manage.py +0 -0
  105. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  106. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  107. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_code_bundle.py +0 -0
  108. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_code_commands.py +0 -0
  109. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_concurrency.py +0 -0
  110. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_connector_client.py +0 -0
  111. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_connector_host.py +0 -0
  112. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_credential_provider.py +0 -0
  113. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_engine_e2e.py +0 -0
  114. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_engine_execution_adoption.py +0 -0
  115. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  116. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_engine_postgres_e2e.py +0 -0
  117. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_continuous.py +0 -0
  118. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_exact_scope.py +0 -0
  119. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_local_store.py +0 -0
  120. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_report_codec.py +0 -0
  121. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_routes.py +0 -0
  122. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_scope.py +0 -0
  123. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_seam.py +0 -0
  124. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_execution_validate.py +0 -0
  125. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_extension_discovery.py +0 -0
  126. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_extension_runner.py +0 -0
  127. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_extension_wheel_acceptance.py +0 -0
  128. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_hashing.py +0 -0
  129. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_hosted_cli.py +0 -0
  130. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_mapping_candidates.py +0 -0
  131. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_mapping_owner_mapping.py +0 -0
  132. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_mapping_pending_relationships.py +0 -0
  133. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_mapping_record_mapping.py +0 -0
  134. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_planner.py +0 -0
  135. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_registry.py +0 -0
  136. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_safe_local_io.py +0 -0
  137. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/tests/test_spec.py +0 -0
  138. {sanka_cli-0.2.2 → sanka_cli-0.2.4}/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.4
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
@@ -149,3 +149,22 @@ The distribution license expression is
149
149
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
150
150
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
151
151
  are authoritative.
152
+
153
+
154
+ ### Compact lifecycle output
155
+
156
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
157
+ `verify`, and extension management commands when an agent needs a short result.
158
+ The flags are mutually exclusive; `--json` retains its existing contract.
159
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
160
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
161
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
162
+ Legacy aliases are not repeated. With full artifacts available, generated file
163
+ contents and route source code are replaced by explicit omission counts and file
164
+ names. Read the returned artifact for those details; a compact summary is not a
165
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
166
+ use that core hash for apply, not a nested extension hash.
167
+
168
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
169
+ manual gaps is not complete. Test establishes generated scope; scenario verification
170
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -119,3 +119,22 @@ The distribution license expression is
119
119
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
120
120
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
121
121
  are authoritative.
122
+
123
+
124
+ ### Compact lifecycle output
125
+
126
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
127
+ `verify`, and extension management commands when an agent needs a short result.
128
+ The flags are mutually exclusive; `--json` retains its existing contract.
129
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
130
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
131
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
132
+ Legacy aliases are not repeated. With full artifacts available, generated file
133
+ contents and route source code are replaced by explicit omission counts and file
134
+ names. Read the returned artifact for those details; a compact summary is not a
135
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
136
+ use that core hash for apply, not a nested extension hash.
137
+
138
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
139
+ manual gaps is not complete. Test establishes generated scope; scenario verification
140
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -85,17 +85,21 @@ class _CliArgumentParser(argparse.ArgumentParser):
85
85
 
86
86
  def main(argv: list[str] | None = None) -> int:
87
87
  arguments = list(sys.argv[1:] if argv is None else argv)
88
- json_errors = "--json" in arguments
88
+ json_errors = "--json" in arguments or "--compact-dsl" in arguments
89
89
  parser = _build_parser(json_errors=json_errors)
90
90
  try:
91
91
  args = parser.parse_args(arguments)
92
92
  except CliUsageError as error:
93
93
  command = arguments[0] if arguments and arguments[0] in SDK_COMMANDS else "sanka"
94
94
  return _print_cli_error(
95
- argparse.Namespace(command=command, json=json_errors),
95
+ argparse.Namespace(
96
+ command=command, json=json_errors, compact_dsl="--compact-dsl" in arguments
97
+ ),
96
98
  error,
97
99
  exit_code=2,
98
100
  )
101
+ if getattr(args, "compact_dsl", False):
102
+ args.json = True
99
103
  if args.command is None:
100
104
  parser.print_help()
101
105
  return 0
@@ -173,12 +177,17 @@ def _json_result(
173
177
  return envelope
174
178
 
175
179
 
176
- def _print_json(payload: dict[str, Any]) -> None:
177
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
180
+ def _print_result(args: argparse.Namespace, payload: dict[str, Any]) -> None:
181
+ if getattr(args, "compact_dsl", False):
182
+ from sanka.cli._compact import render_compact
183
+
184
+ print(render_compact(payload))
185
+ else:
186
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
178
187
 
179
188
 
180
189
  def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: int) -> int:
181
- if getattr(args, "json", False):
190
+ if getattr(args, "json", False) or getattr(args, "compact_dsl", False):
182
191
  code = getattr(error, "code", None)
183
192
  details = getattr(error, "details", None)
184
193
  structured_error: dict[str, Any] = {
@@ -187,13 +196,14 @@ def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: i
187
196
  }
188
197
  if isinstance(details, dict) and details:
189
198
  structured_error["details"] = details
190
- _print_json(
199
+ _print_result(
200
+ args,
191
201
  _json_result(
192
202
  str(getattr(args, "command", "sanka") or "sanka"),
193
203
  {"error": structured_error},
194
204
  outcome="error",
195
205
  migration_state="not_started" if exit_code == 2 else "failed",
196
- )
206
+ ),
197
207
  )
198
208
  else:
199
209
  _terminal(args).failure(str(error))
@@ -221,9 +231,15 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
221
231
 
222
232
  def presentation(sub: argparse.ArgumentParser, *, json_option: bool = True) -> None:
223
233
  if json_option:
224
- sub.add_argument(
234
+ formats = sub.add_mutually_exclusive_group()
235
+ formats.add_argument(
225
236
  "--json", action="store_true", help="print one sanka-cli/v1 JSON document"
226
237
  )
238
+ formats.add_argument(
239
+ "--compact-dsl",
240
+ action="store_true",
241
+ help="print a compact result; full generated code stays in artifacts",
242
+ )
227
243
  sub.add_argument("--no-color", action="store_true", help="disable ANSI color")
228
244
  detail = sub.add_mutually_exclusive_group()
229
245
  detail.add_argument("--quiet", action="store_true", help="print only outcome and errors")
@@ -414,7 +430,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
414
430
  description=(
415
431
  "Check hashes, generated files, route coverage, and configured read-only HTTP "
416
432
  "comparisons. With --scenarios, replay a scenario file against the source "
417
- "application and a candidate FastAPI app and diff status, body, declared "
433
+ "application and a candidate FastAPI or Flask app and diff status, body, declared "
418
434
  "headers, and database state; that mode needs no plan or generated manifest."
419
435
  ),
420
436
  epilog=(
@@ -436,6 +452,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
436
452
  help="application repository root",
437
453
  )
438
454
  verify.add_argument("--to", help="select an application plan")
455
+ verify.add_argument("--settings", help="source Django settings module for scenario replay")
439
456
  verify.add_argument("--artifact-dir", default=DEFAULT_ARTIFACT_DIR)
440
457
  verify.add_argument("--output", default=None, help="generated FastAPI output directory")
441
458
  verify.add_argument(
@@ -667,11 +684,53 @@ def _extension_result(
667
684
  records: list[dict[str, Any]],
668
685
  ) -> int:
669
686
  data = {"operation": operation, "records": records}
670
- if args.json:
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))
687
+ if args.json or getattr(args, "compact_dsl", False):
688
+ _print_result(args, _json_result("extension", data, migration_state="not_started"))
689
+ return 0
690
+
691
+ terminal = _terminal(args)
692
+ if operation == "list":
693
+ terminal.heading("Extensions")
694
+ terminal.table(
695
+ ("Extension", "Kind", "Version", "Status", "Marketplace"),
696
+ [
697
+ (
698
+ str(record.get("id", "")),
699
+ str(record.get("kind", "")),
700
+ str(record.get("version", "")),
701
+ ", ".join(map(str, record.get("status", []))),
702
+ str(record.get("marketplace", "")),
703
+ )
704
+ for record in records
705
+ ],
706
+ )
707
+ elif operation == "marketplace_list":
708
+ terminal.heading("Marketplaces")
709
+ terminal.table(
710
+ ("Marketplace", "Source", "Commit", "Trusted"),
711
+ [
712
+ (
713
+ str(record.get("name", "")),
714
+ str(record.get("source", "")),
715
+ str(record.get("resolved_commit") or "-")[:12],
716
+ "yes" if record.get("trusted") else "no",
717
+ )
718
+ for record in records
719
+ ],
720
+ )
721
+ elif operation == "add":
722
+ record = records[0]
723
+ terminal.success(f"Installed {record['id']} {record['version']}")
724
+ elif operation == "remove":
725
+ terminal.success(f"Removed {records[0]['id']}")
726
+ elif operation == "marketplace_add":
727
+ terminal.success(f"Added marketplace {records[0]['name']}")
728
+ elif operation == "marketplace_upgrade":
729
+ terminal.success(
730
+ "Updated marketplace " + ", ".join(str(record["name"]) for record in records)
731
+ )
732
+ elif operation == "marketplace_remove":
733
+ terminal.success(f"Removed marketplace {records[0]['name']}")
675
734
  return 0
676
735
 
677
736
 
@@ -853,8 +912,9 @@ def _print_application_result(
853
912
  *,
854
913
  migration_state: str,
855
914
  ) -> int:
856
- if args.json:
857
- _print_json(
915
+ if args.json or getattr(args, "compact_dsl", False):
916
+ _print_result(
917
+ args,
858
918
  _json_result(
859
919
  command,
860
920
  result.data,
@@ -862,7 +922,7 @@ def _print_application_result(
862
922
  artifacts=list(result.artifacts),
863
923
  limitations=list(result.limitations),
864
924
  next_actions=list(result.next_actions),
865
- )
925
+ ),
866
926
  )
867
927
  else:
868
928
  terminal = _terminal(args)
@@ -884,8 +944,9 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
884
944
  engine = _engine(args.state)
885
945
  run_id = engine.create(spec)
886
946
  migration_plan = await engine.plan(run_id)
887
- if args.json:
888
- _print_json(
947
+ if args.json or getattr(args, "compact_dsl", False):
948
+ _print_result(
949
+ args,
889
950
  _json_result(
890
951
  "plan",
891
952
  {
@@ -911,7 +972,7 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
911
972
  ]
912
973
  )
913
974
  ],
914
- )
975
+ ),
915
976
  )
916
977
  else:
917
978
  _print_plan(run_id, migration_plan)
@@ -953,8 +1014,9 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
953
1014
  if run.plan_json is None:
954
1015
  raise ExecutionError("no plan for this spec yet; run `sanka plan` first")
955
1016
  await engine.apply(run_id, plan_hash=args.plan_hash)
956
- if args.json:
957
- _print_json(
1017
+ if args.json or getattr(args, "compact_dsl", False):
1018
+ _print_result(
1019
+ args,
958
1020
  _json_result(
959
1021
  "apply",
960
1022
  {
@@ -975,7 +1037,7 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
975
1037
  ]
976
1038
  )
977
1039
  ],
978
- )
1040
+ ),
979
1041
  )
980
1042
  else:
981
1043
  print(f"run {run_id}: applied")
@@ -1011,8 +1073,9 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1011
1073
  engine = _engine(args.state)
1012
1074
  run_id = engine.create(spec)
1013
1075
  verify_report = await engine.verify(run_id)
1014
- if args.json:
1015
- _print_json(
1076
+ if args.json or getattr(args, "compact_dsl", False):
1077
+ _print_result(
1078
+ args,
1016
1079
  _json_result(
1017
1080
  "verify",
1018
1081
  {
@@ -1035,7 +1098,7 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1035
1098
  "verified_within_scope" if verify_report.ok else "verification_failed"
1036
1099
  ),
1037
1100
  artifacts=[str(Path(args.state).resolve())],
1038
- )
1101
+ ),
1039
1102
  )
1040
1103
  else:
1041
1104
  _print_verify(verify_report)
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ """Model-facing presentation of the existing result, with JSON-escaped values."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def _summary(value: Any, *, path: tuple[str, ...] = (), artifacts: bool = False) -> Any:
11
+ if isinstance(value, dict):
12
+ if path in {("files",), ("extension", "files")} and artifacts:
13
+ return {"names": sorted(value), "omitted_file_contents": len(value)}
14
+ result = {}
15
+ for name, item in value.items():
16
+ if name == "extension" and isinstance(item, dict):
17
+ # Core lifecycle responses repeat extension fields; retain differing hashes.
18
+ item = {k: v for k, v in item.items() if k not in value or value[k] != v}
19
+ if not item:
20
+ continue
21
+ if (
22
+ name == "source"
23
+ and {"classification", "method"} <= value.keys()
24
+ and isinstance(item, str)
25
+ and artifacts
26
+ ):
27
+ result["omitted_source_characters"] = len(item)
28
+ else:
29
+ result[name] = _summary(item, path=(*path, name), artifacts=artifacts)
30
+ return result
31
+ if isinstance(value, list):
32
+ return [_summary(item, path=(*path, "[]"), artifacts=artifacts) for item in value]
33
+ return value
34
+
35
+
36
+ def render_compact(payload: dict[str, Any]) -> str:
37
+ """One header, then key=JSON lines. Never truncate failures, warnings or hashes."""
38
+ lines = [" ".join(str(payload[k]) for k in ("command", "outcome", "migration_state"))]
39
+ lines[0] = "sanka-compact/v1 " + lines[0]
40
+ data = _summary(payload["data"], artifacts=bool(payload.get("artifacts")))
41
+ for key, value in data.items():
42
+ # Escape even extension-defined keys so a newline cannot forge another record.
43
+ label = key if key.isidentifier() else json.dumps(key, ensure_ascii=False)
44
+ lines.append(label + "=" + json.dumps(value, ensure_ascii=False, separators=(",", ":")))
45
+ for key in ("artifacts", "limitations", "next_actions"):
46
+ if payload.get(key):
47
+ lines.append(
48
+ key + "=" + json.dumps(payload[key], ensure_ascii=False, separators=(",", ":"))
49
+ )
50
+ return "\n".join(lines)
@@ -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
 
@@ -820,7 +820,7 @@ class ApplicationLifecycle:
820
820
  written, so the source fingerprint legitimately differs from any reviewed plan and
821
821
  a plan may not exist yet. The extension lock still has to match its recommendation
822
822
  exactly; only the plan, fingerprint, and artifact checks that guard apply and test
823
- are skipped, because replay writes nothing into the project.
823
+ are skipped, because replay only writes verification artifacts in its artifact directory.
824
824
  """
825
825
  explicit_env_names = _canonical_environment_names(explicit_env_names)
826
826
  fingerprint = fingerprint_repository(self.project_root)
@@ -839,7 +839,6 @@ class ApplicationLifecycle:
839
839
  if result.outcome != "success":
840
840
  self._raise_failure(result)
841
841
  data = dict(result.data)
842
- data["extension"] = result.data
843
842
  return ExtensionResult(
844
843
  outcome="success",
845
844
  data=data,
@@ -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.4"
@@ -87,16 +87,43 @@ class SankaApiClient:
87
87
  path: str,
88
88
  *,
89
89
  params: dict[str, Any] | None = None,
90
+ headers: dict[str, str] | None = None,
91
+ max_bytes: int | None = None,
90
92
  ) -> tuple[bytes, dict[str, str]]:
91
93
  """Fetch a non-JSON body (currently only the Custom Code bundle download).
92
94
 
93
95
  Returns headers alongside the payload so the caller can verify the digest the
94
96
  server reports without a second request.
95
97
  """
98
+ request_headers = self._headers()
99
+ request_headers.update(headers or {})
100
+ if max_bytes is not None:
101
+ with self.client.stream(
102
+ method.upper(),
103
+ path,
104
+ headers=request_headers,
105
+ params=params,
106
+ ) as response:
107
+ content = bytearray()
108
+ for chunk in response.iter_bytes(chunk_size=65536):
109
+ if len(content) + len(chunk) > max_bytes:
110
+ raise APIError(
111
+ status_code=413, message="API artifact exceeds its size limit"
112
+ )
113
+ content.extend(chunk)
114
+ if response.status_code >= 400:
115
+ self._raise_for_response(
116
+ httpx.Response(
117
+ response.status_code,
118
+ content=bytes(content),
119
+ headers=response.headers,
120
+ )
121
+ )
122
+ return bytes(content), dict(response.headers)
96
123
  response = self.client.request(
97
124
  method.upper(),
98
125
  path,
99
- headers=self._headers(),
126
+ headers=request_headers,
100
127
  params=params,
101
128
  )
102
129
  if response.status_code >= 400: