Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/src/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ cocoindex update [OPTIONS] APP_TARGET
| `--reset` | Drop existing setup before updating (equivalent to running 'cocoindex drop' first). |
| `--full-reprocess` | Reprocess everything and invalidate existing caches. |
| `-L, --live` | Run in live mode (live components continue processing after initial update). |
| `--preview` | Compute target actions without applying them. Prints planned actions. |
| `--help` | Show this message and exit. |

---
Expand Down
21 changes: 18 additions & 3 deletions python/cocoindex/_internal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ def __init__(
self,
init_coro: Any, # Coroutine that returns core.UpdateHandle
main_fn: Any = None,
preview: bool = False,
) -> None:
self._init_coro = init_coro
self._core_handle: core.UpdateHandle | None = None
self._main_fn = main_fn # used for return type inspection
self._preview = preview

async def _ensure_started(self) -> core.UpdateHandle:
if self._core_handle is None:
Expand Down Expand Up @@ -133,6 +135,9 @@ async def watch(self) -> AsyncIterator[UpdateSnapshot[R]]:
async def result(self) -> R:
"""Await the update result. Raises on error."""
handle = await self._ensure_started()
if self._preview:
await handle.result()
return handle.take_preview_actions() # type: ignore[return-value]
pyvalue: Any = await handle.result()
return pyvalue.get(fn_ret_deserializer(self._main_fn)) # type: ignore[no-any-return]

Expand Down Expand Up @@ -301,6 +306,7 @@ def update(
*,
full_reprocess: bool = False,
live: bool = False,
preview: bool = False,
) -> UpdateHandle[R]:
"""
Start an update and return a handle for tracking progress and awaiting the result.
Expand All @@ -312,6 +318,8 @@ def update(
full_reprocess: If True, reprocess everything and invalidate existing caches.
live: If True, run in live mode (live components continue processing
after mark_ready).
preview: If True, compute target actions without applying them.
The handle's result will be a list of raw action objects.

Returns:
An UpdateHandle that provides access to stats(), watch(), and result().
Expand All @@ -327,18 +335,20 @@ async def _init() -> core.UpdateHandle:
processor,
full_reprocess=full_reprocess,
live=live,
preview=preview,
host_ctx=env._context_provider,
)

return UpdateHandle(_init(), main_fn=self._main_fn)
return UpdateHandle(_init(), main_fn=self._main_fn, preview=preview)

def update_blocking(
self,
*,
report_to_stdout: bool = False,
full_reprocess: bool = False,
live: bool = False,
) -> R:
preview: bool = False,
) -> R | list[Any]:
"""
Update the app synchronously (run the app once to process all pending changes).

