feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
import gyxx_flow.accounts as accounts
|
||||
from gyxx_flow.adapters.browser import BrowserCookieStore
|
||||
from gyxx_flow.adapters.integration import AccountBinding
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.scheduler_service import PythonScheduler
|
||||
|
||||
|
||||
def _account(tmp_path: Path) -> AccountBinding:
|
||||
root = tmp_path / "data" / "state" / "accounts" / "demo-shop"
|
||||
return AccountBinding(
|
||||
account_id="demo-shop",
|
||||
cdp_port=22200,
|
||||
cdp_url="http://127.0.0.1:22200",
|
||||
profile_dir=root / "profile",
|
||||
cookie_file=root / "cookies.json",
|
||||
storage_state_file=root / "storage_state.json",
|
||||
required_cookie_domains=("example.test",),
|
||||
required_cookie_names=("session",),
|
||||
keepalive_enabled=True,
|
||||
keepalive_url="https://example.test/console/home",
|
||||
keepalive_interval_seconds=600,
|
||||
)
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
def __init__(self, cookies: list[dict[str, object]]) -> None:
|
||||
self._cookies = cookies
|
||||
|
||||
def cookies(self) -> list[dict[str, object]]:
|
||||
return list(self._cookies)
|
||||
|
||||
def storage_state(self) -> dict[str, object]:
|
||||
return {"cookies": self.cookies(), "origins": []}
|
||||
|
||||
|
||||
class _FakeBrowser:
|
||||
final_url = "https://example.test/console/home"
|
||||
cookies = [
|
||||
{
|
||||
"name": "session",
|
||||
"value": "fresh-session",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": 4_102_444_800,
|
||||
}
|
||||
]
|
||||
instances: list[_FakeBrowser] = []
|
||||
|
||||
def __init__(self, **options: object) -> None:
|
||||
self.options = options
|
||||
self.context = _FakeContext(self.cookies)
|
||||
self.persisted = False
|
||||
self.closed = False
|
||||
self.instances.append(self)
|
||||
|
||||
def start(self) -> _FakeBrowser:
|
||||
return self
|
||||
|
||||
def run_fetch_action(self, url: str, action, **_options: object):
|
||||
page = SimpleNamespace(url=self.final_url)
|
||||
assert url == "https://example.test/console/home"
|
||||
return SimpleNamespace(status=200), action(page)
|
||||
|
||||
def persist_bound_state(self) -> None:
|
||||
store = BrowserCookieStore(
|
||||
self.options["cookie_file"], # type: ignore[arg-type]
|
||||
self.options["storage_state_file"], # type: ignore[arg-type]
|
||||
)
|
||||
store.save_cookies(self.context.cookies())
|
||||
store.save_storage_state(self.context.storage_state())
|
||||
self.persisted = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeCatalog:
|
||||
def __init__(self, account: AccountBinding) -> None:
|
||||
self.accounts = (account,)
|
||||
self.sync_calls = 0
|
||||
|
||||
def account_for(self, account_id: str) -> AccountBinding:
|
||||
assert account_id == self.accounts[0].account_id
|
||||
return self.accounts[0]
|
||||
|
||||
def sync_account_state(self, account_id: str) -> list[dict[str, object]]:
|
||||
assert account_id == self.accounts[0].account_id
|
||||
self.sync_calls += 1
|
||||
return [{"script_id": "demo:first.py", "synced": True}]
|
||||
|
||||
|
||||
def _write_old_state(account: AccountBinding) -> None:
|
||||
old_cookie = {
|
||||
"name": "session",
|
||||
"value": "old-session",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": 4_102_444_800,
|
||||
}
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
store.save_cookies([old_cookie])
|
||||
store.save_storage_state({"cookies": [old_cookie], "origins": []})
|
||||
|
||||
|
||||
def test_keepalive_publishes_only_a_verified_authenticated_snapshot(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
account = _account(tmp_path)
|
||||
_write_old_state(account)
|
||||
account.cookie_file.with_name("keepalive_status.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "expired",
|
||||
"identity_mismatch": True,
|
||||
"final_url_login": False,
|
||||
"error_type": "TargetClosedError",
|
||||
"http_status": 502,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
catalog = _FakeCatalog(account)
|
||||
_FakeBrowser.instances = []
|
||||
monkeypatch.setattr(accounts, "ScraplingBrowser", _FakeBrowser)
|
||||
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=catalog, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert manager.refresh_account("demo-shop") == {
|
||||
"status": "success",
|
||||
"cookie_count": 1,
|
||||
"synced_members": 1,
|
||||
}
|
||||
assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == (
|
||||
"fresh-session"
|
||||
)
|
||||
assert catalog.sync_calls == 1
|
||||
assert _FakeBrowser.instances[0].persisted is True
|
||||
assert _FakeBrowser.instances[0].closed is True
|
||||
|
||||
status = json.loads(
|
||||
account.cookie_file.with_name("keepalive_status.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert status["status"] == "success"
|
||||
assert "identity_mismatch" not in status
|
||||
assert "final_url_login" not in status
|
||||
assert "error_type" not in status
|
||||
assert "http_status" not in status
|
||||
assert "fresh-session" not in json.dumps(status)
|
||||
|
||||
|
||||
def test_keepalive_does_not_overwrite_vault_after_login_redirect(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
account = _account(tmp_path)
|
||||
_write_old_state(account)
|
||||
catalog = _FakeCatalog(account)
|
||||
_FakeBrowser.instances = []
|
||||
_FakeBrowser.final_url = "https://example.test/passport/login"
|
||||
monkeypatch.setattr(accounts, "ScraplingBrowser", _FakeBrowser)
|
||||
|
||||
try:
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=catalog, # type: ignore[arg-type]
|
||||
)
|
||||
assert manager.refresh_account("demo-shop")["status"] == "expired"
|
||||
assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == (
|
||||
"old-session"
|
||||
)
|
||||
assert catalog.sync_calls == 0
|
||||
assert _FakeBrowser.instances[0].persisted is False
|
||||
finally:
|
||||
_FakeBrowser.final_url = "https://example.test/console/home"
|
||||
|
||||
|
||||
def test_login_url_is_not_an_authenticated_identity_without_markers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
account = _account(tmp_path)
|
||||
|
||||
assert not accounts._page_matches_account_identity(
|
||||
SimpleNamespace(url="https://example.test/passport/login"), account
|
||||
)
|
||||
assert accounts._looks_like_login_url(
|
||||
"https://loginmyseller.taobao.com/?redirect_url=https%3A%2F%2Fqn.taobao.com"
|
||||
)
|
||||
|
||||
|
||||
def test_probe_account_page_waits_for_client_redirects(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
account = _account(tmp_path)
|
||||
waits: list[int] = []
|
||||
page = SimpleNamespace(
|
||||
url="https://example.test/console/home",
|
||||
wait_for_timeout=waits.append,
|
||||
)
|
||||
|
||||
state = accounts._probe_account_page(page, account)
|
||||
|
||||
assert state == {
|
||||
"url": "https://example.test/console/home",
|
||||
"identity_matches": True,
|
||||
}
|
||||
assert waits == [3_000]
|
||||
|
||||
|
||||
def test_keepalive_rejects_a_valid_cookie_for_the_wrong_shop(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = AccountBinding(
|
||||
account_id=base.account_id,
|
||||
cdp_port=base.cdp_port,
|
||||
cdp_url=base.cdp_url,
|
||||
profile_dir=base.profile_dir,
|
||||
cookie_file=base.cookie_file,
|
||||
storage_state_file=base.storage_state_file,
|
||||
required_cookie_domains=base.required_cookie_domains,
|
||||
required_cookie_names=base.required_cookie_names,
|
||||
login_identity_markers=("demo shop",),
|
||||
keepalive_enabled=True,
|
||||
keepalive_url=base.keepalive_url,
|
||||
keepalive_interval_seconds=600,
|
||||
)
|
||||
_write_old_state(account)
|
||||
catalog = _FakeCatalog(account)
|
||||
_FakeBrowser.instances = []
|
||||
|
||||
class _WrongShopPage:
|
||||
url = _FakeBrowser.final_url
|
||||
|
||||
def evaluate(self, _script: str, _markers: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
class _WrongShopBrowser(_FakeBrowser):
|
||||
def run_fetch_action(self, url: str, action, **_options: object):
|
||||
page = _WrongShopPage()
|
||||
assert url == "https://example.test/console/home"
|
||||
return SimpleNamespace(status=200), action(page)
|
||||
|
||||
monkeypatch.setattr(accounts, "ScraplingBrowser", _WrongShopBrowser)
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=catalog, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert manager.refresh_account(account.account_id) == {
|
||||
"status": "expired",
|
||||
"final_url_login": False,
|
||||
"identity_mismatch": True,
|
||||
}
|
||||
assert catalog.sync_calls == 0
|
||||
assert _WrongShopBrowser.instances[0].persisted is False
|
||||
|
||||
|
||||
def test_identity_mismatch_requires_login_even_with_valid_cookie() -> None:
|
||||
assert accounts._keepalive_requires_relogin(
|
||||
{"status": "success", "identity_mismatch": True}
|
||||
)
|
||||
assert accounts._keepalive_requires_relogin({"status": "expired"})
|
||||
assert not accounts._keepalive_requires_relogin({"status": "success"})
|
||||
|
||||
|
||||
def test_keepalive_skips_expired_vault_without_starting_browser(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
account = _account(tmp_path)
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
store.save_cookies(
|
||||
[
|
||||
{
|
||||
"name": "session",
|
||||
"value": "expired",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
accounts,
|
||||
"ScraplingBrowser",
|
||||
lambda **_options: pytest.fail("expired vault must not start a browser"),
|
||||
)
|
||||
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=_FakeCatalog(account), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert manager.refresh_account("demo-shop")["status"] == "not_logged_in"
|
||||
|
||||
|
||||
def test_keepalive_runs_configured_login_when_account_vault_is_expired(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = replace(
|
||||
base,
|
||||
login_mode="C",
|
||||
login_command_id="demo.login",
|
||||
credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"),
|
||||
)
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
store.save_cookies(
|
||||
[
|
||||
{
|
||||
"name": "session",
|
||||
"value": "expired",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
login_calls: list[str] = []
|
||||
|
||||
def login_runner(login_account: AccountBinding) -> dict[str, object]:
|
||||
login_calls.append(login_account.account_id)
|
||||
fresh_cookie = {
|
||||
"name": "session",
|
||||
"value": "recovered-session",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": 4_102_444_800,
|
||||
}
|
||||
store.save_cookies([fresh_cookie])
|
||||
store.save_storage_state({"cookies": [fresh_cookie], "origins": []})
|
||||
return {"status": "success"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
accounts,
|
||||
"ScraplingBrowser",
|
||||
lambda **_options: pytest.fail("login recovery should run the login flow"),
|
||||
)
|
||||
catalog = _FakeCatalog(account)
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=catalog, # type: ignore[arg-type]
|
||||
login_runner=login_runner,
|
||||
)
|
||||
|
||||
assert manager.refresh_account(account.account_id) == {
|
||||
"status": "success",
|
||||
"login_recovered": True,
|
||||
"cookie_count": 1,
|
||||
"synced_members": 1,
|
||||
}
|
||||
assert login_calls == [account.account_id]
|
||||
assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == (
|
||||
"recovered-session"
|
||||
)
|
||||
|
||||
|
||||
def test_keepalive_runs_configured_login_after_server_side_expiry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = replace(
|
||||
base,
|
||||
login_mode="C",
|
||||
login_command_id="demo.login",
|
||||
credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"),
|
||||
)
|
||||
_write_old_state(account)
|
||||
account.cookie_file.with_name("keepalive_status.json").write_text(
|
||||
json.dumps({"status": "expired", "final_url_login": True}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
def login_runner(login_account: AccountBinding) -> dict[str, object]:
|
||||
calls.append(login_account.account_id)
|
||||
return {"status": "success"}
|
||||
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=_FakeCatalog(account), # type: ignore[arg-type]
|
||||
login_runner=login_runner,
|
||||
)
|
||||
|
||||
assert manager.refresh_account(account.account_id)["status"] == "success"
|
||||
assert calls == [account.account_id]
|
||||
|
||||
|
||||
def test_configured_login_keeps_credentials_out_of_process_arguments(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = replace(
|
||||
base,
|
||||
login_mode="C",
|
||||
login_command_id="demo.login",
|
||||
credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"),
|
||||
)
|
||||
|
||||
class _LoginCatalog(_FakeCatalog):
|
||||
def binding_for(self, binding_id: str) -> SimpleNamespace:
|
||||
assert binding_id == "demo.login"
|
||||
return SimpleNamespace(
|
||||
command_id="demo.login",
|
||||
module="demo",
|
||||
entry="login.py",
|
||||
)
|
||||
|
||||
def environment_for(
|
||||
self,
|
||||
binding_id: str,
|
||||
base_environment: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
assert binding_id == "demo.login"
|
||||
return dict(base_environment)
|
||||
|
||||
monkeypatch.setenv("DEMO_ACCOUNT", "demo-user")
|
||||
monkeypatch.setenv("DEMO_PASSWORD", "demo-password")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace:
|
||||
captured["command"] = command
|
||||
captured["env"] = kwargs["env"]
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(accounts.subprocess, "run", fake_run)
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=_LoginCatalog(account), # type: ignore[arg-type]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_login_script_path",
|
||||
lambda _binding: tmp_path / "login.py",
|
||||
)
|
||||
|
||||
result = manager._run_configured_login(account)
|
||||
|
||||
assert result == {
|
||||
"status": "success",
|
||||
"login_attempted": True,
|
||||
"login_exit_code": 0,
|
||||
}
|
||||
assert captured["command"][-1] == "--login-only"
|
||||
assert "demo-password" not in captured["command"]
|
||||
environment = captured["env"]
|
||||
assert isinstance(environment, dict)
|
||||
assert environment["ERP_USERNAME"] == "demo-user"
|
||||
assert environment["ERP_PASSWORD"] == "demo-password"
|
||||
|
||||
|
||||
def test_configured_login_can_use_runtime_product_config_without_env_credentials(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = replace(
|
||||
base,
|
||||
login_mode="C",
|
||||
login_command_id="demo.login",
|
||||
credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"),
|
||||
)
|
||||
config_path = (
|
||||
tmp_path / "data" / "state" / "product_commerce" / "auto-flow-config.json"
|
||||
)
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(
|
||||
json.dumps({"login": {"username": "configured-user", "password": "secret"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("DEMO_ACCOUNT", raising=False)
|
||||
monkeypatch.delenv("DEMO_PASSWORD", raising=False)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace:
|
||||
captured["command"] = command
|
||||
captured["env"] = kwargs["env"]
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
class _LoginCatalog(_FakeCatalog):
|
||||
def binding_for(self, binding_id: str) -> SimpleNamespace:
|
||||
assert binding_id == "demo.login"
|
||||
return SimpleNamespace(
|
||||
command_id="demo.login",
|
||||
module="demo",
|
||||
entry="login.py",
|
||||
)
|
||||
|
||||
def environment_for(
|
||||
self,
|
||||
binding_id: str,
|
||||
base_environment: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
assert binding_id == "demo.login"
|
||||
return dict(base_environment)
|
||||
|
||||
monkeypatch.setattr(accounts.subprocess, "run", fake_run)
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=_LoginCatalog(account), # type: ignore[arg-type]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_login_script_path",
|
||||
lambda _binding: tmp_path / "login.py",
|
||||
)
|
||||
|
||||
result = manager._run_configured_login(account)
|
||||
|
||||
assert result == {
|
||||
"status": "success",
|
||||
"login_attempted": True,
|
||||
"login_exit_code": 0,
|
||||
}
|
||||
assert captured["command"][-1] == "--login-only"
|
||||
environment = captured["env"]
|
||||
assert isinstance(environment, dict)
|
||||
assert environment["GYXX_PRODUCT_CONFIG"] == str(config_path)
|
||||
assert "secret" not in captured["command"]
|
||||
|
||||
|
||||
def test_keepalive_initial_delay_staggers_first_refresh(tmp_path: Path) -> None:
|
||||
base = _account(tmp_path)
|
||||
account = AccountBinding(
|
||||
account_id=base.account_id,
|
||||
cdp_port=base.cdp_port,
|
||||
cdp_url=base.cdp_url,
|
||||
profile_dir=base.profile_dir,
|
||||
cookie_file=base.cookie_file,
|
||||
storage_state_file=base.storage_state_file,
|
||||
required_cookie_domains=base.required_cookie_domains,
|
||||
required_cookie_names=base.required_cookie_names,
|
||||
keepalive_enabled=True,
|
||||
keepalive_url=base.keepalive_url,
|
||||
keepalive_initial_delay_seconds=60,
|
||||
keepalive_interval_seconds=600,
|
||||
)
|
||||
clock = [100.0]
|
||||
manager = accounts.AccountKeepaliveManager(
|
||||
Settings(project_root=tmp_path, data_root=tmp_path / "data"),
|
||||
catalog=_FakeCatalog(account), # type: ignore[arg-type]
|
||||
clock=lambda: clock[0],
|
||||
)
|
||||
calls: list[str] = []
|
||||
manager.refresh_account = lambda account_id: ( # type: ignore[method-assign]
|
||||
calls.append(account_id) or {"status": "success"}
|
||||
)
|
||||
|
||||
assert manager.tick() == []
|
||||
clock[0] = 159.0
|
||||
assert manager.tick() == []
|
||||
clock[0] = 160.0
|
||||
assert manager.tick() == [{"status": "success"}]
|
||||
assert calls == ["demo-shop"]
|
||||
|
||||
|
||||
def test_scheduler_maintenance_runs_in_execute_mode_but_not_dry_run(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
catalog = WorkflowCatalog(
|
||||
timezone="Asia/Shanghai",
|
||||
workflows=(),
|
||||
schedules=(),
|
||||
)
|
||||
calls: list[str] = []
|
||||
now = datetime(2026, 8, 20, 10, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
|
||||
scheduler = PythonScheduler(
|
||||
catalog,
|
||||
tmp_path / "execute-data",
|
||||
launcher=object(), # no workflows are present in this boundary test
|
||||
maintenance=lambda: calls.append("execute"),
|
||||
)
|
||||
scheduler.tick(now)
|
||||
|
||||
dry_run_scheduler = PythonScheduler(
|
||||
catalog,
|
||||
tmp_path / "dry-run-data",
|
||||
launcher=object(),
|
||||
dry_run=True,
|
||||
maintenance=lambda: calls.append("dry-run"),
|
||||
)
|
||||
dry_run_scheduler.tick(now)
|
||||
|
||||
assert calls == ["execute"]
|
||||
Reference in New Issue
Block a user