forest-cli 0.1.0__py3-none-any.whl

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.
forest/manifest.py ADDED
@@ -0,0 +1,112 @@
1
+ """Manifest loading and ID resolution for forest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import polars as pl
8
+
9
+ _cache: dict[str, pl.DataFrame] = {}
10
+
11
+
12
+ class ManifestError(Exception):
13
+ """Raised for manifest loading or query errors."""
14
+
15
+
16
+ def load_manifest(path: str | Path) -> pl.DataFrame:
17
+ """Load a manifest CSV into a polars DataFrame.
18
+
19
+ The DataFrame is cached by resolved file path so that repeated
20
+ calls within the same process reuse the same object.
21
+
22
+ Raises FileNotFoundError with the path when the file does not exist.
23
+ """
24
+ path = Path(path)
25
+ if not path.exists():
26
+ raise FileNotFoundError(f"Manifest file not found: {path}")
27
+
28
+ key = str(path.resolve())
29
+ if key in _cache:
30
+ return _cache[key]
31
+
32
+ df: pl.DataFrame = pl.read_csv(path, infer_schema_length=0)
33
+
34
+ # Strip whitespace from column names
35
+ df = df.rename({col: col.strip() for col in df.columns})
36
+
37
+ # Strip whitespace from all string values
38
+ df = df.with_columns(pl.col(col).str.strip_chars() for col in df.columns)
39
+
40
+ _cache[key] = df
41
+ return df
42
+
43
+
44
+ def resolve_id(
45
+ manifest: pl.DataFrame,
46
+ id_value: str,
47
+ id_column: str,
48
+ stage_columns: dict[str, str],
49
+ stage_name: str | None = None,
50
+ ) -> list[tuple[str, str]]:
51
+ """Resolve a manifest ID to (stage, unit_id) pairs.
52
+
53
+ Filters the manifest by *id_column* == *id_value*, then for each
54
+ stage in *stage_columns* extracts the unique values from the
55
+ corresponding column. When *stage_name* is given only that
56
+ single stage is returned.
57
+
58
+ Raises ManifestError when *id_column* or a stage column is
59
+ missing from the DataFrame.
60
+ """
61
+ _check_column(manifest, id_column)
62
+
63
+ columns = stage_columns
64
+ if stage_name is not None:
65
+ columns = {stage_name: stage_columns[stage_name]}
66
+
67
+ for _stage, col in columns.items():
68
+ _check_column(manifest, col)
69
+
70
+ filtered = manifest.filter(pl.col(id_column) == id_value)
71
+
72
+ result: list[tuple[str, str]] = []
73
+ for stage, col in columns.items():
74
+ unit_ids = filtered[col].unique().sort().to_list()
75
+ for uid in unit_ids:
76
+ if uid is not None and str(uid).strip():
77
+ result.append((stage, uid))
78
+
79
+ return result
80
+
81
+
82
+ def list_ids(manifest: pl.DataFrame, id_column: str) -> list[str]:
83
+ """Return all unique IDs from the given column.
84
+
85
+ Raises ManifestError when *id_column* is missing from the DataFrame.
86
+ """
87
+ _check_column(manifest, id_column)
88
+ return manifest[id_column].unique().sort().to_list()
89
+
90
+
91
+ def get_metadata(
92
+ manifest: pl.DataFrame,
93
+ id_value: str,
94
+ id_column: str,
95
+ ) -> pl.DataFrame:
96
+ """Return all rows matching *id_value* in *id_column*.
97
+
98
+ Raises ManifestError when *id_column* is missing from the DataFrame.
99
+ """
100
+ _check_column(manifest, id_column)
101
+ return manifest.filter(pl.col(id_column) == id_value)
102
+
103
+
104
+ def clear_cache() -> None:
105
+ """Clear the module-level manifest cache."""
106
+ _cache.clear()
107
+
108
+
109
+ def _check_column(df: pl.DataFrame, column: str) -> None:
110
+ """Raise ManifestError if *column* is not in the DataFrame."""
111
+ if column not in df.columns:
112
+ raise ManifestError(f"Column '{column}' not found in manifest. Available columns: {df.columns}")
forest/metrics.py ADDED
@@ -0,0 +1,67 @@
1
+ """Operational metrics for forest transfers and rclone subprocess calls.
2
+
3
+ Two sinks, both keyed by the invocation ``run_id`` from ``forest.logger``:
4
+
5
+ - every sample is logged as a structured ``metric`` event on the
6
+ ``forest.metrics`` logger (visible via ``FOREST_LOG_FILE`` / ``FOREST_LOG_FORMAT``);
7
+ - when ``FOREST_METRICS_FILE`` is set, samples are appended as JSON lines so
8
+ external collectors (Prometheus textfile exporters, Datadog agents, ad-hoc
9
+ polars/jq analysis) can ingest them without parsing human output.
10
+
11
+ Sample shape:
12
+ ``{"ts": ..., "metric": "push.duration_seconds", "value": 1.23, "unit": "s",
13
+ "tags": {"stage": "raw", "remote": "origin"}, "run_id": "..."}``
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import time
21
+ from collections.abc import Iterator
22
+ from contextlib import contextmanager, suppress
23
+ from datetime import datetime, timezone
24
+ from typing import Any
25
+
26
+ from forest.logger import get_logger, get_run_id
27
+
28
+ _log = get_logger("forest.metrics")
29
+
30
+
31
+ def append_jsonl(env_var: str, record: dict[str, Any]) -> None:
32
+ """Append record as one JSON line to the file named by env var. Never raises."""
33
+ path = os.environ.get(env_var)
34
+ if not path:
35
+ return
36
+ with suppress(OSError), open(path, "a", encoding="utf-8") as fh:
37
+ fh.write(json.dumps(record, default=str) + "\n")
38
+
39
+
40
+ def emit(metric: str, value: float, *, unit: str = "", tags: dict[str, str] | None = None) -> None:
41
+ """Record one metric sample. Never raises: telemetry must not break syncs."""
42
+ sample = {
43
+ "ts": datetime.now(tz=timezone.utc).isoformat(),
44
+ "metric": metric,
45
+ "value": value,
46
+ "unit": unit,
47
+ "tags": tags or {},
48
+ "run_id": get_run_id(),
49
+ }
50
+ _log.info("metric", extra={"metric": metric, "value": value, "unit": unit, "tags": sample["tags"]})
51
+ append_jsonl("FOREST_METRICS_FILE", sample)
52
+
53
+
54
+ @contextmanager
55
+ def timed(metric: str, *, tags: dict[str, str] | None = None) -> Iterator[None]:
56
+ """Measure a block's wall time and emit it as ``<metric>`` in seconds."""
57
+ start = time.monotonic()
58
+ try:
59
+ yield
60
+ finally:
61
+ emit(metric, round(time.monotonic() - start, 3), unit="s", tags=tags)
62
+
63
+
64
+ def record_transfer(op: str, *, files: int, transferred_bytes: int, tags: dict[str, str] | None = None) -> None:
65
+ """Emit the standard sample pair for a completed push/pull unit."""
66
+ emit(f"{op}.files", files, tags=tags)
67
+ emit(f"{op}.bytes", transferred_bytes, unit="B", tags=tags)
forest/monitoring.py ADDED
@@ -0,0 +1,138 @@
1
+ """Error tracking and failure alerting for forest. Both opt-in, both no-ops
2
+ unless configured, and both best-effort: telemetry must never break a sync.
3
+
4
+ Error tracking (Sentry)
5
+ Set ``FOREST_SENTRY_DSN`` and install the ``observability`` extra
6
+ (``pip install "forest[observability]"``). Events carry the release
7
+ (``forest@<version>``), the invocation ``run_id`` and command as tags, and
8
+ breadcrumbs from the structured ``forest.*`` loggers (rclone attempts,
9
+ retries, transfer results), so a production stack trace arrives with the
10
+ full operation trail attached.
11
+
12
+ Alerting (webhook)
13
+ Set ``FOREST_ALERT_WEBHOOK`` to an HTTPS endpoint (Slack/Mattermost
14
+ incoming webhook or any JSON collector). Failed commands and failed
15
+ push/pull runs POST a compact JSON payload; scheduled syncs (cron/CI) then
16
+ page a channel instead of failing silently. See docs/runbooks/.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import platform
24
+ import socket
25
+ import urllib.request
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ from forest import __version__
30
+ from forest.logger import get_logger, get_run_id, scrub_obj
31
+
32
+ _log = get_logger("forest.monitoring")
33
+
34
+ _MAX_DETAIL_CHARS = 500
35
+ _WEBHOOK_TIMEOUT_SECONDS = 5.0
36
+
37
+ _sentry_enabled = False
38
+
39
+
40
+ def _scrub_event(event: Any, _hint: Any) -> Any:
41
+ """Sentry before_send hook: redact secret-shaped values from events."""
42
+ return scrub_obj(event)
43
+
44
+
45
+ def init_error_tracking(command: str) -> bool:
46
+ """Initialize Sentry when FOREST_SENTRY_DSN is set and the SDK is present."""
47
+ global _sentry_enabled
48
+ _sentry_enabled = False
49
+ dsn = os.environ.get("FOREST_SENTRY_DSN")
50
+ if not dsn:
51
+ return False
52
+ try:
53
+ import sentry_sdk
54
+ except ImportError:
55
+ _log.warning(
56
+ "FOREST_SENTRY_DSN is set but sentry-sdk is not installed; "
57
+ 'install with: pip install "forest[observability]"'
58
+ )
59
+ return False
60
+
61
+ sentry_sdk.init(
62
+ dsn=dsn,
63
+ release=f"forest@{__version__}",
64
+ before_send=_scrub_event,
65
+ # Default integrations include logging: INFO+ records from forest.*
66
+ # loggers become breadcrumbs, giving each event its operation trail.
67
+ )
68
+ sentry_sdk.set_tag("forest.run_id", get_run_id())
69
+ sentry_sdk.set_tag("forest.command", command)
70
+ sentry_sdk.set_context(
71
+ "forest",
72
+ {"version": __version__, "python": platform.python_version(), "platform": platform.platform()},
73
+ )
74
+ _sentry_enabled = True
75
+ return True
76
+
77
+
78
+ def capture_exception(exc: BaseException) -> None:
79
+ """Forward an exception to Sentry when error tracking is active."""
80
+ if not _sentry_enabled:
81
+ return
82
+ try:
83
+ import sentry_sdk
84
+
85
+ sentry_sdk.capture_exception(exc)
86
+ sentry_sdk.flush(timeout=2.0)
87
+ except Exception:
88
+ _log.warning("failed to deliver exception to Sentry", exc_info=True)
89
+
90
+
91
+ def alert(event: str, detail: str, *, command: str) -> bool:
92
+ """POST a failure notification to FOREST_ALERT_WEBHOOK. Returns delivery."""
93
+ url = os.environ.get("FOREST_ALERT_WEBHOOK")
94
+ if not url:
95
+ return False
96
+ if not url.lower().startswith("https://"):
97
+ _log.warning("FOREST_ALERT_WEBHOOK must be an https:// URL; alert not sent")
98
+ return False
99
+
100
+ detail = (detail or "").strip()
101
+ if len(detail) > _MAX_DETAIL_CHARS:
102
+ detail = detail[:_MAX_DETAIL_CHARS] + "..."
103
+
104
+ payload: dict[str, Any] = {
105
+ "source": "forest",
106
+ "version": __version__,
107
+ "event": event,
108
+ "command": command,
109
+ "detail": detail,
110
+ "run_id": get_run_id(),
111
+ "host": socket.gethostname(),
112
+ "ts": datetime.now(tz=timezone.utc).isoformat(),
113
+ # Slack/Mattermost-compatible summary line.
114
+ "text": f"forest {command} failed on {socket.gethostname()}: {event} (run {get_run_id()})",
115
+ }
116
+
117
+ request = urllib.request.Request(
118
+ url,
119
+ data=json.dumps(scrub_obj(payload)).encode("utf-8"),
120
+ headers={"Content-Type": "application/json", "User-Agent": f"forest/{__version__}"},
121
+ method="POST",
122
+ )
123
+ try:
124
+ with urllib.request.urlopen(request, timeout=_WEBHOOK_TIMEOUT_SECONDS): # nosec B310 - https enforced above
125
+ pass
126
+ except Exception as exc:
127
+ _log.warning("alert webhook delivery failed", extra={"error": str(exc)})
128
+ return False
129
+ _log.info("alert delivered", extra={"event": event, "command": command})
130
+ return True
131
+
132
+
133
+ def report_failure(event: str, detail: str, *, command: str, exc: BaseException | None = None) -> None:
134
+ """One-stop failure hook: structured log + Sentry + webhook alert."""
135
+ _log.error(event, extra={"command": command, "detail": (detail or "")[:_MAX_DETAIL_CHARS]})
136
+ if exc is not None:
137
+ capture_exception(exc)
138
+ alert(event, detail, command=command)
forest/paths.py ADDED
@@ -0,0 +1,192 @@
1
+ """Path resolution and unit discovery for forest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ import polars as pl
8
+
9
+ from forest.config import ProjectConfig, StageConfig
10
+ from forest.manifest import resolve_id
11
+
12
+ _JUNK_NAMES = {".DS_Store"}
13
+
14
+
15
+ def list_sync_files(unit_path: str | Path) -> list[Path]:
16
+ """Return all files under a data unit, skipping OS junk.
17
+
18
+ Schema-free replacement for the old schema-driven sync filter.
19
+ """
20
+ unit_path = Path(unit_path)
21
+ if not unit_path.is_dir():
22
+ return []
23
+ files: list[Path] = []
24
+ for f in unit_path.rglob("*"):
25
+ if not f.is_file():
26
+ continue
27
+ if f.name in _JUNK_NAMES or f.name.startswith("._") or f.suffix == ".tmp":
28
+ continue
29
+ files.append(f)
30
+ return sorted(files)
31
+
32
+
33
+ def _required_stage_path(stage_cfg: StageConfig) -> Path:
34
+ """Return a configured stage path or raise a user-facing value error."""
35
+ if stage_cfg.path is None:
36
+ raise ValueError("Stage path is not configured. Bind the stage before using local files.")
37
+ return stage_cfg.path
38
+
39
+
40
+ def _effective_remote_base(stage_cfg: StageConfig) -> Path:
41
+ """Return the remote base path for a stage.
42
+
43
+ When ``remote_path`` is set on the stage, it is used for the remote
44
+ mapping. Otherwise the stage ``path`` is used (default behaviour).
45
+ """
46
+ if stage_cfg.remote_path is not None:
47
+ return stage_cfg.remote_path
48
+ return _required_stage_path(stage_cfg)
49
+
50
+
51
+ def resolve_manifest_pairs(
52
+ config: ProjectConfig,
53
+ manifest: pl.DataFrame,
54
+ id_value: str,
55
+ stage_name: str | None = None,
56
+ ) -> list[tuple[str, str]]:
57
+ """Resolve a manifest ID to (stage, unit_id) pairs using the project config.
58
+
59
+ Wraps :func:`forest.manifest.resolve_id` with the ``id_column`` and
60
+ ``stage_columns`` plumbing so callers never repeat it.
61
+
62
+ Raises ``ValueError`` when the project config has no manifest section.
63
+ """
64
+ if config.manifest is None:
65
+ raise ValueError("Manifest configuration is required for ID-based resolution")
66
+ return resolve_id(manifest, id_value, config.manifest.id_column, config.manifest.stage_columns, stage_name)
67
+
68
+
69
+ def resolve_paths(
70
+ config: ProjectConfig,
71
+ manifest: pl.DataFrame,
72
+ id_value: str,
73
+ stage_name: str | None = None,
74
+ ) -> list[tuple[str, str, Path, str]]:
75
+ """Resolve a manifest ID to (stage, unit_id, local_path, remote_path) tuples.
76
+
77
+ Uses the manifest's ``id_column`` and ``stage_columns`` from the project
78
+ config to map *id_value* to concrete (stage, unit_id) pairs, then
79
+ computes local and remote paths for each.
80
+
81
+ *local_path* is a relative ``Path`` (stage_path / unit_id).
82
+ *remote_path* is the POSIX string form of the remote-side relative path,
83
+ which uses ``remote_path`` when set on the stage config.
84
+
85
+ Raises ``ValueError`` when the project config has no manifest section.
86
+ """
87
+ pairs = resolve_manifest_pairs(config, manifest, id_value, stage_name)
88
+
89
+ result: list[tuple[str, str, Path, str]] = []
90
+ for stage, unit_id in pairs:
91
+ stage_cfg = config.stages[stage]
92
+ stage_path = _required_stage_path(stage_cfg)
93
+ local_path = stage_path if stage_cfg.sync_by == "directory" else stage_path / unit_id
94
+ remote_base = _effective_remote_base(stage_cfg)
95
+ remote_rel = remote_base if stage_cfg.sync_by == "directory" else remote_base / unit_id
96
+ remote_path = PurePosixPath(remote_rel).as_posix()
97
+ result.append((stage, unit_id, local_path, remote_path))
98
+
99
+ return result
100
+
101
+
102
+ def resolve_stage_paths(
103
+ config: ProjectConfig,
104
+ stage_name: str,
105
+ unit_ids: list[str],
106
+ ) -> list[tuple[str, Path, str]]:
107
+ """Resolve unit IDs in a stage to (unit_id, local_path, remote_path) tuples.
108
+
109
+ *local_path* is a relative ``Path`` (stage_path / unit_id).
110
+ *remote_path* is the POSIX string form of the remote-side relative path,
111
+ which uses ``remote_path`` when set on the stage config.
112
+
113
+ Raises ``KeyError`` when *stage_name* is not in the config stages.
114
+ """
115
+ stage_cfg = config.stages[stage_name]
116
+ stage_path = _required_stage_path(stage_cfg)
117
+ remote_base = _effective_remote_base(stage_cfg)
118
+
119
+ result: list[tuple[str, Path, str]] = []
120
+ for unit_id in unit_ids:
121
+ local_path = stage_path if stage_cfg.sync_by == "directory" else stage_path / unit_id
122
+ remote_rel = remote_base if stage_cfg.sync_by == "directory" else remote_base / unit_id
123
+ remote_path = PurePosixPath(remote_rel).as_posix()
124
+ result.append((unit_id, local_path, remote_path))
125
+
126
+ return result
127
+
128
+
129
+ def build_remote_path(
130
+ remote_url: str,
131
+ local_path: str | Path,
132
+ project_root: str | Path,
133
+ *,
134
+ remote_path_override: str | Path | None = None,
135
+ ) -> str:
136
+ """Build a full remote path from a remote URL and a local path.
137
+
138
+ When *remote_path_override* is given, it is used as the relative
139
+ portion instead of computing from *local_path* / *project_root*.
140
+
141
+ Otherwise computes the relative portion of *local_path* with respect
142
+ to *project_root* and appends it to *remote_url*.
143
+
144
+ If *local_path* is already relative, it is used as-is.
145
+ """
146
+ if remote_path_override is not None:
147
+ return remote_url.rstrip("/") + "/" + PurePosixPath(str(remote_path_override)).as_posix()
148
+
149
+ local_path = Path(local_path)
150
+ project_root = Path(project_root)
151
+
152
+ relative = local_path.relative_to(project_root) if local_path.is_absolute() else local_path
153
+
154
+ return remote_url.rstrip("/") + "/" + PurePosixPath(relative).as_posix()
155
+
156
+
157
+ def discover_units(
158
+ stage_config: StageConfig,
159
+ project_root: str | Path,
160
+ ) -> list[tuple[str, Path]]:
161
+ """Discover data units in a stage directory on disk.
162
+
163
+ Returns a sorted list of ``(unit_id, local_path)`` tuples where
164
+ *local_path* is the absolute path to the unit.
165
+
166
+ Handles ``sync_by`` modes:
167
+
168
+ - ``directory`` — the entire stage path is one unit.
169
+ - ``subdirectory`` — each child directory is a separate unit.
170
+ - ``file`` — each file in the stage directory is a unit.
171
+
172
+ Returns an empty list when the stage directory does not exist.
173
+ """
174
+ project_root = Path(project_root)
175
+ configured_stage_path = _required_stage_path(stage_config)
176
+ stage_path = project_root / configured_stage_path
177
+
178
+ if not stage_path.is_dir():
179
+ return []
180
+
181
+ if stage_config.sync_by == "directory":
182
+ return [(configured_stage_path.name, stage_path)]
183
+
184
+ if stage_config.sync_by == "subdirectory":
185
+ children = sorted(stage_path.iterdir())
186
+ return [(child.name, child) for child in children if child.is_dir()]
187
+
188
+ if stage_config.sync_by == "file":
189
+ children = sorted(stage_path.iterdir())
190
+ return [(child.name, child) for child in children if child.is_file()]
191
+
192
+ return []