signals-engine 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. signals_engine-0.1.0/PKG-INFO +18 -0
  2. signals_engine-0.1.0/README.md +9 -0
  3. signals_engine-0.1.0/pyproject.toml +20 -0
  4. signals_engine-0.1.0/setup.cfg +4 -0
  5. signals_engine-0.1.0/src/signal_engine/__init__.py +1 -0
  6. signals_engine-0.1.0/src/signal_engine/cli.py +37 -0
  7. signals_engine-0.1.0/src/signal_engine/commands/__init__.py +0 -0
  8. signals_engine-0.1.0/src/signal_engine/commands/collect.py +110 -0
  9. signals_engine-0.1.0/src/signal_engine/commands/config.py +38 -0
  10. signals_engine-0.1.0/src/signal_engine/commands/diagnose.py +57 -0
  11. signals_engine-0.1.0/src/signal_engine/commands/lanes.py +20 -0
  12. signals_engine-0.1.0/src/signal_engine/commands/status.py +26 -0
  13. signals_engine-0.1.0/src/signal_engine/core/__init__.py +27 -0
  14. signals_engine-0.1.0/src/signal_engine/core/context.py +33 -0
  15. signals_engine-0.1.0/src/signal_engine/core/debuglog.py +23 -0
  16. signals_engine-0.1.0/src/signal_engine/core/errors.py +26 -0
  17. signals_engine-0.1.0/src/signal_engine/core/models.py +52 -0
  18. signals_engine-0.1.0/src/signal_engine/core/paths.py +24 -0
  19. signals_engine-0.1.0/src/signal_engine/lanes/__init__.py +4 -0
  20. signals_engine-0.1.0/src/signal_engine/lanes/registry.py +21 -0
  21. signals_engine-0.1.0/src/signal_engine/lanes/x_feed.py +237 -0
  22. signals_engine-0.1.0/src/signal_engine/output/__init__.py +0 -0
  23. signals_engine-0.1.0/src/signal_engine/runtime/__init__.py +1 -0
  24. signals_engine-0.1.0/src/signal_engine/runtime/collect.py +31 -0
  25. signals_engine-0.1.0/src/signal_engine/runtime/diagnose.py +192 -0
  26. signals_engine-0.1.0/src/signal_engine/runtime/run_manifest.py +76 -0
  27. signals_engine-0.1.0/src/signal_engine/runtime/status.py +54 -0
  28. signals_engine-0.1.0/src/signal_engine/signals/__init__.py +1 -0
  29. signals_engine-0.1.0/src/signal_engine/signals/frontmatter.py +51 -0
  30. signals_engine-0.1.0/src/signal_engine/signals/index.py +18 -0
  31. signals_engine-0.1.0/src/signal_engine/signals/render.py +117 -0
  32. signals_engine-0.1.0/src/signal_engine/signals/writer.py +21 -0
  33. signals_engine-0.1.0/src/signal_engine/sources/__init__.py +0 -0
  34. signals_engine-0.1.0/src/signal_engine/sources/x/__init__.py +30 -0
  35. signals_engine-0.1.0/src/signal_engine/sources/x/auth.py +117 -0
  36. signals_engine-0.1.0/src/signal_engine/sources/x/client.py +155 -0
  37. signals_engine-0.1.0/src/signal_engine/sources/x/errors.py +54 -0
  38. signals_engine-0.1.0/src/signal_engine/sources/x/models.py +47 -0
  39. signals_engine-0.1.0/src/signal_engine/sources/x/parser.py +203 -0
  40. signals_engine-0.1.0/src/signal_engine/sources/x/timeline.py +125 -0
  41. signals_engine-0.1.0/src/signal_engine/state/__init__.py +0 -0
  42. signals_engine-0.1.0/src/signal_engine/utils/__init__.py +0 -0
  43. signals_engine-0.1.0/src/signals_engine.egg-info/PKG-INFO +18 -0
  44. signals_engine-0.1.0/src/signals_engine.egg-info/SOURCES.txt +52 -0
  45. signals_engine-0.1.0/src/signals_engine.egg-info/dependency_links.txt +1 -0
  46. signals_engine-0.1.0/src/signals_engine.egg-info/entry_points.txt +2 -0
  47. signals_engine-0.1.0/src/signals_engine.egg-info/requires.txt +2 -0
  48. signals_engine-0.1.0/src/signals_engine.egg-info/top_level.txt +1 -0
  49. signals_engine-0.1.0/tests/test_cli_entrypoint.py +52 -0
  50. signals_engine-0.1.0/tests/test_render.py +201 -0
  51. signals_engine-0.1.0/tests/test_runtime_debug_logging.py +61 -0
  52. signals_engine-0.1.0/tests/test_x_feed_collect.py +410 -0
  53. signals_engine-0.1.0/tests/test_x_feed_diagnose_native.py +110 -0
  54. signals_engine-0.1.0/tests/test_x_source.py +595 -0
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: signals-engine
3
+ Version: 0.1.0
4
+ Summary: Python collect CLI for signal-oriented collection lanes
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: PyYAML>=6.0
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # Signal Engine
11
+
12
+ Python collect CLI for signal-oriented collection lanes.
13
+
14
+ ## v1 scope
15
+ - collect-only runtime
16
+ - signal markdown / index / state outputs
17
+ - thin run.json manifest
18
+ - first migration target: x-feed
@@ -0,0 +1,9 @@
1
+ # Signal Engine
2
+
3
+ Python collect CLI for signal-oriented collection lanes.
4
+
5
+ ## v1 scope
6
+ - collect-only runtime
7
+ - signal markdown / index / state outputs
8
+ - thin run.json manifest
9
+ - first migration target: x-feed
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "signals-engine"
7
+ version = "0.1.0"
8
+ description = "Python collect CLI for signal-oriented collection lanes"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["PyYAML>=6.0", "httpx>=0.27.0"]
12
+
13
+ [project.scripts]
14
+ signal-engine = "signal_engine.cli:main"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,37 @@
1
+ """Signal Engine CLI entry point."""
2
+ import argparse
3
+ import sys
4
+
5
+ from .commands import collect, diagnose, status, lanes, config
6
+
7
+
8
+ def main() -> int:
9
+ parser = argparse.ArgumentParser(prog="signal-engine")
10
+ sub = parser.add_subparsers(dest="command", required=True)
11
+
12
+ collect.add_parser(sub)
13
+ diagnose.add_parser(sub)
14
+ status.add_parser(sub)
15
+ lanes.add_parser(sub)
16
+ config.add_parser(sub)
17
+
18
+ args = parser.parse_args()
19
+
20
+ if args.command is None:
21
+ parser.print_help()
22
+ return 1
23
+
24
+ return COMMANDS[args.command](args)
25
+
26
+
27
+ COMMANDS: dict[str, callable] = {
28
+ "collect": collect.run,
29
+ "diagnose": diagnose.run,
30
+ "status": status.run,
31
+ "lanes": lanes.run,
32
+ "config": config.run,
33
+ }
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
1
+ """collect command."""
2
+ import argparse
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from ..core import RunContext, ConfigError, RunStatus
7
+
8
+
9
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
10
+ p = sub.add_parser("collect", help="Collect signals for a lane")
11
+ p.add_argument("--lane", required=True, help="Lane name (e.g. x-feed)")
12
+ p.add_argument("--date", default=None, help="Date in YYYY-MM-DD format (default: today)")
13
+ p.add_argument("--data-dir", default=None, help="Data directory path")
14
+ p.add_argument("--config", default=None, help="Config file path")
15
+ p.add_argument(
16
+ "--debug-log",
17
+ default=None,
18
+ help="Path to debug log file (default: <data-dir>/debug.log)",
19
+ )
20
+ return p
21
+
22
+
23
+ def load_config(config_path: str | None) -> dict:
24
+ """Load lanes config from yaml."""
25
+ if config_path:
26
+ path = Path(config_path).expanduser()
27
+ else:
28
+ default_config = os.environ.get(
29
+ "DAILY_LANE_CONFIG",
30
+ str(Path.home() / ".daily-lane" / "config" / "lanes.yaml")
31
+ )
32
+ path = Path(default_config)
33
+
34
+ if not path.exists():
35
+ raise ConfigError(f"Config file not found: {path}")
36
+
37
+ with open(path) as f:
38
+ import yaml
39
+ return yaml.safe_load(f)
40
+
41
+
42
+ def run(args: argparse.Namespace) -> int:
43
+ """Execute the collect command.
44
+
45
+ Exit codes:
46
+ 0 = SUCCESS or EMPTY (normal completion)
47
+ 1 = FAILED or exception
48
+ """
49
+ import sys
50
+ from datetime import date
51
+ from ..core.debuglog import debug_log
52
+ from ..runtime.collect import collect_lane
53
+
54
+ lane = args.lane
55
+ run_date = args.date or date.today().isoformat()
56
+
57
+ if args.data_dir:
58
+ data_dir = Path(args.data_dir).expanduser()
59
+ else:
60
+ data_dir = Path(os.environ.get(
61
+ "DAILY_LANE_DATA_DIR",
62
+ str(Path.home() / ".daily-lane-data")
63
+ ))
64
+
65
+ # Debug log path
66
+ if args.debug_log:
67
+ debug_log_path = Path(args.debug_log)
68
+ else:
69
+ debug_log_path = data_dir / "debug.log"
70
+
71
+ config = load_config(args.config)
72
+ ctx = RunContext(
73
+ lane=lane,
74
+ date=run_date,
75
+ data_dir=data_dir,
76
+ config=config,
77
+ debug_log_path=debug_log_path,
78
+ )
79
+ ctx.ensure_dirs()
80
+
81
+ debug_log(f"[collect] START lane={lane} date={run_date} data_dir={data_dir}", log_file=debug_log_path)
82
+
83
+ try:
84
+ result = collect_lane(ctx)
85
+ debug_log(
86
+ f"[collect] END status={result.status.value} signals={result.signals_written} "
87
+ f"session={result.session_id or 'n/a'}",
88
+ log_file=debug_log_path,
89
+ )
90
+ if result.errors:
91
+ for err in result.errors:
92
+ debug_log(f"[collect] ERROR: {err}", log_file=debug_log_path)
93
+
94
+ print(
95
+ f"[{result.status.value}] {lane}/{run_date}: "
96
+ f"{result.signals_written} signals, "
97
+ f"session={result.session_id or 'n/a'}",
98
+ file=sys.stderr,
99
+ )
100
+ if result.errors:
101
+ for err in result.errors:
102
+ print(f" ERROR: {err}", file=sys.stderr)
103
+
104
+ # Exit code: 0 for SUCCESS/EMPTY, 1 for FAILED
105
+ return 0 if result.status in (RunStatus.SUCCESS, RunStatus.EMPTY) else 1
106
+
107
+ except Exception as e:
108
+ debug_log(f"[collect] EXCEPTION: {e}", log_file=debug_log_path)
109
+ print(f"ERROR: {e}", file=sys.stderr)
110
+ return 1
@@ -0,0 +1,38 @@
1
+ """config command."""
2
+ import argparse
3
+
4
+
5
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
6
+ p = sub.add_parser("config", help="Config operations")
7
+ sub2 = p.add_subparsers(dest="subcommand")
8
+ sub2.add_parser("check", help="Check config file")
9
+ return p
10
+
11
+
12
+ def run(args: argparse.Namespace) -> int:
13
+ """Execute the config command."""
14
+ import sys
15
+ import yaml
16
+ import os
17
+ from pathlib import Path
18
+
19
+ if args.subcommand == "check" or args.subcommand is None:
20
+ default_config = os.environ.get(
21
+ "DAILY_LANE_CONFIG",
22
+ str(Path.home() / ".daily-lane" / "config" / "lanes.yaml")
23
+ )
24
+ path = Path(default_config)
25
+ if not path.exists():
26
+ print(f"ERROR: config not found: {path}", file=sys.stderr)
27
+ return 1
28
+ try:
29
+ with open(path) as f:
30
+ data = yaml.safe_load(f)
31
+ lanes = list(data.get("lanes", {}).keys())
32
+ print(f"OK: {path}", file=sys.stdout)
33
+ print(f"Lanes: {', '.join(lanes)}", file=sys.stdout)
34
+ return 0
35
+ except Exception as e:
36
+ print(f"ERROR: {e}", file=sys.stderr)
37
+ return 1
38
+ return 0
@@ -0,0 +1,57 @@
1
+ """diagnose command."""
2
+ import argparse
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
10
+ p = sub.add_parser("diagnose", help="Run diagnostics for a lane")
11
+ p.add_argument("--lane", required=True, help="Lane name")
12
+ p.add_argument("--data-dir", default=None, help="Data directory path")
13
+ p.add_argument("--config", default=None, help="Config file path")
14
+ p.add_argument(
15
+ "--debug-log",
16
+ default=None,
17
+ help="Path to debug log file (default: <data-dir>/debug.log)",
18
+ )
19
+ return p
20
+
21
+
22
+ def run(args: argparse.Namespace) -> int:
23
+ """Execute the diagnose command."""
24
+ import sys
25
+ from ..core.debuglog import debug_log
26
+ from ..runtime.diagnose import diagnose_lane
27
+
28
+ data_dir = Path(args.data_dir) if args.data_dir else None
29
+ debug_log_path = None
30
+ if args.debug_log:
31
+ debug_log_path = Path(args.debug_log)
32
+ elif data_dir:
33
+ debug_log_path = data_dir / "debug.log"
34
+
35
+ config = None
36
+ config_path = args.config or os.environ.get(
37
+ "DAILY_LANE_CONFIG",
38
+ str(Path.home() / ".daily-lane" / "config" / "lanes.yaml")
39
+ )
40
+ if Path(config_path).exists():
41
+ try:
42
+ with open(config_path) as f:
43
+ config = yaml.safe_load(f)
44
+ except Exception:
45
+ pass
46
+
47
+ debug_log(f"[diagnose] START lane={args.lane} data_dir={data_dir}", log_file=debug_log_path)
48
+
49
+ try:
50
+ result = diagnose_lane(args.lane, data_dir=data_dir, config=config)
51
+ debug_log(f"[diagnose] END exit_code={result.exit_code}", log_file=debug_log_path)
52
+ print(result.output, file=sys.stdout)
53
+ return 0 if result.exit_code == 0 else 1
54
+ except Exception as e:
55
+ debug_log(f"[diagnose] EXCEPTION: {e}", log_file=debug_log_path)
56
+ print(f"ERROR: {e}", file=sys.stderr)
57
+ return 1
@@ -0,0 +1,20 @@
1
+ """lanes command."""
2
+ import argparse
3
+
4
+
5
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
6
+ p = sub.add_parser("lanes", help="List available lanes")
7
+ sub2 = p.add_subparsers(dest="subcommand")
8
+ sub2.add_parser("list", help="List lanes")
9
+ return p
10
+
11
+
12
+ def run(args: argparse.Namespace) -> int:
13
+ """Execute the lanes command."""
14
+ import sys
15
+ from ..lanes.registry import LANE_REGISTRY
16
+ if args.subcommand == "list" or args.subcommand is None:
17
+ for name in LANE_REGISTRY:
18
+ print(name, file=sys.stdout)
19
+ return 0
20
+ return 0
@@ -0,0 +1,26 @@
1
+ """status command."""
2
+ import argparse
3
+ from pathlib import Path
4
+
5
+
6
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
7
+ p = sub.add_parser("status", help="Show status for a lane run")
8
+ p.add_argument("--lane", required=True, help="Lane name")
9
+ p.add_argument("--date", required=True, help="Date in YYYY-MM-DD format")
10
+ p.add_argument("--data-dir", default=None, help="Data directory path")
11
+ return p
12
+
13
+
14
+ def run(args: argparse.Namespace) -> int:
15
+ """Execute the status command."""
16
+ import sys
17
+ import json
18
+ from ..runtime.status import get_run_status
19
+ data_dir = Path(args.data_dir) if args.data_dir else None
20
+ try:
21
+ result = get_run_status(args.lane, args.date, data_dir=data_dir)
22
+ print(json.dumps(result, indent=2), file=sys.stdout)
23
+ return 0
24
+ except Exception as e:
25
+ print(f"ERROR: {e}", file=sys.stderr)
26
+ return 1
@@ -0,0 +1,27 @@
1
+ """Core module: models, context, paths, errors."""
2
+ from .models import RunStatus, SignalRecord, RunResult
3
+ from .context import RunContext
4
+ from .paths import signal_file_path, index_file_path, run_json_path, state_file_path
5
+ from .errors import (
6
+ SignalEngineError,
7
+ SourceError,
8
+ ConfigError,
9
+ RenderError,
10
+ WriteError,
11
+ )
12
+
13
+ __all__ = [
14
+ "RunStatus",
15
+ "SignalRecord",
16
+ "RunResult",
17
+ "RunContext",
18
+ "signal_file_path",
19
+ "index_file_path",
20
+ "run_json_path",
21
+ "state_file_path",
22
+ "SignalEngineError",
23
+ "SourceError",
24
+ "ConfigError",
25
+ "RenderError",
26
+ "WriteError",
27
+ ]
@@ -0,0 +1,33 @@
1
+ """Run context for Signal Engine."""
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+
6
+ @dataclass
7
+ class RunContext:
8
+ """Immutable run context passed through the collect pipeline."""
9
+ lane: str
10
+ date: str
11
+ data_dir: Path
12
+ config: dict
13
+ debug_log_path: Path | None = field(default=None)
14
+
15
+ @property
16
+ def signals_dir(self) -> Path:
17
+ return self.data_dir / "signals" / self.lane / self.date / "signals"
18
+
19
+ @property
20
+ def state_dir(self) -> Path:
21
+ return self.data_dir / "signals" / self.lane / "state"
22
+
23
+ @property
24
+ def index_path(self) -> Path:
25
+ return self.data_dir / "signals" / self.lane / self.date / "index.md"
26
+
27
+ @property
28
+ def run_json_path(self) -> Path:
29
+ return self.data_dir / "signals" / self.lane / self.date / "run.json"
30
+
31
+ def ensure_dirs(self) -> None:
32
+ self.signals_dir.mkdir(parents=True, exist_ok=True)
33
+ self.state_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,23 @@
1
+ """Minimal debug logging helper.
2
+
3
+ Writes to stderr and optionally appends to a log file.
4
+ No log rotation, no structured JSON — just plain text lines.
5
+ """
6
+ from pathlib import Path
7
+ import sys
8
+
9
+
10
+ def debug_log(message: str, log_file: Path | str | None = None) -> None:
11
+ """Write a debug line to stderr and optionally append to a log file.
12
+
13
+ Args:
14
+ message: The debug message to log.
15
+ log_file: Optional path to a file to append to. If None, only stderr.
16
+ """
17
+ line = f"[debug] {message}"
18
+ print(line, file=sys.stderr)
19
+ if log_file is not None:
20
+ p = Path(log_file).expanduser()
21
+ p.parent.mkdir(parents=True, exist_ok=True)
22
+ with open(p, "a") as f:
23
+ f.write(line + "\n")
@@ -0,0 +1,26 @@
1
+ """Custom errors for Signal Engine."""
2
+
3
+
4
+ class SignalEngineError(Exception):
5
+ """Base exception for all Signal Engine errors."""
6
+ pass
7
+
8
+
9
+ class SourceError(SignalEngineError):
10
+ """Raised when a data source fails to return usable data."""
11
+ pass
12
+
13
+
14
+ class ConfigError(SignalEngineError):
15
+ """Raised when configuration is invalid or missing."""
16
+ pass
17
+
18
+
19
+ class RenderError(SignalEngineError):
20
+ """Raised when rendering a derived artifact fails."""
21
+ pass
22
+
23
+
24
+ class WriteError(SignalEngineError):
25
+ """Raised when writing an output file fails."""
26
+ pass
@@ -0,0 +1,52 @@
1
+ """Core data models for Signal Engine."""
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+
5
+
6
+ class RunStatus(str, Enum):
7
+ SUCCESS = "success"
8
+ FAILED = "failed"
9
+ EMPTY = "empty"
10
+
11
+
12
+ @dataclass
13
+ class SignalRecord:
14
+ # Core fixed fields (align with v1 spec)
15
+ lane: str
16
+ signal_type: str
17
+ source: str
18
+ entity_type: str
19
+ entity_id: str
20
+ title: str
21
+ source_url: str
22
+ fetched_at: str
23
+ file_path: str | None = None
24
+
25
+ # x-feed explicit internal fields
26
+ session_id: str = "" # set at collect time for frontmatter compatibility
27
+ handle: str = ""
28
+ post_id: str = ""
29
+ created_at: str = ""
30
+ position: int = 0
31
+ text_preview: str = ""
32
+ likes: int = 0
33
+ retweets: int = 0
34
+ replies: int = 0
35
+ views: int = 0
36
+
37
+
38
+ @dataclass
39
+ class RunResult:
40
+ lane: str
41
+ date: str
42
+ status: RunStatus
43
+ started_at: str
44
+ session_id: str | None = None
45
+ finished_at: str | None = None
46
+ warnings: list[str] = field(default_factory=list)
47
+ errors: list[str] = field(default_factory=list)
48
+ signal_records: list[SignalRecord] = field(default_factory=list)
49
+ repos_checked: int = 0
50
+ signals_written: int = 0
51
+ signal_types_count: dict[str, int] = field(default_factory=dict)
52
+ index_file: str | None = None
@@ -0,0 +1,24 @@
1
+ """Path construction utilities for Signal Engine."""
2
+ from pathlib import Path
3
+
4
+
5
+ def signal_file_path(lane: str, date: str, filename: str) -> Path:
6
+ """Return the full path to a signal file under a lane/date/signals/ directory."""
7
+ return Path("signals") / lane / date / "signals" / filename
8
+
9
+
10
+ def index_file_path(lane: str, date: str) -> Path:
11
+ """Return the full path to an index.md under a lane/date/ directory."""
12
+ return Path("signals") / lane / date / "index.md"
13
+
14
+
15
+ def run_json_path(lane: str, date: str) -> Path:
16
+ """Return the full path to a run.json under a lane/date/ directory."""
17
+ return Path("signals") / lane / date / "run.json"
18
+
19
+
20
+ def state_file_path(lane: str, owner: str, repo: str, file_type: str) -> Path:
21
+ """Return the full path to a state file."""
22
+ safe_owner = owner.replace("/", "__")
23
+ safe_repo = repo.replace("/", "__")
24
+ return Path("signals") / lane / "state" / f"{safe_owner}__{safe_repo}__{file_type}.md"
@@ -0,0 +1,4 @@
1
+ """Lanes module: registry and lane implementations."""
2
+ from .registry import LANE_REGISTRY, get_lane_collector
3
+
4
+ __all__ = ["LANE_REGISTRY", "get_lane_collector"]
@@ -0,0 +1,21 @@
1
+ """Lane registry — maps lane name to collector callable."""
2
+ from typing import Callable
3
+ from ..core import RunResult, RunContext
4
+
5
+
6
+ CollectorFn = Callable[[RunContext], RunResult]
7
+
8
+
9
+ LANE_REGISTRY: dict[str, CollectorFn | None] = {
10
+ "x-feed": None, # filled by lanes.x_feed
11
+ }
12
+
13
+
14
+ def get_lane_collector(lane: str) -> CollectorFn | None:
15
+ """Return the collector for a given lane, or None if not registered."""
16
+ return LANE_REGISTRY.get(lane)
17
+
18
+
19
+ def register_lane(lane: str, collector: CollectorFn) -> None:
20
+ """Register a lane collector at runtime."""
21
+ LANE_REGISTRY[lane] = collector