diff --git a/README.md b/README.md index 61b9ea3..e79fecb 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,13 @@ The hosted API client and local migration adapter are separate surfaces: | Import | What runs | Authentication | |---|---|---| | `from sanka_sdk import SankaClient` | Sanka's hosted HTTP API | API token | -| `SankaMigrate` or `AsyncSankaMigrate` from `sanka_sdk.migrate` | A local `sanka-migrate` subprocess | None | +| `SankaMigrate` or `AsyncSankaMigrate` from `sanka_sdk.migrate` | A local `sanka` subprocess | None | Install the runtime separately. Installing `sanka-sdk` does not install or -authenticate `sanka-migrate`. +authenticate `sanka`. ```bash -uv tool install sanka-migrate +uv tool install sanka-cli ``` Use a runtime release that includes the extension marketplace commands and @@ -80,7 +80,7 @@ verified = migrate.verify() `scan.data["recommendations"]` contains the selected extension, its target, matching evidence, and install status. When the exact default package is -already installed and has not been disabled, `sanka-migrate` can lock it on +already installed and has not been disabled, `sanka` can lock it on the first scan. Otherwise, if no matching extension is enabled, the command stops with `SANKA_EXTENSION_REQUIRED`. The error details contain the recommendations and exact `add_command`; the SDK does not bypass the runtime's @@ -88,7 +88,7 @@ selection and trust checks. `extension_config` accepts JSON-compatible values and is serialized as stable, sorted JSON. `extension_environment` accepts environment variable names, not -secret values. `sanka-migrate` forwards only those named values to the selected +secret values. `sanka` forwards only those named values to the selected extension. Both options are available on `scan()`, `plan()`, `apply()`, `test()`, and `verify()`. @@ -114,7 +114,7 @@ marketplaces.remove("partner") The Python methods map directly to these local commands: -| Python method | `sanka-migrate` command | +| Python method | `sanka` command | |---|---| | `extensions.list()` | `extension list --json` | | `extensions.add(id, marketplace=...)` | `extension add ID --marketplace NAME --json` | @@ -125,7 +125,7 @@ The Python methods map directly to these local commands: | `extensions.marketplaces.remove(name)` | `extension marketplace remove NAME --json` | `trust=True` is an explicit operator decision. The SDK only passes `--trust`. -`sanka-migrate` owns source identity checks, immutable marketplace snapshots, +`sanka` owns source identity checks, immutable marketplace snapshots, artifact verification, project locks, extension installation, upgrades, and removal safety. An untrusted source fails with `SANKA_MARKETPLACE_TRUST_REQUIRED`; the SDK does not bypass that check. @@ -161,11 +161,11 @@ Each method maps directly to the local runtime: | Python method | Runtime command | Purpose | |---|---|---| -| `scan()` | `sanka-migrate scan ... --json` | Inspect the source and write the scan artifact | -| `plan()` | `sanka-migrate plan ... --json` | Create a reviewable plan and plan hash | -| `apply()` | `sanka-migrate apply ... --json` | Generate only from the supplied reviewed plan hash | -| `test()` | `sanka-migrate test ... --json` | Prepare the generated target environment and run its tests | -| `verify()` | `sanka-migrate verify ... --json` | Verify integrity and configured behavior | +| `scan()` | `sanka scan ... --json` | Inspect the source and write the scan artifact | +| `plan()` | `sanka plan ... --json` | Create a reviewable plan and plan hash | +| `apply()` | `sanka apply ... --json` | Generate only from the supplied reviewed plan hash | +| `test()` | `sanka test ... --json` | Prepare the generated target environment and run its tests | +| `verify()` | `sanka verify ... --json` | Verify integrity and configured behavior | ### Results, failures, and subprocess safety @@ -200,7 +200,7 @@ any other exit code is a protocol error. Defaults, framework detection, marketplace trust, immutable snapshots, extension subprocess execution, generated-target environments, and plan-hash -safety remain in `sanka-migrate`. The SDK is a typed local adapter, not a second +safety remain in `sanka`. The SDK is a typed local adapter, not a second migration runtime. See the [CLI execution model](https://github.com/sankaHQ/sanka/blob/main/docs/django-to-fastapi.md#cli-and-sdk-execution-model) diff --git a/handwritten/sanka_sdk/migrate.py b/handwritten/sanka_sdk/migrate.py index 6c20e4d..0e2e9ef 100644 --- a/handwritten/sanka_sdk/migrate.py +++ b/handwritten/sanka_sdk/migrate.py @@ -1,7 +1,7 @@ """Local Sanka migration commands for Python applications. ``SankaMigrate`` and ``AsyncSankaMigrate`` expose the same generic lifecycle as -the ``sanka-migrate`` CLI: ``scan -> plan -> apply -> test -> verify``. Both run +the ``sanka`` CLI: ``scan -> plan -> apply -> test -> verify``. Both run the separately installed CLI in non-interactive JSON mode; neither calls Sanka's hosted API. """ @@ -21,6 +21,8 @@ JsonValue = Union[None, bool, int, float, str, List["JsonValue"], Dict[str, "JsonValue"]] SankaMigrateCommand = Literal["scan", "plan", "apply", "test", "verify", "extension"] CLI_SCHEMA_VERSION = "sanka-cli/v1" +DEFAULT_EXECUTABLE = "sanka" +INSTALL_HINT = "Install it with: uv tool install sanka-cli" __all__ = [ "ApplyData", @@ -51,7 +53,7 @@ class ExtensionEvidence(TypedDict): class ExtensionRecommendation(TypedDict): - """One compatible extension recommended by ``sanka-migrate``.""" + """One compatible extension recommended by ``sanka``.""" id: str version: str @@ -162,10 +164,10 @@ class SankaMigrate: """Run local Sanka migration commands without a Sanka API token. Args: - cwd: Working directory used by ``sanka-migrate``. Relative command + cwd: Working directory used by ``sanka``. Relative command paths and default artifacts resolve from this directory. executable: CLI executable name or path. Install it separately with - ``uv tool install sanka-migrate``. + ``uv tool install sanka-cli``. env: Environment variables merged over the current process environment. The adapter is non-interactive and always requests one ``sanka-cli/v1`` @@ -177,7 +179,7 @@ def __init__( self, *, cwd: Optional[PathValue] = None, - executable: PathValue = "sanka-migrate", + executable: PathValue = DEFAULT_EXECUTABLE, env: Optional[Mapping[str, str]] = None, ) -> None: executable_value = os.fspath(executable) @@ -196,7 +198,7 @@ def scan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ScanData]: - """Inspect a source application with ``sanka-migrate scan``. + """Inspect a source application with ``sanka scan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -239,7 +241,7 @@ def plan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[PlanData]: - """Create a reviewable, hash-bound plan with ``sanka-migrate plan``. + """Create a reviewable, hash-bound plan with ``sanka plan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -305,7 +307,7 @@ def apply( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ApplyData]: - """Apply exactly one reviewed plan with ``sanka-migrate apply``. + """Apply exactly one reviewed plan with ``sanka apply``. Args: plan_hash: Non-empty hash returned by ``plan()``; writes are bound to it. @@ -369,7 +371,7 @@ def test( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[TestData]: - """Run generated-target tests with ``sanka-migrate test``. + """Run generated-target tests with ``sanka test``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -423,7 +425,7 @@ def verify( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[VerifyData]: - """Verify the selected migration with ``sanka-migrate verify``. + """Verify the selected migration with ``sanka verify``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -488,7 +490,7 @@ def _run(self, command: SankaMigrateCommand, args: Sequence[str]) -> SankaMigrat raise _missing_executable(command) from error except OSError as error: raise SankaMigrateError( - "could not execute sanka-migrate: {}".format(error), + "could not execute sanka: {}".format(error), command=command, ) from error @@ -504,7 +506,7 @@ def _prepare( ) -> Tuple[List[str], Dict[str, str]]: if self.cwd is not None and not os.path.isdir(self.cwd): raise SankaMigrateError( - "sanka-migrate working directory was not found: {}".format(self.cwd), + "sanka working directory was not found: {}".format(self.cwd), command=command, ) environment = os.environ.copy() @@ -516,7 +518,7 @@ class AsyncSankaMigrate(SankaMigrate): """Run local Sanka migration commands without blocking the event loop. Args: - cwd: Working directory used by ``sanka-migrate``. + cwd: Working directory used by ``sanka``. executable: Separately installed CLI executable name or path. env: Environment variables merged over the current process environment. @@ -533,7 +535,7 @@ async def scan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ScanData]: - """Asynchronously inspect a source with ``sanka-migrate scan``. + """Asynchronously inspect a source with ``sanka scan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -576,7 +578,7 @@ async def plan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[PlanData]: - """Asynchronously create a hash-bound plan with ``sanka-migrate plan``. + """Asynchronously create a hash-bound plan with ``sanka plan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -641,7 +643,7 @@ async def apply( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ApplyData]: - """Asynchronously apply one reviewed plan with ``sanka-migrate apply``. + """Asynchronously apply one reviewed plan with ``sanka apply``. Args: plan_hash: Non-empty hash returned by ``plan``. @@ -705,7 +707,7 @@ async def test( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[TestData]: - """Asynchronously run generated tests with ``sanka-migrate test``. + """Asynchronously run generated tests with ``sanka test``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -758,7 +760,7 @@ async def verify( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[VerifyData]: - """Asynchronously verify with ``sanka-migrate verify``. + """Asynchronously verify with ``sanka verify``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -823,7 +825,7 @@ async def _run_async( raise _missing_executable(command) from error except OSError as error: raise SankaMigrateError( - "could not execute sanka-migrate: {}".format(error), + "could not execute sanka: {}".format(error), command=command, ) from error @@ -840,7 +842,7 @@ async def _run_async( exit_code = process.returncode if exit_code is None: - raise SankaMigrateError("sanka-migrate {} did not exit".format(command), command=command) + raise SankaMigrateError("sanka {} did not exit".format(command), command=command) return _finish_result( stdout_bytes.decode("utf-8", errors="replace"), command=command, @@ -1193,7 +1195,7 @@ def _finish_result( message = ( str(error_data.get("message")) if error_data and error_data.get("message") - else "sanka-migrate {} failed with exit code {}".format(command, exit_code) + else "sanka {} failed with exit code {}".format(command, exit_code) ) raise SankaMigrateError( message, @@ -1208,8 +1210,7 @@ def _finish_result( def _missing_executable(command: SankaMigrateCommand) -> SankaMigrateError: return SankaMigrateError( - "sanka-migrate executable was not found; install it with " - "`uv tool install sanka-migrate` or pass executable=...", + "{} executable was not found. {} or pass executable=...".format(DEFAULT_EXECUTABLE, INSTALL_HINT), command=command, ) @@ -1225,14 +1226,14 @@ def _decode_result( payload = json.loads(stdout) except (json.JSONDecodeError, TypeError) as error: raise SankaMigrateError( - "sanka-migrate {} did not return one valid JSON document".format(command), + "sanka {} did not return one valid JSON document".format(command), command=command, exit_code=exit_code, stderr=stderr, ) from error if not isinstance(payload, dict): raise SankaMigrateError( - "sanka-migrate {} returned a non-object JSON document".format(command), + "sanka {} returned a non-object JSON document".format(command), command=command, exit_code=exit_code, stderr=stderr, @@ -1242,14 +1243,14 @@ def _decode_result( payload_command = payload.get("command") if schema_version != CLI_SCHEMA_VERSION: raise SankaMigrateError( - "unsupported sanka-migrate protocol: {!r}".format(schema_version), + "unsupported sanka protocol: {!r}".format(schema_version), command=command, exit_code=exit_code, stderr=stderr, ) if payload_command != command: raise SankaMigrateError( - "sanka-migrate returned command {!r}, expected {!r}".format(payload_command, command), + "sanka returned command {!r}, expected {!r}".format(payload_command, command), command=command, exit_code=exit_code, stderr=stderr, @@ -1260,14 +1261,14 @@ def _decode_result( raise _invalid_field(command, exit_code, stderr, "outcome", "'success' or 'error'") if exit_code not in (0, 1, 2): raise SankaMigrateError( - "invalid sanka-migrate exit code {}; expected 0, 1, or 2".format(exit_code), + "invalid sanka exit code {}; expected 0, 1, or 2".format(exit_code), command=command, exit_code=exit_code, stderr=stderr, ) if (outcome == "success") != (exit_code == 0): raise SankaMigrateError( - "sanka-migrate outcome {!r} is inconsistent with exit code {}".format(outcome, exit_code), + "sanka outcome {!r} is inconsistent with exit code {}".format(outcome, exit_code), command=command, exit_code=exit_code, stderr=stderr, diff --git a/src/sanka_sdk/migrate.py b/src/sanka_sdk/migrate.py index 6c20e4d..0e2e9ef 100644 --- a/src/sanka_sdk/migrate.py +++ b/src/sanka_sdk/migrate.py @@ -1,7 +1,7 @@ """Local Sanka migration commands for Python applications. ``SankaMigrate`` and ``AsyncSankaMigrate`` expose the same generic lifecycle as -the ``sanka-migrate`` CLI: ``scan -> plan -> apply -> test -> verify``. Both run +the ``sanka`` CLI: ``scan -> plan -> apply -> test -> verify``. Both run the separately installed CLI in non-interactive JSON mode; neither calls Sanka's hosted API. """ @@ -21,6 +21,8 @@ JsonValue = Union[None, bool, int, float, str, List["JsonValue"], Dict[str, "JsonValue"]] SankaMigrateCommand = Literal["scan", "plan", "apply", "test", "verify", "extension"] CLI_SCHEMA_VERSION = "sanka-cli/v1" +DEFAULT_EXECUTABLE = "sanka" +INSTALL_HINT = "Install it with: uv tool install sanka-cli" __all__ = [ "ApplyData", @@ -51,7 +53,7 @@ class ExtensionEvidence(TypedDict): class ExtensionRecommendation(TypedDict): - """One compatible extension recommended by ``sanka-migrate``.""" + """One compatible extension recommended by ``sanka``.""" id: str version: str @@ -162,10 +164,10 @@ class SankaMigrate: """Run local Sanka migration commands without a Sanka API token. Args: - cwd: Working directory used by ``sanka-migrate``. Relative command + cwd: Working directory used by ``sanka``. Relative command paths and default artifacts resolve from this directory. executable: CLI executable name or path. Install it separately with - ``uv tool install sanka-migrate``. + ``uv tool install sanka-cli``. env: Environment variables merged over the current process environment. The adapter is non-interactive and always requests one ``sanka-cli/v1`` @@ -177,7 +179,7 @@ def __init__( self, *, cwd: Optional[PathValue] = None, - executable: PathValue = "sanka-migrate", + executable: PathValue = DEFAULT_EXECUTABLE, env: Optional[Mapping[str, str]] = None, ) -> None: executable_value = os.fspath(executable) @@ -196,7 +198,7 @@ def scan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ScanData]: - """Inspect a source application with ``sanka-migrate scan``. + """Inspect a source application with ``sanka scan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -239,7 +241,7 @@ def plan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[PlanData]: - """Create a reviewable, hash-bound plan with ``sanka-migrate plan``. + """Create a reviewable, hash-bound plan with ``sanka plan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -305,7 +307,7 @@ def apply( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ApplyData]: - """Apply exactly one reviewed plan with ``sanka-migrate apply``. + """Apply exactly one reviewed plan with ``sanka apply``. Args: plan_hash: Non-empty hash returned by ``plan()``; writes are bound to it. @@ -369,7 +371,7 @@ def test( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[TestData]: - """Run generated-target tests with ``sanka-migrate test``. + """Run generated-target tests with ``sanka test``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -423,7 +425,7 @@ def verify( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[VerifyData]: - """Verify the selected migration with ``sanka-migrate verify``. + """Verify the selected migration with ``sanka verify``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -488,7 +490,7 @@ def _run(self, command: SankaMigrateCommand, args: Sequence[str]) -> SankaMigrat raise _missing_executable(command) from error except OSError as error: raise SankaMigrateError( - "could not execute sanka-migrate: {}".format(error), + "could not execute sanka: {}".format(error), command=command, ) from error @@ -504,7 +506,7 @@ def _prepare( ) -> Tuple[List[str], Dict[str, str]]: if self.cwd is not None and not os.path.isdir(self.cwd): raise SankaMigrateError( - "sanka-migrate working directory was not found: {}".format(self.cwd), + "sanka working directory was not found: {}".format(self.cwd), command=command, ) environment = os.environ.copy() @@ -516,7 +518,7 @@ class AsyncSankaMigrate(SankaMigrate): """Run local Sanka migration commands without blocking the event loop. Args: - cwd: Working directory used by ``sanka-migrate``. + cwd: Working directory used by ``sanka``. executable: Separately installed CLI executable name or path. env: Environment variables merged over the current process environment. @@ -533,7 +535,7 @@ async def scan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ScanData]: - """Asynchronously inspect a source with ``sanka-migrate scan``. + """Asynchronously inspect a source with ``sanka scan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -576,7 +578,7 @@ async def plan( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[PlanData]: - """Asynchronously create a hash-bound plan with ``sanka-migrate plan``. + """Asynchronously create a hash-bound plan with ``sanka plan``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -641,7 +643,7 @@ async def apply( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[ApplyData]: - """Asynchronously apply one reviewed plan with ``sanka-migrate apply``. + """Asynchronously apply one reviewed plan with ``sanka apply``. Args: plan_hash: Non-empty hash returned by ``plan``. @@ -705,7 +707,7 @@ async def test( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[TestData]: - """Asynchronously run generated tests with ``sanka-migrate test``. + """Asynchronously run generated tests with ``sanka test``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -758,7 +760,7 @@ async def verify( extension_config: Optional[Mapping[str, JsonValue]] = None, extension_environment: Sequence[str] = (), ) -> SankaMigrateResult[VerifyData]: - """Asynchronously verify with ``sanka-migrate verify``. + """Asynchronously verify with ``sanka verify``. Args: root: Source repository root. Omit it to use ``cwd``. @@ -823,7 +825,7 @@ async def _run_async( raise _missing_executable(command) from error except OSError as error: raise SankaMigrateError( - "could not execute sanka-migrate: {}".format(error), + "could not execute sanka: {}".format(error), command=command, ) from error @@ -840,7 +842,7 @@ async def _run_async( exit_code = process.returncode if exit_code is None: - raise SankaMigrateError("sanka-migrate {} did not exit".format(command), command=command) + raise SankaMigrateError("sanka {} did not exit".format(command), command=command) return _finish_result( stdout_bytes.decode("utf-8", errors="replace"), command=command, @@ -1193,7 +1195,7 @@ def _finish_result( message = ( str(error_data.get("message")) if error_data and error_data.get("message") - else "sanka-migrate {} failed with exit code {}".format(command, exit_code) + else "sanka {} failed with exit code {}".format(command, exit_code) ) raise SankaMigrateError( message, @@ -1208,8 +1210,7 @@ def _finish_result( def _missing_executable(command: SankaMigrateCommand) -> SankaMigrateError: return SankaMigrateError( - "sanka-migrate executable was not found; install it with " - "`uv tool install sanka-migrate` or pass executable=...", + "{} executable was not found. {} or pass executable=...".format(DEFAULT_EXECUTABLE, INSTALL_HINT), command=command, ) @@ -1225,14 +1226,14 @@ def _decode_result( payload = json.loads(stdout) except (json.JSONDecodeError, TypeError) as error: raise SankaMigrateError( - "sanka-migrate {} did not return one valid JSON document".format(command), + "sanka {} did not return one valid JSON document".format(command), command=command, exit_code=exit_code, stderr=stderr, ) from error if not isinstance(payload, dict): raise SankaMigrateError( - "sanka-migrate {} returned a non-object JSON document".format(command), + "sanka {} returned a non-object JSON document".format(command), command=command, exit_code=exit_code, stderr=stderr, @@ -1242,14 +1243,14 @@ def _decode_result( payload_command = payload.get("command") if schema_version != CLI_SCHEMA_VERSION: raise SankaMigrateError( - "unsupported sanka-migrate protocol: {!r}".format(schema_version), + "unsupported sanka protocol: {!r}".format(schema_version), command=command, exit_code=exit_code, stderr=stderr, ) if payload_command != command: raise SankaMigrateError( - "sanka-migrate returned command {!r}, expected {!r}".format(payload_command, command), + "sanka returned command {!r}, expected {!r}".format(payload_command, command), command=command, exit_code=exit_code, stderr=stderr, @@ -1260,14 +1261,14 @@ def _decode_result( raise _invalid_field(command, exit_code, stderr, "outcome", "'success' or 'error'") if exit_code not in (0, 1, 2): raise SankaMigrateError( - "invalid sanka-migrate exit code {}; expected 0, 1, or 2".format(exit_code), + "invalid sanka exit code {}; expected 0, 1, or 2".format(exit_code), command=command, exit_code=exit_code, stderr=stderr, ) if (outcome == "success") != (exit_code == 0): raise SankaMigrateError( - "sanka-migrate outcome {!r} is inconsistent with exit code {}".format(outcome, exit_code), + "sanka outcome {!r} is inconsistent with exit code {}".format(outcome, exit_code), command=command, exit_code=exit_code, stderr=stderr, diff --git a/tests/test_migrate.py b/tests/test_migrate.py index 9c25f0e..f1b43d9 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -71,7 +71,7 @@ } ], "status": ["available"], - "add_command": "sanka-migrate extension add sanka/drf-to-fastapi", + "add_command": "sanka extension add sanka/drf-to-fastapi", } ] } @@ -104,7 +104,7 @@ def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) - self.executable = self.root / "fake-sanka-migrate" + self.executable = self.root / "fake-sanka" self.executable.write_text(textwrap.dedent(_FAKE_CLI), encoding="utf-8") self.executable.chmod(0o755) self.migrate = SankaMigrate(cwd=self.root, executable=self.executable) @@ -458,7 +458,7 @@ def test_structured_errors_keep_exit_and_cli_details(self) -> None: def test_protocol_rejects_malformed_schema_and_command(self) -> None: for mode, message in ( ("malformed", "one valid JSON document"), - ("wrong-schema", "unsupported sanka-migrate protocol"), + ("wrong-schema", "unsupported sanka protocol"), ("wrong-command", "expected 'scan'"), ): with self.subTest(mode=mode): @@ -569,11 +569,20 @@ def test_apply_requires_a_reviewed_plan_hash(self) -> None: with self.assertRaisesRegex(ValueError, "plan_hash"): self.migrate.apply(plan_hash="") - def test_missing_executable_has_an_install_hint(self) -> None: + def test_default_executable_and_missing_hint(self) -> None: + self.assertEqual(SankaMigrate().executable, "sanka") + self.assertEqual(AsyncSankaMigrate().executable, "sanka") migrate = SankaMigrate(cwd=self.root, executable=self.root / "missing") - with self.assertRaisesRegex(SankaMigrateError, "uv tool install sanka-migrate"): + with self.assertRaisesRegex(SankaMigrateError, "uv tool install sanka-cli"): migrate.scan() + def test_active_cli_guidance_uses_the_unified_executable(self) -> None: + repository = Path(__file__).resolve().parents[1] + source = (repository / "handwritten/sanka_sdk/migrate.py").read_text(encoding="utf-8") + + self.assertNotIn("sanka-migrate", source) + self.assertIn("uv tool install sanka-cli", source) + def test_every_public_symbol_and_method_has_hover_documentation(self) -> None: for name in migrate_module.__all__: if name not in ("JsonValue", "SankaMigrateCommand"): @@ -603,12 +612,93 @@ def test_packaged_module_matches_the_regeneration_source(self) -> None: ) +@unittest.skipUnless( + "SANKA_CLI_EXECUTABLE" in os.environ, + "SANKA_CLI_EXECUTABLE is not set", +) +class RealSankaCliAcceptanceTests(unittest.TestCase): + def test_real_cli_success_and_failure_envelopes_without_a_shell(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "$(touch real-cli-shell-marker)" + project.mkdir() + (project / "views.py").write_text( + "from rest_framework.views import APIView\n", + encoding="utf-8", + ) + executable = os.environ["SANKA_CLI_EXECUTABLE"] + migrate = SankaMigrate( + cwd=root, + executable=executable, + env={"SANKA_HOME": str(root / "sanka-home")}, + ) + self.assertEqual(migrate.executable, executable) + scan = migrate.scan(root=project.name) + listed = migrate.extensions.list() + + for result, command in ((scan, "scan"), (listed, "extension")): + with self.subTest(command=command): + self.assertEqual( + (result.schema_version, result.command, result.outcome), + ("sanka-cli/v1", command, "success"), + ) + self.assertFalse((root / "real-cli-shell-marker").exists()) + + failures = ( + ( + "extension", + "SANKA_EXTENSION_NOT_FOUND", + lambda: migrate.extensions.add("sanka/drf-to-fastapi"), + ), + ( + "plan", + "SANKA_EXTENSION_REQUIRED", + lambda: migrate.plan(root=project.name, to="fastapi"), + ), + ( + "apply", + "SANKA_EXTENSION_IDENTITY", + lambda: migrate.apply( + plan_hash="sha256:missing", + root=project.name, + to="fastapi", + ), + ), + ( + "test", + "SANKA_EXTENSION_IDENTITY", + lambda: migrate.test(root=project.name, to="fastapi"), + ), + ( + "verify", + "SANKA_EXTENSION_IDENTITY", + lambda: migrate.verify(root=project.name, to="fastapi"), + ), + ) + for command, error_code, call in failures: + with self.subTest(command=command): + with self.assertRaises(SankaMigrateError) as raised: + call() + error = raised.exception + self.assertEqual( + ( + error.command, + error.exit_code, + error.parsed_error["code"], + error.result.schema_version, + error.result.command, + error.result.outcome, + ), + (command, 1, error_code, "sanka-cli/v1", command, "error"), + ) + + class AsyncSankaMigrateTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) - self.executable = self.root / "fake-sanka-migrate" + self.executable = self.root / "fake-sanka" self.executable.write_text(textwrap.dedent(_FAKE_CLI), encoding="utf-8") self.executable.chmod(0o755) self.migrate = AsyncSankaMigrate(cwd=self.root, executable=self.executable) @@ -724,7 +814,7 @@ async def test_cancellation_kills_and_reaps_the_cli_process(self) -> None: async def test_missing_executable_has_an_async_install_hint(self) -> None: migrate = AsyncSankaMigrate(cwd=self.root, executable=self.root / "missing") - with self.assertRaisesRegex(SankaMigrateError, "uv tool install sanka-migrate"): + with self.assertRaisesRegex(SankaMigrateError, "uv tool install sanka-cli"): await migrate.scan()