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.
- signals_engine-0.1.0/PKG-INFO +18 -0
- signals_engine-0.1.0/README.md +9 -0
- signals_engine-0.1.0/pyproject.toml +20 -0
- signals_engine-0.1.0/setup.cfg +4 -0
- signals_engine-0.1.0/src/signal_engine/__init__.py +1 -0
- signals_engine-0.1.0/src/signal_engine/cli.py +37 -0
- signals_engine-0.1.0/src/signal_engine/commands/__init__.py +0 -0
- signals_engine-0.1.0/src/signal_engine/commands/collect.py +110 -0
- signals_engine-0.1.0/src/signal_engine/commands/config.py +38 -0
- signals_engine-0.1.0/src/signal_engine/commands/diagnose.py +57 -0
- signals_engine-0.1.0/src/signal_engine/commands/lanes.py +20 -0
- signals_engine-0.1.0/src/signal_engine/commands/status.py +26 -0
- signals_engine-0.1.0/src/signal_engine/core/__init__.py +27 -0
- signals_engine-0.1.0/src/signal_engine/core/context.py +33 -0
- signals_engine-0.1.0/src/signal_engine/core/debuglog.py +23 -0
- signals_engine-0.1.0/src/signal_engine/core/errors.py +26 -0
- signals_engine-0.1.0/src/signal_engine/core/models.py +52 -0
- signals_engine-0.1.0/src/signal_engine/core/paths.py +24 -0
- signals_engine-0.1.0/src/signal_engine/lanes/__init__.py +4 -0
- signals_engine-0.1.0/src/signal_engine/lanes/registry.py +21 -0
- signals_engine-0.1.0/src/signal_engine/lanes/x_feed.py +237 -0
- signals_engine-0.1.0/src/signal_engine/output/__init__.py +0 -0
- signals_engine-0.1.0/src/signal_engine/runtime/__init__.py +1 -0
- signals_engine-0.1.0/src/signal_engine/runtime/collect.py +31 -0
- signals_engine-0.1.0/src/signal_engine/runtime/diagnose.py +192 -0
- signals_engine-0.1.0/src/signal_engine/runtime/run_manifest.py +76 -0
- signals_engine-0.1.0/src/signal_engine/runtime/status.py +54 -0
- signals_engine-0.1.0/src/signal_engine/signals/__init__.py +1 -0
- signals_engine-0.1.0/src/signal_engine/signals/frontmatter.py +51 -0
- signals_engine-0.1.0/src/signal_engine/signals/index.py +18 -0
- signals_engine-0.1.0/src/signal_engine/signals/render.py +117 -0
- signals_engine-0.1.0/src/signal_engine/signals/writer.py +21 -0
- signals_engine-0.1.0/src/signal_engine/sources/__init__.py +0 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/__init__.py +30 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/auth.py +117 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/client.py +155 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/errors.py +54 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/models.py +47 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/parser.py +203 -0
- signals_engine-0.1.0/src/signal_engine/sources/x/timeline.py +125 -0
- signals_engine-0.1.0/src/signal_engine/state/__init__.py +0 -0
- signals_engine-0.1.0/src/signal_engine/utils/__init__.py +0 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/PKG-INFO +18 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/SOURCES.txt +52 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/dependency_links.txt +1 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/entry_points.txt +2 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/requires.txt +2 -0
- signals_engine-0.1.0/src/signals_engine.egg-info/top_level.txt +1 -0
- signals_engine-0.1.0/tests/test_cli_entrypoint.py +52 -0
- signals_engine-0.1.0/tests/test_render.py +201 -0
- signals_engine-0.1.0/tests/test_runtime_debug_logging.py +61 -0
- signals_engine-0.1.0/tests/test_x_feed_collect.py +410 -0
- signals_engine-0.1.0/tests/test_x_feed_diagnose_native.py +110 -0
- 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,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 @@
|
|
|
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())
|
|
File without changes
|
|
@@ -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,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
|