Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions PUBLIC-EXPORT-MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
{
"mode": "644",
"path": "README.md",
"sha256": "f73e0c1859dc69be7a796253ca87d58f45ce77911ab02598cbe3fe6b0a1b9607"
"sha256": "66b54aa4b9fadd5d3126ee6b24ad84073db99a8559451ba04a10d86cc3bbe92f"
},
{
"mode": "644",
Expand Down Expand Up @@ -659,7 +659,7 @@
{
"mode": "644",
"path": "runtimes/python/README.md",
"sha256": "38e7e9dbffba5632fdad5aab673050140d118f82baa8fc1d994721213d14ff3c"
"sha256": "8685c30e1e14015d602ed0aa78c037f8c1854262e373182e505a5033448b3ceb"
},
{
"mode": "644",
Expand Down Expand Up @@ -799,7 +799,7 @@
{
"mode": "644",
"path": "runtimes/python/src/vyral_runtime/_starter.py",
"sha256": "cd79d15b65f4cad7ab795c7616c2fba85772133ae47e11d0c3b7e839f692be16"
"sha256": "7c628c66603796ecfc3e083221cccb9a4ba54da6739323e4cbd13b064349f5a4"
},
{
"mode": "644",
Expand Down Expand Up @@ -1194,7 +1194,7 @@
{
"mode": "644",
"path": "runtimes/python/tests/test_host_cli.py",
"sha256": "642ca4f2f0cda08f3a80f45238f3cde6a44e5cff1e32ea56672477d70fdb41b5"
"sha256": "44ce6d00fc4253c78e2b5f2de043e8009c40623f1889811b55900075c77a5ca0"
},
{
"mode": "644",
Expand Down Expand Up @@ -1269,7 +1269,7 @@
{
"mode": "644",
"path": "runtimes/python/tests/test_starter.py",
"sha256": "e6166b2fffad3940da2809c6fca1aee133bede29bf26445c441c4c7a825d8f1c"
"sha256": "08e042ae61608223f7be1201da65d61cc200ed11fa5c229b0d0a7ae849b3a31c"
},
{
"mode": "644",
Expand Down Expand Up @@ -4089,5 +4089,5 @@
],
"schemaVersion": 1,
"sourceDirty": false,
"treeSha256": "16d953977567cbb53f57e1d59b047e8da3631cce744876a6753477016ba353a1"
"treeSha256": "3c91643fa12d674d5854b57885be3f5a56fdd2ac55917ebd5e232ee2d1e1923f"
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ durable receipt before dispatch, closes and reopens its local runtime, and then
completes the preserved run. Rerunning the file replays the same idempotent run
instead of dispatching duplicate work. The generator creates one readable
Python file, never overwrites an existing path, and keeps its state visibly
beside the file under `.vyral/starter`. After editing the intended work, the
beside the file under its own `.vyral/<application-id>` directory (for the
example above, `.vyral/vyral_app`). Sibling generated applications receive
distinct state and durable identities. After editing the intended work, the
developer increments the visible `RUN_VERSION` to admit a new idempotent run.

Run the connected retrieval-and-execution quickstart against a separate,
Expand Down
8 changes: 5 additions & 3 deletions runtimes/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ python ./vyral_app.py

The generated file uses `@vyral(...)`, admits work with a stable idempotency
key, prints the durable receipt, closes the first runtime before dispatch,
reopens `.vyral/starter`, and completes the preserved run. Running the same
file again reports `replayed=true` and dispatches no duplicate work. The
generator refuses to overwrite an existing path; the result is ordinary
reopens its own `.vyral/<application-id>` directory, and completes the
preserved run. For `vyral_app.py`, that directory is `.vyral/vyral_app`.
Running the same file again reports `replayed=true` and dispatches no duplicate
work. Sibling generated files receive distinct state and durable identities.
The generator refuses to overwrite an existing path; the result is ordinary
Python source intended to be edited or absorbed into an application. A visible
`RUN_VERSION` makes intent explicit: leave it unchanged to prove replay, then
increment it after changing the work to admit a new run.
Expand Down
54 changes: 47 additions & 7 deletions runtimes/python/src/vyral_runtime/_starter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path


Expand All @@ -24,9 +25,13 @@
)


STATE_ROOT = Path(__file__).resolve().parent / ".vyral" / "starter"
PLUGIN_ID = "starter"
HANDLER_ID = "starter.hello"
STATE_ROOT = (
Path(__file__).resolve().parent
/ ".vyral"
/ __VYRAL_STARTER_STATE_DIRECTORY__
)
PLUGIN_ID = __VYRAL_STARTER_PLUGIN_ID__
HANDLER_ID = __VYRAL_STARTER_HANDLER_ID__
# Rerun unchanged to prove idempotency. Increment only to admit new work.
RUN_VERSION = 1

Expand All @@ -41,7 +46,7 @@ async def hello(context: ExecutionRunContext) -> ExecutionRunResult:
if isinstance(candidate, str) and candidate.strip():
name = candidate.strip()
await context.record_event(
"starter.hello",
HANDLER_ID,
message="The user-owned Vyral handler is running.",
)
return ExecutionRunResult.succeeded_result(
Expand All @@ -62,7 +67,7 @@ async def main() -> None:
HANDLER_ID,
plugin_id=PLUGIN_ID,
payload={"name": "Vyral", "runVersion": RUN_VERSION},
idempotency_key=f"starter.hello.v{RUN_VERSION}",
idempotency_key=f"{HANDLER_ID}.v{RUN_VERSION}",
)

with VyralRuntime.open_local(
Expand Down Expand Up @@ -109,11 +114,13 @@ async def main() -> None:
class LocalStarterResult:
created_path: Path
state_root_path: Path
app_id: str

def to_dict(self) -> dict[str, object]:
return {
"createdPath": str(self.created_path),
"stateRootPath": str(self.state_root_path),
"appId": self.app_id,
"runArguments": ["python", str(self.created_path)],
}

Expand All @@ -130,18 +137,51 @@ def create_local_starter(output_path: str | Path) -> LocalStarterResult:
)
requested.parent.mkdir(parents=True, exist_ok=True)
target = requested.absolute()
identity = _starter_identity(target.stem)
state_directory = identity.removeprefix("starter.")
handler_id = f"{identity}.hello"
source = (
_STARTER_SOURCE.replace(
"__VYRAL_STARTER_STATE_DIRECTORY__",
repr(state_directory),
)
.replace("__VYRAL_STARTER_PLUGIN_ID__", repr(identity))
.replace("__VYRAL_STARTER_HANDLER_ID__", repr(handler_id))
)
try:
with target.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(_STARTER_SOURCE)
stream.write(source)
except FileExistsError as error:
raise ValueError(
f"Refusing to overwrite an existing starter path: {target}"
) from error
created = target.resolve()
return LocalStarterResult(
created_path=created,
state_root_path=created.parent / ".vyral" / "starter",
state_root_path=created.parent / ".vyral" / state_directory,
app_id=identity,
)


def _starter_identity(stem: str) -> str:
characters: list[str] = []
previous_separator = False
for character in stem.casefold():
if character.isascii() and (
character.isalnum() or character in {"_", "-"}
):
characters.append(character)
previous_separator = False
elif not previous_separator:
characters.append("-")
previous_separator = True
normalized = "".join(characters).strip("-_") or "app"
changed = normalized != stem or len(normalized) > 80
if changed:
digest = sha256(stem.encode("utf-8")).hexdigest()[:8]
base = normalized[:70].rstrip("-_") or "app"
normalized = f"{base}-{digest}"
return f"starter.{normalized}"


__all__ = ["LocalStarterResult", "create_local_starter"]
5 changes: 5 additions & 0 deletions runtimes/python/tests/test_host_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ def test_init_creates_an_editable_application_without_server_extra(
self.assertEqual(0, status)
result = json.loads(output.getvalue())
self.assertEqual(str(target.resolve()), result["createdPath"])
self.assertEqual("starter.vyral_app", result["appId"])
self.assertEqual(
str((target.parent / ".vyral" / "vyral_app").resolve()),
result["stateRootPath"],
)
self.assertEqual(
["python", str(target.resolve())],
result["runArguments"],
Expand Down
66 changes: 66 additions & 0 deletions runtimes/python/tests/test_starter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def test_generated_application_survives_restart_and_replays(self) -> None:
result = create_local_starter(target)

self.assertEqual(target.resolve(), result.created_path)
self.assertEqual("starter.vyral_app", result.app_id)
self.assertEqual(
(target.parent / ".vyral" / "vyral_app").resolve(),
result.state_root_path,
)
self.assertIn("@vyral(", target.read_text(encoding="utf-8"))
environment = os.environ.copy()
source = str(Path(__file__).resolve().parents[1] / "src")
Expand Down Expand Up @@ -92,6 +97,67 @@ def test_generated_application_survives_restart_and_replays(self) -> None:
self.assertNotEqual(first_run.group(1), versioned_run.group(1))
self.assertTrue(result.state_root_path.is_dir())

def test_sibling_generated_apps_have_isolated_durable_identity(self) -> None:
with tempfile.TemporaryDirectory(
prefix="vyral-local-starter-siblings-"
) as temporary:
root = Path(temporary)
first_result = create_local_starter(root / "alpha.py")
second_result = create_local_starter(root / "beta.py")
environment = os.environ.copy()
source = str(Path(__file__).resolve().parents[1] / "src")
environment["PYTHONPATH"] = os.pathsep.join(
value
for value in (source, environment.get("PYTHONPATH", ""))
if value
)

first = subprocess.run(
[sys.executable, str(first_result.created_path)],
check=True,
capture_output=True,
text=True,
env=environment,
timeout=30,
)
second = subprocess.run(
[sys.executable, str(second_result.created_path)],
check=True,
capture_output=True,
text=True,
env=environment,
timeout=30,
)

self.assertNotEqual(first_result.app_id, second_result.app_id)
self.assertNotEqual(
first_result.state_root_path,
second_result.state_root_path,
)
self.assertIn("status=queued replayed=false", first.stdout)
self.assertIn("status=queued replayed=false", second.stdout)
self.assertTrue(first_result.state_root_path.is_dir())
self.assertTrue(second_result.state_root_path.is_dir())

def test_generated_identity_is_safe_for_unusual_filenames(self) -> None:
with tempfile.TemporaryDirectory(
prefix="vyral-local-starter-identity-"
) as temporary:
target = Path(temporary) / "My app (draft).py"
result = create_local_starter(target)
source = target.read_text(encoding="utf-8")

self.assertRegex(
result.app_id,
r"^starter\.my-app-draft-[0-9a-f]{8}$",
)
self.assertEqual(
result.app_id.removeprefix("starter."),
result.state_root_path.name,
)
self.assertIn(repr(result.app_id), source)
self.assertNotIn("__VYRAL_STARTER_", source)

def test_generator_refuses_overwrite_and_non_python_paths(self) -> None:
with tempfile.TemporaryDirectory(
prefix="vyral-local-starter-boundary-"
Expand Down
Loading