Expand All @@ -347,9 +357,11 @@ def update_blocking(
full_reprocess: If True, reprocess everything and invalidate existing caches.
live: If True, run in live mode (live components continue processing
after mark_ready).
preview: If True, compute target actions without applying them.
Returns a list of raw action objects instead of the main function result.

Returns:
The result of the main function.
The result of the main function, or a list of actions in preview mode.
"""
env, core_app = self._get_core_env_app_sync()
root_path = core.StablePath()
Expand All @@ -362,7 +374,10 @@ def update_blocking(
host_ctx=env._context_provider,
report_to_stdout=report_to_stdout,
live=live,
preview=preview,
)
if preview:
return pyvalue # type: ignore[no-any-return]
return pyvalue.get(fn_ret_deserializer(self._main_fn)) # type: ignore[no-any-return]

async def drop(self) -> None:
Expand Down
5 changes: 4 additions & 1 deletion python/cocoindex/_internal/core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class UpdateHandle:
def stats_snapshot(self) -> tuple[int, bool, dict[str, dict[str, int]]]: ...
def changed(self) -> Coroutine[Any, Any, int]: ...
def result(self) -> Coroutine[Any, Any, StoredValue]: ...
def take_preview_actions(self) -> list[Any]: ...

# --- DropHandle ---
class DropHandle:
Expand All @@ -209,12 +210,14 @@ class App:
host_ctx: Any = None,
report_to_stdout: bool = False,
live: bool = False,
) -> StoredValue: ...
preview: bool = False,
) -> StoredValue | list[Any]: ...
def update_async(
self,
root_processor: ComponentProcessor[T_co],
full_reprocess: bool = False,
live: bool = False,
preview: bool = False,
host_ctx: Any = None,
) -> UpdateHandle: ...
def drop(self, host_ctx: Any = None, report_to_stdout: bool = False) -> None: ...
Expand Down
27 changes: 27 additions & 0 deletions python/cocoindex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,19 +686,32 @@ async def _stop_all_environments() -> None:
default=False,
help="Run in live mode (live components continue processing after initial update).",
)
@click.option(
"--preview",
is_flag=True,
show_default=True,
default=False,
help="Compute target actions without applying them. Prints planned actions.",
)
def update(
app_target: str,
force: bool,
quiet: bool,
reset: bool,
full_reprocess: bool,
live: bool,
preview: bool,
) -> None:
"""
Run an app in catch-up mode. With --live, run in live mode.

`APP_TARGET`: `path/to/app.py`, `module`, `path/to/app.py:app_name`, or `module:app_name`.
"""
if preview and reset:
raise click.UsageError("--preview and --reset cannot be used together.")
if preview and live:
raise click.UsageError("--preview and --live cannot be used together.")

app = _load_app(app_target)

async def _do(cancelled: Any) -> None:
Expand All @@ -711,6 +724,20 @@ async def _do(cancelled: Any) -> None:
f"Running app '{app._name}' from environment '{env.name}' (db path: {env.settings.db_path})"
)

if preview:
handle = app.update(
full_reprocess=full_reprocess,
preview=True,
)
actions: list[Any] = await handle.result()
click.echo("Preview: planned target actions")
if actions:
for action in actions:
click.echo(f" {action!r}")
else:
click.echo(" No target actions planned.")
return

# --reset: drop existing state first (equivalent to `cocoindex drop ...`)
if reset:
if not force:
Expand Down
60 changes: 60 additions & 0 deletions python/tests/cli/flat_target_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Test app with flat/leaf target states only (no child providers)."""

from __future__ import annotations

import pathlib
from typing import Any, Collection

import cocoindex as coco

_HERE = pathlib.Path(__file__).resolve().parent
DB_PATH = _HERE / "cocoindex.db"

env = coco.Environment(coco.Settings.from_env(db_path=DB_PATH))


class _FlatStore:
def __init__(self) -> None:
self.data: dict[str, Any] = {}

def _sink(
self,
context_provider: coco.ContextProvider,
actions: Collection[tuple[str, Any | coco.NonExistenceType]],
/,
) -> None:
for key, value in actions:
if coco.is_non_existence(value):
self.data.pop(key, None)
else:
self.data[key] = value

def reconcile(
self,
key: coco.StableKey,
desired_state: Any | coco.NonExistenceType,
prev_possible_records: Collection[Any],
prev_may_be_missing: bool,
) -> (
coco.TargetReconcileOutput[tuple[str, Any | coco.NonExistenceType], Any] | None
):
assert isinstance(key, str)
return coco.TargetReconcileOutput(
action=(key, desired_state),
sink=coco.TargetActionSink.from_fn(self._sink),
tracking_record=desired_state,
)


_flat_store = _FlatStore()
_provider = coco.register_root_target_states_provider(
"test_cli/flat_preview", _flat_store
)


@coco.fn
def build() -> None:
coco.declare_target_state(_provider.target_state("x", 42))


app = coco.App(coco.AppConfig(name="FlatPreviewApp", environment=env), build)
25 changes: 25 additions & 0 deletions python/tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,31 @@ def test_drop_quiet_suppresses_informational_output(self) -> None:
# =============================================================================


class TestPreview:
"""Tests for the --preview flag on update."""

def test_preview_prints_actions(self) -> None:
"""update --preview should print planned actions without writing."""
result = run_cli("update", "./flat_target_app.py", "--preview")
assert "Preview: planned target actions" in result.stdout
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also check the specific actions really printed out in the output?


def test_preview_reset_rejected(self) -> None:
"""--preview --reset should be rejected."""
result = run_cli(
"update", "./single_app.py", "--preview", "--reset", check=False
)
assert result.returncode != 0
assert "cannot be used together" in result.stderr.lower()

def test_preview_live_rejected(self) -> None:
"""--preview --live should be rejected."""
result = run_cli(
"update", "./single_app.py", "--preview", "--live", check=False
)
assert result.returncode != 0
assert "cannot be used together" in result.stderr.lower()


class TestShowTree:
"""Tests for the show command with --tree flag."""

Expand Down
21 changes: 21 additions & 0 deletions python/tests/core/test_component_target_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,3 +1110,24 @@ def test_mount_target_delete() -> None:
}
assert DictsTarget.store.metrics.collect() == {"sink": 2, "delete": 1}
assert DictsTarget.store.collect_child_metrics() == {"sink": 1, "upsert": 1}


##################################################################################
# Test: preview rejects child target providers
##################################################################################


def test_preview_rejects_child_target_providers() -> None:
DictsTarget.store.clear()
_source_data.clear()

app = coco.App(
coco.AppConfig(
name="test_preview_rejects_child_target_providers", environment=coco_env
),
_declare_dicts_data_together,
)

_source_data["D1"] = {"a": 1}
with pytest.raises(Exception, match="child target providers"):
app.update_blocking(preview=True)
38 changes: 38 additions & 0 deletions python/tests/core/test_flat_target_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,44 @@ def test_async_global_dict_target_state_insert() -> None:
assert AsyncGlobalDictTarget.store.metrics.collect() == {"sink": 1, "upsert": 1}


def test_global_dict_preview_returns_actions_without_writing() -> None:
GlobalDictTarget.store.clear()
_source_data.clear()

app = coco.App(
coco.AppConfig(
name="test_global_dict_preview_returns_actions", environment=coco_env
),
declare_global_dict_entries,
)

_source_data["a"] = 1
_source_data["b"] = 2
actions = app.update_blocking(preview=True)

assert isinstance(actions, list)
assert len(actions) > 0
assert GlobalDictTarget.store.data == {}


@pytest.mark.asyncio
async def test_global_dict_preview_async() -> None:
GlobalDictTarget.store.clear()
_source_data.clear()

app = coco.App(
coco.AppConfig(name="test_global_dict_preview_async", environment=coco_env),
declare_global_dict_entries,
)

_source_data["a"] = 1
actions = await app.update(preview=True)

assert isinstance(actions, list)
assert len(actions) > 0
assert GlobalDictTarget.store.data == {}


def test_global_dict_target_state_proceed_with_exception() -> None:
GlobalDictTarget.store.clear()
_source_data.clear()
Expand Down
Loading
Loading