feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.baseline import (
|
||||
DataRootSpec,
|
||||
PowerShellScheduledTaskProvider,
|
||||
ProjectSpec,
|
||||
ScheduledTask,
|
||||
TaskCountMismatch,
|
||||
build_baseline_manifest,
|
||||
collect_code_inventory,
|
||||
collect_data_summary,
|
||||
collect_scheduled_task_inventory,
|
||||
project_specs_from_env,
|
||||
write_manifest_atomic,
|
||||
)
|
||||
|
||||
|
||||
class FakeTaskProvider:
|
||||
def __init__(self, tasks: list[ScheduledTask]) -> None:
|
||||
self._tasks = tasks
|
||||
|
||||
def scheduled_tasks(self) -> list[ScheduledTask]:
|
||||
return self._tasks
|
||||
|
||||
|
||||
def test_powershell_provider_parses_tasks_without_using_a_shell(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout='[{"task_id":"\\\\Daily","command":"python.exe",'
|
||||
'"arguments":"D:\\\\legacy\\\\run.py","working_directory":"D:\\\\legacy"}]',
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
tasks = PowerShellScheduledTaskProvider().scheduled_tasks()
|
||||
|
||||
assert tasks == [
|
||||
ScheduledTask(
|
||||
task_id=r"\Daily",
|
||||
command="python.exe",
|
||||
arguments=r"D:\legacy\run.py",
|
||||
working_directory=r"D:\legacy",
|
||||
)
|
||||
]
|
||||
assert captured["command"][0] == "powershell.exe"
|
||||
assert captured["kwargs"]["shell"] is False
|
||||
assert captured["kwargs"]["check"] is True
|
||||
|
||||
|
||||
def test_project_specs_are_built_only_from_injected_legacy_roots(tmp_path: Path) -> None:
|
||||
roots = {
|
||||
"GYXX_LEGACY_CONTENT_ROOT": tmp_path / "content",
|
||||
"GYXX_LEGACY_SHOP_ROOT": tmp_path / "shop",
|
||||
"GYXX_LEGACY_PRODUCT_ROOT": tmp_path / "product",
|
||||
"GYXX_LEGACY_SUPPLY_ROOT": tmp_path / "supply",
|
||||
}
|
||||
specs = project_specs_from_env({key: str(value) for key, value in roots.items()})
|
||||
|
||||
assert [spec.project_id for spec in specs] == [
|
||||
"content_marketing",
|
||||
"shop_intelligence",
|
||||
"product_commerce",
|
||||
"supply_chain",
|
||||
]
|
||||
assert [spec.root for spec in specs] == list(roots.values())
|
||||
assert [item.label for item in specs[0].data_roots] == ["data", "reports"]
|
||||
assert specs[3].data_roots[0].path == roots["GYXX_LEGACY_SUPPLY_ROOT"] / "shared-data"
|
||||
|
||||
|
||||
def test_project_specs_report_all_missing_root_variables() -> None:
|
||||
with pytest.raises(ValueError, match="GYXX_LEGACY_SHOP_ROOT.*GYXX_LEGACY_PRODUCT_ROOT"):
|
||||
project_specs_from_env({"GYXX_LEGACY_CONTENT_ROOT": "configured"})
|
||||
|
||||
|
||||
def test_code_inventory_hashes_allowed_files_and_excludes_runtime_and_secrets(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "legacy"
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "src" / "worker.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
(root / "README.md").write_text("docs\n", encoding="utf-8")
|
||||
(root / "payload.csv").write_text("private,data\n", encoding="utf-8")
|
||||
|
||||
excluded_files = [
|
||||
root / ".git" / "tracked.py",
|
||||
root / ".venv" / "site.py",
|
||||
root / "var" / "runtime.py",
|
||||
root / "data" / "raw.py",
|
||||
root / "logs" / "run.py",
|
||||
root / ".chrome_profile" / "state.py",
|
||||
root / "cookies" / "session.py",
|
||||
root / "config" / ".env",
|
||||
root / "config" / "credentials.json",
|
||||
]
|
||||
for path in excluded_files:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("TOP_SECRET=must-not-leak", encoding="utf-8")
|
||||
|
||||
inventory = collect_code_inventory(root)
|
||||
|
||||
assert [item["relative_path"] for item in inventory] == [
|
||||
"README.md",
|
||||
"src/worker.py",
|
||||
]
|
||||
worker = inventory[1]
|
||||
worker_bytes = (root / "src" / "worker.py").read_bytes()
|
||||
assert set(worker) == {"relative_path", "size_bytes", "mtime_ns", "sha256"}
|
||||
assert worker["size_bytes"] == len(worker_bytes)
|
||||
assert worker["sha256"] == hashlib.sha256(worker_bytes).hexdigest()
|
||||
serialized = json.dumps(inventory)
|
||||
assert "TOP_SECRET" not in serialized
|
||||
assert "private,data" not in serialized
|
||||
|
||||
|
||||
def test_data_summary_contains_only_aggregate_counts_bytes_extensions_and_top_level(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "data"
|
||||
root.mkdir()
|
||||
(root / "root.csv").write_bytes(b"123")
|
||||
(root / "orders").mkdir()
|
||||
(root / "orders" / "one.json").write_bytes(b"12")
|
||||
(root / "orders" / "archive").mkdir()
|
||||
(root / "orders" / "archive" / "two.JSON").write_bytes(b"1234")
|
||||
|
||||
summary = collect_data_summary(root)
|
||||
|
||||
assert summary == {
|
||||
"file_count": 3,
|
||||
"total_bytes": 9,
|
||||
"by_extension": {
|
||||
".csv": {"file_count": 1, "total_bytes": 3},
|
||||
".json": {"file_count": 2, "total_bytes": 6},
|
||||
},
|
||||
"by_top_level": {
|
||||
".": {"file_count": 1, "total_bytes": 3},
|
||||
"orders": {"file_count": 2, "total_bytes": 6},
|
||||
},
|
||||
}
|
||||
serialized = json.dumps(summary)
|
||||
assert "root.csv" not in serialized
|
||||
assert "one.json" not in serialized
|
||||
assert "archive" not in serialized
|
||||
|
||||
|
||||
def _task_specs(tmp_path: Path) -> tuple[ProjectSpec, ...]:
|
||||
roots = [tmp_path / name for name in ("one", "two", "three", "four")]
|
||||
for root in roots:
|
||||
root.mkdir()
|
||||
return tuple(
|
||||
ProjectSpec(project_id=f"project_{index}", root=root)
|
||||
for index, root in enumerate(roots, start=1)
|
||||
)
|
||||
|
||||
|
||||
def test_task_inventory_filters_by_legacy_roots_and_exposes_no_command_values(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
specs = _task_specs(tmp_path)
|
||||
tasks = [
|
||||
ScheduledTask(
|
||||
task_id=f"legacy-{index:02d}",
|
||||
command="python.exe",
|
||||
arguments=f'"{specs[index % 4].root / "run.py"}" --token SECRET-{index}',
|
||||
)
|
||||
for index in range(21)
|
||||
]
|
||||
tasks.append(
|
||||
ScheduledTask(
|
||||
task_id="unrelated",
|
||||
command="python.exe",
|
||||
arguments=r"C:\unrelated\run.py --token OUTSIDE_SECRET",
|
||||
)
|
||||
)
|
||||
tasks.append(
|
||||
ScheduledTask(
|
||||
task_id="lookalike-root",
|
||||
command="python.exe",
|
||||
arguments=f'"{specs[0].root}-backup\\run.py"',
|
||||
)
|
||||
)
|
||||
|
||||
result = collect_scheduled_task_inventory(
|
||||
specs, FakeTaskProvider(tasks), expected_count=21
|
||||
)
|
||||
|
||||
assert result["expected_count"] == 21
|
||||
assert result["actual_count"] == 21
|
||||
assert result["is_complete"] is True
|
||||
assert len(result["tasks"]) == 21
|
||||
assert set(result["tasks"][0]) == {"task_id", "project_id"}
|
||||
serialized = json.dumps(result)
|
||||
assert "SECRET" not in serialized
|
||||
assert "unrelated" not in serialized
|
||||
assert "lookalike-root" not in serialized
|
||||
|
||||
|
||||
def test_task_inventory_rejects_any_count_other_than_exactly_21(tmp_path: Path) -> None:
|
||||
specs = _task_specs(tmp_path)
|
||||
tasks = [
|
||||
ScheduledTask(task_id=f"task-{index}", command=str(specs[0].root / "run.py"))
|
||||
for index in range(20)
|
||||
]
|
||||
|
||||
with pytest.raises(TaskCountMismatch, match="expected 21.*found 20"):
|
||||
collect_scheduled_task_inventory(
|
||||
specs, FakeTaskProvider(tasks), expected_count=21
|
||||
)
|
||||
|
||||
|
||||
def test_build_manifest_has_code_data_and_exact_task_baselines_without_values(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "legacy"
|
||||
data = root / "shared-data"
|
||||
data.mkdir(parents=True)
|
||||
credential_key = "PASS" + "WORD"
|
||||
credential_value = "not-" + "exported"
|
||||
(root / "job.py").write_text(
|
||||
f"{credential_key} = '{credential_value}'\n", encoding="utf-8"
|
||||
)
|
||||
(data / "dataset.json").write_text('{"token":"hidden"}', encoding="utf-8")
|
||||
spec = ProjectSpec(
|
||||
project_id="project",
|
||||
root=root,
|
||||
data_roots=(DataRootSpec(label="data", path=data),),
|
||||
)
|
||||
tasks = [
|
||||
ScheduledTask(
|
||||
task_id=f"task-{index:02d}",
|
||||
command=str(root / "job.py"),
|
||||
arguments=f"--password secret-{index}",
|
||||
)
|
||||
for index in range(21)
|
||||
]
|
||||
|
||||
manifest = build_baseline_manifest(
|
||||
(spec,),
|
||||
FakeTaskProvider(tasks),
|
||||
generated_at=datetime(2026, 7, 27, 5, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert manifest["schema_version"] == 1
|
||||
assert manifest["generated_at"] == "2026-07-27T05:00:00+00:00"
|
||||
assert manifest["scheduled_tasks"]["actual_count"] == 21
|
||||
assert len(manifest["projects"][0]["code_inventory"]) == 1
|
||||
assert manifest["projects"][0]["code_inventory"][0]["relative_path"] == "job.py"
|
||||
assert manifest["projects"][0]["data_summaries"][0]["summary"]["file_count"] == 1
|
||||
serialized = json.dumps(manifest)
|
||||
assert credential_value not in serialized
|
||||
assert "dataset.json" not in serialized
|
||||
assert "secret-0" not in serialized
|
||||
|
||||
|
||||
def test_manifest_write_is_atomic_and_leaves_no_temporary_file(tmp_path: Path) -> None:
|
||||
target = tmp_path / "baseline" / "manifest.json"
|
||||
write_manifest_atomic(target, {"version": 1})
|
||||
write_manifest_atomic(target, {"version": 2})
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"version": 2}
|
||||
assert list(target.parent.glob(".*.tmp")) == []
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules import (
|
||||
EXPECTED_MODULE_IDS,
|
||||
BusinessModule,
|
||||
ModuleRegistry,
|
||||
create_default_registry,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing import ContentMarketingModule
|
||||
from gyxx_flow.modules.product_commerce import ProductCommerceModule
|
||||
from gyxx_flow.modules.shop_intelligence import ShopIntelligenceModule
|
||||
from gyxx_flow.modules.supply_chain import SupplyChainModule
|
||||
from gyxx_flow.workflow import WorkflowDefinition
|
||||
|
||||
|
||||
def test_default_registry_contains_exactly_the_four_business_modules() -> None:
|
||||
registry = create_default_registry()
|
||||
|
||||
assert registry.module_ids == EXPECTED_MODULE_IDS
|
||||
assert isinstance(registry.get("content_marketing"), ContentMarketingModule)
|
||||
assert isinstance(registry.get("product_commerce"), ProductCommerceModule)
|
||||
assert isinstance(registry.get("shop_intelligence"), ShopIntelligenceModule)
|
||||
assert isinstance(registry.get("supply_chain"), SupplyChainModule)
|
||||
|
||||
|
||||
def test_catalog_composition_registers_all_migrated_scheduled_and_manual_workflows() -> None:
|
||||
catalog = WorkflowCatalog.load(Path(__file__).parents[1] / "config")
|
||||
|
||||
registry = create_default_registry(catalog=catalog)
|
||||
|
||||
assert len(registry.workflow_ids) == 28
|
||||
assert set(registry.workflow_ids) == {
|
||||
"shop.metrics.weekly",
|
||||
"shop.competitor.weekly",
|
||||
"supply.purchase_confirmation.daily",
|
||||
"supply.replenishment.weekly",
|
||||
"supply.replenishment_alert.daily",
|
||||
"supply.purchase_order_update",
|
||||
"content.metrics.daily",
|
||||
"content.marketing_report.daily",
|
||||
"content.relogin.weekly",
|
||||
"content.self_operated.weekly",
|
||||
"content.creator_report.monthly",
|
||||
"content.summary.monthly",
|
||||
"content.cooperations.daily",
|
||||
"content.comments.weekly",
|
||||
"content.summary.weekly",
|
||||
"content.mapping.refresh",
|
||||
"content.retry_failed",
|
||||
"content.metrics.backfill",
|
||||
"product.persona.daily",
|
||||
"product.daily",
|
||||
"product.alert.daily",
|
||||
"product.import.daily",
|
||||
"product.style_analysis.interval",
|
||||
"product.main_image.jd.weekly",
|
||||
"product.main_image.weekly",
|
||||
"product.backfill",
|
||||
"product.market_rank",
|
||||
"product.review_collection",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "module_id", "expected_workflow_ids"),
|
||||
[
|
||||
(ContentMarketingModule(), "content_marketing", ()),
|
||||
(ProductCommerceModule(), "product_commerce", ()),
|
||||
(ShopIntelligenceModule(), "shop_intelligence", ()),
|
||||
(SupplyChainModule(), "supply_chain", ()),
|
||||
],
|
||||
)
|
||||
def test_every_module_implements_the_uniform_contract(
|
||||
module: BusinessModule, module_id: str, expected_workflow_ids: tuple[str, ...]
|
||||
) -> None:
|
||||
assert isinstance(module, BusinessModule)
|
||||
assert module.module_id == module_id
|
||||
assert tuple(
|
||||
definition.workflow_id for definition in module.workflow_definitions()
|
||||
) == expected_workflow_ids
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_module_ids() -> None:
|
||||
with pytest.raises(ValueError, match="duplicate module id"):
|
||||
ModuleRegistry([ContentMarketingModule(), ContentMarketingModule()])
|
||||
|
||||
|
||||
def test_registry_indexes_workflows_without_exposing_mutable_state() -> None:
|
||||
class ExampleModule:
|
||||
module_id = "content_marketing"
|
||||
|
||||
def workflow_definitions(self) -> tuple[WorkflowDefinition, ...]:
|
||||
return (WorkflowDefinition("content.example", ()),)
|
||||
|
||||
registry = ModuleRegistry([ExampleModule()])
|
||||
|
||||
assert registry.workflow_ids == ("content.example",)
|
||||
assert registry.workflow("content.example").workflow_id == "content.example"
|
||||
with pytest.raises(KeyError):
|
||||
registry.workflow("content.missing")
|
||||
|
||||
|
||||
def test_business_packages_do_not_import_each_other_or_forbidden_gyxx_layers() -> None:
|
||||
source_root = Path(__file__).parents[1] / "src" / "gyxx_flow" / "modules"
|
||||
business_packages = set(EXPECTED_MODULE_IDS)
|
||||
allowed_shared_roots = {
|
||||
"gyxx_flow.core",
|
||||
"gyxx_flow.workflow",
|
||||
"gyxx_flow.adapters",
|
||||
"gyxx_flow.contracts",
|
||||
"gyxx_flow.modules.contracts",
|
||||
}
|
||||
violations: list[str] = []
|
||||
|
||||
for package in sorted(business_packages):
|
||||
allowed_roots = allowed_shared_roots | {f"gyxx_flow.modules.{package}"}
|
||||
for path in (source_root / package).rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
for imported in _resolved_imports(tree, package):
|
||||
other_business = {
|
||||
f"gyxx_flow.modules.{name}" for name in business_packages - {package}
|
||||
}
|
||||
if any(imported == root or imported.startswith(f"{root}.") for root in other_business):
|
||||
violations.append(f"{path.name}: cross-module import {imported}")
|
||||
continue
|
||||
if imported.startswith("gyxx_flow.") and not any(
|
||||
imported == root or imported.startswith(f"{root}.")
|
||||
for root in allowed_roots
|
||||
):
|
||||
violations.append(f"{path.name}: forbidden layer import {imported}")
|
||||
|
||||
assert violations == []
|
||||
|
||||
|
||||
def _resolved_imports(tree: ast.AST, package: str) -> tuple[str, ...]:
|
||||
names: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
names.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level == 0 and node.module:
|
||||
names.append(node.module)
|
||||
continue
|
||||
base = ["gyxx_flow", "modules", package]
|
||||
parent = base[: len(base) - node.level + 1]
|
||||
if node.module:
|
||||
names.append(".".join([*parent, node.module]))
|
||||
else:
|
||||
names.extend(".".join([*parent, alias.name]) for alias in node.names)
|
||||
return tuple(names)
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_catalog_maps_exactly_21_current_scheduled_tasks() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
|
||||
assert len(scheduled) == 21
|
||||
assert len({item.source_task_name for item in scheduled}) == 21
|
||||
assert Counter(item.module for item in scheduled) == {
|
||||
"content_marketing": 9,
|
||||
"product_commerce": 7,
|
||||
"shop_intelligence": 2,
|
||||
"supply_chain": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_keeps_known_manual_or_unregistered_workflows_unscheduled() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert {
|
||||
"content.mapping.refresh",
|
||||
"content.retry_failed",
|
||||
"product.backfill",
|
||||
"product.market_rank",
|
||||
"product.review_collection",
|
||||
"supply.purchase_order_update",
|
||||
}.issubset({item.workflow_id for item in catalog.manual_workflows()})
|
||||
assert "product.weekly_aggregate.documented_missing" in {
|
||||
item.workflow_id for item in catalog.unavailable_workflows()
|
||||
}
|
||||
|
||||
|
||||
def test_every_scheduled_workflow_has_one_valid_asia_shanghai_schedule() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert catalog.timezone == "Asia/Shanghai"
|
||||
assert len(catalog.schedules) == 21
|
||||
for workflow in catalog.scheduled_workflows():
|
||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||
assert schedule.workflow_id == workflow.workflow_id
|
||||
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
|
||||
assert schedule.at.count(":") == 1
|
||||
|
||||
|
||||
def test_catalog_rejects_duplicate_workflow_ids(tmp_path: Path) -> None:
|
||||
(tmp_path / "workflows.json").write_text(
|
||||
'{"schema_version":2,"workflows":['
|
||||
'{"id":"a.one","module":"content_marketing","trigger":"manual",'
|
||||
'"execution":{"entry":"run.py"}},'
|
||||
'{"id":"a.one","module":"content_marketing","trigger":"manual",'
|
||||
'"execution":{"entry":"other.py"}}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "schedules.json").write_text(
|
||||
'{"schema_version":1,"timezone":"Asia/Shanghai","schedules":[]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(CatalogError, match="duplicate workflow id"):
|
||||
WorkflowCatalog.load(tmp_path)
|
||||
|
||||
|
||||
def test_catalog_rejects_absolute_execution_entry(tmp_path: Path) -> None:
|
||||
(tmp_path / "workflows.json").write_text(
|
||||
'{"schema_version":2,"workflows":['
|
||||
'{"id":"a.one","module":"content_marketing","trigger":"manual",'
|
||||
'"execution":{"entry":"D:/old/run.py"}}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "schedules.json").write_text(
|
||||
'{"schema_version":1,"timezone":"Asia/Shanghai","schedules":[]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(CatalogError, match="relative"):
|
||||
WorkflowCatalog.load(tmp_path)
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
|
||||
|
||||
def _environment_with_source() -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
existing_pythonpath = environment.get("PYTHONPATH")
|
||||
paths = [str(SOURCE_ROOT)]
|
||||
if existing_pythonpath:
|
||||
paths.append(existing_pythonpath)
|
||||
environment["PYTHONPATH"] = os.pathsep.join(paths)
|
||||
return environment
|
||||
|
||||
|
||||
def _assert_successful_help(result: subprocess.CompletedProcess[str]) -> None:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "usage:" in result.stdout.lower()
|
||||
assert "GYXX Flow" in result.stdout
|
||||
|
||||
|
||||
def test_python_module_help_has_no_filesystem_side_effects(tmp_path: Path) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "gyxx_flow", "--help"],
|
||||
cwd=tmp_path,
|
||||
env=_environment_with_source(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
_assert_successful_help(result)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_installed_console_script_help_has_no_filesystem_side_effects(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
executable = shutil.which("gyxx", path=str(Path(sys.executable).parent))
|
||||
assert executable is not None, "The installed gyxx console script was not found"
|
||||
|
||||
result = subprocess.run(
|
||||
[executable, "--help"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
_assert_successful_help(result)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.cli import (
|
||||
EXIT_CONFIGURATION,
|
||||
EXIT_SUCCESS,
|
||||
EXIT_WORKFLOW_FAILED,
|
||||
build_default_registry,
|
||||
main,
|
||||
)
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.ops import RunIndex
|
||||
from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition
|
||||
from gyxx_flow.workflow.registry import WorkflowRegistry
|
||||
from gyxx_flow.workflow.steps import StepExecution
|
||||
|
||||
|
||||
class RecordingStep:
|
||||
def __init__(self, *, fail_on: date | None = None) -> None:
|
||||
self.fail_on = fail_on
|
||||
self.calls: list[RunContext] = []
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
context: RunContext,
|
||||
timeout_seconds: float | None,
|
||||
dry_run: bool,
|
||||
) -> StepExecution:
|
||||
del timeout_seconds, dry_run
|
||||
self.calls.append(context)
|
||||
if context.business_date == self.fail_on:
|
||||
return StepExecution(exit_code=9, error="fixture failure")
|
||||
return StepExecution(exit_code=0)
|
||||
|
||||
|
||||
def test_default_cli_registry_exposes_all_21_migrated_scheduled_workflows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project_root = Path(__file__).parents[1]
|
||||
registry = build_default_registry(
|
||||
Settings(project_root=project_root, data_root=tmp_path)
|
||||
)
|
||||
|
||||
assert sum(
|
||||
registry.is_registered(entry.workflow_id)
|
||||
for entry in registry.catalog.scheduled_workflows()
|
||||
) == 21
|
||||
|
||||
|
||||
def _catalog(tmp_path: Path) -> WorkflowCatalog:
|
||||
config = tmp_path / "config"
|
||||
config.mkdir()
|
||||
(config / "workflows.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflows": [
|
||||
{
|
||||
"id": "shop.metrics.weekly",
|
||||
"module": "shop_intelligence",
|
||||
"trigger": "scheduled",
|
||||
"execution": {"entry": "run.py"},
|
||||
"provenance": {
|
||||
"source_project": "shop",
|
||||
"task_name": "shop-weekly",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "shop.missing",
|
||||
"module": "shop_intelligence",
|
||||
"trigger": "unavailable",
|
||||
"execution": {"entry": "missing.py"},
|
||||
"provenance": {"source_project": "shop"},
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config / "schedules.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"schedules": [
|
||||
{
|
||||
"workflow_id": "shop.metrics.weekly",
|
||||
"kind": "weekly",
|
||||
"at": "12:00",
|
||||
"days": ["Monday"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return WorkflowCatalog.load(config)
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path, workflow: WorkflowDefinition) -> tuple[WorkflowRegistry, Settings]:
|
||||
registry = WorkflowRegistry(_catalog(tmp_path))
|
||||
registry.register(workflow)
|
||||
settings = Settings(project_root=tmp_path, data_root=tmp_path / "runtime")
|
||||
return registry, settings
|
||||
|
||||
|
||||
def _invoke(
|
||||
argv: list[str], registry: WorkflowRegistry, settings: Settings
|
||||
) -> tuple[int, str, str]:
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
code = main(
|
||||
argv,
|
||||
registry=registry,
|
||||
settings=settings,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
return code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
|
||||
def test_list_reports_catalog_and_registration_state_as_json(tmp_path: Path) -> None:
|
||||
action = RecordingStep()
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly", (StepDefinition("collect", action),)
|
||||
),
|
||||
)
|
||||
|
||||
code, output, error = _invoke(["list", "--json"], registry, settings)
|
||||
|
||||
assert code == EXIT_SUCCESS
|
||||
assert error == ""
|
||||
rows = json.loads(output)
|
||||
assert rows == [
|
||||
{
|
||||
"id": "shop.metrics.weekly",
|
||||
"module": "shop_intelligence",
|
||||
"registered": True,
|
||||
"trigger": "scheduled",
|
||||
},
|
||||
{
|
||||
"id": "shop.missing",
|
||||
"module": "shop_intelligence",
|
||||
"registered": False,
|
||||
"trigger": "unavailable",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_run_is_dry_run_by_default_and_records_a_traceable_run(tmp_path: Path) -> None:
|
||||
action = RecordingStep()
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly", (StepDefinition("collect", action),)
|
||||
),
|
||||
)
|
||||
|
||||
code, output, error = _invoke(
|
||||
["run", "shop.metrics.weekly", "--date", "2026-07-27"],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert code == EXIT_SUCCESS
|
||||
assert error == ""
|
||||
payload = json.loads(output)
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["status"] == "success"
|
||||
assert payload["steps"]["collect"] == "skipped"
|
||||
assert payload["journal_path"].startswith("runs/")
|
||||
assert action.calls == []
|
||||
journals = list((settings.data_root / "runs").rglob("run.json"))
|
||||
assert len(journals) == 1
|
||||
assert json.loads(journals[0].read_text(encoding="utf-8"))["status"] == "success"
|
||||
assert RunIndex(settings.data_root).get(payload["run_id"]).status == "success"
|
||||
|
||||
|
||||
def test_run_execute_returns_the_workflow_failure_exit_code(tmp_path: Path) -> None:
|
||||
action = RecordingStep(fail_on=date(2026, 7, 27))
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly", (StepDefinition("collect", action),)
|
||||
),
|
||||
)
|
||||
|
||||
code, output, error = _invoke(
|
||||
[
|
||||
"run",
|
||||
"shop.metrics.weekly",
|
||||
"--date",
|
||||
"2026-07-27",
|
||||
"--execute",
|
||||
],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert code == EXIT_WORKFLOW_FAILED
|
||||
assert error == ""
|
||||
assert json.loads(output)["status"] == "failed"
|
||||
assert len(action.calls) == 1
|
||||
|
||||
|
||||
def test_run_unknown_unavailable_or_unregistered_is_a_configuration_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
registry = WorkflowRegistry(_catalog(tmp_path))
|
||||
settings = Settings(project_root=tmp_path, data_root=tmp_path / "runtime")
|
||||
|
||||
for workflow_id in ("unknown.workflow", "shop.missing", "shop.metrics.weekly"):
|
||||
code, output, error = _invoke(
|
||||
["run", workflow_id, "--date", "2026-07-27"], registry, settings
|
||||
)
|
||||
assert code == EXIT_CONFIGURATION
|
||||
assert output == ""
|
||||
assert workflow_id in error
|
||||
|
||||
assert not settings.data_root.exists()
|
||||
|
||||
|
||||
def test_backfill_runs_each_business_date_and_aggregates_final_exit_code(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
action = RecordingStep(fail_on=date(2026, 7, 28))
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly", (StepDefinition("collect", action),)
|
||||
),
|
||||
)
|
||||
|
||||
code, output, error = _invoke(
|
||||
[
|
||||
"backfill",
|
||||
"shop.metrics.weekly",
|
||||
"--from",
|
||||
"2026-07-27",
|
||||
"--to",
|
||||
"2026-07-29",
|
||||
"--execute",
|
||||
],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert code == EXIT_WORKFLOW_FAILED
|
||||
assert error == ""
|
||||
payload = json.loads(output)
|
||||
assert payload["status"] == "failed"
|
||||
assert [run["business_date"] for run in payload["runs"]] == [
|
||||
"2026-07-27",
|
||||
"2026-07-28",
|
||||
"2026-07-29",
|
||||
]
|
||||
assert [context.business_date for context in action.calls] == [
|
||||
date(2026, 7, 27),
|
||||
date(2026, 7, 28),
|
||||
date(2026, 7, 29),
|
||||
]
|
||||
|
||||
|
||||
def test_step_rerun_and_resume_select_only_the_requested_execution_slice(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
collect = RecordingStep()
|
||||
normalize = RecordingStep()
|
||||
publish = RecordingStep()
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly",
|
||||
(
|
||||
StepDefinition("collect", collect),
|
||||
StepDefinition("normalize", normalize, depends_on=("collect",)),
|
||||
StepDefinition("publish", publish, depends_on=("normalize",)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
rerun_code, _, rerun_error = _invoke(
|
||||
[
|
||||
"run",
|
||||
"shop.metrics.weekly",
|
||||
"--date",
|
||||
"2026-07-27",
|
||||
"--execute",
|
||||
"--rerun-step",
|
||||
"normalize",
|
||||
],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
resume_code, _, resume_error = _invoke(
|
||||
[
|
||||
"run",
|
||||
"shop.metrics.weekly",
|
||||
"--date",
|
||||
"2026-07-28",
|
||||
"--execute",
|
||||
"--resume-from",
|
||||
"normalize",
|
||||
],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert (rerun_code, resume_code) == (EXIT_SUCCESS, EXIT_SUCCESS)
|
||||
assert rerun_error == resume_error == ""
|
||||
assert [context.business_date for context in collect.calls] == []
|
||||
assert [context.business_date for context in normalize.calls] == [
|
||||
date(2026, 7, 27),
|
||||
date(2026, 7, 28),
|
||||
]
|
||||
assert [context.business_date for context in publish.calls] == [date(2026, 7, 28)]
|
||||
|
||||
|
||||
def test_backfill_rejects_reverse_or_excessive_ranges_without_creating_runs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
action = RecordingStep()
|
||||
registry, settings = _runtime(
|
||||
tmp_path,
|
||||
WorkflowDefinition(
|
||||
"shop.metrics.weekly", (StepDefinition("collect", action),)
|
||||
),
|
||||
)
|
||||
|
||||
for start, end in (("2026-07-29", "2026-07-27"), ("2025-01-01", "2026-07-27")):
|
||||
code, output, error = _invoke(
|
||||
[
|
||||
"backfill",
|
||||
"shop.metrics.weekly",
|
||||
"--from",
|
||||
start,
|
||||
"--to",
|
||||
end,
|
||||
"--execute",
|
||||
],
|
||||
registry,
|
||||
settings,
|
||||
)
|
||||
assert code == EXIT_CONFIGURATION
|
||||
assert output == ""
|
||||
assert error
|
||||
|
||||
assert action.calls == []
|
||||
assert not settings.data_root.exists()
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import runtime_paths
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_ROOT = (
|
||||
PROJECT_ROOT / "src" / "gyxx_flow" / "modules" / "content_marketing" / "runtime"
|
||||
)
|
||||
|
||||
|
||||
def _source(relative: str) -> str:
|
||||
return (RUNTIME_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def test_content_output_resolver_keeps_paths_inside_the_selected_layer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "exports" / "content_marketing"
|
||||
|
||||
relative = runtime_paths.resolve_layer_output(
|
||||
"reports/monthly.md", layer_root=root, field="--output"
|
||||
)
|
||||
absolute = runtime_paths.resolve_layer_output(
|
||||
root / "summary" / "daily.md", layer_root=root, field="--output"
|
||||
)
|
||||
|
||||
assert relative == (root / "reports" / "monthly.md").resolve()
|
||||
assert absolute == (root / "summary" / "daily.md").resolve()
|
||||
with pytest.raises(ValueError, match="--output"):
|
||||
runtime_paths.resolve_layer_output(
|
||||
"../escape.md", layer_root=root, field="--output"
|
||||
)
|
||||
with pytest.raises(ValueError, match="--output"):
|
||||
runtime_paths.resolve_layer_output(
|
||||
tmp_path / "outside.md", layer_root=root, field="--output"
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_mapping_uses_normalized_cache_and_read_only_source_seed() -> None:
|
||||
source = _source("feishu_mapping.py")
|
||||
|
||||
assert 'MAPPING_DIR = PATHS.normalized_root / "mappings"' in source
|
||||
assert 'MAPPING_SEED_PATH = PATHS.config_root /' in source
|
||||
assert 'SELF_MAPPING_SEED_PATH = PATHS.config_root /' in source
|
||||
assert 'MAPPING_PATH = PATHS.config_root /' not in source
|
||||
assert "if data_dir:" not in source
|
||||
|
||||
|
||||
def test_v2_outputs_and_checkpoints_use_their_semantic_layers() -> None:
|
||||
assert 'Path("data") / "v2_results"' not in _source("chanmama_scraper.py")
|
||||
assert 'PATHS.normalized_root / "v2_results"' in _source("chanmama_scraper.py")
|
||||
|
||||
for relative in (
|
||||
"bilibili_scraper.py",
|
||||
"pgy_xhs_scraper_v2.py",
|
||||
"xingtu_scraper_v2.py",
|
||||
"self_bilibili_scraper.py",
|
||||
"self_douyin_scraper.py",
|
||||
):
|
||||
assert 'PATHS.normalized_root / "v2_results"' in _source(relative), relative
|
||||
|
||||
assert 'PATHS.curated_root / "run_all"' in _source("run_all.py")
|
||||
assert 'PATHS.state_root / "checkpoints" / "bilibili"' in _source(
|
||||
"bilibili_scraper.py"
|
||||
)
|
||||
assert "PATHS.browser_storage_state_file.write_text" in _source(
|
||||
"xingtu_scraper.py"
|
||||
)
|
||||
assert 'PATHS.state_root / "checkpoints" / "pgy"' in _source(
|
||||
"pgy_xhs_scraper_v2.py"
|
||||
)
|
||||
|
||||
for relative in (
|
||||
"data/tools/check_status.py",
|
||||
"data/tools/retry_failed.py",
|
||||
"data/tools/sync_metrics_to_cmt_notes.py",
|
||||
):
|
||||
source = _source(relative)
|
||||
assert 'PATHS.normalized_root / "v2_results"' in source, relative
|
||||
assert 'PATHS.raw_root / "v2_results"' not in source, relative
|
||||
|
||||
|
||||
def test_transient_content_artifacts_use_tmp_root() -> None:
|
||||
doc_writer = _source("data/tools/feishu_doc_writer.py")
|
||||
report_card = _source("data/tools/daily_report_card.py")
|
||||
|
||||
assert 'TMP_DIR = PATHS.tmp_root / "feishu_doc_writer"' in doc_writer
|
||||
assert 'PATHS.tmp_root / "daily_report_card"' in report_card
|
||||
assert 'chart_path.with_name(f"{chart_path.stem}_feishu.png")' not in report_card
|
||||
|
||||
|
||||
def test_batch_launchers_never_fall_back_to_module_local_var() -> None:
|
||||
for path in sorted((RUNTIME_ROOT / "data" / "tools").glob("*.bat")):
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if "GYXX_DATA_ROOT" not in source:
|
||||
continue
|
||||
assert '%PROJECT_DIR%\\var' not in source, path.name
|
||||
assert '%GYXX_PROJECT_ROOT%\\var' in source, path.name
|
||||
assert "if not defined GYXX_PROJECT_ROOT" in source, path.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relative", "layer_expression"),
|
||||
(
|
||||
("data/tools/analyze_comments.py", "PATHS.exports_root"),
|
||||
("data/tools/analyze_note.py", "PATHS.exports_root"),
|
||||
("data/tools/collect_note_metrics.py", "PATHS.normalized_root"),
|
||||
("data/tools/generate_creator_report.py", "PATHS.exports_root"),
|
||||
),
|
||||
)
|
||||
def test_cli_output_overrides_are_constrained_to_a_data_layer(
|
||||
relative: str, layer_expression: str
|
||||
) -> None:
|
||||
source = _source(relative)
|
||||
|
||||
assert "resolve_layer_output(" in source
|
||||
assert layer_expression in source
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules.content_marketing import (
|
||||
CONTENT_MANUAL_WORKFLOW_IDS,
|
||||
CONTENT_RESOURCE,
|
||||
CONTENT_SCHEDULED_WORKFLOW_IDS,
|
||||
CONTENT_TIMEOUT_SECONDS,
|
||||
CONTENT_WORKFLOW_IDS,
|
||||
ContentMarketingModule,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_content_module_registers_all_scheduled_and_manual_entries() -> None:
|
||||
module = ContentMarketingModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
definitions = module.workflow_definitions()
|
||||
assert tuple(item.workflow_id for item in definitions) == CONTENT_WORKFLOW_IDS
|
||||
assert len(CONTENT_SCHEDULED_WORKFLOW_IDS) == 9
|
||||
assert len(CONTENT_MANUAL_WORKFLOW_IDS) == 3
|
||||
|
||||
|
||||
def test_content_workflows_use_project_owned_module_commands() -> None:
|
||||
module = ContentMarketingModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
for definition in module.workflow_definitions():
|
||||
step = definition.steps[0]
|
||||
assert step.step_id == "module_run"
|
||||
assert isinstance(step.action, DeferredModuleCommandStep)
|
||||
assert step.timeout_seconds == CONTENT_TIMEOUT_SECONDS == 4 * 60 * 60
|
||||
assert step.max_attempts == 1
|
||||
assert step.resources == (CONTENT_RESOURCE,) == ("module:content_marketing",)
|
||||
assert step.production_sink is True
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
RUNTIME_ROOT = (
|
||||
PROJECT_ROOT / "src" / "gyxx_flow" / "modules" / "content_marketing" / "runtime"
|
||||
)
|
||||
MANIFEST_PATH = (
|
||||
PROJECT_ROOT / "config" / "source-manifests" / "content_marketing.json"
|
||||
)
|
||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
OLD_PROJECT_ROOTS = (
|
||||
"d:\\yingxiaoyunying",
|
||||
"d:/yingxiaoyunying",
|
||||
"d:\\shop-data-flow",
|
||||
"d:/shop-data-flow",
|
||||
"d:\\product-collector-analyze-flow",
|
||||
"d:/product-collector-analyze-flow",
|
||||
"e:\\auto-flow",
|
||||
"e:/auto-flow",
|
||||
)
|
||||
FIXED_USER_PATHS = (
|
||||
"c:\\users\\administrator",
|
||||
"c:/users/administrator",
|
||||
)
|
||||
EXPECTED_CATEGORIES = {
|
||||
"config",
|
||||
"dependency",
|
||||
"launcher",
|
||||
"source",
|
||||
"sql",
|
||||
"test",
|
||||
"tool",
|
||||
}
|
||||
REQUIRED_SOURCES = {
|
||||
"run_all.py",
|
||||
"bilibili_scraper.py",
|
||||
"pgy_xhs_scraper_v2.py",
|
||||
"xingtu_scraper_v2.py",
|
||||
"chanmama_scraper.py",
|
||||
"daily_marketing_report.py",
|
||||
"data/tools/daily_run.bat",
|
||||
"data/tools/friday_relogin_parallel.py",
|
||||
"data/tools/schema_gyxx_super_data.sql",
|
||||
"data/tools/migrations/005_style_product_profile.sql",
|
||||
"data/config/db.env.example",
|
||||
"data/config/款式_多维表格_对照.json",
|
||||
"tests/test_collection_completeness.py",
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _manifest() -> dict[str, object]:
|
||||
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_content_source_snapshot_manifest_is_complete_and_well_formed() -> None:
|
||||
manifest = _manifest()
|
||||
|
||||
assert manifest["schema_version"] == 1
|
||||
assert manifest["module"] == "content_marketing"
|
||||
files = manifest["files"]
|
||||
assert isinstance(files, list)
|
||||
assert len(files) == 90
|
||||
|
||||
sources = [entry["source_relative_path"] for entry in files]
|
||||
targets = [entry["target_relative_path"] for entry in files]
|
||||
assert sources == sorted(sources, key=str.casefold)
|
||||
assert len(sources) == len(set(sources))
|
||||
assert len(targets) == len(set(targets))
|
||||
assert REQUIRED_SOURCES <= set(sources)
|
||||
assert {entry["category"] for entry in files} == EXPECTED_CATEGORIES
|
||||
|
||||
for entry in files:
|
||||
relative = PurePosixPath(entry["target_relative_path"])
|
||||
assert not relative.is_absolute()
|
||||
assert ".." not in relative.parts
|
||||
assert relative.parts[:5] == (
|
||||
"src",
|
||||
"gyxx_flow",
|
||||
"modules",
|
||||
"content_marketing",
|
||||
"runtime",
|
||||
)
|
||||
assert SHA256.fullmatch(entry["source_sha256"])
|
||||
assert SHA256.fullmatch(entry["target_sha256"])
|
||||
assert entry["transformed"] is (
|
||||
entry["source_sha256"] != entry["target_sha256"]
|
||||
)
|
||||
|
||||
|
||||
def test_content_source_snapshot_targets_exist_and_match_manifest_hashes() -> None:
|
||||
for entry in _manifest()["files"]:
|
||||
target = PROJECT_ROOT.joinpath(*PurePosixPath(entry["target_relative_path"]).parts)
|
||||
assert target.is_file(), entry["target_relative_path"]
|
||||
assert _sha256(target) == entry["target_sha256"]
|
||||
|
||||
generated = _manifest()["generated_files"]
|
||||
assert [entry["target_relative_path"] for entry in generated] == [
|
||||
"src/gyxx_flow/modules/content_marketing/runtime/runtime_paths.py"
|
||||
]
|
||||
for entry in generated:
|
||||
target = PROJECT_ROOT.joinpath(*PurePosixPath(entry["target_relative_path"]).parts)
|
||||
assert target.is_file(), entry["target_relative_path"]
|
||||
assert SHA256.fullmatch(entry["target_sha256"])
|
||||
assert _sha256(target) == entry["target_sha256"]
|
||||
|
||||
|
||||
def test_content_source_snapshot_exclusions_cover_runtime_and_sensitive_state() -> None:
|
||||
excluded = {entry["pattern"] for entry in _manifest()["intentionally_excluded"]}
|
||||
|
||||
assert {
|
||||
".git/**",
|
||||
"**/__pycache__/**",
|
||||
"data/config/*.env",
|
||||
"data/**/*cookies*",
|
||||
".*chrome_profile*/**",
|
||||
"data/logs/**",
|
||||
"data/v2_results/**",
|
||||
"data/notes/**",
|
||||
"data/reports/**",
|
||||
"data/tmp/**",
|
||||
} <= excluded
|
||||
|
||||
|
||||
def test_content_source_snapshot_has_no_legacy_roots_or_plaintext_credentials() -> None:
|
||||
manifest_text = MANIFEST_PATH.read_text(encoding="utf-8").casefold()
|
||||
assert not any(root in manifest_text for root in OLD_PROJECT_ROOTS)
|
||||
|
||||
for path in RUNTIME_ROOT.rglob("*"):
|
||||
if not path.is_file() or b"\x00" in path.read_bytes():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8-sig", errors="strict").casefold()
|
||||
assert not any(root in text for root in OLD_PROJECT_ROOTS), path
|
||||
assert not any(root in text for root in FIXED_USER_PATHS), path
|
||||
|
||||
assert scan_repository(RUNTIME_ROOT) == []
|
||||
|
||||
|
||||
def test_chanmama_credentials_are_externalized_in_the_copied_source() -> None:
|
||||
source = (RUNTIME_ROOT / "chanmama_scraper.py").read_text(encoding="utf-8-sig")
|
||||
|
||||
assert 'os.getenv("CHANMAMA_ACCOUNT", "")' in source
|
||||
assert 'os.getenv("CHANMAMA_PASSWORD", "")' in source
|
||||
assert not re.search(r'^PASSWORD\s*=\s*["\'][^"\']+["\']', source, re.MULTILINE)
|
||||
|
||||
|
||||
def test_content_runtime_has_one_portable_path_boundary() -> None:
|
||||
python_sources = list(RUNTIME_ROOT.rglob("*.py"))
|
||||
path_mutations: list[Path] = []
|
||||
forbidden_cross_module_imports: list[Path] = []
|
||||
|
||||
for path in python_sources:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if re.search(r"sys\.path\.(?:insert|append)\(", source):
|
||||
path_mutations.append(path.relative_to(RUNTIME_ROOT))
|
||||
if "lark_cli_runtime" in source:
|
||||
forbidden_cross_module_imports.append(path.relative_to(RUNTIME_ROOT))
|
||||
|
||||
assert path_mutations == []
|
||||
assert forbidden_cross_module_imports == []
|
||||
|
||||
runtime_paths = (
|
||||
RUNTIME_ROOT / "runtime_paths.py"
|
||||
).read_text(encoding="utf-8-sig")
|
||||
assert "GYXX_DATA_ROOT" in runtime_paths
|
||||
assert "GYXX_MODULE_ROOT" in runtime_paths
|
||||
|
||||
|
||||
def test_content_runtime_uses_portable_python_launchers_and_data_paths() -> None:
|
||||
batch_sources = list(RUNTIME_ROOT.rglob("*.bat"))
|
||||
assert batch_sources
|
||||
|
||||
for path in batch_sources:
|
||||
source = path.read_text(encoding="utf-8-sig").casefold()
|
||||
assert "%gyxx_python%" in source, path
|
||||
assert "hermes-agent" not in source, path
|
||||
assert "appdata" not in source, path
|
||||
|
||||
path_constants = (RUNTIME_ROOT / "runtime_paths.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
assert 'DataLayout(data_root).for_module("content_marketing")' in path_constants
|
||||
assert "raw_root = _DATA_PATHS.raw_root" in path_constants
|
||||
assert "normalized_root = _DATA_PATHS.normalized_root" in path_constants
|
||||
assert "curated_root = _DATA_PATHS.curated_root" in path_constants
|
||||
assert "exports_root = _DATA_PATHS.exports_root" in path_constants
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.artifacts import ArtifactManifest, atomic_write_json, sha256_file
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
|
||||
|
||||
def test_settings_default_data_root_is_project_local_var(tmp_path: Path) -> None:
|
||||
settings = Settings.from_env(project_root=tmp_path, env={})
|
||||
|
||||
assert settings.project_root == tmp_path.resolve()
|
||||
assert settings.data_root == (tmp_path / "var").resolve()
|
||||
|
||||
|
||||
def test_settings_accepts_portable_data_root_override(tmp_path: Path) -> None:
|
||||
external = tmp_path / "runtime"
|
||||
|
||||
settings = Settings.from_env(
|
||||
project_root=tmp_path / "repo",
|
||||
env={"GYXX_DATA_ROOT": str(external)},
|
||||
)
|
||||
|
||||
assert settings.data_root == external.resolve()
|
||||
|
||||
|
||||
def test_run_context_validates_date_and_builds_traceable_id() -> None:
|
||||
fixed_now = datetime(2026, 7, 27, 6, 30, 45, tzinfo=timezone.utc)
|
||||
|
||||
context = RunContext.create(
|
||||
"product.daily",
|
||||
"2026-07-26",
|
||||
shadow=True,
|
||||
now=fixed_now,
|
||||
random_suffix="abc123",
|
||||
)
|
||||
|
||||
assert context.business_date.isoformat() == "2026-07-26"
|
||||
assert context.run_id == "product.daily__20260726__20260727T063045Z__abc123"
|
||||
assert context.shadow is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["2026-02-30", "27-07-2026", ""])
|
||||
def test_run_context_rejects_invalid_business_date(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="business_date"):
|
||||
RunContext.create("product.daily", value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", ["../escape", "Bad Workflow", "D:\\legacy"])
|
||||
def test_run_context_rejects_unsafe_workflow_id(workflow_id: str) -> None:
|
||||
with pytest.raises(ValueError, match="workflow_id"):
|
||||
RunContext.create(workflow_id, "2026-07-26")
|
||||
|
||||
|
||||
def test_data_layout_separates_raw_runs_and_runtime_state(tmp_path: Path) -> None:
|
||||
layout = DataLayout(tmp_path)
|
||||
|
||||
raw = layout.raw(
|
||||
domain="commerce",
|
||||
source="jd",
|
||||
dataset="product_daily",
|
||||
business_date="2026-07-26",
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
assert raw == (
|
||||
tmp_path
|
||||
/ "data"
|
||||
/ "raw"
|
||||
/ "commerce"
|
||||
/ "jd"
|
||||
/ "product_daily"
|
||||
/ "business_date=2026-07-26"
|
||||
/ "run_id=run-001"
|
||||
)
|
||||
assert layout.state("cookies", "xingtu") == tmp_path / "state" / "cookies" / "xingtu"
|
||||
assert layout.tmp("run-001") == tmp_path / "tmp" / "run-001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("segment", ["..", "a/b", "a\\b", "C:\\data", ""])
|
||||
def test_data_layout_rejects_unsafe_segments(tmp_path: Path, segment: str) -> None:
|
||||
layout = DataLayout(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="path segment"):
|
||||
layout.state("cookies", segment)
|
||||
|
||||
|
||||
def test_atomic_json_and_artifact_manifest_are_verifiable(tmp_path: Path) -> None:
|
||||
payload_path = tmp_path / "part-000.json"
|
||||
atomic_write_json(payload_path, {"rows": [{"sku": "A-1", "qty": 3}]})
|
||||
|
||||
payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
||||
digest = sha256_file(payload_path)
|
||||
manifest = ArtifactManifest.from_file(
|
||||
payload_path,
|
||||
artifact_id="artifact-001",
|
||||
dataset="product_daily",
|
||||
workflow_id="product.daily",
|
||||
run_id="run-001",
|
||||
business_date="2026-07-26",
|
||||
schema_version="1",
|
||||
row_count=1,
|
||||
upstream_artifact_ids=("source-001",),
|
||||
)
|
||||
|
||||
assert payload["rows"][0]["sku"] == "A-1"
|
||||
assert len(digest) == 64
|
||||
assert manifest.sha256 == digest
|
||||
assert manifest.byte_size == payload_path.stat().st_size
|
||||
assert manifest.to_dict()["upstream_artifact_ids"] == ["source-001"]
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
from gyxx_flow.core.locks import LockManager, ResourceBusyError
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
|
||||
|
||||
def _context() -> RunContext:
|
||||
return RunContext.create(
|
||||
"shop.weekly",
|
||||
"2026-07-27",
|
||||
now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc),
|
||||
random_suffix="abc123",
|
||||
)
|
||||
|
||||
|
||||
def test_run_journal_tracks_steps_and_final_status(tmp_path: Path) -> None:
|
||||
context = _context()
|
||||
journal = RunJournal.create(DataLayout(tmp_path), context)
|
||||
|
||||
journal.start_step("collect.jd", attempt=1)
|
||||
journal.finish_step("collect.jd", status="success", exit_code=0)
|
||||
journal.start_step("publish.feishu", attempt=1)
|
||||
journal.finish_step(
|
||||
"publish.feishu",
|
||||
status="failed",
|
||||
exit_code=3,
|
||||
error="credential binding missing",
|
||||
)
|
||||
journal.finalize("failed", error="publish.feishu failed")
|
||||
|
||||
payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
assert payload["run_id"] == context.run_id
|
||||
assert payload["status"] == "failed"
|
||||
assert payload["steps"]["collect.jd"]["status"] == "success"
|
||||
assert payload["steps"]["publish.feishu"]["exit_code"] == 3
|
||||
assert payload["steps"]["publish.feishu"]["error"] == "credential binding missing"
|
||||
assert payload["error"] == "publish.feishu failed"
|
||||
assert payload["trace"]["paths"]["run"].startswith("runs/")
|
||||
assert payload["trace"]["paths"]["log"].startswith("logs/")
|
||||
assert payload["trace"]["paths"]["evidence"].startswith("data/evidence/")
|
||||
|
||||
|
||||
def test_run_journal_records_deduplicated_trace_references(tmp_path: Path) -> None:
|
||||
journal = RunJournal.create(DataLayout(tmp_path), _context())
|
||||
|
||||
journal.record_input("artifact:raw-001")
|
||||
journal.record_input("artifact:raw-001")
|
||||
journal.record_output("artifact:normalized-001")
|
||||
journal.record_external_write("outbox:msg-001")
|
||||
|
||||
payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
assert payload["trace"]["inputs"] == ["artifact:raw-001"]
|
||||
assert payload["trace"]["outputs"] == ["artifact:normalized-001"]
|
||||
assert payload["trace"]["external_writes"] == ["outbox:msg-001"]
|
||||
|
||||
|
||||
def test_run_journal_rejects_invalid_transitions(tmp_path: Path) -> None:
|
||||
journal = RunJournal.create(DataLayout(tmp_path), _context())
|
||||
|
||||
with pytest.raises(ValueError, match="not running"):
|
||||
journal.finish_step("collect.jd", status="success")
|
||||
|
||||
journal.start_step("collect.jd", attempt=1)
|
||||
with pytest.raises(ValueError, match="already running"):
|
||||
journal.start_step("collect.jd", attempt=2)
|
||||
|
||||
|
||||
def test_named_resource_lock_blocks_concurrent_owner_and_releases(tmp_path: Path) -> None:
|
||||
manager = LockManager(tmp_path)
|
||||
|
||||
with manager.acquire("browser:xingtu", owner="run-001") as first:
|
||||
assert first.path.exists()
|
||||
with pytest.raises(ResourceBusyError, match="browser:xingtu"):
|
||||
with manager.acquire("browser:xingtu", owner="run-002", timeout_seconds=0):
|
||||
pass
|
||||
|
||||
assert not first.path.exists()
|
||||
with manager.acquire("browser:xingtu", owner="run-002", timeout_seconds=0) as second:
|
||||
metadata = json.loads(second.path.read_text(encoding="utf-8"))
|
||||
assert metadata["resource"] == "browser:xingtu"
|
||||
assert metadata["owner"] == "run-002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource", ["", "../state", "resource\nname"])
|
||||
def test_named_resource_lock_rejects_unsafe_name(tmp_path: Path, resource: str) -> None:
|
||||
manager = LockManager(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="resource"):
|
||||
with manager.acquire(resource, owner="run-001"):
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules import create_default_registry
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_default_runtime_uses_only_project_owned_module_steps() -> None:
|
||||
registry = create_default_registry(
|
||||
catalog=WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
for workflow_id in registry.workflow_ids:
|
||||
definition = registry.workflow(workflow_id)
|
||||
assert all(
|
||||
isinstance(step.action, DeferredModuleCommandStep)
|
||||
for step in definition.steps
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_composition_has_no_old_root_environment_contract() -> None:
|
||||
runtime_files = [
|
||||
PROJECT_ROOT / "config" / "workflows.json",
|
||||
*(
|
||||
path
|
||||
for path in (PROJECT_ROOT / "src" / "gyxx_flow" / "modules").glob("*/__init__.py")
|
||||
),
|
||||
PROJECT_ROOT / "src" / "gyxx_flow" / "adapters" / "native.py",
|
||||
]
|
||||
forbidden = ("GYXX_LEGACY_", "D:\\yingxiaoyunying", "D:\\shop-data-flow", "D:\\product-collector-analyze-flow", "E:\\auto-flow")
|
||||
|
||||
violations = [
|
||||
f"{path}: {marker}"
|
||||
for path in runtime_files
|
||||
for marker in forbidden
|
||||
if marker in path.read_text(encoding="utf-8")
|
||||
]
|
||||
assert violations == []
|
||||
@@ -0,0 +1,339 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import gyxx_flow.migration.data as migration_data
|
||||
from gyxx_flow.core.artifacts import sha256_file
|
||||
from gyxx_flow.migration.data import (
|
||||
DataLayer,
|
||||
HistoricalDataMigrationExecutor,
|
||||
HistoricalDataMigrationPlanner,
|
||||
MigrationConflictError,
|
||||
MigrationExecutionError,
|
||||
MigrationSource,
|
||||
MigrationValidationError,
|
||||
)
|
||||
|
||||
|
||||
def _source_tree(root: Path) -> dict[str, bytes]:
|
||||
files = {
|
||||
"2025/01/orders.csv": b"order_id,total\n1,19.90\n",
|
||||
"2025/01/detail.json": b'{"id": 1, "items": 2}\n',
|
||||
}
|
||||
for relative, content in files.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
return files
|
||||
|
||||
|
||||
def _planner(data_root: Path, *, free_bytes: int = 10_000_000) -> HistoricalDataMigrationPlanner:
|
||||
return HistoricalDataMigrationPlanner(
|
||||
data_root=data_root,
|
||||
free_space_provider=lambda _path: free_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _source(root: Path, *, layer: DataLayer = DataLayer.RAW) -> MigrationSource:
|
||||
return MigrationSource(
|
||||
source_id="legacy-orders",
|
||||
module="product_commerce",
|
||||
layer=layer,
|
||||
root=root,
|
||||
)
|
||||
|
||||
|
||||
def test_plan_is_machine_readable_and_routes_files_by_module_and_layer(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
contents = _source_tree(source_root)
|
||||
manifest_path = tmp_path / "new" / "plans" / "migration.json"
|
||||
|
||||
plan = _planner(tmp_path / "new").create_plan(
|
||||
[_source(source_root)], manifest_path=manifest_path
|
||||
)
|
||||
|
||||
assert plan.source_summary.file_count == 2
|
||||
assert plan.source_summary.total_bytes == sum(map(len, contents.values()))
|
||||
assert {entry.relative_path for entry in plan.files} == set(contents)
|
||||
assert {
|
||||
entry.target_relative_path for entry in plan.files
|
||||
} == {
|
||||
f"data/raw/product_commerce/legacy/legacy-orders/{relative}"
|
||||
for relative in contents
|
||||
}
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["source_summary"]["file_count"] == 2
|
||||
assert all(len(entry["sha256"]) == 64 for entry in payload["files"])
|
||||
assert payload["precheck"]["status"] == "ready"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layer", list(DataLayer))
|
||||
def test_each_supported_layer_has_an_explicit_target_directory(
|
||||
tmp_path: Path, layer: DataLayer
|
||||
) -> None:
|
||||
source_root = tmp_path / f"legacy-{layer.value}"
|
||||
(source_root / "one.txt").parent.mkdir(parents=True)
|
||||
(source_root / "one.txt").write_text("one", encoding="utf-8")
|
||||
|
||||
plan = _planner(tmp_path / "new").create_plan([_source(source_root, layer=layer)])
|
||||
|
||||
assert plan.files[0].target_relative_path.startswith(
|
||||
f"data/{layer.value}/product_commerce/legacy/legacy-orders/"
|
||||
)
|
||||
|
||||
|
||||
def test_default_execution_is_plan_only_and_never_creates_data_files(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
|
||||
report = HistoricalDataMigrationExecutor().execute(plan)
|
||||
|
||||
assert report.status == "plan_only"
|
||||
assert report.applied is False
|
||||
assert report.destination_summary.file_count == 0
|
||||
assert not (data_root / "data").exists()
|
||||
|
||||
|
||||
def test_apply_copies_without_changing_or_removing_source_and_reconciles(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
contents = _source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
report_path = data_root / "evidence" / "migration-report.json"
|
||||
before = {
|
||||
relative: (
|
||||
sha256_file(source_root / relative),
|
||||
(source_root / relative).stat().st_mtime_ns,
|
||||
)
|
||||
for relative in contents
|
||||
}
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
|
||||
report = HistoricalDataMigrationExecutor().execute(
|
||||
plan, apply=True, report_path=report_path
|
||||
)
|
||||
|
||||
assert report.status == "reconciled"
|
||||
assert report.applied is True
|
||||
assert report.source_summary == report.destination_summary
|
||||
assert report.copied_count == 2
|
||||
assert report.mismatches == ()
|
||||
for relative, expected_content in contents.items():
|
||||
source_path = source_root / relative
|
||||
destination = (
|
||||
data_root
|
||||
/ "data/raw/product_commerce/legacy/legacy-orders"
|
||||
/ relative
|
||||
)
|
||||
assert source_path.exists()
|
||||
assert source_path.read_bytes() == expected_content
|
||||
assert (sha256_file(source_path), source_path.stat().st_mtime_ns) == before[relative]
|
||||
assert destination.read_bytes() == expected_content
|
||||
assert sha256_file(destination) == before[relative][0]
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
assert payload["status"] == "reconciled"
|
||||
assert payload["destination_summary"] == payload["source_summary"]
|
||||
|
||||
|
||||
def test_progress_reporting_performs_full_destination_reconciliation_only_once(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
plan = _planner(tmp_path / "new").create_plan([_source(source_root)])
|
||||
original = migration_data._summarize_destinations
|
||||
original_write = migration_data._write_report
|
||||
calls = 0
|
||||
writes = 0
|
||||
|
||||
def counted(entries, data_root):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(entries, data_root)
|
||||
|
||||
monkeypatch.setattr(migration_data, "_summarize_destinations", counted)
|
||||
|
||||
def counted_write(path, report):
|
||||
nonlocal writes
|
||||
writes += 1
|
||||
return original_write(path, report)
|
||||
|
||||
monkeypatch.setattr(migration_data, "_write_report", counted_write)
|
||||
|
||||
report = HistoricalDataMigrationExecutor().execute(plan, apply=True)
|
||||
|
||||
assert report.status == "reconciled"
|
||||
assert calls == 1
|
||||
assert writes == 1
|
||||
|
||||
|
||||
def test_repeated_apply_is_idempotent_and_skips_matching_files(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
executor = HistoricalDataMigrationExecutor()
|
||||
first = executor.execute(plan, apply=True)
|
||||
destination = data_root / plan.files[0].target_relative_path
|
||||
first_mtime = destination.stat().st_mtime_ns
|
||||
|
||||
second = executor.execute(plan, apply=True)
|
||||
|
||||
assert first.copied_count == 2
|
||||
assert second.copied_count == 0
|
||||
assert second.skipped_count == 2
|
||||
assert destination.stat().st_mtime_ns == first_mtime
|
||||
assert second.status == "reconciled"
|
||||
|
||||
|
||||
def test_precheck_rejects_conflicting_target_before_copying_anything(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
conflict = (
|
||||
data_root
|
||||
/ "data/raw/product_commerce/legacy/legacy-orders/2025/01/orders.csv"
|
||||
)
|
||||
conflict.parent.mkdir(parents=True)
|
||||
conflict.write_text("different", encoding="utf-8")
|
||||
|
||||
with pytest.raises(MigrationConflictError, match="conflict"):
|
||||
_planner(data_root).create_plan([_source(source_root)])
|
||||
|
||||
assert not (
|
||||
data_root
|
||||
/ "data/raw/product_commerce/legacy/legacy-orders/2025/01/detail.json"
|
||||
).exists()
|
||||
|
||||
|
||||
def test_precheck_rejects_unplanned_files_in_dedicated_target_scope(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
unexpected = (
|
||||
data_root
|
||||
/ "data/raw/product_commerce/legacy/legacy-orders/unexpected.txt"
|
||||
)
|
||||
unexpected.parent.mkdir(parents=True)
|
||||
unexpected.write_text("not in source", encoding="utf-8")
|
||||
|
||||
with pytest.raises(MigrationConflictError, match="unplanned target"):
|
||||
_planner(data_root).create_plan([_source(source_root)])
|
||||
|
||||
|
||||
def test_precheck_rejects_insufficient_disk_space(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
|
||||
with pytest.raises(MigrationValidationError, match="disk space"):
|
||||
_planner(tmp_path / "new", free_bytes=1).create_plan([_source(source_root)])
|
||||
|
||||
|
||||
def test_source_and_destination_must_not_overlap(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
|
||||
with pytest.raises(MigrationValidationError, match="overlap"):
|
||||
_planner(source_root).create_plan([_source(source_root)])
|
||||
|
||||
|
||||
def test_source_change_after_planning_stops_before_overwriting_destination(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
changed = source_root / "2025/01/orders.csv"
|
||||
changed.write_text("changed after planning", encoding="utf-8")
|
||||
|
||||
with pytest.raises(MigrationExecutionError, match="source changed"):
|
||||
HistoricalDataMigrationExecutor().execute(plan, apply=True)
|
||||
|
||||
target = data_root / next(
|
||||
entry.target_relative_path
|
||||
for entry in plan.files
|
||||
if entry.relative_path == "2025/01/orders.csv"
|
||||
)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_apply_persists_progress_and_can_resume_after_interruption(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
data_root = tmp_path / "new"
|
||||
report_path = data_root / "evidence" / "progress.json"
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
executor = HistoricalDataMigrationExecutor()
|
||||
original_copy = executor._copy_file_atomic
|
||||
calls = 0
|
||||
|
||||
def fail_on_second(
|
||||
source: Path, destination: Path, expected_size: int, expected_sha256: str
|
||||
) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError("simulated interruption")
|
||||
original_copy(source, destination, expected_size, expected_sha256)
|
||||
|
||||
monkeypatch.setattr(executor, "_copy_file_atomic", fail_on_second)
|
||||
with pytest.raises(MigrationExecutionError, match="simulated interruption"):
|
||||
executor.execute(plan, apply=True, report_path=report_path)
|
||||
|
||||
interrupted = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
assert interrupted["status"] == "interrupted"
|
||||
assert interrupted["copied_count"] == 1
|
||||
|
||||
resumed = HistoricalDataMigrationExecutor().execute(
|
||||
plan, apply=True, report_path=report_path
|
||||
)
|
||||
assert resumed.status == "reconciled"
|
||||
assert resumed.copied_count == 1
|
||||
assert resumed.skipped_count == 1
|
||||
|
||||
|
||||
def test_corrupt_temporary_copy_is_never_committed_to_destination(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
(source_root / "one.txt").parent.mkdir(parents=True)
|
||||
(source_root / "one.txt").write_text("original", encoding="utf-8")
|
||||
data_root = tmp_path / "new"
|
||||
plan = _planner(data_root).create_plan([_source(source_root)])
|
||||
|
||||
def corrupt_copy(_source: Path, temporary: Path) -> None:
|
||||
temporary.write_text("corrupt", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("gyxx_flow.migration.data.shutil.copy2", corrupt_copy)
|
||||
with pytest.raises(MigrationExecutionError, match="temporary copy verification"):
|
||||
HistoricalDataMigrationExecutor().execute(plan, apply=True)
|
||||
|
||||
destination = data_root / plan.files[0].target_relative_path
|
||||
assert not destination.exists()
|
||||
|
||||
|
||||
def test_unsafe_ids_and_symlink_escape_are_rejected(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "legacy"
|
||||
_source_tree(source_root)
|
||||
|
||||
with pytest.raises(MigrationValidationError, match="module"):
|
||||
MigrationSource("source", "../escape", DataLayer.RAW, source_root)
|
||||
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
link = source_root / "escape.txt"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks are unavailable: {exc}")
|
||||
|
||||
with pytest.raises(MigrationValidationError, match="symbolic link"):
|
||||
_planner(tmp_path / "new").create_plan([_source(source_root)])
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.acceptance import build_acceptance_report, parse_plan_checklist
|
||||
from gyxx_flow.cli import EXIT_ACCEPTANCE_INCOMPLETE, main
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.diagnostics import run_doctor
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_doctor_checks_interpreter_cli_config_paths_and_write_permission(tmp_path: Path) -> None:
|
||||
settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path / "portable-data")
|
||||
|
||||
report = run_doctor(settings)
|
||||
|
||||
assert {check.name for check in report.checks} == {
|
||||
"python",
|
||||
"cli",
|
||||
"project_root",
|
||||
"catalog",
|
||||
"data_root",
|
||||
"data_root_write",
|
||||
}
|
||||
assert report.is_healthy is True
|
||||
assert not (settings.data_root / ".gyxx-doctor-probe").exists()
|
||||
|
||||
|
||||
def test_plan_parser_returns_stable_item_ids_and_completion(tmp_path: Path) -> None:
|
||||
plan = tmp_path / "plan.md"
|
||||
plan.write_text(
|
||||
"- [x] P0.1 complete\n- [ ] P0.2 pending\n- [X] P1.1 complete too\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items = parse_plan_checklist(plan)
|
||||
|
||||
assert [(item.item_id, item.completed) for item in items] == [
|
||||
("P0.1", True),
|
||||
("P0.2", False),
|
||||
("P1.1", True),
|
||||
]
|
||||
|
||||
|
||||
def test_acceptance_report_is_machine_readable_and_tracks_real_evidence() -> None:
|
||||
settings = Settings(project_root=PROJECT_ROOT, data_root=PROJECT_ROOT / "var")
|
||||
|
||||
report = build_acceptance_report(settings)
|
||||
payload = report.as_dict()
|
||||
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["summary"]["total"] > payload["summary"]["completed"]
|
||||
assert payload["checks"]["catalog_21_tasks"] is True
|
||||
assert payload["checks"]["baseline_21_tasks"] is True
|
||||
assert payload["checks"]["secret_scan_clean"] is True
|
||||
assert report.is_complete is False
|
||||
|
||||
|
||||
def test_cli_doctor_and_acceptance_status_return_truthful_exit_codes(tmp_path: Path) -> None:
|
||||
settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path / "data")
|
||||
doctor_output = io.StringIO()
|
||||
acceptance_output = io.StringIO()
|
||||
|
||||
assert main(["doctor", "--json"], settings=settings, stdout=doctor_output) == 0
|
||||
assert json.loads(doctor_output.getvalue())["is_healthy"] is True
|
||||
|
||||
exit_code = main(
|
||||
["acceptance", "status", "--json"],
|
||||
settings=Settings(project_root=PROJECT_ROOT, data_root=PROJECT_ROOT / "var"),
|
||||
stdout=acceptance_output,
|
||||
)
|
||||
assert exit_code == EXIT_ACCEPTANCE_INCOMPLETE
|
||||
assert json.loads(acceptance_output.getvalue())["is_complete"] is False
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.ops.effects import (
|
||||
EffectAlreadyApplied,
|
||||
EffectLedger,
|
||||
EffectStateAmbiguous,
|
||||
)
|
||||
|
||||
|
||||
def test_effect_ledger_blocks_applied_and_ambiguous_replays(tmp_path: Path) -> None:
|
||||
ledger = EffectLedger(tmp_path)
|
||||
first = ledger.begin(
|
||||
workflow_id="product.daily",
|
||||
business_date="2026-07-27",
|
||||
step_id="legacy_execute",
|
||||
run_id="run-001",
|
||||
)
|
||||
ledger.mark_applied(first)
|
||||
|
||||
with pytest.raises(EffectAlreadyApplied):
|
||||
ledger.begin(
|
||||
workflow_id="product.daily",
|
||||
business_date="2026-07-27",
|
||||
step_id="legacy_execute",
|
||||
run_id="run-002",
|
||||
)
|
||||
|
||||
ambiguous = ledger.begin(
|
||||
workflow_id="product.daily",
|
||||
business_date="2026-07-28",
|
||||
step_id="legacy_execute",
|
||||
run_id="run-003",
|
||||
)
|
||||
ledger.mark_ambiguous(ambiguous, reason="nonzero-exit")
|
||||
with pytest.raises(EffectStateAmbiguous):
|
||||
ledger.begin(
|
||||
workflow_id="product.daily",
|
||||
business_date="2026-07-28",
|
||||
step_id="legacy_execute",
|
||||
run_id="run-004",
|
||||
)
|
||||
|
||||
|
||||
def test_effect_ledger_leaves_in_progress_claim_ambiguous_after_crash(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ledger = EffectLedger(tmp_path)
|
||||
ledger.begin(
|
||||
workflow_id="shop.metrics.weekly",
|
||||
business_date="2026-07-27",
|
||||
step_id="legacy_collect",
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
with pytest.raises(EffectStateAmbiguous):
|
||||
ledger.begin(
|
||||
workflow_id="shop.metrics.weekly",
|
||||
business_date="2026-07-27",
|
||||
step_id="legacy_collect",
|
||||
run_id="run-002",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.catalog import WorkflowEntry
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.migration.legacy import (
|
||||
LegacyCommandAdapter,
|
||||
LegacyConfigurationError,
|
||||
LegacyProjectRoots,
|
||||
)
|
||||
|
||||
PROJECT_IDS = ("content", "shop", "product", "supply")
|
||||
|
||||
|
||||
def _entry(**overrides: object) -> WorkflowEntry:
|
||||
values: dict[str, object] = {
|
||||
"workflow_id": "product.backfill",
|
||||
"module": "product_commerce",
|
||||
"trigger": "manual",
|
||||
"source_project": "product",
|
||||
"entry": "jobs/backfill.py",
|
||||
"args": ("--limit", "25"),
|
||||
}
|
||||
values.update(overrides)
|
||||
return WorkflowEntry(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _context(*, shadow: bool = True) -> RunContext:
|
||||
return RunContext.create(
|
||||
"product.backfill",
|
||||
"2026-07-27",
|
||||
shadow=shadow,
|
||||
now=datetime(2026, 7, 27, 2, 30, tzinfo=timezone.utc),
|
||||
random_suffix="abc123",
|
||||
)
|
||||
|
||||
|
||||
def _project_roots(tmp_path: Path) -> dict[str, Path]:
|
||||
roots = {project_id: tmp_path / project_id for project_id in PROJECT_IDS}
|
||||
for root in roots.values():
|
||||
root.mkdir()
|
||||
return roots
|
||||
|
||||
|
||||
def test_legacy_roots_are_loaded_only_from_injected_environment(tmp_path: Path) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
env = {
|
||||
f"GYXX_LEGACY_{project_id.upper()}_ROOT": str(root)
|
||||
for project_id, root in roots.items()
|
||||
}
|
||||
|
||||
configured = LegacyProjectRoots.from_env(env=env)
|
||||
|
||||
assert configured.as_dict() == {
|
||||
project_id: root.resolve() for project_id, root in roots.items()
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_roots_report_all_missing_environment_keys(tmp_path: Path) -> None:
|
||||
content_root = tmp_path / "content"
|
||||
content_root.mkdir()
|
||||
|
||||
with pytest.raises(LegacyConfigurationError) as captured:
|
||||
LegacyProjectRoots.from_env(
|
||||
env={"GYXX_LEGACY_CONTENT_ROOT": str(content_root)}
|
||||
)
|
||||
|
||||
message = str(captured.value)
|
||||
assert "GYXX_LEGACY_SHOP_ROOT" in message
|
||||
assert "GYXX_LEGACY_PRODUCT_ROOT" in message
|
||||
assert "GYXX_LEGACY_SUPPLY_ROOT" in message
|
||||
|
||||
|
||||
def test_explicit_root_configuration_rejects_missing_or_non_directory_paths(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "missing"
|
||||
file_path = tmp_path / "not-a-directory"
|
||||
file_path.write_text("x", encoding="utf-8")
|
||||
|
||||
with pytest.raises(LegacyConfigurationError, match="does not exist"):
|
||||
LegacyProjectRoots({"product": missing})
|
||||
with pytest.raises(LegacyConfigurationError, match="not a directory"):
|
||||
LegacyProjectRoots({"product": file_path})
|
||||
|
||||
|
||||
def test_adapter_builds_python_command_and_injects_trace_context(tmp_path: Path) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
entry_path = roots["product"] / "jobs" / "backfill.py"
|
||||
entry_path.parent.mkdir()
|
||||
entry_path.write_text("raise AssertionError('must not run')", encoding="utf-8")
|
||||
context = _context()
|
||||
adapter = LegacyCommandAdapter(
|
||||
LegacyProjectRoots(roots),
|
||||
base_env={"PATH": "injected-path", "GYXX_RUN_ID": "cannot-win"},
|
||||
python_executable="portable-python",
|
||||
)
|
||||
|
||||
command = adapter.build(_entry(), context=context)
|
||||
|
||||
assert command.argv == (
|
||||
"portable-python",
|
||||
str(entry_path.resolve()),
|
||||
"--limit",
|
||||
"25",
|
||||
)
|
||||
assert command.cwd == roots["product"].resolve()
|
||||
assert command.env["PATH"] == "injected-path"
|
||||
assert command.env["GYXX_WORKFLOW_ID"] == "product.backfill"
|
||||
assert command.env["GYXX_RUN_ID"] == context.run_id
|
||||
assert command.env["GYXX_BUSINESS_DATE"] == "2026-07-27"
|
||||
assert command.env["GYXX_SHADOW"] == "true"
|
||||
assert command.env["GYXX_LEGACY_PROJECT_ROOT"] == str(roots["product"].resolve())
|
||||
|
||||
|
||||
def test_adapter_keeps_batch_entry_and_args_as_separate_argv(tmp_path: Path) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
entry_path = roots["content"] / "tools" / "daily run.bat"
|
||||
entry_path.parent.mkdir()
|
||||
entry_path.write_text("@echo off", encoding="utf-8")
|
||||
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
|
||||
|
||||
command = adapter.build(
|
||||
_entry(
|
||||
workflow_id="content.metrics.daily",
|
||||
module="content_marketing",
|
||||
source_project="content",
|
||||
entry="tools/daily run.bat",
|
||||
args=("value with spaces", "¬-a-shell-fragment"),
|
||||
),
|
||||
context=RunContext.create(
|
||||
"content.metrics.daily",
|
||||
"2026-07-27",
|
||||
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
|
||||
random_suffix="def456",
|
||||
),
|
||||
)
|
||||
|
||||
assert command.argv == (
|
||||
str(entry_path.resolve()),
|
||||
"value with spaces",
|
||||
"¬-a-shell-fragment",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_entry",
|
||||
("../outside.py", "jobs/../../outside.py", "/outside.py", "C:/outside.py", "jobs\\outside.py"),
|
||||
)
|
||||
def test_adapter_rejects_unsafe_entry_paths(tmp_path: Path, unsafe_entry: str) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
outside = tmp_path / "outside.py"
|
||||
outside.write_text("pass", encoding="utf-8")
|
||||
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
|
||||
|
||||
with pytest.raises(LegacyConfigurationError, match="relative|escape"):
|
||||
adapter.build(_entry(entry=unsafe_entry), context=_context())
|
||||
|
||||
|
||||
def test_adapter_rejects_symlink_escape_when_supported(tmp_path: Path) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
outside = tmp_path / "outside.py"
|
||||
outside.write_text("pass", encoding="utf-8")
|
||||
link = roots["product"] / "jobs"
|
||||
try:
|
||||
link.symlink_to(tmp_path, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks are unavailable: {exc}")
|
||||
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
|
||||
|
||||
with pytest.raises(LegacyConfigurationError, match="escape"):
|
||||
adapter.build(_entry(entry="jobs/outside.py"), context=_context())
|
||||
|
||||
|
||||
def test_adapter_rejects_unknown_project_context_mismatch_and_unavailable_entry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
|
||||
|
||||
with pytest.raises(LegacyConfigurationError, match="unknown legacy project"):
|
||||
adapter.build(_entry(source_project="other"), context=_context())
|
||||
with pytest.raises(LegacyConfigurationError, match="does not match"):
|
||||
adapter.build(
|
||||
_entry(),
|
||||
context=RunContext.create(
|
||||
"another.workflow",
|
||||
"2026-07-27",
|
||||
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
|
||||
random_suffix="ghi789",
|
||||
),
|
||||
)
|
||||
with pytest.raises(LegacyConfigurationError, match="unavailable"):
|
||||
adapter.build(_entry(trigger="unavailable"), context=_context())
|
||||
|
||||
|
||||
def test_adapter_construction_never_starts_a_process(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
roots = _project_roots(tmp_path)
|
||||
entry_path = roots["product"] / "jobs" / "backfill.py"
|
||||
entry_path.parent.mkdir()
|
||||
entry_path.write_text("pass", encoding="utf-8")
|
||||
|
||||
def fail_if_called(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("adapter construction must not start subprocesses")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_if_called)
|
||||
command = LegacyCommandAdapter(
|
||||
LegacyProjectRoots(roots),
|
||||
base_env={},
|
||||
python_executable=sys.executable,
|
||||
).build(_entry(), context=_context())
|
||||
|
||||
assert command.argv[0] == sys.executable
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters.native import (
|
||||
DeferredModuleCommandStep,
|
||||
ModuleCommandAdapter,
|
||||
ModuleSourceError,
|
||||
ModuleSourceRoots,
|
||||
)
|
||||
from gyxx_flow.catalog import WorkflowEntry
|
||||
from gyxx_flow.core.context import RunContext
|
||||
|
||||
|
||||
def _entry(**overrides: object) -> WorkflowEntry:
|
||||
values: dict[str, object] = {
|
||||
"workflow_id": "product.backfill",
|
||||
"module": "product_commerce",
|
||||
"trigger": "manual",
|
||||
"entry": "jobs/backfill.py",
|
||||
"args": ("--limit", "25"),
|
||||
"source_project": "product",
|
||||
}
|
||||
values.update(overrides)
|
||||
return WorkflowEntry(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _context() -> RunContext:
|
||||
return RunContext.create(
|
||||
"product.backfill",
|
||||
"2026-07-27",
|
||||
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
|
||||
random_suffix="native1",
|
||||
)
|
||||
|
||||
|
||||
def test_module_adapter_resolves_only_inside_new_module_root(tmp_path: Path) -> None:
|
||||
root = tmp_path / "product_commerce" / "runtime"
|
||||
entry = root / "jobs" / "backfill.py"
|
||||
entry.parent.mkdir(parents=True)
|
||||
entry.write_text("pass", encoding="utf-8")
|
||||
adapter = ModuleCommandAdapter(
|
||||
ModuleSourceRoots({"product_commerce": root}),
|
||||
python_executable=sys.executable,
|
||||
base_env={"PATH": "portable"},
|
||||
project_root=tmp_path,
|
||||
data_root=tmp_path / "var",
|
||||
)
|
||||
|
||||
command = adapter.build(_entry(), context=_context())
|
||||
|
||||
assert command.argv == (sys.executable, str(entry.resolve()), "--limit", "25")
|
||||
assert command.cwd == root.resolve()
|
||||
assert command.env["GYXX_PROJECT_ROOT"] == str(tmp_path.resolve())
|
||||
assert command.env["GYXX_DATA_ROOT"] == str((tmp_path / "var").resolve())
|
||||
assert command.env["GYXX_MODULE_ROOT"] == str(root.resolve())
|
||||
assert not any(name.startswith("GYXX_LEGACY_") for name in command.env)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ("../outside.py", "/outside.py", "C:/outside.py"))
|
||||
def test_module_adapter_rejects_paths_outside_module(tmp_path: Path, entry: str) -> None:
|
||||
root = tmp_path / "runtime"
|
||||
root.mkdir()
|
||||
adapter = ModuleCommandAdapter(ModuleSourceRoots({"product_commerce": root}))
|
||||
|
||||
with pytest.raises(ModuleSourceError, match="relative|escape"):
|
||||
adapter.build(_entry(entry=entry), context=_context())
|
||||
|
||||
|
||||
def test_deferred_module_step_dry_run_never_resolves_or_executes() -> None:
|
||||
def fail(*args: object, **kwargs: object) -> object:
|
||||
raise AssertionError("dry-run must not resolve or execute module source")
|
||||
|
||||
execution = DeferredModuleCommandStep(_entry(), command_factory=fail).execute(
|
||||
context=_context(),
|
||||
timeout_seconds=60,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert execution.exit_code == 0
|
||||
assert execution.skipped is True
|
||||
assert execution.reason == "dry-run"
|
||||
|
||||
|
||||
def test_module_adapter_uses_explicit_windows_launchers_for_batch_and_powershell(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runtime"
|
||||
root.mkdir()
|
||||
(root / "daily.bat").write_text("@echo off", encoding="utf-8")
|
||||
(root / "maint.ps1").write_text("Write-Output ok", encoding="utf-8")
|
||||
adapter = ModuleCommandAdapter(
|
||||
ModuleSourceRoots({"product_commerce": root}),
|
||||
base_env={},
|
||||
project_root=tmp_path,
|
||||
data_root=tmp_path / "var",
|
||||
)
|
||||
|
||||
batch = adapter.build(_entry(entry="daily.bat", args=()), context=_context())
|
||||
powershell = adapter.build(
|
||||
_entry(entry="maint.ps1", args=()), context=_context()
|
||||
)
|
||||
|
||||
assert batch.argv[:4] == ("cmd.exe", "/d", "/s", "/c")
|
||||
assert str((root / "daily.bat").resolve()) in batch.argv[4]
|
||||
assert powershell.argv[:5] == (
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
)
|
||||
|
||||
|
||||
def test_product_config_uses_only_the_new_environment_name(tmp_path: Path) -> None:
|
||||
root = tmp_path / "runtime"
|
||||
root.mkdir()
|
||||
(root / "job.py").write_text("pass", encoding="utf-8")
|
||||
adapter = ModuleCommandAdapter(
|
||||
ModuleSourceRoots({"product_commerce": root}),
|
||||
base_env={"GYXX_PRODUCT_CONFIG": "D:/portable/product.json"},
|
||||
project_root=tmp_path,
|
||||
data_root=tmp_path / "var",
|
||||
)
|
||||
|
||||
command = adapter.build(_entry(entry="job.py", args=()), context=_context())
|
||||
|
||||
assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/portable/product.json"
|
||||
assert "AUTO_FLOW_CONFIG" not in command.env
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.cli import EXIT_SUCCESS, build_default_registry, main
|
||||
from gyxx_flow.core.config import Settings
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_SOURCE_ROOT = PROJECT_ROOT / "src" / "gyxx_flow" / "modules"
|
||||
|
||||
|
||||
def test_workflow_catalog_uses_native_execution_schema() -> None:
|
||||
payload = json.loads(
|
||||
(PROJECT_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
assert payload["schema_version"] == 2
|
||||
for workflow in payload["workflows"]:
|
||||
assert "legacy" not in workflow
|
||||
assert set(workflow["execution"]) >= {"entry"}
|
||||
|
||||
|
||||
def test_every_available_workflow_entry_exists_inside_its_new_module() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
missing: list[str] = []
|
||||
|
||||
for workflow in catalog.workflows:
|
||||
if workflow.trigger == "unavailable":
|
||||
continue
|
||||
entry = MODULE_SOURCE_ROOT / workflow.module / "runtime" / workflow.entry
|
||||
if not entry.is_file():
|
||||
missing.append(f"{workflow.workflow_id}: {entry}")
|
||||
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_runtime_catalog_does_not_require_old_project_roots() -> None:
|
||||
source = (PROJECT_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
|
||||
|
||||
assert "GYXX_LEGACY_" not in source
|
||||
assert "D:\\yingxiaoyunying" not in source
|
||||
assert "D:\\shop-data-flow" not in source
|
||||
assert "D:\\product-collector-analyze-flow" not in source
|
||||
assert "E:\\auto-flow" not in source
|
||||
|
||||
|
||||
def test_all_registered_workflows_dry_run_without_old_roots_or_processes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
for name in (
|
||||
"GYXX_LEGACY_CONTENT_ROOT",
|
||||
"GYXX_LEGACY_SHOP_ROOT",
|
||||
"GYXX_LEGACY_PRODUCT_ROOT",
|
||||
"GYXX_LEGACY_SUPPLY_ROOT",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
def fail_process(*args: object, **kwargs: object) -> object:
|
||||
raise AssertionError("dry-run must not start a module script")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_process)
|
||||
settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path)
|
||||
registry = build_default_registry(settings)
|
||||
|
||||
for workflow_id in registry.catalog.workflows:
|
||||
if not registry.is_registered(workflow_id.workflow_id):
|
||||
continue
|
||||
output = io.StringIO()
|
||||
exit_code = main(
|
||||
["run", workflow_id.workflow_id, "--date", "2026-07-27"],
|
||||
registry=registry,
|
||||
settings=settings,
|
||||
stdout=output,
|
||||
)
|
||||
assert exit_code == EXIT_SUCCESS, workflow_id.workflow_id
|
||||
assert json.loads(output.getvalue())["dry_run"] is True
|
||||
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.ops import IdempotencyConflict, Outbox, RunIndex
|
||||
|
||||
|
||||
def _journal(
|
||||
root: Path,
|
||||
*,
|
||||
workflow_id: str = "shop.weekly",
|
||||
business_date: str = "2026-07-27",
|
||||
suffix: str = "abc123",
|
||||
) -> RunJournal:
|
||||
context = RunContext.create(
|
||||
workflow_id,
|
||||
business_date,
|
||||
now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc),
|
||||
random_suffix=suffix,
|
||||
)
|
||||
return RunJournal.create(DataLayout(root), context)
|
||||
|
||||
|
||||
def test_run_index_snapshots_journals_and_supports_structured_queries(tmp_path: Path) -> None:
|
||||
first = _journal(tmp_path, suffix="abc123")
|
||||
second = _journal(
|
||||
tmp_path,
|
||||
workflow_id="product.daily",
|
||||
business_date="2026-07-26",
|
||||
suffix="def456",
|
||||
)
|
||||
first.finalize("success")
|
||||
second.finalize("failed", error="collector failed")
|
||||
index = RunIndex(tmp_path)
|
||||
|
||||
first_record = index.index_journal(first)
|
||||
second_record = index.index_journal(second.path)
|
||||
|
||||
assert first_record.status == "success"
|
||||
assert second_record.error == "collector failed"
|
||||
assert index.get(first_record.run_id) == first_record
|
||||
assert index.query(status="failed") == (second_record,)
|
||||
assert index.query(workflow_id="shop.weekly") == (first_record,)
|
||||
assert index.query(business_date="2026-07-26") == (second_record,)
|
||||
assert index.query(run_id=first_record.run_id) == (first_record,)
|
||||
assert list((tmp_path / "state" / "ops" / "run-index").glob(".*.tmp")) == []
|
||||
|
||||
|
||||
def test_run_index_refreshes_same_run_after_journal_changes(tmp_path: Path) -> None:
|
||||
journal = _journal(tmp_path)
|
||||
index = RunIndex(tmp_path)
|
||||
running = index.index_journal(journal)
|
||||
|
||||
journal.start_step("collect.attempt-1", attempt=1)
|
||||
journal.finish_step("collect.attempt-1", status="success", exit_code=0)
|
||||
journal.finalize("success")
|
||||
complete = index.index_journal(journal)
|
||||
|
||||
assert running.run_id == complete.run_id
|
||||
assert complete.status == "success"
|
||||
assert complete.step_counts == {"success": 1, "failed": 0, "skipped": 0, "running": 0}
|
||||
assert len(tuple((tmp_path / "state" / "ops" / "run-index").glob("*.json"))) == 1
|
||||
|
||||
|
||||
def test_run_index_rejects_malformed_journal_without_writing_index(tmp_path: Path) -> None:
|
||||
malformed = tmp_path / "malformed.json"
|
||||
malformed.write_text('{"run_id": "unsafe/../id"}', encoding="utf-8")
|
||||
index = RunIndex(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="journal"):
|
||||
index.index_journal(malformed)
|
||||
|
||||
assert index.query() == ()
|
||||
|
||||
|
||||
def test_outbox_enqueue_is_atomic_and_idempotent(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
|
||||
first = outbox.enqueue(
|
||||
idempotency_key="run-001:feishu-report",
|
||||
topic="feishu.report",
|
||||
payload={"report_id": "report-001"},
|
||||
run_id="run-001",
|
||||
)
|
||||
duplicate = outbox.enqueue(
|
||||
idempotency_key="run-001:feishu-report",
|
||||
topic="feishu.report",
|
||||
payload={"report_id": "report-001"},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
assert duplicate == first
|
||||
assert first.status == "pending"
|
||||
assert outbox.get(first.message_id) == first
|
||||
assert outbox.list(status="pending") == (first,)
|
||||
assert len(tuple((tmp_path / "state" / "ops" / "outbox" / "messages").glob("*.json"))) == 1
|
||||
assert list((tmp_path / "state" / "ops" / "outbox" / "messages").glob(".*.tmp")) == []
|
||||
|
||||
|
||||
def test_outbox_rejects_reusing_key_for_different_semantics(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
outbox.enqueue(
|
||||
idempotency_key="run-001:db-upsert",
|
||||
topic="db.upsert",
|
||||
payload={"row_id": 1},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
with pytest.raises(IdempotencyConflict, match="idempotency key"):
|
||||
outbox.enqueue(
|
||||
idempotency_key="run-001:db-upsert",
|
||||
topic="db.upsert",
|
||||
payload={"row_id": 2},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
|
||||
def test_outbox_idempotency_compares_normalized_json_semantics(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
first = outbox.enqueue(
|
||||
idempotency_key="run-001:normalized",
|
||||
topic="sink.publish",
|
||||
payload={"row_ids": [1, 2]},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
duplicate = outbox.enqueue(
|
||||
idempotency_key="run-001:normalized",
|
||||
topic="sink.publish",
|
||||
payload={"row_ids": (1, 2)},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
assert duplicate == first
|
||||
|
||||
|
||||
def test_outbox_state_transitions_and_safe_replay(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
pending = outbox.enqueue(
|
||||
idempotency_key="run-001:notify",
|
||||
topic="notification.official",
|
||||
payload={"event": "complete"},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
failed = outbox.mark_failed(pending.message_id, error="temporary outage")
|
||||
replayed = outbox.replay(failed.message_id)
|
||||
sent = outbox.mark_sent(replayed.message_id)
|
||||
|
||||
assert failed.status == "failed"
|
||||
assert failed.attempts == 1
|
||||
assert replayed.status == "pending"
|
||||
assert replayed.message_id == pending.message_id
|
||||
assert replayed.idempotency_key == pending.idempotency_key
|
||||
assert sent.status == "sent"
|
||||
assert outbox.replay(sent.message_id) == sent
|
||||
with pytest.raises(ValueError, match="sent"):
|
||||
outbox.mark_failed(sent.message_id, error="must not regress")
|
||||
|
||||
|
||||
def test_outbox_dispatch_never_redelivers_sent_and_retries_with_same_key(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
pending = outbox.enqueue(
|
||||
idempotency_key="run-001:publish",
|
||||
topic="sink.publish",
|
||||
payload={"artifact_id": "artifact-001"},
|
||||
run_id="run-001",
|
||||
)
|
||||
received_keys: list[str] = []
|
||||
|
||||
def fail_once(message: object) -> None:
|
||||
key = getattr(message, "idempotency_key")
|
||||
received_keys.append(key)
|
||||
if len(received_keys) == 1:
|
||||
raise RuntimeError("temporary")
|
||||
|
||||
failed = outbox.dispatch(pending.message_id, fail_once)
|
||||
assert failed.status == "failed"
|
||||
assert "RuntimeError" in (failed.error or "")
|
||||
|
||||
outbox.replay(failed.message_id)
|
||||
sent = outbox.dispatch(failed.message_id, fail_once)
|
||||
sent_again = outbox.dispatch(sent.message_id, fail_once)
|
||||
|
||||
assert sent.status == "sent"
|
||||
assert sent_again == sent
|
||||
assert received_keys == [pending.idempotency_key, pending.idempotency_key]
|
||||
|
||||
|
||||
def test_outbox_rejects_tampered_state_invariants(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
message = outbox.enqueue(
|
||||
idempotency_key="run-001:tamper",
|
||||
topic="audit.write",
|
||||
payload={},
|
||||
run_id="run-001",
|
||||
)
|
||||
path = outbox.message_path(message.message_id)
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw["status"] = "sent"
|
||||
raw["sent_at"] = None
|
||||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid outbox message"):
|
||||
outbox.get(message.message_id)
|
||||
|
||||
|
||||
def test_outbox_files_are_structured_and_do_not_store_callable_results(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
message = outbox.enqueue(
|
||||
idempotency_key="run-001:audit",
|
||||
topic="audit.write",
|
||||
payload={"value": 1},
|
||||
run_id="run-001",
|
||||
)
|
||||
|
||||
raw = json.loads(outbox.message_path(message.message_id).read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["status"] == "pending"
|
||||
assert raw["payload"] == {"value": 1}
|
||||
assert set(raw) == {
|
||||
"schema_version",
|
||||
"message_id",
|
||||
"idempotency_key",
|
||||
"topic",
|
||||
"payload",
|
||||
"run_id",
|
||||
"status",
|
||||
"attempts",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"sent_at",
|
||||
"error",
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules.product_commerce import (
|
||||
PRODUCT_MANUAL_WORKFLOW_IDS,
|
||||
PRODUCT_RESOURCE,
|
||||
PRODUCT_SCHEDULED_WORKFLOW_IDS,
|
||||
PRODUCT_TIMEOUT_SECONDS,
|
||||
PRODUCT_WORKFLOW_IDS,
|
||||
ProductCommerceModule,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_product_module_registers_all_available_entries() -> None:
|
||||
module = ProductCommerceModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS
|
||||
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 7
|
||||
assert len(PRODUCT_MANUAL_WORKFLOW_IDS) == 3
|
||||
|
||||
|
||||
def test_product_workflows_use_project_owned_module_commands() -> None:
|
||||
module = ProductCommerceModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
for definition in module.workflow_definitions():
|
||||
step = definition.steps[0]
|
||||
assert step.step_id == "module_execute"
|
||||
assert isinstance(step.action, DeferredModuleCommandStep)
|
||||
assert step.timeout_seconds == PRODUCT_TIMEOUT_SECONDS == 6 * 60 * 60
|
||||
assert step.max_attempts == 1
|
||||
assert step.resources == (PRODUCT_RESOURCE,) == ("module:product_commerce",)
|
||||
assert step.production_sink is True
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_ROOT = (
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ "product_commerce"
|
||||
/ "runtime"
|
||||
)
|
||||
|
||||
|
||||
def _load_runtime_paths(monkeypatch: pytest.MonkeyPatch, data_root: Path):
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", str(data_root))
|
||||
monkeypatch.setenv("GYXX_MODULE_ROOT", str(RUNTIME_ROOT))
|
||||
module_path = RUNTIME_ROOT / "runtime_paths.py"
|
||||
name = "_gyxx_product_runtime_paths_boundaries"
|
||||
spec = importlib.util.spec_from_file_location(name, module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
sys.modules.pop(name, None)
|
||||
return module
|
||||
|
||||
|
||||
def test_managed_data_path_rejects_writes_outside_data_home(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
paths = _load_runtime_paths(monkeypatch, tmp_path)
|
||||
|
||||
assert paths.managed_data_path("data/raw/product_commerce/run.json") == (
|
||||
tmp_path / "data" / "raw" / "product_commerce" / "run.json"
|
||||
)
|
||||
assert paths.managed_data_path(paths.TMP_ROOT / "batch.json") == (
|
||||
tmp_path / "tmp" / "product_commerce" / "batch.json"
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside GYXX_DATA_ROOT"):
|
||||
paths.managed_data_path(tmp_path.parent / "escape.json")
|
||||
with pytest.raises(ValueError, match="outside GYXX_DATA_ROOT"):
|
||||
paths.managed_data_path("../escape.json")
|
||||
|
||||
|
||||
def test_artifact_relative_path_is_relative_to_data_home(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
paths = _load_runtime_paths(monkeypatch, tmp_path)
|
||||
|
||||
artifact = paths.RAW_DATA_ROOT / "jd" / "record.json"
|
||||
assert paths.artifact_relative_path(artifact) == (
|
||||
"data/raw/product_commerce/jd/record.json"
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside GYXX_DATA_ROOT"):
|
||||
paths.artifact_relative_path(RUNTIME_ROOT / "record.json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relative", "forbidden"),
|
||||
[
|
||||
("collect_retry_utils.py", 'Path("data/logs")'),
|
||||
("rebuild_market_rank_documents.py", 'PROJECT_ROOT / "data'),
|
||||
("scripts/insert_main_image_records.py", 'PROJECT_ROOT / "_tmp_'),
|
||||
("jd_product_data_collector.py", 'os.path.join(PROJECT_ROOT, "debug"'),
|
||||
("vendors/jd-data-flow/jd_data_collector.py", "os.getcwd()"),
|
||||
(
|
||||
"vendors/jd-data-flow/jd_peer_product_data_collector.py",
|
||||
"os.path.dirname(os.path.abspath(__file__)), \"data\"",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_writers_do_not_fall_back_to_cwd_or_source_tree(
|
||||
relative: str, forbidden: str
|
||||
) -> None:
|
||||
text = (RUNTIME_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
assert forbidden not in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative",
|
||||
[
|
||||
"collect_jd_market_rank.py",
|
||||
"collect_sycm_market_rank.py",
|
||||
"import_product_reviews.py",
|
||||
"scripts/insert_jd_main_image_records.py",
|
||||
"scripts/insert_main_image_records.py",
|
||||
],
|
||||
)
|
||||
def test_runtime_artifact_references_do_not_assume_source_tree_parent(
|
||||
relative: str,
|
||||
) -> None:
|
||||
text = (RUNTIME_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
assert ".relative_to(PROJECT_ROOT)" not in text
|
||||
|
||||
|
||||
def test_checkpoints_and_operational_json_use_state_and_log_roots() -> None:
|
||||
retry_text = (RUNTIME_ROOT / "collect_retry_utils.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
assert "LOG_ROOT / \"failures\"" in retry_text
|
||||
|
||||
for relative in (
|
||||
"collect_dy_market_rank.py",
|
||||
"collect_jd_market_rank.py",
|
||||
"collect_sycm_market_rank.py",
|
||||
):
|
||||
text = (RUNTIME_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
assert "STATE_ROOT" in text
|
||||
assert "CHECKPOINT_PATH = STATE_ROOT" in text
|
||||
|
||||
decline = (RUNTIME_ROOT / "check_nine_day_decline.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
insert = (RUNTIME_ROOT / "insert_bitable_records.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
assert "LOG_PATH = LOG_ROOT" in decline
|
||||
assert "failures_path = LOG_ROOT" in insert
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative",
|
||||
[
|
||||
"aggregate_daily_final.py",
|
||||
"collect_erp_yesterday_metrics.py",
|
||||
"check_nine_day_decline.py",
|
||||
"jd_main_image_collector.py",
|
||||
"jd_self_inventory_sales_collector.py",
|
||||
"jd_product_data_collector.py",
|
||||
"taobao_dmp_item_crowd_insight_screenshots.py",
|
||||
"taobao_wanxiang_ai_creative_report.py",
|
||||
"scripts/insert_jd_main_image_records.py",
|
||||
"scripts/insert_main_image_records.py",
|
||||
"vendors/jd-data-flow/jd_data_collector.py",
|
||||
"vendors/jd-data-flow/jd_product_data_collector.py",
|
||||
],
|
||||
)
|
||||
def test_cli_output_overrides_use_the_data_home_guard(relative: str) -> None:
|
||||
text = (RUNTIME_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
assert "managed_data_path" in text
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.security import scan_repository
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_ROOT = (
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ "product_commerce"
|
||||
/ "runtime"
|
||||
)
|
||||
MANIFEST_PATH = (
|
||||
PROJECT_ROOT / "config" / "source-manifests" / "product_commerce.json"
|
||||
)
|
||||
|
||||
PRODUCTION_PYTHON = {
|
||||
"aggregate_daily_final.py",
|
||||
"analyze_style_with_hermes.py",
|
||||
"backfill_poseidon_sales.py",
|
||||
"backfill_collect.py",
|
||||
"backfill_one_day.py",
|
||||
"check_nine_day_decline.py",
|
||||
"collect_dy_market_rank.py",
|
||||
"collect_dy_persona_to_bitable.py",
|
||||
"collect_erp_yesterday_metrics.py",
|
||||
"collect_jd_market_rank.py",
|
||||
"collect_jd_persona_to_bitable.py",
|
||||
"collect_persona_to_bitable.py",
|
||||
"collect_retry_utils.py",
|
||||
"collect_sycm_market_rank.py",
|
||||
"config/__init__.py",
|
||||
"config/style_config_loader.py",
|
||||
"db/__init__.py",
|
||||
"db/sync_dim_style.py",
|
||||
"db/sync_sku_master.py",
|
||||
"dy_audience_profile_collect.py",
|
||||
"dy_product_scraping.py",
|
||||
"erp_login_product_analysis.py",
|
||||
"erp_metric_overrides.py",
|
||||
"export_bitable_records.py",
|
||||
"feishu_doc_native.py",
|
||||
"import_product_daily.py",
|
||||
"import_product_reviews.py",
|
||||
"insert_bitable_records.py",
|
||||
"jd_main_image_collector.py",
|
||||
"jd_product_data_collector.py",
|
||||
"jd_self_inventory_sales_collector.py",
|
||||
"lark_cli_runtime.py",
|
||||
"main_image_db.py",
|
||||
"main_image_paths.py",
|
||||
"market_rank_hermes_notification.py",
|
||||
"market_rank_report_archive.py",
|
||||
"orchestrate_daily_collection.py",
|
||||
"orchestrate_market_rank_collection.py",
|
||||
"orchestrate_review_collection.py",
|
||||
"reapply_erp_override.py",
|
||||
"rebuild_market_rank_documents.py",
|
||||
"run_alerts_with_retry.py",
|
||||
"run_daily_persona.py",
|
||||
"run_weekly_jd_main_image.py",
|
||||
"run_weekly_main_image.py",
|
||||
"scripts/insert_jd_main_image_records.py",
|
||||
"scripts/insert_main_image_records.py",
|
||||
"taobao_dmp_item_crowd_insight_screenshots.py",
|
||||
"taobao_sycm_collect.py",
|
||||
"taobao_sycm_collect_backfill.py",
|
||||
"taobao_sycm_products.py",
|
||||
"taobao_wanxiang_ai_creative_report.py",
|
||||
"upload_video_to_guanghe.py",
|
||||
"vendors/dy-data-flow/adaptive_selectors.py",
|
||||
"vendors/dy-data-flow/dy_store_competitor_store_scraping.py",
|
||||
"vendors/dy-data-flow/dynamic_session_src.py",
|
||||
"vendors/jd-data-flow/collection_progress.py",
|
||||
"vendors/jd-data-flow/config.py",
|
||||
"vendors/jd-data-flow/jd_data_collector.py",
|
||||
"vendors/jd-data-flow/jd_peer_product_data_collector.py",
|
||||
"vendors/jd-data-flow/jd_product_data_collector.py",
|
||||
"vendors/jd-data-flow/state.py",
|
||||
"weekly_aggregate.py",
|
||||
}
|
||||
|
||||
RESOURCES = {
|
||||
".env.example",
|
||||
"bitable_main_image_map.json",
|
||||
"bitable_style_map.json",
|
||||
"config/auto-flow-config.example.json",
|
||||
"db/schema.sql",
|
||||
"styles_input.json",
|
||||
}
|
||||
|
||||
LEGACY_TESTS = {
|
||||
"test_backfill_poseidon_sales.py",
|
||||
"test_db_config.py",
|
||||
"test_dy_market_rank.py",
|
||||
"test_dy_session_reuse.py",
|
||||
"test_erp_metric_overrides.py",
|
||||
"test_erp_no_data_freshness.py",
|
||||
"test_erp_slow_skip_codes.py",
|
||||
"test_feishu_doc_native.py",
|
||||
"test_guanghe_metadata.py",
|
||||
"test_guanghe_store_routing.py",
|
||||
"test_import_product_daily.py",
|
||||
"test_jd_enter_shangzhi.py",
|
||||
"test_jd_market_rank.py",
|
||||
"test_main_image_concurrency.py",
|
||||
"test_market_rank_hermes_notification.py",
|
||||
"test_market_rank_report_archive.py",
|
||||
"test_market_rank_workflow.py",
|
||||
"test_persona_launcher.py",
|
||||
"test_style_analysis_orchestration.py",
|
||||
"test_style_config_loader_erp_source.py",
|
||||
"test_sycm_market_rank.py",
|
||||
"test_tm_persona_recovery.py",
|
||||
"test_wanxiang_report_template.py",
|
||||
}
|
||||
|
||||
LAUNCHER_REFERENCES = {
|
||||
"run_alerts.bat",
|
||||
"run_daily_collect.bat",
|
||||
"run_daily_import.bat",
|
||||
"run_daily_import.ps1",
|
||||
"run_daily_persona.bat",
|
||||
"run_style_analysis_3d.bat",
|
||||
"run_weekly_jd_main_image.bat",
|
||||
"run_weekly_main_image.bat",
|
||||
"run_weekly_market_rank.bat",
|
||||
}
|
||||
|
||||
INTENTIONALLY_EXCLUDED = {
|
||||
"debug_canvax_dump.py",
|
||||
"debug_canvax_dump2.py",
|
||||
"debug_erp_filter.py",
|
||||
"debug_jd_main_image_snapshot.py",
|
||||
"inspect_dy_comment_filters.py",
|
||||
"jd_collect_test.py",
|
||||
}
|
||||
|
||||
SUBPROCESS_TARGETS = {
|
||||
"aggregate_daily_final.py",
|
||||
"analyze_style_with_hermes.py",
|
||||
"check_nine_day_decline.py",
|
||||
"collect_dy_market_rank.py",
|
||||
"collect_dy_persona_to_bitable.py",
|
||||
"collect_erp_yesterday_metrics.py",
|
||||
"collect_jd_market_rank.py",
|
||||
"collect_jd_persona_to_bitable.py",
|
||||
"collect_persona_to_bitable.py",
|
||||
"collect_sycm_market_rank.py",
|
||||
"dy_audience_profile_collect.py",
|
||||
"dy_product_scraping.py",
|
||||
"export_bitable_records.py",
|
||||
"import_product_reviews.py",
|
||||
"insert_bitable_records.py",
|
||||
"jd_main_image_collector.py",
|
||||
"jd_product_data_collector.py",
|
||||
"orchestrate_daily_collection.py",
|
||||
"scripts/insert_jd_main_image_records.py",
|
||||
"scripts/insert_main_image_records.py",
|
||||
"taobao_dmp_item_crowd_insight_screenshots.py",
|
||||
"taobao_sycm_collect.py",
|
||||
"taobao_sycm_collect_backfill.py",
|
||||
"taobao_sycm_products.py",
|
||||
"taobao_wanxiang_ai_creative_report.py",
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _load_runtime_paths():
|
||||
path = RUNTIME_ROOT / "runtime_paths.py"
|
||||
spec = importlib.util.spec_from_file_location("product_runtime_paths_test", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_product_runtime_contains_the_complete_reviewed_source_snapshot() -> None:
|
||||
assert len(PRODUCTION_PYTHON) == 63
|
||||
assert all((RUNTIME_ROOT / relative).is_file() for relative in PRODUCTION_PYTHON)
|
||||
assert all((RUNTIME_ROOT / relative).is_file() for relative in RESOURCES)
|
||||
assert all(
|
||||
(RUNTIME_ROOT / "runtime_tests" / name).is_file() for name in LEGACY_TESTS
|
||||
)
|
||||
assert all(
|
||||
(RUNTIME_ROOT / "launchers_reference" / name).is_file()
|
||||
for name in LAUNCHER_REFERENCES
|
||||
)
|
||||
|
||||
|
||||
def test_product_source_manifest_is_complete_and_verifiable() -> None:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
assert set(manifest) == {
|
||||
"schema_version",
|
||||
"module",
|
||||
"snapshot",
|
||||
"files",
|
||||
"intentionally_excluded",
|
||||
"transformation_rules",
|
||||
}
|
||||
assert manifest["schema_version"] == 1
|
||||
assert manifest["module"] == "product_commerce"
|
||||
assert manifest["snapshot"] == "current-filesystem"
|
||||
audit = next(
|
||||
rule["metadata"]
|
||||
for rule in manifest["transformation_rules"]
|
||||
if rule["id"] == "migration-audit"
|
||||
)
|
||||
assert audit["source_project"] == "product-collector-analyze-flow"
|
||||
assert audit["snapshot_kind"] == "working_tree"
|
||||
security_review = audit["security_review"]
|
||||
assert security_review["reviewed_source_python_findings"] == 15
|
||||
assert security_review["migrated_findings"] == 14
|
||||
assert security_review["excluded_findings"] == 1
|
||||
assert security_review["target_findings"] == 0
|
||||
assert len(security_review["decisions"]) == 15
|
||||
assert {
|
||||
item["source_relative_path"]
|
||||
for item in manifest["intentionally_excluded"]
|
||||
} == INTENTIONALLY_EXCLUDED
|
||||
|
||||
expected_targets = {
|
||||
*(
|
||||
f"src/gyxx_flow/modules/product_commerce/runtime/{path}"
|
||||
for path in PRODUCTION_PYTHON | RESOURCES
|
||||
),
|
||||
*(
|
||||
f"src/gyxx_flow/modules/product_commerce/runtime/runtime_tests/{name}"
|
||||
for name in LEGACY_TESTS
|
||||
),
|
||||
*(
|
||||
f"src/gyxx_flow/modules/product_commerce/runtime/launchers_reference/{name}"
|
||||
for name in LAUNCHER_REFERENCES
|
||||
),
|
||||
}
|
||||
entries = manifest["files"]
|
||||
assert {item["target_relative_path"] for item in entries} == expected_targets
|
||||
assert len(entries) == len(expected_targets)
|
||||
|
||||
for item in entries:
|
||||
target = PROJECT_ROOT / item["target_relative_path"]
|
||||
assert target.is_file()
|
||||
assert item["category"] in {
|
||||
"production_source",
|
||||
"source_resource",
|
||||
"runtime_resource",
|
||||
"regression_test",
|
||||
"launcher_provenance",
|
||||
}
|
||||
assert isinstance(item["transformed"], bool)
|
||||
assert len(item["source_sha256"]) == 64
|
||||
assert item["target_sha256"] == _sha256(target)
|
||||
if not item["transformed"]:
|
||||
assert item["source_sha256"] == item["target_sha256"]
|
||||
|
||||
generated = audit["generated_files"]
|
||||
assert {item["target_relative_path"] for item in generated} == {
|
||||
"src/gyxx_flow/modules/product_commerce/runtime/runtime_paths.py"
|
||||
}
|
||||
for item in generated:
|
||||
target = PROJECT_ROOT / item["target_relative_path"]
|
||||
assert item["target_sha256"] == _sha256(target)
|
||||
assert manifest["transformation_rules"]
|
||||
|
||||
|
||||
def test_migrated_product_snapshot_has_no_plaintext_credentials_or_old_roots() -> None:
|
||||
assert scan_repository(RUNTIME_ROOT) == []
|
||||
|
||||
forbidden = ("E:\\auto-flow", "D:\\product-collector-analyze-flow")
|
||||
for path in RUNTIME_ROOT.rglob("*"):
|
||||
if not path.is_file() or path.suffix.casefold() in {".pyc", ".pyo"}:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
assert not any(value.casefold() in text.casefold() for value in forbidden), path
|
||||
|
||||
|
||||
def test_product_runtime_paths_are_portable_and_layered(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("GYXX_MODULE_ROOT", str(RUNTIME_ROOT))
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", str(tmp_path))
|
||||
paths = _load_runtime_paths()
|
||||
|
||||
assert paths.MODULE_ROOT == RUNTIME_ROOT.resolve()
|
||||
assert paths.DATA_HOME == tmp_path.resolve()
|
||||
assert paths.RAW_DATA_ROOT == tmp_path / "data" / "raw" / "product_commerce"
|
||||
assert paths.NORMALIZED_DATA_ROOT == (
|
||||
tmp_path / "data" / "normalized" / "product_commerce"
|
||||
)
|
||||
assert paths.CURATED_DATA_ROOT == (
|
||||
tmp_path / "data" / "curated" / "product_commerce"
|
||||
)
|
||||
assert paths.EXPORTS_DATA_ROOT == (
|
||||
tmp_path / "data" / "exports" / "product_commerce"
|
||||
)
|
||||
assert paths.STATE_ROOT == tmp_path / "state" / "product_commerce"
|
||||
assert paths.PROFILE_ROOT == (
|
||||
tmp_path / "state" / "product_commerce" / "browser-profiles"
|
||||
)
|
||||
assert paths.LOG_ROOT == tmp_path / "logs" / "product_commerce"
|
||||
assert paths.TMP_ROOT == tmp_path / "tmp" / "product_commerce"
|
||||
|
||||
assert paths.vendor_source_root("jd") == (
|
||||
RUNTIME_ROOT / "vendors" / "jd-data-flow"
|
||||
).resolve()
|
||||
assert paths.vendor_source_root("dy") == (
|
||||
RUNTIME_ROOT / "vendors" / "dy-data-flow"
|
||||
).resolve()
|
||||
for relative in SUBPROCESS_TARGETS:
|
||||
assert paths.runtime_script(relative).is_file()
|
||||
|
||||
|
||||
def test_product_runtime_has_no_machine_or_legacy_path_escape_hatches() -> None:
|
||||
forbidden = (
|
||||
"D:\\yingxiaoyunying",
|
||||
"D:\\shop-data-flow",
|
||||
"D:\\product-collector-analyze-flow",
|
||||
"E:\\auto-flow",
|
||||
"D:\\jd-data-flow",
|
||||
"E:\\jd-data-flow",
|
||||
"D:\\dy-data-flow",
|
||||
"E:\\dy-data-flow",
|
||||
"JD_DATA_FLOW_ROOT",
|
||||
"DY_DATA_FLOW_ROOT",
|
||||
"AUTO_FLOW_CONFIG",
|
||||
"C:\\ChromeDebug",
|
||||
"C:\\Users\\Administrator",
|
||||
'PROJECT_ROOT / "data"',
|
||||
'ROOT / "data"',
|
||||
'os.path.join(PROJECT_ROOT, "data"',
|
||||
)
|
||||
for path in RUNTIME_ROOT.rglob("*"):
|
||||
if not path.is_file() or path.suffix.casefold() in {".pyc", ".pyo"}:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
assert not any(value.casefold() in text.casefold() for value in forbidden), path
|
||||
|
||||
|
||||
def test_product_runtime_has_no_hardcoded_long_hex_keys() -> None:
|
||||
hardcoded_key = re.compile(
|
||||
r"(?is)(?:api[_-]?key|secret|token).{0,160}?[\"'][0-9a-f]{32,}[\"']"
|
||||
)
|
||||
for path in RUNTIME_ROOT.rglob("*.py"):
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
assert hardcoded_key.search(text) is None, path
|
||||
|
||||
analyzer = (RUNTIME_ROOT / "analyze_style_with_hermes.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
assert 'os.getenv("HERMES_API_KEY")' in analyzer
|
||||
assert "HERMES_API_KEY is required" in analyzer
|
||||
|
||||
|
||||
def test_product_core_runtime_has_no_known_f821_regressions() -> None:
|
||||
erp_tree = ast.parse(
|
||||
(RUNTIME_ROOT / "collect_erp_yesterday_metrics.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
)
|
||||
assert any(
|
||||
isinstance(node, ast.FunctionDef) and node.name == "_ensure_iframe_alive"
|
||||
for node in erp_tree.body
|
||||
)
|
||||
|
||||
class ScopeNames(ast.NodeVisitor):
|
||||
def __init__(self, root: ast.FunctionDef) -> None:
|
||||
self.root = root
|
||||
self.loaded: set[str] = set()
|
||||
self.stored: set[str] = set()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
if node is self.root:
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Name(self, node: ast.Name) -> None:
|
||||
if isinstance(node.ctx, ast.Load):
|
||||
self.loaded.add(node.id)
|
||||
elif isinstance(node.ctx, ast.Store):
|
||||
self.stored.add(node.id)
|
||||
|
||||
jd_paths = (
|
||||
RUNTIME_ROOT / "jd_product_data_collector.py",
|
||||
RUNTIME_ROOT / "vendors" / "jd-data-flow" / "jd_product_data_collector.py",
|
||||
)
|
||||
for path in jd_paths:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
implementations = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "collect_each_spu_detail"
|
||||
]
|
||||
assert implementations
|
||||
for implementation in implementations:
|
||||
names = ScopeNames(implementation)
|
||||
names.visit(implementation)
|
||||
assert "week_range" not in names.loaded or "week_range" in names.stored
|
||||
|
||||
|
||||
def test_dynamic_session_snapshot_is_classified_as_source_resource() -> None:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
item = next(
|
||||
item
|
||||
for item in manifest["files"]
|
||||
if item["source_relative_path"]
|
||||
== "vendors/dy-data-flow/dynamic_session_src.py"
|
||||
)
|
||||
assert item["category"] == "source_resource"
|
||||
assert item["lint_policy"] == "not_a_standalone_module"
|
||||
assert item["reason"]
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run_relocated(
|
||||
relocated: Path, data_root: Path, *args: str
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
for name in tuple(env):
|
||||
if name.startswith("GYXX_LEGACY_"):
|
||||
env.pop(name)
|
||||
env["PYTHONPATH"] = str(relocated / "src")
|
||||
env["GYXX_DATA_ROOT"] = str(data_root)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "gyxx_flow", *args],
|
||||
cwd=relocated,
|
||||
env=env,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def relocated_project(tmp_path: Path) -> Path:
|
||||
relocated = tmp_path / "moved-gyxx-flow"
|
||||
shutil.copytree(PROJECT_ROOT / "src", relocated / "src")
|
||||
shutil.copytree(PROJECT_ROOT / "config", relocated / "config")
|
||||
return relocated
|
||||
|
||||
|
||||
def test_catalog_and_scripts_work_after_project_and_data_roots_move(
|
||||
relocated_project: Path, tmp_path: Path
|
||||
) -> None:
|
||||
data_root = tmp_path / "independent-data"
|
||||
|
||||
workflows = _run_relocated(relocated_project, data_root, "list", "--json")
|
||||
scripts = _run_relocated(
|
||||
relocated_project, data_root, "scripts", "list", "--json"
|
||||
)
|
||||
|
||||
assert workflows.returncode == 0, workflows.stderr
|
||||
assert scripts.returncode == 0, scripts.stderr
|
||||
assert len(json.loads(workflows.stdout)) == 29
|
||||
assert len(json.loads(scripts.stdout)) >= 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_id",
|
||||
(
|
||||
"content.metrics.daily",
|
||||
"product.daily",
|
||||
"shop.metrics.weekly",
|
||||
"supply.purchase_confirmation.daily",
|
||||
),
|
||||
)
|
||||
def test_each_relocated_module_dry_runs_without_old_projects(
|
||||
relocated_project: Path, tmp_path: Path, workflow_id: str
|
||||
) -> None:
|
||||
result = _run_relocated(
|
||||
relocated_project,
|
||||
tmp_path / "independent-data",
|
||||
"run",
|
||||
workflow_id,
|
||||
"--date",
|
||||
"2026-07-27",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters import (
|
||||
BrowserCookieStore,
|
||||
RuntimeIntegrationCatalog,
|
||||
RuntimeIntegrationError,
|
||||
RuntimeServicePolicy,
|
||||
)
|
||||
from gyxx_flow.adapters.bootstrap import _rewrite_browser_arguments
|
||||
from gyxx_flow.adapters.native import ModuleCommandAdapter, ModuleSourceRoots
|
||||
from gyxx_flow.catalog import WorkflowEntry
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.script_catalog import ScriptCatalog, ScriptEntry
|
||||
|
||||
|
||||
def _catalog(tmp_path: Path) -> ScriptCatalog:
|
||||
root = tmp_path / "runtime"
|
||||
root.mkdir()
|
||||
first = root / "first.py"
|
||||
second = root / "nested" / "second.py"
|
||||
second.parent.mkdir()
|
||||
first.write_text("print('first')\n", encoding="utf-8")
|
||||
second.write_text("print('second')\n", encoding="utf-8")
|
||||
return ScriptCatalog(
|
||||
(
|
||||
ScriptEntry("demo:first.py", "demo", "first.py", "python", first),
|
||||
ScriptEntry(
|
||||
"demo:nested/second.py",
|
||||
"demo",
|
||||
"nested/second.py",
|
||||
"python",
|
||||
second,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _write_config(path: Path) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"cdp_host": "127.0.0.1",
|
||||
"scripts": {
|
||||
"demo:first.py": 22001,
|
||||
"demo:nested/second.py": 22002,
|
||||
},
|
||||
"services": {
|
||||
"feishu": "legacy",
|
||||
"postgres": "cloud",
|
||||
"hermes": "local",
|
||||
"hermes_url": "http://127.0.0.1:8642/v1/chat/completions",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_project_registry_covers_every_script_with_unique_stable_port() -> None:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
scripts = ScriptCatalog.discover_default()
|
||||
first = RuntimeIntegrationCatalog.load(
|
||||
project_root / "config" / "runtime-bindings.json",
|
||||
scripts=scripts,
|
||||
data_root=project_root / "var",
|
||||
)
|
||||
second = RuntimeIntegrationCatalog.load(
|
||||
project_root / "config" / "runtime-bindings.json",
|
||||
scripts=scripts,
|
||||
data_root=project_root / "var",
|
||||
)
|
||||
|
||||
assert len(scripts.script_ids) == 131
|
||||
bindings = [first.binding_for(script_id) for script_id in scripts.script_ids]
|
||||
assert len({binding.cdp_port for binding in bindings}) == 131
|
||||
assert all(22000 <= binding.cdp_port <= 22999 for binding in bindings)
|
||||
assert all(binding.cdp_url.startswith("http://127.0.0.1:") for binding in bindings)
|
||||
assert bindings == [second.binding_for(item.script_id) for item in bindings]
|
||||
|
||||
|
||||
def test_bindings_isolate_browser_state_and_relocate_with_data_root(tmp_path: Path) -> None:
|
||||
scripts = _catalog(tmp_path)
|
||||
config = tmp_path / "bindings.json"
|
||||
_write_config(config)
|
||||
data_root = tmp_path / "portable-data"
|
||||
catalog = RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=data_root)
|
||||
|
||||
first = catalog.binding_for("demo:first.py")
|
||||
second = catalog.binding_for("demo:nested/second.py")
|
||||
assert first.profile_dir != second.profile_dir
|
||||
assert first.cookie_file != second.cookie_file
|
||||
assert first.storage_state_file != second.storage_state_file
|
||||
for path in (
|
||||
first.profile_dir,
|
||||
first.cookie_file,
|
||||
first.storage_state_file,
|
||||
second.profile_dir,
|
||||
):
|
||||
assert path.is_relative_to(data_root.resolve())
|
||||
assert not first.profile_dir.exists()
|
||||
|
||||
|
||||
def test_parent_and_nested_child_receive_different_runtime_environment(tmp_path: Path) -> None:
|
||||
scripts = _catalog(tmp_path)
|
||||
config = tmp_path / "bindings.json"
|
||||
_write_config(config)
|
||||
catalog = RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "data")
|
||||
|
||||
parent = catalog.environment_for("demo:first.py", {"KEEP": "yes"})
|
||||
child = catalog.environment_for("demo:nested/second.py", parent)
|
||||
|
||||
assert child["KEEP"] == "yes"
|
||||
assert parent["GYXX_SCRIPT_ID"] == "demo:first.py"
|
||||
assert child["GYXX_SCRIPT_ID"] == "demo:nested/second.py"
|
||||
assert parent["GYXX_BROWSER_CDP_PORT"] != child["GYXX_BROWSER_CDP_PORT"]
|
||||
assert parent["GYXX_BROWSER_PROFILE_DIR"] != child["GYXX_BROWSER_PROFILE_DIR"]
|
||||
assert parent["GYXX_BROWSER_COOKIE_FILE"] != child["GYXX_BROWSER_COOKIE_FILE"]
|
||||
|
||||
|
||||
def test_module_command_adapter_injects_entry_script_binding(tmp_path: Path) -> None:
|
||||
scripts = _catalog(tmp_path)
|
||||
config = tmp_path / "bindings.json"
|
||||
_write_config(config)
|
||||
catalog = RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "data")
|
||||
runtime_root = tmp_path / "runtime"
|
||||
adapter = ModuleCommandAdapter(
|
||||
ModuleSourceRoots({"demo": runtime_root}),
|
||||
base_env={"KEEP": "yes"},
|
||||
project_root=tmp_path,
|
||||
data_root=tmp_path / "data",
|
||||
integration_catalog=catalog,
|
||||
)
|
||||
entry = WorkflowEntry("demo.workflow", "demo", "manual", "first.py")
|
||||
context = RunContext.create("demo.workflow", "2026-07-27")
|
||||
|
||||
command = adapter.build(entry, context=context)
|
||||
|
||||
assert command.env["KEEP"] == "yes"
|
||||
assert command.env["GYXX_SCRIPT_ID"] == "demo:first.py"
|
||||
assert command.env["GYXX_BROWSER_CDP_PORT"] == "22001"
|
||||
|
||||
|
||||
def test_cookie_store_round_trip_and_storage_state_are_atomic(tmp_path: Path) -> None:
|
||||
store = BrowserCookieStore(
|
||||
cookie_file=tmp_path / "cookies" / "cookies.json",
|
||||
storage_state_file=tmp_path / "cookies" / "storage_state.json",
|
||||
)
|
||||
cookies = [{"name": "session", "value": "secret-sentinel", "domain": ".example.test", "path": "/"}]
|
||||
state = {"cookies": cookies, "origins": []}
|
||||
|
||||
assert store.load_cookies() == []
|
||||
assert store.load_storage_state() is None
|
||||
store.save_cookies(cookies)
|
||||
store.save_storage_state(state)
|
||||
|
||||
assert store.load_cookies() == cookies
|
||||
assert store.load_storage_state() == state
|
||||
assert not list((tmp_path / "cookies").glob("*.tmp"))
|
||||
assert "secret-sentinel" not in repr(store)
|
||||
|
||||
|
||||
def test_service_policy_preserves_original_feishu_and_cloud_db_and_local_hermes() -> None:
|
||||
policy = RuntimeServicePolicy(
|
||||
hermes_url="http://127.0.0.1:8642/v1/chat/completions"
|
||||
)
|
||||
original = {
|
||||
"LARK_PROFILE": "original-profile",
|
||||
"PG_HOST": "cloud-db.example.test",
|
||||
"PG_PASSWORD": "placeholder",
|
||||
"DB_HOST": "",
|
||||
"CUSTOM": "keep",
|
||||
}
|
||||
|
||||
result = policy.apply(original)
|
||||
|
||||
assert result["LARK_PROFILE"] == "original-profile"
|
||||
assert result["PG_HOST"] == "cloud-db.example.test"
|
||||
assert result["PG_PASSWORD"] == "placeholder"
|
||||
assert result["DB_HOST"] == "cloud-db.example.test"
|
||||
assert result["AUTOFLOW_PG_HOST"] == "cloud-db.example.test"
|
||||
assert result["GYXX_FEISHU_MODE"] == "legacy"
|
||||
assert result["GYXX_POSTGRES_MODE"] == "cloud"
|
||||
assert result["GYXX_HERMES_MODE"] == "local"
|
||||
assert result["HERMES_ANALYZER_URL"].startswith("http://127.0.0.1:")
|
||||
assert "secret-sentinel" not in repr(policy)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment", "message"),
|
||||
[
|
||||
({"PG_HOST": "127.0.0.1"}, "cloud"),
|
||||
({"DB_HOST": "localhost"}, "cloud"),
|
||||
({"AUTOFLOW_PG_HOST": "::1"}, "cloud"),
|
||||
({"HERMES_ANALYZER_URL": "https://remote.example.test/v1"}, "local"),
|
||||
({"ANALYZER_API_SERVER_URL": "http://10.0.0.8:8642/v1"}, "local"),
|
||||
],
|
||||
)
|
||||
def test_service_policy_rejects_local_database_or_remote_hermes(
|
||||
environment: dict[str, str], message: str
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeIntegrationError, match=message):
|
||||
RuntimeServicePolicy().apply(environment)
|
||||
|
||||
|
||||
def test_registry_rejects_missing_duplicate_or_unknown_script_allocations(tmp_path: Path) -> None:
|
||||
scripts = _catalog(tmp_path)
|
||||
config = tmp_path / "bindings.json"
|
||||
_write_config(config)
|
||||
payload = json.loads(config.read_text(encoding="utf-8"))
|
||||
payload["scripts"]["demo:nested/second.py"] = 22001
|
||||
config.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(RuntimeIntegrationError, match="unique"):
|
||||
RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "data")
|
||||
|
||||
payload["scripts"].pop("demo:nested/second.py")
|
||||
payload["scripts"]["demo:unknown.py"] = 22003
|
||||
config.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(RuntimeIntegrationError, match="coverage"):
|
||||
RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "data")
|
||||
|
||||
|
||||
def test_nested_script_browser_arguments_are_rebound(monkeypatch, tmp_path: Path) -> None:
|
||||
scripts = _catalog(tmp_path)
|
||||
config = tmp_path / "bindings.json"
|
||||
_write_config(config)
|
||||
binding = RuntimeIntegrationCatalog.load(
|
||||
config, scripts=scripts, data_root=tmp_path / "data"
|
||||
).binding_for("demo:nested/second.py")
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
[
|
||||
"second.py",
|
||||
"--user-data-dir",
|
||||
"parent-profile",
|
||||
"--cdp-url=http://127.0.0.1:22001",
|
||||
"--cdp-port",
|
||||
"22001",
|
||||
],
|
||||
)
|
||||
|
||||
_rewrite_browser_arguments(binding)
|
||||
|
||||
assert sys.argv[2] == str(binding.profile_dir)
|
||||
assert sys.argv[3] == f"--cdp-url={binding.cdp_url}"
|
||||
assert sys.argv[5] == str(binding.cdp_port)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_ROOT = PROJECT_ROOT / "src" / "gyxx_flow" / "modules"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "forbidden"),
|
||||
[
|
||||
("content_marketing", 'Path("data") / "v2_results"'),
|
||||
("product_commerce", 'Path("data/logs")'),
|
||||
("product_commerce", 'SCRIPT_DIR / "data"'),
|
||||
("product_commerce", 'PROJECT_ROOT / "debug"'),
|
||||
("product_commerce", "relative_to(PROJECT_ROOT)"),
|
||||
("shop_intelligence", 'os.path.join(os.getcwd(), "debug"'),
|
||||
("supply_chain", 'PACKAGE_ROOT / "shared-data"'),
|
||||
("supply_chain", "~/Downloads/停产产品.xlsx"),
|
||||
],
|
||||
)
|
||||
def test_migrated_runtimes_have_no_known_mutable_output_bypasses(
|
||||
module: str, forbidden: str
|
||||
) -> None:
|
||||
offenders = []
|
||||
for path in (MODULE_ROOT / module / "runtime").rglob("*.py"):
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if forbidden in source:
|
||||
offenders.append(path.relative_to(PROJECT_ROOT).as_posix())
|
||||
|
||||
assert offenders == [], f"{forbidden!r} bypasses the canonical data boundary"
|
||||
|
||||
|
||||
def test_runtime_path_modules_do_not_use_source_local_data_fallbacks() -> None:
|
||||
path_files = [
|
||||
MODULE_ROOT / "content_marketing" / "runtime" / "runtime_paths.py",
|
||||
MODULE_ROOT / "product_commerce" / "runtime" / "runtime_paths.py",
|
||||
MODULE_ROOT / "shop_intelligence" / "runtime" / "paths.py",
|
||||
MODULE_ROOT / "supply_chain" / "runtime" / "paths.py",
|
||||
]
|
||||
forbidden = ('MODULE_ROOT / ".runtime-data"', 'module_root / "var"', 'PROJECT_ROOT / "data"')
|
||||
|
||||
for path in path_files:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
assert not any(pattern in source for pattern in forbidden), path
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.scheduler import (
|
||||
CurrentScheduledTask,
|
||||
PowerShellCurrentTaskProvider,
|
||||
SchedulerConfig,
|
||||
build_schedule_plan,
|
||||
detect_schedule_drift,
|
||||
write_schedule_plan_bundle,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
NS = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
|
||||
|
||||
def test_windows_provider_reads_managed_tasks_without_mutation(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=json.dumps(
|
||||
[{"full_name": r"\GYXX\product.daily", "xml": "<Task />"}]
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
tasks = PowerShellCurrentTaskProvider().current_tasks("\\GYXX\\")
|
||||
|
||||
assert tasks == [CurrentScheduledTask(r"\GYXX\product.daily", "<Task />")]
|
||||
assert captured["command"][0] == "powershell.exe"
|
||||
assert "Register-ScheduledTask" not in captured["command"][-1]
|
||||
assert captured["kwargs"]["shell"] is False
|
||||
|
||||
|
||||
def _plan(tmp_path: Path):
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
return build_schedule_plan(
|
||||
catalog,
|
||||
SchedulerConfig(
|
||||
python_executable=Path(r"C:\Portable\Python312\python.exe"),
|
||||
project_root=tmp_path / "portable-project",
|
||||
),
|
||||
start_date=date(2026, 7, 27),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_generates_21_inert_portable_windows_task_definitions(tmp_path: Path) -> None:
|
||||
plan = _plan(tmp_path)
|
||||
|
||||
assert plan.timezone == "Asia/Shanghai"
|
||||
assert len(plan.tasks) == 21
|
||||
assert len({task.full_name for task in plan.tasks}) == 21
|
||||
assert all(task.full_name.startswith("\\GYXX\\") for task in plan.tasks)
|
||||
assert all("Register-ScheduledTask" not in task.xml for task in plan.tasks)
|
||||
|
||||
sample = next(task for task in plan.tasks if task.workflow_id == "product.daily")
|
||||
root = ET.fromstring(sample.xml)
|
||||
assert root.findtext(".//t:Exec/t:Command", namespaces=NS) == str(
|
||||
plan.config.python_executable
|
||||
)
|
||||
assert root.findtext(".//t:Exec/t:Arguments", namespaces=NS) == (
|
||||
"-m gyxx_flow run product.daily --scheduled"
|
||||
)
|
||||
assert root.findtext(".//t:Exec/t:WorkingDirectory", namespaces=NS) == str(
|
||||
plan.config.project_root
|
||||
)
|
||||
assert root.findtext(".//t:MultipleInstancesPolicy", namespaces=NS) == "IgnoreNew"
|
||||
|
||||
|
||||
def test_plan_preserves_daily_weekly_monthly_and_interval_semantics(tmp_path: Path) -> None:
|
||||
plan = _plan(tmp_path)
|
||||
by_id = {task.workflow_id: ET.fromstring(task.xml) for task in plan.tasks}
|
||||
|
||||
assert by_id["product.daily"].findtext(
|
||||
".//t:ScheduleByDay/t:DaysInterval", namespaces=NS
|
||||
) == "1"
|
||||
weekly = by_id["content.comments.weekly"]
|
||||
assert weekly.find(".//t:ScheduleByWeek/t:DaysOfWeek/t:Sunday", NS) is not None
|
||||
monthly = by_id["content.summary.monthly"]
|
||||
assert monthly.findtext(
|
||||
".//t:ScheduleByMonth/t:DaysOfMonth/t:Day", namespaces=NS
|
||||
) == "1"
|
||||
interval = by_id["product.style_analysis.interval"]
|
||||
assert interval.findtext(
|
||||
".//t:ScheduleByDay/t:DaysInterval", namespaces=NS
|
||||
) == "3"
|
||||
assert interval.findtext(".//t:StartBoundary", namespaces=NS).startswith(
|
||||
"2026-07-25T11:00:00"
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_write_is_a_plan_only_and_never_applies_tasks(tmp_path: Path) -> None:
|
||||
plan = _plan(tmp_path)
|
||||
destination = tmp_path / "schedule-plan"
|
||||
|
||||
written = write_schedule_plan_bundle(destination, plan)
|
||||
|
||||
assert len(list((destination / "tasks").glob("*.xml"))) == 21
|
||||
assert written == destination.resolve()
|
||||
payload = json.loads((destination / "plan.json").read_text(encoding="utf-8"))
|
||||
assert payload["apply_required"] is True
|
||||
assert payload["applied"] is False
|
||||
install = (destination / "install.ps1").read_text(encoding="utf-8")
|
||||
assert "Register-ScheduledTask" in install
|
||||
assert "China Standard Time" in install
|
||||
assert "-WhatIf" in install
|
||||
assert "[string]$WorkflowId" in install
|
||||
assert "Apply requires exactly one safe -WorkflowId" in install
|
||||
assert "ForEach-Object" not in install
|
||||
|
||||
|
||||
def test_drift_reports_missing_changed_and_managed_extra_without_values(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plan = _plan(tmp_path)
|
||||
first, second, *rest = plan.tasks
|
||||
changed_xml = second.xml.replace("--scheduled", "--changed")
|
||||
current = [
|
||||
CurrentScheduledTask(first.full_name, first.xml),
|
||||
CurrentScheduledTask(second.full_name, changed_xml),
|
||||
*[CurrentScheduledTask(task.full_name, task.xml) for task in rest],
|
||||
CurrentScheduledTask(r"\GYXX\obsolete.workflow", first.xml),
|
||||
CurrentScheduledTask(r"\Unrelated\keep", "secret-value-must-not-appear"),
|
||||
]
|
||||
current = [item for item in current if item.full_name != rest[-1].full_name]
|
||||
|
||||
report = detect_schedule_drift(plan, current)
|
||||
|
||||
assert report.is_clean is False
|
||||
assert [(item.full_name, item.status) for item in report.items] == [
|
||||
(second.full_name, "changed"),
|
||||
(rest[-1].full_name, "missing"),
|
||||
(r"\GYXX\obsolete.workflow", "extra"),
|
||||
]
|
||||
assert "secret-value-must-not-appear" not in json.dumps(report.as_dict())
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.cli import main
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.scheduler import CurrentScheduledTask
|
||||
from gyxx_flow.workflow import WorkflowDefinition
|
||||
from gyxx_flow.workflow.registry import WorkflowRegistry
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FakeCurrentTaskProvider:
|
||||
def __init__(self, tasks: list[CurrentScheduledTask]) -> None:
|
||||
self.tasks = tasks
|
||||
self.requested_path: str | None = None
|
||||
|
||||
def current_tasks(self, task_path: str) -> list[CurrentScheduledTask]:
|
||||
self.requested_path = task_path
|
||||
return self.tasks
|
||||
|
||||
|
||||
def test_schedule_plan_cli_writes_inert_bundle_and_drift_report(tmp_path: Path) -> None:
|
||||
provider = FakeCurrentTaskProvider([])
|
||||
output = io.StringIO()
|
||||
destination = tmp_path / "generated-plan"
|
||||
settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path / "data")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"schedule",
|
||||
"plan",
|
||||
"--output",
|
||||
str(destination),
|
||||
"--start-date",
|
||||
"2026-07-27",
|
||||
"--python-executable",
|
||||
str(PROJECT_ROOT / ".venv" / "Scripts" / "python.exe"),
|
||||
],
|
||||
settings=settings,
|
||||
current_task_provider=provider,
|
||||
stdout=output,
|
||||
stderr=io.StringIO(),
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
payload = json.loads(output.getvalue())
|
||||
assert payload["desired_count"] == 21
|
||||
assert payload["applied"] is False
|
||||
assert payload["drift_count"] == 21
|
||||
assert provider.requested_path == "\\GYXX\\"
|
||||
assert (destination / "install.ps1").is_file()
|
||||
assert (destination / "drift.json").is_file()
|
||||
assert json.loads((destination / "plan.json").read_text(encoding="utf-8"))[
|
||||
"applied"
|
||||
] is False
|
||||
|
||||
|
||||
def test_scheduled_run_uses_shanghai_business_date_and_executes_registered_workflow(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
registry = WorkflowRegistry(catalog)
|
||||
registry.register(WorkflowDefinition("product.daily", ()))
|
||||
monkeypatch.setattr("gyxx_flow.cli._today_shanghai", lambda: date(2026, 7, 27))
|
||||
output = io.StringIO()
|
||||
|
||||
exit_code = main(
|
||||
["run", "product.daily", "--scheduled"],
|
||||
registry=registry,
|
||||
settings=Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
|
||||
stdout=output,
|
||||
stderr=io.StringIO(),
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
payload = json.loads(output.getvalue())
|
||||
assert payload["business_date"] == "2026-07-27"
|
||||
assert payload["dry_run"] is False
|
||||
assert payload["status"] == "success"
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.cli import EXIT_SUCCESS, main
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.script_catalog import ScriptCatalog, ScriptCatalogError
|
||||
|
||||
|
||||
def test_script_catalog_discovers_python_main_and_launchers(tmp_path: Path) -> None:
|
||||
runtime = tmp_path / "content_marketing" / "runtime"
|
||||
(runtime / "tools").mkdir(parents=True)
|
||||
(runtime / "tools" / "run_job.py").write_text(
|
||||
'if __name__ == "__main__":\n raise SystemExit(0)\n', encoding="utf-8"
|
||||
)
|
||||
(runtime / "library.py").write_text("def helper():\n return 1\n", encoding="utf-8")
|
||||
(runtime / "daily.bat").write_text("@echo off\n", encoding="utf-8")
|
||||
(runtime / "maint.ps1").write_text("Write-Output ok\n", encoding="utf-8")
|
||||
(runtime / "runtime_tests").mkdir()
|
||||
(runtime / "runtime_tests" / "test_old.py").write_text(
|
||||
'if __name__ == "__main__":\n pass\n', encoding="utf-8"
|
||||
)
|
||||
|
||||
catalog = ScriptCatalog.discover({"content_marketing": runtime})
|
||||
|
||||
assert catalog.script_ids == (
|
||||
"content_marketing:daily.bat",
|
||||
"content_marketing:maint.ps1",
|
||||
"content_marketing:tools/run_job.py",
|
||||
)
|
||||
assert catalog.get("content_marketing:tools/run_job.py").entry == "tools/run_job.py"
|
||||
|
||||
|
||||
def test_default_script_catalog_discovers_every_project_owned_runtime() -> None:
|
||||
catalog = ScriptCatalog.discover_default()
|
||||
|
||||
modules = {item.module for item in catalog.scripts}
|
||||
assert modules == {
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
"shop_intelligence",
|
||||
"supply_chain",
|
||||
}
|
||||
assert len(catalog.scripts) > 21
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_id", ("missing", "content_marketing:../run.py"))
|
||||
def test_script_catalog_rejects_unknown_or_unsafe_ids(
|
||||
tmp_path: Path, script_id: str
|
||||
) -> None:
|
||||
runtime = tmp_path / "content_marketing" / "runtime"
|
||||
runtime.mkdir(parents=True)
|
||||
catalog = ScriptCatalog.discover({"content_marketing": runtime})
|
||||
|
||||
with pytest.raises(ScriptCatalogError):
|
||||
catalog.get(script_id)
|
||||
|
||||
|
||||
def test_cli_lists_and_dry_runs_project_owned_scripts(tmp_path: Path) -> None:
|
||||
output = io.StringIO()
|
||||
settings = Settings(project_root=Path(__file__).parents[1], data_root=tmp_path)
|
||||
|
||||
assert main(["scripts", "list", "--json"], settings=settings, stdout=output) == 0
|
||||
rows = json.loads(output.getvalue())
|
||||
assert len(rows) > 21
|
||||
assert {row["module"] for row in rows} == {
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
"shop_intelligence",
|
||||
"supply_chain",
|
||||
}
|
||||
|
||||
output = io.StringIO()
|
||||
exit_code = main(
|
||||
[
|
||||
"scripts",
|
||||
"run",
|
||||
"content_marketing:run_all.py",
|
||||
"--date",
|
||||
"2026-07-27",
|
||||
],
|
||||
settings=settings,
|
||||
stdout=output,
|
||||
)
|
||||
payload = json.loads(output.getvalue())
|
||||
assert exit_code == EXIT_SUCCESS
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["script_id"] == "content_marketing:run_all.py"
|
||||
assert payload["steps"] == {"module_script": "skipped"}
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_scan_repository_reports_only_location_and_rule(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
first_value = "not-a-real-" + "password-7f31"
|
||||
second_value = "not-a-real-" + "url-secret-91ac"
|
||||
private_key_header = "-----BEGIN " + "PRIVATE KEY-----"
|
||||
source = tmp_path / "src" / "settings.py"
|
||||
source.parent.mkdir()
|
||||
source.write_text(
|
||||
"DEBUG = True\n"
|
||||
f'DATABASE_PASSWORD = "{first_value}"\n'
|
||||
f'endpoint = "https://service-user:{second_value}@example.invalid/api"\n'
|
||||
f'key = "{private_key_header}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
findings = scan_repository(tmp_path)
|
||||
|
||||
assert [asdict(finding) for finding in findings] == [
|
||||
{"file": "src/settings.py", "line": 2, "rule": "plaintext-credential"},
|
||||
{"file": "src/settings.py", "line": 3, "rule": "credential-in-url"},
|
||||
{"file": "src/settings.py", "line": 4, "rule": "private-key-material"},
|
||||
]
|
||||
rendered = repr(findings)
|
||||
assert first_value not in rendered
|
||||
assert second_value not in rendered
|
||||
assert set(asdict(findings[0])) == {"file", "line", "rule"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("directory", ["var", ".git", ".venv", ".learnings"])
|
||||
def test_scan_repository_excludes_runtime_and_tool_directories(tmp_path, directory):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
value = "not-a-real-" + "excluded-secret-18d2"
|
||||
source = tmp_path / directory / "nested" / "settings.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(f'API_KEY = "{value}"\n', encoding="utf-8")
|
||||
|
||||
assert scan_repository(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_repository_ignores_empty_and_externalized_values(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
password_name = "DATABASE_" + "PASSWORD"
|
||||
api_key_name = "API_" + "KEY"
|
||||
client_secret_name = "CLIENT_" + "SECRET"
|
||||
access_token_name = "ACCESS_" + "TOKEN"
|
||||
source = tmp_path / "config" / "settings.env"
|
||||
source.parent.mkdir()
|
||||
source.write_text(
|
||||
f"{password_name}=\n"
|
||||
f"{api_key_name}=${{GYXX_API_KEY}}\n"
|
||||
f"{client_secret_name}=$GYXX_CLIENT_SECRET\n"
|
||||
f"{access_token_name}=os.getenv('GYXX_ACCESS_TOKEN')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert scan_repository(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_repository_detects_common_application_secret_names(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
app_secret_name = "FEISHU_APP_" + "SECRET"
|
||||
secret_key_name = "DJANGO_" + "SECRET_KEY"
|
||||
first_value = "not-a-real-" + "application-secret-20f9"
|
||||
second_value = "not-a-real-" + "signing-secret-52bb"
|
||||
source = tmp_path / "config" / "application.env"
|
||||
source.parent.mkdir()
|
||||
source.write_text(
|
||||
f"{app_secret_name}={first_value}\n"
|
||||
f"{secret_key_name}={second_value}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert [asdict(finding) for finding in scan_repository(tmp_path)] == [
|
||||
{"file": "config/application.env", "line": 1, "rule": "plaintext-credential"},
|
||||
{"file": "config/application.env", "line": 2, "rule": "plaintext-credential"},
|
||||
]
|
||||
|
||||
|
||||
def test_scan_repository_ignores_declarations_references_and_comparisons(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
password_name = "DATABASE_" + "PASSWORD"
|
||||
api_key_name = "API_" + "KEY"
|
||||
client_secret_name = "CLIENT_" + "SECRET"
|
||||
source = tmp_path / "src" / "model.py"
|
||||
source.parent.mkdir()
|
||||
source.write_text(
|
||||
f"{password_name}: str\n"
|
||||
f"{api_key_name}: SecretStr\n"
|
||||
f"{client_secret_name} = settings.client_secret\n"
|
||||
f"if {password_name} == candidate:\n pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert scan_repository(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_repository_rejects_a_non_directory_root(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
target = tmp_path / "settings.py"
|
||||
target.write_text("DEBUG = True\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
scan_repository(target)
|
||||
|
||||
|
||||
def test_scan_repository_detects_multiline_environment_secret_default(tmp_path):
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
source = tmp_path / "collector.py"
|
||||
source.write_text(
|
||||
'API_KEY = os.getenv(\n "SERVICE_API_KEY",\n'
|
||||
' "0123456789abcdef0123456789abcdef",\n)\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
findings = scan_repository(tmp_path)
|
||||
|
||||
assert [(item.line, item.rule) for item in findings] == [
|
||||
(1, "hardcoded-long-hex-credential")
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.migration.shadow import (
|
||||
ComparisonSpec,
|
||||
MetricTolerance,
|
||||
compare_structured_files,
|
||||
write_comparison_report,
|
||||
)
|
||||
|
||||
FIXED_TIME = datetime(2026, 7, 27, 8, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
path.write_text(
|
||||
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_csv(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def test_compare_jsonl_to_csv_reports_key_metric_and_error_differences_without_rows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
baseline = tmp_path / "baseline.jsonl"
|
||||
candidate = tmp_path / "candidate.csv"
|
||||
_write_jsonl(
|
||||
baseline,
|
||||
[
|
||||
{
|
||||
"shop_id": "shop-secret-1",
|
||||
"sku": "sku-secret-a",
|
||||
"category": "tops",
|
||||
"revenue": 100.0,
|
||||
"errors": ["upstream token exposed"],
|
||||
"customer_email": "private@example.test",
|
||||
},
|
||||
{
|
||||
"shop_id": "shop-secret-1",
|
||||
"sku": "sku-secret-b",
|
||||
"category": "shoes",
|
||||
"revenue": 20.0,
|
||||
"errors": [],
|
||||
"customer_email": "hidden@example.test",
|
||||
},
|
||||
],
|
||||
)
|
||||
_write_csv(
|
||||
candidate,
|
||||
[
|
||||
{
|
||||
"shop_id": "shop-secret-1",
|
||||
"sku": "sku-secret-a",
|
||||
"category": "tops",
|
||||
"revenue": "101.5",
|
||||
"errors": "replacement failure",
|
||||
"customer_email": "private@example.test",
|
||||
},
|
||||
{
|
||||
"shop_id": "shop-secret-2",
|
||||
"sku": "sku-secret-c",
|
||||
"category": "accessories",
|
||||
"revenue": "9.0",
|
||||
"errors": "",
|
||||
"customer_email": "do-not-report@example.test",
|
||||
},
|
||||
],
|
||||
)
|
||||
spec = ComparisonSpec(
|
||||
primary_key=("shop_id", "sku"),
|
||||
metrics={"revenue": MetricTolerance(absolute=1.0, relative=0.0)},
|
||||
error_fields=("errors",),
|
||||
safe_fields=("category",),
|
||||
hash_secret="test-only-hmac-key",
|
||||
)
|
||||
|
||||
report = compare_structured_files(
|
||||
baseline, candidate, spec=spec, generated_at=FIXED_TIME
|
||||
)
|
||||
payload = report.to_dict()
|
||||
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["status"] == "mismatch"
|
||||
assert payload["generated_at"] == "2026-07-27T08:30:00+00:00"
|
||||
assert payload["sources"] == {
|
||||
"baseline_format": "jsonl",
|
||||
"candidate_format": "csv",
|
||||
}
|
||||
assert payload["summary"] == {
|
||||
"baseline_rows": 2,
|
||||
"candidate_rows": 2,
|
||||
"common_keys": 1,
|
||||
"missing_keys": 1,
|
||||
"extra_keys": 1,
|
||||
"metric_mismatches": 1,
|
||||
"error_set_differences": 2,
|
||||
}
|
||||
assert payload["keys"]["missing"][0]["safe_fields"] == {"category": "shoes"}
|
||||
assert payload["keys"]["extra"][0]["safe_fields"] == {
|
||||
"category": "accessories"
|
||||
}
|
||||
assert len(payload["keys"]["missing"][0]["key_hash"]) == 64
|
||||
assert payload["metrics"]["revenue"]["mismatch_count"] == 1
|
||||
assert payload["metrics"]["revenue"]["max_absolute_delta"] == 1.5
|
||||
assert len(payload["errors"]["only_baseline_hashes"][0]) == 64
|
||||
assert len(payload["errors"]["only_candidate_hashes"][0]) == 64
|
||||
|
||||
rendered = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
for sensitive in (
|
||||
"shop-secret-1",
|
||||
"shop-secret-2",
|
||||
"sku-secret-a",
|
||||
"sku-secret-b",
|
||||
"sku-secret-c",
|
||||
"upstream token exposed",
|
||||
"replacement failure",
|
||||
"private@example.test",
|
||||
"hidden@example.test",
|
||||
"do-not-report@example.test",
|
||||
"test-only-hmac-key",
|
||||
):
|
||||
assert sensitive not in rendered
|
||||
|
||||
|
||||
def test_metric_values_within_absolute_or_relative_tolerance_match(tmp_path: Path) -> None:
|
||||
baseline = tmp_path / "baseline.json"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"records": [
|
||||
{"id": "one", "amount": 100.0},
|
||||
{"id": "two", "amount": 0.0},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_jsonl(
|
||||
candidate,
|
||||
[
|
||||
{"id": "one", "amount": 100.5},
|
||||
{"id": "two", "amount": 0.01},
|
||||
],
|
||||
)
|
||||
spec = ComparisonSpec(
|
||||
primary_key=("id",),
|
||||
metrics={
|
||||
"amount": MetricTolerance(absolute=0.01, relative=0.01),
|
||||
},
|
||||
)
|
||||
|
||||
payload = compare_structured_files(baseline, candidate, spec=spec).to_dict()
|
||||
|
||||
assert payload["status"] == "match"
|
||||
assert payload["metrics"]["amount"]["mismatch_count"] == 0
|
||||
assert payload["summary"]["metric_mismatches"] == 0
|
||||
|
||||
|
||||
def test_comparison_rejects_duplicate_or_missing_primary_keys(tmp_path: Path) -> None:
|
||||
baseline = tmp_path / "baseline.jsonl"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
_write_jsonl(baseline, [{"id": "same"}, {"id": "same"}])
|
||||
_write_jsonl(candidate, [{"id": "same"}])
|
||||
|
||||
with pytest.raises(ValueError, match="duplicate primary key"):
|
||||
compare_structured_files(
|
||||
baseline, candidate, spec=ComparisonSpec(primary_key=("id",))
|
||||
)
|
||||
|
||||
_write_jsonl(baseline, [{"name": "missing"}])
|
||||
with pytest.raises(ValueError, match="missing primary key field"):
|
||||
compare_structured_files(
|
||||
baseline, candidate, spec=ComparisonSpec(primary_key=("id",))
|
||||
)
|
||||
|
||||
|
||||
def test_comparison_rejects_unsupported_or_malformed_inputs(tmp_path: Path) -> None:
|
||||
bad_extension = tmp_path / "rows.txt"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
bad_extension.write_text("{}\n", encoding="utf-8")
|
||||
_write_jsonl(candidate, [{"id": "1", "amount": 1}])
|
||||
spec = ComparisonSpec(
|
||||
primary_key=("id",), metrics={"amount": MetricTolerance()}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported structured data format"):
|
||||
compare_structured_files(bad_extension, candidate, spec=spec)
|
||||
|
||||
malformed = tmp_path / "malformed.jsonl"
|
||||
malformed.write_text('[1, 2, 3]\n', encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="record must be a JSON object"):
|
||||
compare_structured_files(malformed, candidate, spec=spec)
|
||||
|
||||
too_large = tmp_path / "too-large.jsonl"
|
||||
_write_jsonl(too_large, [{"id": "1", "amount": "1e9999"}])
|
||||
with pytest.raises(ValueError, match="exceeds report range"):
|
||||
compare_structured_files(too_large, candidate, spec=spec)
|
||||
|
||||
|
||||
def test_difference_samples_are_deterministic_and_bounded(tmp_path: Path) -> None:
|
||||
baseline = tmp_path / "baseline.jsonl"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
_write_jsonl(baseline, [{"id": f"old-{index}"} for index in range(5)])
|
||||
_write_jsonl(candidate, [{"id": f"new-{index}"} for index in range(5)])
|
||||
spec = ComparisonSpec(primary_key=("id",), difference_limit=2)
|
||||
|
||||
payload = compare_structured_files(baseline, candidate, spec=spec).to_dict()
|
||||
|
||||
assert payload["summary"]["missing_keys"] == 5
|
||||
assert payload["keys"]["missing_total"] == 5
|
||||
assert payload["keys"]["missing_truncated"] is True
|
||||
assert len(payload["keys"]["missing"]) == 2
|
||||
assert payload["keys"]["missing"] == sorted(
|
||||
payload["keys"]["missing"], key=lambda item: item["key_hash"]
|
||||
)
|
||||
|
||||
|
||||
def test_write_comparison_report_is_atomic_and_machine_readable(tmp_path: Path) -> None:
|
||||
baseline = tmp_path / "baseline.jsonl"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
destination = tmp_path / "reports" / "comparison.json"
|
||||
_write_jsonl(baseline, [{"id": "same"}])
|
||||
_write_jsonl(candidate, [{"id": "same"}])
|
||||
report = compare_structured_files(
|
||||
baseline,
|
||||
candidate,
|
||||
spec=ComparisonSpec(primary_key=("id",)),
|
||||
generated_at=FIXED_TIME,
|
||||
)
|
||||
|
||||
written = write_comparison_report(destination, report)
|
||||
|
||||
assert written == destination
|
||||
assert json.loads(destination.read_text(encoding="utf-8")) == report.to_dict()
|
||||
assert not list(destination.parent.glob(".*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, message",
|
||||
[
|
||||
({"primary_key": ()}, "primary_key"),
|
||||
({"primary_key": ("id", "id")}, "primary_key"),
|
||||
({"primary_key": ("id",), "difference_limit": 0}, "difference_limit"),
|
||||
],
|
||||
)
|
||||
def test_comparison_spec_validates_declarations(
|
||||
kwargs: dict[str, object], message: str
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
ComparisonSpec(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_metric_tolerance_rejects_negative_or_non_finite_values() -> None:
|
||||
with pytest.raises(ValueError, match="absolute"):
|
||||
MetricTolerance(absolute=-0.1)
|
||||
with pytest.raises(ValueError, match="relative"):
|
||||
MetricTolerance(relative=float("inf"))
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters import (
|
||||
BrowserProfileManager,
|
||||
FeishuOutboxAdapter,
|
||||
HermesCommandAdapter,
|
||||
PostgresOutboxAdapter,
|
||||
)
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.locks import ResourceBusyError
|
||||
from gyxx_flow.ops import Outbox
|
||||
|
||||
|
||||
def test_feishu_and_postgres_writes_share_idempotent_outbox_boundary(tmp_path: Path) -> None:
|
||||
outbox = Outbox(tmp_path)
|
||||
feishu = FeishuOutboxAdapter(outbox)
|
||||
postgres = PostgresOutboxAdapter(outbox)
|
||||
|
||||
first = feishu.upsert(
|
||||
run_id="run-001",
|
||||
operation_key="weekly-report",
|
||||
artifact_id="artifact-report-001",
|
||||
)
|
||||
duplicate = feishu.upsert(
|
||||
run_id="run-001",
|
||||
operation_key="weekly-report",
|
||||
artifact_id="artifact-report-001",
|
||||
)
|
||||
db_message = postgres.upsert(
|
||||
run_id="run-001",
|
||||
operation_key="shop-metrics",
|
||||
artifact_id="artifact-metrics-001",
|
||||
)
|
||||
|
||||
assert duplicate == first
|
||||
assert first.topic == "feishu.upsert"
|
||||
assert db_message.topic == "postgres.upsert"
|
||||
assert first.payload == {"artifact_id": "artifact-report-001"}
|
||||
assert db_message.payload == {"artifact_id": "artifact-metrics-001"}
|
||||
assert first.idempotency_key != db_message.idempotency_key
|
||||
|
||||
|
||||
def test_hermes_adapter_builds_explicit_command_and_dry_run_starts_no_process(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
prompt = tmp_path / "prompt.md"
|
||||
prompt.write_text("analyze artifact-001", encoding="utf-8")
|
||||
adapter = HermesCommandAdapter(executable="hermes", base_env={"PROFILE": "analysis"})
|
||||
command = adapter.build(prompt_file=prompt, cwd=tmp_path)
|
||||
|
||||
assert command.argv == ("hermes", "-z", "analyze artifact-001")
|
||||
assert command.cwd == tmp_path.resolve()
|
||||
assert command.env == {"PROFILE": "analysis"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: pytest.fail("dry-run must not start Hermes"),
|
||||
)
|
||||
context = RunContext.create("product.style", "2026-07-27")
|
||||
outcome = command.execute(context=context, timeout_seconds=60, dry_run=True)
|
||||
assert outcome.skipped is True
|
||||
|
||||
|
||||
def test_browser_profiles_are_portable_and_exclusively_locked(tmp_path: Path) -> None:
|
||||
manager = BrowserProfileManager(tmp_path)
|
||||
|
||||
with manager.acquire("shop-compass", owner="run-001") as first:
|
||||
assert first.profile_path == (
|
||||
tmp_path / "state" / "browser_profiles" / "shop-compass"
|
||||
).resolve()
|
||||
assert first.profile_path.is_dir()
|
||||
with pytest.raises(ResourceBusyError):
|
||||
with manager.acquire(
|
||||
"shop-compass", owner="run-002", timeout_seconds=0
|
||||
):
|
||||
pass
|
||||
|
||||
with manager.acquire("shop-compass", owner="run-002", timeout_seconds=0) as second:
|
||||
assert second.profile_path == first.profile_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsafe", ["../escape", "a/b", "a\\b", "", ".."])
|
||||
def test_browser_profile_id_rejects_path_escape(tmp_path: Path, unsafe: str) -> None:
|
||||
with pytest.raises(ValueError, match="profile"):
|
||||
with BrowserProfileManager(tmp_path).acquire(unsafe, owner="run-001"):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules.shop_intelligence import (
|
||||
COLLECTION_TIMEOUT_SECONDS,
|
||||
SHOP_RESOURCE,
|
||||
SHOP_WORKFLOW_IDS,
|
||||
ShopIntelligenceModule,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_shop_module_registers_both_weekly_workflows() -> None:
|
||||
module = ShopIntelligenceModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
assert tuple(item.workflow_id for item in module.workflow_definitions()) == SHOP_WORKFLOW_IDS
|
||||
|
||||
|
||||
def test_shop_workflows_use_project_owned_module_commands() -> None:
|
||||
module = ShopIntelligenceModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
for definition in module.workflow_definitions():
|
||||
step = definition.steps[0]
|
||||
assert step.step_id == "module_collect"
|
||||
assert isinstance(step.action, DeferredModuleCommandStep)
|
||||
assert step.timeout_seconds == COLLECTION_TIMEOUT_SECONDS == 4 * 60 * 60
|
||||
assert step.max_attempts == 1
|
||||
assert step.resources == (SHOP_RESOURCE,) == ("module:shop_intelligence",)
|
||||
assert step.production_sink is True
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters.native import ModuleCommandAdapter, ModuleSourceRoots
|
||||
from gyxx_flow.catalog import WorkflowEntry
|
||||
from gyxx_flow.core.context import RunContext
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULES_ROOT = PROJECT_ROOT / "src" / "gyxx_flow" / "modules"
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (PROJECT_ROOT / relative).read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def _load_shop_paths(monkeypatch: pytest.MonkeyPatch, data_root: Path):
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", str(data_root))
|
||||
path = MODULES_ROOT / "shop_intelligence" / "runtime" / "paths.py"
|
||||
name = "_gyxx_test_shop_routing_paths"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
sys.modules.pop(name, None)
|
||||
return module
|
||||
|
||||
|
||||
def test_shop_output_resolver_confines_collectors_to_raw_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
paths = _load_shop_paths(monkeypatch, tmp_path / "portable")
|
||||
default = paths.RAW_ROOT / "京东" / "店铺数据"
|
||||
|
||||
assert paths.resolve_raw_output(None, default=default) == default
|
||||
assert paths.resolve_raw_output("京东/竞店数据", default=default) == (
|
||||
paths.RAW_ROOT / "京东" / "竞店数据"
|
||||
)
|
||||
assert paths.resolve_raw_output(
|
||||
str(paths.RAW_ROOT / "京东" / "店铺数据"), default=default
|
||||
) == (paths.RAW_ROOT / "京东" / "店铺数据")
|
||||
|
||||
with pytest.raises(ValueError, match="RAW_ROOT"):
|
||||
paths.resolve_raw_output(tmp_path / "outside", default=default)
|
||||
with pytest.raises(ValueError, match="RAW_ROOT"):
|
||||
paths.resolve_raw_output("../outside", default=default)
|
||||
|
||||
|
||||
def test_shop_debug_and_cli_outputs_use_canonical_roots() -> None:
|
||||
jd_shop = _read(
|
||||
"src/gyxx_flow/modules/shop_intelligence/runtime/collectors/jd_data_collector.py"
|
||||
)
|
||||
jd_peer = _read(
|
||||
"src/gyxx_flow/modules/shop_intelligence/runtime/collectors/"
|
||||
"jd_peer_store_data_collector.py"
|
||||
)
|
||||
|
||||
assert "os.getcwd()" not in jd_shop
|
||||
assert 'TMP_ROOT / "debug" / "jd_date_picker"' in jd_shop
|
||||
assert "resolve_raw_output(args.output" in jd_shop
|
||||
assert "resolve_raw_output(args.output" in jd_peer
|
||||
|
||||
|
||||
def test_supply_python_outputs_use_state_raw_tmp_and_export_layers() -> None:
|
||||
feishu = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/feishu_sheets.py"
|
||||
)
|
||||
workflow = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/mcp_workflow.py"
|
||||
)
|
||||
monitor = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/monitor.py"
|
||||
)
|
||||
replenishment = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"ProductReplenishment.py"
|
||||
)
|
||||
confirmation = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"PurchaseConfirmation.py"
|
||||
)
|
||||
batch = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"batch_process.py"
|
||||
)
|
||||
bitable = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"insert_replenishment_bitable.py"
|
||||
)
|
||||
|
||||
assert 'STATE_ROOT / "cache" / "discontinued_skus_feishu.json"' in feishu
|
||||
assert 'STATE_ROOT / "locks"' in workflow
|
||||
assert "STATUS_FILE = STATE_ROOT / \"status.json\"" in monitor
|
||||
assert "PROCESSING_FILE = STATE_ROOT / \"processing_status.json\"" in monitor
|
||||
|
||||
assert "REPLENISHMENT_RAW_ROOT" in replenishment
|
||||
assert "REPLENISHMENT_WORK_ROOT" in replenishment
|
||||
assert "REPLENISHMENT_EXPORT_ROOT" in replenishment
|
||||
assert 'output_dir="data"' not in replenishment
|
||||
assert "os.listdir(raw_dir)" not in replenishment
|
||||
assert "PURCHASE_CONFIRMATION_RAW_ROOT" in confirmation
|
||||
assert "PURCHASE_CONFIRMATION_EXPORT_ROOT" in confirmation
|
||||
assert "Downloads" not in replenishment
|
||||
assert "Downloads" not in batch
|
||||
assert 'glob("run_id=*/pending_insert.json")' in bitable
|
||||
|
||||
|
||||
def test_supply_raw_outputs_are_run_scoped_and_never_cleaned_in_place() -> None:
|
||||
workflow = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/mcp_workflow.py"
|
||||
)
|
||||
for script in (
|
||||
"collect_confirmation.ps1",
|
||||
"collect_purchase_order_update.ps1",
|
||||
"collect_replenishment.ps1",
|
||||
):
|
||||
source = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/" + script
|
||||
)
|
||||
assert '"run_id=$workflowId"' in source
|
||||
assert (
|
||||
"Get-ChildItem -Path $sharedDir -File -ErrorAction SilentlyContinue | Remove-Item"
|
||||
not in source
|
||||
)
|
||||
assert (
|
||||
"Get-ChildItem -Path $sharedReplenishmentDir -File -ErrorAction SilentlyContinue | Remove-Item"
|
||||
not in source
|
||||
)
|
||||
assert (
|
||||
"Get-ChildItem -Path $sharedAlertDir -File -ErrorAction SilentlyContinue | Remove-Item"
|
||||
not in source
|
||||
)
|
||||
assert "Remove-Item -LiteralPath $chromeProfileDir" not in source
|
||||
|
||||
clear_body = workflow.split("def _clear_shared_outputs", 1)[1].split(
|
||||
"def _matching_shared_outputs", 1
|
||||
)[0]
|
||||
cleanup_body = workflow.split("def _cleanup_workflow_artifacts", 1)[1].split(
|
||||
"def _verify_bitable_insert_in_pg", 1
|
||||
)[0]
|
||||
assert ".unlink(" not in clear_body
|
||||
assert "SHARED_DIR" not in cleanup_body
|
||||
assert ".rglob(pattern)" in workflow
|
||||
assert 'collector_dir / "data"' not in workflow
|
||||
assert "def _publish_recent_script_outputs" not in workflow
|
||||
|
||||
|
||||
def test_purchase_order_trigger_uses_state_task_and_never_cleans_raw_or_source() -> None:
|
||||
trigger = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"trigger_purchase_order_update.py"
|
||||
)
|
||||
collector = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/"
|
||||
"collect_purchase_order_update.ps1"
|
||||
)
|
||||
|
||||
assert "TASK_FILE = STATE_ROOT" in trigger
|
||||
assert "cleanup_old_artifacts" not in trigger
|
||||
assert ".unlink(" not in trigger
|
||||
assert '$taskFile = Join-Path $stateRoot "tasks\\purchase-order-update\\task.json"' in collector
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
[
|
||||
"collect_confirmation.ps1",
|
||||
"collect_purchase_order_update.ps1",
|
||||
"collect_replenishment.ps1",
|
||||
],
|
||||
)
|
||||
def test_supply_powershell_prefers_injected_module_roots(script: str) -> None:
|
||||
source = _read(
|
||||
"src/gyxx_flow/modules/supply_chain/runtime/orchestrator/scripts/" + script
|
||||
)
|
||||
|
||||
for variable in (
|
||||
"GYXX_SUPPLY_RAW_ROOT",
|
||||
"GYXX_SUPPLY_STATE_ROOT",
|
||||
"GYXX_SUPPLY_EXPORT_ROOT",
|
||||
"GYXX_SUPPLY_WORK_ROOT",
|
||||
):
|
||||
assert f"$env:{variable}" in source
|
||||
assert 'Join-Path $projectRoot "var"' in source
|
||||
assert 'Join-Path $dataRoot "raw\\supply_chain"' not in source
|
||||
assert 'Join-Path $dataRoot "exports\\supply_chain"' not in source
|
||||
|
||||
if script == "collect_confirmation.ps1":
|
||||
assert "$collectionStartedAt = Get-Date" in source
|
||||
assert 'Get-ChildItem -Path $dataDir -Include "*.csv", "*.xlsx"' not in source
|
||||
|
||||
|
||||
def test_native_adapter_injects_supply_module_roots(tmp_path: Path) -> None:
|
||||
runtime = tmp_path / "runtime"
|
||||
runtime.mkdir()
|
||||
(runtime / "collect.ps1").write_text("Write-Output ok", encoding="utf-8")
|
||||
adapter = ModuleCommandAdapter(
|
||||
ModuleSourceRoots({"supply_chain": runtime}),
|
||||
base_env={},
|
||||
project_root=tmp_path,
|
||||
data_root=tmp_path / "portable-data",
|
||||
)
|
||||
entry = WorkflowEntry(
|
||||
workflow_id="supply.collect",
|
||||
module="supply_chain",
|
||||
trigger="manual",
|
||||
entry="collect.ps1",
|
||||
args=(),
|
||||
source_project="supply",
|
||||
)
|
||||
context = RunContext.create(
|
||||
"supply.collect",
|
||||
"2026-07-27",
|
||||
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
|
||||
random_suffix="routing1",
|
||||
)
|
||||
|
||||
command = adapter.build(entry, context=context)
|
||||
|
||||
module_root = tmp_path / "portable-data"
|
||||
assert command.env["GYXX_SUPPLY_RAW_ROOT"] == str(
|
||||
(module_root / "data" / "raw" / "supply_chain").resolve()
|
||||
)
|
||||
assert command.env["GYXX_SUPPLY_STATE_ROOT"] == str(
|
||||
(module_root / "state" / "supply_chain").resolve()
|
||||
)
|
||||
assert command.env["GYXX_SUPPLY_EXPORT_ROOT"] == str(
|
||||
(module_root / "data" / "exports" / "supply_chain").resolve()
|
||||
)
|
||||
assert command.env["GYXX_SUPPLY_WORK_ROOT"] == str(
|
||||
(module_root / "tmp" / "supply_chain").resolve()
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.security.scanner import scan_repository
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_ROOT = PROJECT_ROOT / "config" / "source-manifests"
|
||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
OLD_ROOTS = (
|
||||
"d:\\shop-data-flow",
|
||||
"d:/shop-data-flow",
|
||||
"e:\\auto-flow",
|
||||
"e:/auto-flow",
|
||||
)
|
||||
NON_PORTABLE_RUNTIME_PATTERNS = (
|
||||
re.compile(r"e:[\\/]+auto-flow", re.IGNORECASE),
|
||||
re.compile(r"d:[\\/]+erp", re.IGNORECASE),
|
||||
re.compile(r"path\s*\(\s*['\"]d:[\\/]+['\"]\s*\)\.iterdir", re.IGNORECASE),
|
||||
re.compile(r"sys\.path\.insert\s*\(", re.IGNORECASE),
|
||||
)
|
||||
EXPECTED = {
|
||||
"shop_intelligence": {
|
||||
"minimum_files": 22,
|
||||
"required": {
|
||||
"adaptive_selectors.py",
|
||||
"collectors/jd_data_collector.py",
|
||||
"collectors/jd_peer_store_data_collector.py",
|
||||
"collectors/dy_store_competitor_store_scraping.py",
|
||||
"collectors/taobao_sycm.py",
|
||||
"runners/run_shop.py",
|
||||
"runners/run_peer_store.py",
|
||||
"writers/shop_base_writer.py",
|
||||
"writers/peer_store_writer.py",
|
||||
"db/db.py",
|
||||
"db/schema.sql",
|
||||
"scripts/setup_scheduler.ps1",
|
||||
},
|
||||
"classifications": {"scheduled", "internal", "maintenance"},
|
||||
},
|
||||
"supply_chain": {
|
||||
"minimum_files": 37,
|
||||
"required": {
|
||||
"run.py",
|
||||
"orchestrator/mcp_workflow.py",
|
||||
"orchestrator/monitor.py",
|
||||
"orchestrator/pg_writer.py",
|
||||
"orchestrator/scripts/PurchaseConfirmation.py",
|
||||
"orchestrator/scripts/ProductReplenishment.py",
|
||||
"orchestrator/scripts/PurchaseOrderUpdate.py",
|
||||
"orchestrator/scripts/batch_process.py",
|
||||
"orchestrator/scripts/trigger_purchase_order_update.py",
|
||||
"orchestrator/scripts/insert_replenishment_bitable.py",
|
||||
"orchestrator/scripts/send_card_notification.py",
|
||||
"orchestrator/sql/backfill_history.py",
|
||||
"skills/collector/SKILL.md",
|
||||
"skills/analyzer/SKILL.md",
|
||||
},
|
||||
"classifications": {
|
||||
"scheduled",
|
||||
"manual",
|
||||
"event",
|
||||
"maintenance",
|
||||
"internal",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", sorted(EXPECTED))
|
||||
def test_source_manifest_covers_runtime_sources_and_entrypoints(module: str) -> None:
|
||||
expected = EXPECTED[module]
|
||||
manifest_path = MANIFEST_ROOT / f"{module}.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert manifest["schema_version"] == 1
|
||||
assert manifest["module"] == module
|
||||
files = manifest["files"]
|
||||
assert len(files) >= expected["minimum_files"]
|
||||
assert expected["required"] <= {
|
||||
item["source_relative_path"] for item in files
|
||||
}
|
||||
|
||||
target_paths = [item["target_relative_path"] for item in files]
|
||||
assert len(target_paths) == len(set(target_paths))
|
||||
for item in files:
|
||||
target = PurePosixPath(item["target_relative_path"])
|
||||
assert not target.is_absolute()
|
||||
assert ".." not in target.parts
|
||||
assert target.parts[:5] == (
|
||||
"src",
|
||||
"gyxx_flow",
|
||||
"modules",
|
||||
module,
|
||||
"runtime",
|
||||
)
|
||||
assert SHA256.fullmatch(item["source_sha256"])
|
||||
assert SHA256.fullmatch(item["target_sha256"])
|
||||
assert item["transformed"] is (
|
||||
item["source_sha256"] != item["target_sha256"]
|
||||
)
|
||||
|
||||
entrypoints = manifest["entrypoints"]
|
||||
assert {item["classification"] for item in entrypoints} == expected[
|
||||
"classifications"
|
||||
]
|
||||
assert all(item["source_relative_path"] for item in entrypoints)
|
||||
assert all(item["target_relative_path"] for item in entrypoints)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", sorted(EXPECTED))
|
||||
def test_runtime_files_match_manifest_and_are_decoupled(module: str) -> None:
|
||||
manifest_path = MANIFEST_ROOT / f"{module}.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
runtime_root = (
|
||||
PROJECT_ROOT / "src" / "gyxx_flow" / "modules" / module / "runtime"
|
||||
)
|
||||
|
||||
for item in manifest["files"]:
|
||||
target = PROJECT_ROOT.joinpath(
|
||||
*PurePosixPath(item["target_relative_path"]).parts
|
||||
)
|
||||
assert target.is_file(), item["target_relative_path"]
|
||||
assert _sha256(target) == item["target_sha256"]
|
||||
|
||||
for path in runtime_root.rglob("*"):
|
||||
if not path.is_file() or b"\x00" in path.read_bytes():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8-sig", errors="strict").casefold()
|
||||
assert not any(root in text for root in OLD_ROOTS), path
|
||||
|
||||
manifest_text = manifest_path.read_text(encoding="utf-8").casefold()
|
||||
assert not any(root in manifest_text for root in OLD_ROOTS)
|
||||
assert scan_repository(runtime_root) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", sorted(EXPECTED))
|
||||
def test_source_manifest_explicitly_excludes_secrets_and_runtime_state(
|
||||
module: str,
|
||||
) -> None:
|
||||
manifest = json.loads(
|
||||
(MANIFEST_ROOT / f"{module}.json").read_text(encoding="utf-8")
|
||||
)
|
||||
excluded = {item["pattern"] for item in manifest["intentionally_excluded"]}
|
||||
|
||||
assert {
|
||||
".git/**",
|
||||
".venv/**",
|
||||
"**/__pycache__/**",
|
||||
".env",
|
||||
"**/*cookies*",
|
||||
"**/*profile*/**",
|
||||
"**/data/**",
|
||||
"**/logs/**",
|
||||
} <= excluded
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", sorted(EXPECTED))
|
||||
def test_runtime_has_no_legacy_absolute_paths_or_sys_path_bootstrap(
|
||||
module: str,
|
||||
) -> None:
|
||||
runtime_root = (
|
||||
PROJECT_ROOT / "src" / "gyxx_flow" / "modules" / module / "runtime"
|
||||
)
|
||||
for path in runtime_root.rglob("*"):
|
||||
if not path.is_file() or b"\x00" in path.read_bytes():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8-sig", errors="strict")
|
||||
assert not any(
|
||||
pattern.search(text) for pattern in NON_PORTABLE_RUNTIME_PATTERNS
|
||||
), path
|
||||
|
||||
|
||||
def test_supply_backfill_reads_only_from_portable_shared_data_root() -> None:
|
||||
backfill = (
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ "supply_chain"
|
||||
/ "runtime"
|
||||
/ "orchestrator"
|
||||
/ "sql"
|
||||
/ "backfill_history.py"
|
||||
).read_text(encoding="utf-8-sig")
|
||||
|
||||
assert "from gyxx_flow.modules.supply_chain.runtime.paths import SHARED_ROOT" in backfill
|
||||
assert "ROOT = SHARED_ROOT" in backfill
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.script_catalog import ScriptCatalog
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULES = (
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
"shop_intelligence",
|
||||
"supply_chain",
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def test_all_source_manifests_use_one_schema_and_verify_target_hashes() -> None:
|
||||
for module in MODULES:
|
||||
path = PROJECT_ROOT / "config" / "source-manifests" / f"{module}.json"
|
||||
manifest = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
assert manifest["schema_version"] == 1
|
||||
assert manifest["module"] == module
|
||||
assert isinstance(manifest["snapshot"], str) and manifest["snapshot"]
|
||||
assert manifest["files"]
|
||||
assert isinstance(manifest["intentionally_excluded"], list)
|
||||
for item in manifest["files"]:
|
||||
target = PROJECT_ROOT.joinpath(
|
||||
*PurePosixPath(item["target_relative_path"]).parts
|
||||
)
|
||||
assert target.is_file(), target
|
||||
assert _sha256(target) == item["target_sha256"], target
|
||||
|
||||
|
||||
def test_every_workflow_and_manual_script_resolves_inside_new_project() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
for workflow in catalog.workflows:
|
||||
if workflow.trigger == "unavailable":
|
||||
continue
|
||||
target = (
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ workflow.module
|
||||
/ "runtime"
|
||||
/ workflow.entry
|
||||
).resolve(strict=True)
|
||||
assert target.is_relative_to(PROJECT_ROOT)
|
||||
|
||||
scripts = ScriptCatalog.discover_default()
|
||||
assert len(scripts.scripts) >= 100
|
||||
assert all(script.path.is_relative_to(PROJECT_ROOT) for script in scripts.scripts)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.modules.supply_chain import (
|
||||
MANUAL_SUPPLY_WORKFLOW_IDS,
|
||||
SCHEDULED_SUPPLY_WORKFLOW_IDS,
|
||||
SUPPLY_RESOURCE,
|
||||
SUPPLY_TIMEOUT_SECONDS,
|
||||
SUPPLY_WORKFLOW_IDS,
|
||||
SupplyChainModule,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_supply_module_registers_scheduled_and_manual_workflows() -> None:
|
||||
module = SupplyChainModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
assert tuple(item.workflow_id for item in module.workflow_definitions()) == SUPPLY_WORKFLOW_IDS
|
||||
assert len(SCHEDULED_SUPPLY_WORKFLOW_IDS) == 3
|
||||
assert len(MANUAL_SUPPLY_WORKFLOW_IDS) == 1
|
||||
|
||||
|
||||
def test_supply_workflows_use_project_owned_module_commands() -> None:
|
||||
module = SupplyChainModule.from_catalog(
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
|
||||
for definition in module.workflow_definitions():
|
||||
step = definition.steps[0]
|
||||
assert step.step_id == "module_supply"
|
||||
assert isinstance(step.action, DeferredModuleCommandStep)
|
||||
assert step.timeout_seconds == SUPPLY_TIMEOUT_SECONDS == 2 * 60 * 60
|
||||
assert step.max_attempts == 1
|
||||
assert step.resources == (SUPPLY_RESOURCE,) == ("module:supply_chain",)
|
||||
assert step.production_sink is True
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_ROOT = PROJECT_ROOT / "src" / "gyxx_flow" / "modules"
|
||||
|
||||
|
||||
def _load_path_module(module: str, filename: str, monkeypatch, data_root: Path):
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", str(data_root))
|
||||
runtime_root = RUNTIME_ROOT / module / "runtime"
|
||||
monkeypatch.setenv("GYXX_MODULE_ROOT", str(runtime_root))
|
||||
path = runtime_root / filename
|
||||
name = f"_gyxx_test_paths_{module}"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = loaded
|
||||
try:
|
||||
spec.loader.exec_module(loaded)
|
||||
finally:
|
||||
sys.modules.pop(name, None)
|
||||
return loaded
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "filename", "names"),
|
||||
[
|
||||
(
|
||||
"content_marketing",
|
||||
"runtime_paths.py",
|
||||
("raw_root", "normalized_root", "curated_root", "exports_root"),
|
||||
),
|
||||
(
|
||||
"product_commerce",
|
||||
"runtime_paths.py",
|
||||
(
|
||||
"RAW_DATA_ROOT",
|
||||
"NORMALIZED_DATA_ROOT",
|
||||
"CURATED_DATA_ROOT",
|
||||
"EXPORTS_DATA_ROOT",
|
||||
),
|
||||
),
|
||||
(
|
||||
"shop_intelligence",
|
||||
"paths.py",
|
||||
("RAW_ROOT", "NORMALIZED_ROOT", "CURATED_ROOT", "EXPORTS_ROOT"),
|
||||
),
|
||||
(
|
||||
"supply_chain",
|
||||
"paths.py",
|
||||
("SHARED_ROOT", "NORMALIZED_ROOT", "CURATED_ROOT", "BACKUP_ROOT"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_all_module_path_boundaries_use_the_canonical_data_tree(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
module: str,
|
||||
filename: str,
|
||||
names: tuple[str, str, str, str],
|
||||
) -> None:
|
||||
data_root = tmp_path / "relocatable-data"
|
||||
loaded = _load_path_module(module, filename, monkeypatch, data_root)
|
||||
|
||||
layers = ("raw", "normalized", "curated", "exports")
|
||||
for name, layer in zip(names, layers, strict=True):
|
||||
assert getattr(loaded, name) == data_root / "data" / layer / module
|
||||
assert not data_root.exists(), "path resolution must not mutate the filesystem"
|
||||
|
||||
|
||||
def test_module_data_paths_cover_collected_and_exported_file_types(tmp_path: Path) -> None:
|
||||
paths = DataLayout(tmp_path).for_module("content_marketing")
|
||||
|
||||
assert paths.raw_path("api", "response.json") == (
|
||||
tmp_path / "data" / "raw" / "content_marketing" / "api" / "response.json"
|
||||
)
|
||||
assert paths.raw_path("downloads", "source.xlsx") == (
|
||||
tmp_path / "data" / "raw" / "content_marketing" / "downloads" / "source.xlsx"
|
||||
)
|
||||
assert paths.raw_path("downloads", "source.xls") == (
|
||||
tmp_path / "data" / "raw" / "content_marketing" / "downloads" / "source.xls"
|
||||
)
|
||||
assert paths.raw_path("downloads", "source.csv") == (
|
||||
tmp_path / "data" / "raw" / "content_marketing" / "downloads" / "source.csv"
|
||||
)
|
||||
assert paths.export_path("reports", "weekly.md") == (
|
||||
tmp_path / "data" / "exports" / "content_marketing" / "reports" / "weekly.md"
|
||||
)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsafe", ["", "..", "a/b", "a\\b", "C:\\data"])
|
||||
def test_module_data_paths_reject_unsafe_module_names(tmp_path: Path, unsafe: str) -> None:
|
||||
with pytest.raises(ValueError, match="path segment"):
|
||||
DataLayout(tmp_path).for_module(unsafe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsafe", [("..", "escape.json"), ("C:\\", "escape.json")])
|
||||
def test_module_data_paths_reject_paths_outside_the_layer(
|
||||
tmp_path: Path, unsafe: tuple[str, str]
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="escapes module data root"):
|
||||
DataLayout(tmp_path).for_module("product_commerce").raw_path(*unsafe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", ["", " "])
|
||||
@pytest.mark.parametrize(
|
||||
("module", "filename", "root_name"),
|
||||
[
|
||||
("content_marketing", "runtime_paths.py", "data_root"),
|
||||
("product_commerce", "runtime_paths.py", "DATA_HOME"),
|
||||
("shop_intelligence", "paths.py", "DATA_ROOT"),
|
||||
("supply_chain", "paths.py", "DATA_ROOT"),
|
||||
],
|
||||
)
|
||||
def test_blank_data_root_uses_the_project_var_default(
|
||||
monkeypatch,
|
||||
configured: str,
|
||||
module: str,
|
||||
filename: str,
|
||||
root_name: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", configured)
|
||||
runtime_root = RUNTIME_ROOT / module / "runtime"
|
||||
monkeypatch.setenv("GYXX_MODULE_ROOT", str(runtime_root))
|
||||
path = runtime_root / filename
|
||||
name = f"_gyxx_test_blank_paths_{module}"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = loaded
|
||||
try:
|
||||
spec.loader.exec_module(loaded)
|
||||
finally:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
assert getattr(loaded, root_name) == PROJECT_ROOT / "var"
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.ops.effects import EffectLedger
|
||||
from gyxx_flow.workflow.engine import WorkflowEngine
|
||||
from gyxx_flow.workflow.model import (
|
||||
StepDefinition,
|
||||
WorkflowDefinition,
|
||||
WorkflowValidationError,
|
||||
)
|
||||
from gyxx_flow.workflow.steps import CommandStep, StepExecution
|
||||
|
||||
|
||||
def _context(*, shadow: bool = False) -> RunContext:
|
||||
return RunContext.create(
|
||||
"shop.weekly",
|
||||
"2026-07-27",
|
||||
shadow=shadow,
|
||||
now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc),
|
||||
random_suffix="abc123",
|
||||
)
|
||||
|
||||
|
||||
class FakeStep:
|
||||
def __init__(self, *outcomes: StepExecution) -> None:
|
||||
self.outcomes = deque(outcomes or (StepExecution(exit_code=0),))
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
context: RunContext,
|
||||
timeout_seconds: float | None,
|
||||
dry_run: bool,
|
||||
) -> StepExecution:
|
||||
self.calls.append(
|
||||
{
|
||||
"context": context,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
)
|
||||
return self.outcomes.popleft()
|
||||
|
||||
|
||||
def test_workflow_validates_relations_and_uses_stable_topological_order() -> None:
|
||||
action = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
workflow_id="shop.weekly",
|
||||
steps=(
|
||||
StepDefinition("publish", action, depends_on=("collect",)),
|
||||
StepDefinition("audit", action),
|
||||
StepDefinition("collect", action),
|
||||
),
|
||||
)
|
||||
|
||||
assert [step.step_id for step in workflow.ordered_steps()] == [
|
||||
"audit",
|
||||
"collect",
|
||||
"publish",
|
||||
]
|
||||
|
||||
with pytest.raises(WorkflowValidationError, match="duplicate step id"):
|
||||
WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(StepDefinition("collect", action), StepDefinition("collect", action)),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowValidationError, match="unknown dependency"):
|
||||
WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(StepDefinition("publish", action, depends_on=("missing",)),),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowValidationError, match="cycle"):
|
||||
WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(
|
||||
StepDefinition("first", action, depends_on=("second",)),
|
||||
StepDefinition("second", action, depends_on=("first",)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "message"),
|
||||
[
|
||||
({"max_attempts": 0}, "max_attempts"),
|
||||
({"timeout_seconds": 0}, "timeout_seconds"),
|
||||
({"retry_delay_seconds": -1}, "retry_delay_seconds"),
|
||||
({"depends_on": ("one", "one")}, "duplicate dependency"),
|
||||
],
|
||||
)
|
||||
def test_step_definition_validates_retry_timeout_and_dependencies(
|
||||
kwargs: dict[str, object], message: str
|
||||
) -> None:
|
||||
with pytest.raises(WorkflowValidationError, match=message):
|
||||
StepDefinition("collect", FakeStep(), **kwargs)
|
||||
|
||||
|
||||
def test_command_step_uses_argv_explicit_cwd_env_and_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
captured["argv"] = argv
|
||||
captured.update(kwargs)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
step = CommandStep(
|
||||
argv=("C:/Python312/python.exe", "worker.py", "--date", "2026-07-27"),
|
||||
cwd=tmp_path,
|
||||
env={"MODE": "test"},
|
||||
)
|
||||
|
||||
outcome = step.execute(context=_context(), timeout_seconds=9.5, dry_run=False)
|
||||
|
||||
assert outcome.exit_code == 0
|
||||
assert outcome.stdout == "ok"
|
||||
assert captured["argv"] == [
|
||||
"C:/Python312/python.exe",
|
||||
"worker.py",
|
||||
"--date",
|
||||
"2026-07-27",
|
||||
]
|
||||
assert captured["cwd"] == tmp_path
|
||||
assert captured["env"] == {"MODE": "test"}
|
||||
assert captured["timeout"] == 9.5
|
||||
assert captured["shell"] is False
|
||||
|
||||
|
||||
def test_command_step_dry_run_never_starts_a_process(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
def fail_if_called(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("subprocess must not be started")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_if_called)
|
||||
step = CommandStep(argv=("tool.exe", "--write"), cwd=tmp_path, env={})
|
||||
|
||||
outcome = step.execute(context=_context(), timeout_seconds=None, dry_run=True)
|
||||
|
||||
assert outcome.skipped is True
|
||||
assert outcome.reason == "dry-run"
|
||||
assert outcome.exit_code == 0
|
||||
|
||||
|
||||
def test_command_step_reports_timeout_as_a_failed_exit(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
def timeout(*args: object, **kwargs: object) -> None:
|
||||
raise subprocess.TimeoutExpired(cmd=["tool.exe"], timeout=2)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", timeout)
|
||||
step = CommandStep(argv=("tool.exe",), cwd=tmp_path, env={})
|
||||
|
||||
outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False)
|
||||
|
||||
assert outcome.exit_code == 124
|
||||
assert "timed out" in (outcome.error or "")
|
||||
|
||||
|
||||
def test_engine_retries_records_every_attempt_and_aggregates_failures(tmp_path: Path) -> None:
|
||||
flaky = FakeStep(
|
||||
StepExecution(exit_code=7, error="temporary"),
|
||||
StepExecution(exit_code=0),
|
||||
)
|
||||
optional = FakeStep(StepExecution(exit_code=5, error="optional failed"))
|
||||
required = FakeStep(StepExecution(exit_code=9, error="required failed"))
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(
|
||||
StepDefinition(
|
||||
"flaky",
|
||||
flaky,
|
||||
timeout_seconds=4,
|
||||
max_attempts=2,
|
||||
retry_delay_seconds=0,
|
||||
resources=("browser:shop",),
|
||||
),
|
||||
StepDefinition("optional", optional, critical=False),
|
||||
StepDefinition("required", required),
|
||||
),
|
||||
)
|
||||
context = _context()
|
||||
layout = DataLayout(tmp_path)
|
||||
journal = RunJournal.create(layout, context)
|
||||
|
||||
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
|
||||
workflow,
|
||||
context=context,
|
||||
journal=journal,
|
||||
)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.exit_code == 1
|
||||
assert result.steps["flaky"].status == "success"
|
||||
assert result.steps["flaky"].final_exit_code == 0
|
||||
assert [attempt.exit_code for attempt in result.steps["flaky"].attempts] == [7, 0]
|
||||
assert result.steps["optional"].status == "failed"
|
||||
assert result.steps["required"].status == "failed"
|
||||
assert len(flaky.calls) == 2
|
||||
assert all(call["timeout_seconds"] == 4 for call in flaky.calls)
|
||||
|
||||
payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
assert payload["status"] == "failed"
|
||||
assert payload["steps"]["flaky.attempt-1"]["exit_code"] == 7
|
||||
assert payload["steps"]["flaky.attempt-2"]["exit_code"] == 0
|
||||
assert payload["steps"]["required.attempt-1"]["exit_code"] == 9
|
||||
assert payload["trace"]["inputs"] == [
|
||||
"step:flaky",
|
||||
"step:optional",
|
||||
"step:required",
|
||||
]
|
||||
assert "step:flaky:attempt:1:failed:exit:7" in payload["trace"]["outputs"]
|
||||
assert "step:flaky:attempt:2:success:exit:0" in payload["trace"]["outputs"]
|
||||
assert list((tmp_path / "locks").glob("*.lock")) == []
|
||||
|
||||
|
||||
def test_noncritical_failure_does_not_fail_the_workflow(tmp_path: Path) -> None:
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(
|
||||
StepDefinition(
|
||||
"optional",
|
||||
FakeStep(StepExecution(exit_code=2, error="not available")),
|
||||
critical=False,
|
||||
),
|
||||
StepDefinition("required", FakeStep()),
|
||||
),
|
||||
)
|
||||
context = _context()
|
||||
journal = RunJournal.create(DataLayout(tmp_path), context)
|
||||
|
||||
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
|
||||
workflow, context=context, journal=journal
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.exit_code == 0
|
||||
assert result.warnings == ("optional",)
|
||||
|
||||
|
||||
def test_engine_shadow_mode_skips_production_sinks_and_official_notifications(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
safe = FakeStep()
|
||||
sink = FakeStep()
|
||||
notify = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(
|
||||
StepDefinition("safe", safe),
|
||||
StepDefinition("publish", sink, production_sink=True, depends_on=("safe",)),
|
||||
StepDefinition(
|
||||
"notify",
|
||||
notify,
|
||||
official_notification=True,
|
||||
depends_on=("publish",),
|
||||
),
|
||||
),
|
||||
)
|
||||
context = _context(shadow=True)
|
||||
journal = RunJournal.create(DataLayout(tmp_path), context)
|
||||
|
||||
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
|
||||
workflow, context=context, journal=journal
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.steps["safe"].status == "success"
|
||||
assert result.steps["publish"].status == "skipped"
|
||||
assert result.steps["publish"].reason == "shadow-policy"
|
||||
assert result.steps["notify"].status == "skipped"
|
||||
assert sink.calls == []
|
||||
assert notify.calls == []
|
||||
|
||||
payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
assert payload["steps"]["publish.attempt-1"]["status"] == "skipped"
|
||||
assert payload["steps"]["notify.attempt-1"]["status"] == "skipped"
|
||||
assert payload["trace"]["external_writes"] == []
|
||||
|
||||
|
||||
def test_successful_sink_records_declared_external_write_in_trace(tmp_path: Path) -> None:
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(StepDefinition("publish", FakeStep(), production_sink=True),),
|
||||
)
|
||||
context = _context()
|
||||
journal = RunJournal.create(DataLayout(tmp_path), context)
|
||||
|
||||
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
|
||||
workflow, context=context, journal=journal
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
assert payload["trace"]["external_writes"] == ["step:publish:production_sink"]
|
||||
|
||||
|
||||
def test_effect_ledger_skips_a_successful_production_sink_replay(tmp_path: Path) -> None:
|
||||
action = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(StepDefinition("publish", action, production_sink=True),),
|
||||
)
|
||||
ledger = EffectLedger(tmp_path)
|
||||
engine = WorkflowEngine(
|
||||
LockManager(tmp_path / "locks"), effect_ledger=ledger
|
||||
)
|
||||
first_context = _context()
|
||||
first = engine.execute(
|
||||
workflow,
|
||||
context=first_context,
|
||||
journal=RunJournal.create(DataLayout(tmp_path), first_context),
|
||||
)
|
||||
second_context = RunContext.create(
|
||||
"shop.weekly", "2026-07-27", random_suffix="replay2"
|
||||
)
|
||||
second = engine.execute(
|
||||
workflow,
|
||||
context=second_context,
|
||||
journal=RunJournal.create(DataLayout(tmp_path), second_context),
|
||||
)
|
||||
|
||||
assert first.status == "success"
|
||||
assert second.status == "success"
|
||||
assert second.steps["publish"].reason == "idempotency-replay"
|
||||
assert len(action.calls) == 1
|
||||
|
||||
|
||||
def test_failed_dependency_skips_downstream_critical_step(tmp_path: Path) -> None:
|
||||
downstream = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.weekly",
|
||||
(
|
||||
StepDefinition("collect", FakeStep(StepExecution(exit_code=3, error="bad input"))),
|
||||
StepDefinition("publish", downstream, depends_on=("collect",)),
|
||||
),
|
||||
)
|
||||
context = _context()
|
||||
journal = RunJournal.create(DataLayout(tmp_path), context)
|
||||
|
||||
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
|
||||
workflow, context=context, journal=journal
|
||||
)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.steps["publish"].status == "skipped"
|
||||
assert result.steps["publish"].reason == "dependency-failed"
|
||||
assert downstream.calls == []
|
||||
Reference in New Issue
Block a user