feat: consolidate legacy workflows into gyxx-flow

This commit is contained in:
2026-07-28 14:51:15 +08:00
commit c23b62a8c8
374 changed files with 132990 additions and 0 deletions
+276
View File
@@ -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")) == []
+156
View File
@@ -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)
+86
View File
@@ -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)
+58
View File
@@ -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()) == []
+358
View File
@@ -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()
+122
View File
@@ -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
+43
View File
@@ -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
+200
View File
@@ -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
+117
View File
@@ -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")) == []
+98
View File
@@ -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
+42
View File
@@ -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 == []
+339
View File
@@ -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)])
+77
View File
@@ -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
+66
View File
@@ -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",
)
+221
View File
@@ -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", "&not-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",
"&not-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
+133
View File
@@ -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
+82
View File
@@ -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
+242
View File
@@ -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",
}
+42
View File
@@ -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
+429
View File
@@ -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"]
+81
View File
@@ -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
+252
View File
@@ -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)
+47
View File
@@ -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
+147
View File
@@ -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())
+85
View File
@@ -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"
+93
View File
@@ -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"}
+133
View File
@@ -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")
]
+270
View File
@@ -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"))
+93
View File
@@ -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
+38
View File
@@ -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
+240
View File
@@ -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()
)
+201
View File
@@ -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
+62
View File
@@ -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)
+42
View File
@@ -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
+144
View File
@@ -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"
+365
View File
@@ -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 == []