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/__init__.py +16 -0
- forest/analytics.py +29 -0
- forest/checkout.py +992 -0
- forest/cli.py +2747 -0
- forest/config.py +286 -0
- forest/flags.py +20 -0
- forest/flow.py +157 -0
- forest/fsutil.py +36 -0
- forest/logger.py +197 -0
- forest/manifest.py +112 -0
- forest/metrics.py +67 -0
- forest/monitoring.py +138 -0
- forest/paths.py +192 -0
- forest/rclone.py +561 -0
- forest/resilience.py +122 -0
- forest/sync_state.py +182 -0
- forest_cli-0.1.0.dist-info/METADATA +233 -0
- forest_cli-0.1.0.dist-info/RECORD +22 -0
- forest_cli-0.1.0.dist-info/WHEEL +5 -0
- forest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- forest_cli-0.1.0.dist-info/top_level.txt +1 -0
forest/config.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Configuration models and loaders for forest.yaml and .forest/local.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
from forest.fsutil import atomic_write
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""Raised for configuration loading or validation errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
LEGACY_TRANSPORT_WARNING = "transport: config is obsolete (rclone replaces backends); ignoring"
|
|
21
|
+
_transport_warning_emitted = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RemoteConfig(BaseModel):
|
|
25
|
+
"""Configuration for a single named remote."""
|
|
26
|
+
|
|
27
|
+
url: str
|
|
28
|
+
region: str | None = None
|
|
29
|
+
endpoint: str | None = None
|
|
30
|
+
profile: str | None = None
|
|
31
|
+
key_file: str | None = None
|
|
32
|
+
known_hosts: str | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def remote_type(self) -> str:
|
|
36
|
+
"""Infer remote type from URL pattern."""
|
|
37
|
+
if self.url.startswith("s3://"):
|
|
38
|
+
return "s3"
|
|
39
|
+
if self.url.startswith("sftp://"):
|
|
40
|
+
return "sftp"
|
|
41
|
+
if self.url.startswith("/"):
|
|
42
|
+
return "local"
|
|
43
|
+
if re.match(r"[^:@/]+@.+:.+", self.url):
|
|
44
|
+
return "sftp"
|
|
45
|
+
return "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StageConfig(BaseModel):
|
|
49
|
+
"""Configuration for a single data stage."""
|
|
50
|
+
|
|
51
|
+
path: Path | None = None
|
|
52
|
+
remote_path: Path | None = None
|
|
53
|
+
schema_: str | None = Field(default=None, alias="schema")
|
|
54
|
+
sync_by: Literal["directory", "subdirectory", "file"] = "subdirectory"
|
|
55
|
+
|
|
56
|
+
model_config = {"populate_by_name": True}
|
|
57
|
+
|
|
58
|
+
@field_validator("path", mode="before")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _coerce_path(cls, v: object) -> Path | None:
|
|
61
|
+
if v is None:
|
|
62
|
+
return None
|
|
63
|
+
return Path(str(v))
|
|
64
|
+
|
|
65
|
+
@field_validator("remote_path", mode="before")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _coerce_remote_path(cls, v: object) -> Path | None:
|
|
68
|
+
if v is None:
|
|
69
|
+
return None
|
|
70
|
+
return Path(str(v))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ManifestConfig(BaseModel):
|
|
74
|
+
"""Configuration for the manifest CSV."""
|
|
75
|
+
|
|
76
|
+
file: Path
|
|
77
|
+
id_column: str
|
|
78
|
+
stage_columns: dict[str, str] = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@field_validator("file", mode="before")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _coerce_file(cls, v: object) -> Path:
|
|
83
|
+
if v is None:
|
|
84
|
+
raise ValueError("Manifest file path must not be null")
|
|
85
|
+
return Path(str(v))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProjectConfig(BaseModel):
|
|
89
|
+
"""Top-level project configuration from forest.yaml."""
|
|
90
|
+
|
|
91
|
+
project: str
|
|
92
|
+
remotes: dict[str, RemoteConfig] = Field(default_factory=dict)
|
|
93
|
+
stages: dict[str, StageConfig]
|
|
94
|
+
manifest: ManifestConfig | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LocalConfig(BaseModel):
|
|
98
|
+
"""Local state from .forest/local.yaml."""
|
|
99
|
+
|
|
100
|
+
active_remote: str | None = None
|
|
101
|
+
stage_paths: dict[str, Path] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
@field_validator("stage_paths", mode="before")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _coerce_stage_paths(cls, v: object) -> object:
|
|
106
|
+
if v is None:
|
|
107
|
+
return {}
|
|
108
|
+
if not isinstance(v, dict):
|
|
109
|
+
return v
|
|
110
|
+
|
|
111
|
+
coerced: dict[str, Path] = {}
|
|
112
|
+
for name, path in v.items():
|
|
113
|
+
if path is None:
|
|
114
|
+
raise ValueError(f"Stage path for {name!r} must not be null")
|
|
115
|
+
if not isinstance(path, (str, Path)):
|
|
116
|
+
raise TypeError(f"Stage path for {name!r} must be a string path")
|
|
117
|
+
coerced[str(name)] = Path(path)
|
|
118
|
+
return coerced
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _drop_legacy_transport_blocks(data: dict[str, object]) -> None:
|
|
122
|
+
"""Drop obsolete per-remote ``transport`` blocks, warning once per process."""
|
|
123
|
+
remotes = data.get("remotes")
|
|
124
|
+
if not isinstance(remotes, dict):
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
found_legacy_transport = False
|
|
128
|
+
for remote_data in remotes.values():
|
|
129
|
+
if isinstance(remote_data, dict) and "transport" in remote_data:
|
|
130
|
+
remote_data.pop("transport", None)
|
|
131
|
+
found_legacy_transport = True
|
|
132
|
+
|
|
133
|
+
global _transport_warning_emitted
|
|
134
|
+
if found_legacy_transport and not _transport_warning_emitted:
|
|
135
|
+
print(LEGACY_TRANSPORT_WARNING, file=sys.stderr, flush=True)
|
|
136
|
+
_transport_warning_emitted = True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_config(path: Path) -> ProjectConfig:
|
|
140
|
+
"""Load and validate a forest.yaml configuration file.
|
|
141
|
+
|
|
142
|
+
Raises ConfigError with a clear message for missing files,
|
|
143
|
+
invalid YAML, or validation failures.
|
|
144
|
+
"""
|
|
145
|
+
path = Path(path)
|
|
146
|
+
if not path.exists():
|
|
147
|
+
raise ConfigError(f"Configuration file not found: {path}")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
raw = path.read_text()
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise ConfigError(f"Cannot read configuration file {path}: {exc}") from None
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
data = yaml.safe_load(raw)
|
|
156
|
+
except yaml.YAMLError as exc:
|
|
157
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from None
|
|
158
|
+
|
|
159
|
+
if data is None:
|
|
160
|
+
raise ConfigError(f"Configuration file is empty: {path}")
|
|
161
|
+
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
raise ConfigError(f"Expected a dict in {path}, got {type(data).__name__}")
|
|
164
|
+
|
|
165
|
+
_drop_legacy_transport_blocks(data)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
return ProjectConfig(**data)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
raise ConfigError(f"Invalid configuration in {path}: {exc}") from None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_local_config(project_root: Path) -> LocalConfig:
|
|
174
|
+
"""Load .forest/local.yaml from the project root.
|
|
175
|
+
|
|
176
|
+
Returns a LocalConfig with active_remote=None if the file doesn't exist.
|
|
177
|
+
Raises ConfigError for malformed content or validation failures.
|
|
178
|
+
"""
|
|
179
|
+
local_path = Path(project_root) / ".forest" / "local.yaml"
|
|
180
|
+
return load_local_config_file(local_path)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_local_config_file(local_path: Path) -> LocalConfig:
|
|
184
|
+
"""Load a LocalConfig from an explicit local.yaml path."""
|
|
185
|
+
local_path = Path(local_path)
|
|
186
|
+
if not local_path.exists():
|
|
187
|
+
return LocalConfig()
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
raw = local_path.read_text()
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
raise ConfigError(f"Cannot read local config {local_path}: {exc}") from None
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
data = yaml.safe_load(raw)
|
|
196
|
+
except yaml.YAMLError as exc:
|
|
197
|
+
raise ConfigError(f"Invalid YAML in {local_path}: {exc}") from None
|
|
198
|
+
|
|
199
|
+
if data is None:
|
|
200
|
+
return LocalConfig()
|
|
201
|
+
|
|
202
|
+
if not isinstance(data, dict):
|
|
203
|
+
raise ConfigError(f"Expected a dict in {local_path}, got {type(data).__name__}")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return LocalConfig(**data)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
raise ConfigError(f"Invalid local config in {local_path}: {exc}") from None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _remote_yaml_entry(remote: RemoteConfig) -> dict[str, str]:
|
|
212
|
+
"""Serialize a RemoteConfig to its forest.yaml mapping, omitting unset fields."""
|
|
213
|
+
entry = {"url": remote.url}
|
|
214
|
+
optional_fields = {
|
|
215
|
+
"region": remote.region,
|
|
216
|
+
"endpoint": remote.endpoint,
|
|
217
|
+
"profile": remote.profile,
|
|
218
|
+
"key_file": remote.key_file,
|
|
219
|
+
"known_hosts": remote.known_hosts,
|
|
220
|
+
}
|
|
221
|
+
entry.update({key: value for key, value in optional_fields.items() if value is not None})
|
|
222
|
+
return entry
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stage_yaml_entry(stage: StageConfig, *, include_stage_paths: bool) -> dict[str, object]:
|
|
226
|
+
"""Serialize a StageConfig to its forest.yaml mapping."""
|
|
227
|
+
s_data: dict[str, object] = {}
|
|
228
|
+
if include_stage_paths and stage.path is not None:
|
|
229
|
+
s_data["path"] = str(stage.path)
|
|
230
|
+
if stage.remote_path is not None:
|
|
231
|
+
s_data["remote_path"] = str(stage.remote_path)
|
|
232
|
+
s_data["schema"] = stage.schema_
|
|
233
|
+
if stage.sync_by != "subdirectory":
|
|
234
|
+
s_data["sync_by"] = stage.sync_by
|
|
235
|
+
return s_data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _manifest_yaml_entry(manifest: ManifestConfig) -> dict[str, object]:
|
|
239
|
+
"""Serialize a ManifestConfig to its forest.yaml mapping."""
|
|
240
|
+
m_data: dict[str, object] = {
|
|
241
|
+
"file": str(manifest.file),
|
|
242
|
+
"id_column": manifest.id_column,
|
|
243
|
+
}
|
|
244
|
+
if manifest.stage_columns:
|
|
245
|
+
m_data["stage_columns"] = manifest.stage_columns
|
|
246
|
+
return m_data
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def save_config(config: ProjectConfig, path: Path, *, include_stage_paths: bool = True) -> None:
|
|
250
|
+
"""Save ProjectConfig to a forest.yaml file.
|
|
251
|
+
|
|
252
|
+
Serializes the Pydantic model to a clean YAML representation,
|
|
253
|
+
converting Path objects to strings and using the 'schema' alias.
|
|
254
|
+
"""
|
|
255
|
+
data: dict[str, object] = {"project": config.project}
|
|
256
|
+
|
|
257
|
+
if config.remotes:
|
|
258
|
+
data["remotes"] = {name: _remote_yaml_entry(remote) for name, remote in config.remotes.items()}
|
|
259
|
+
|
|
260
|
+
data["stages"] = {
|
|
261
|
+
name: _stage_yaml_entry(stage, include_stage_paths=include_stage_paths) for name, stage in config.stages.items()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if config.manifest is not None:
|
|
265
|
+
data["manifest"] = _manifest_yaml_entry(config.manifest)
|
|
266
|
+
|
|
267
|
+
atomic_write(Path(path), yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def save_local_config(config: LocalConfig, project_root: Path) -> None:
|
|
271
|
+
"""Save LocalConfig to .forest/local.yaml in the project root.
|
|
272
|
+
|
|
273
|
+
Creates the .forest/ directory if it doesn't exist.
|
|
274
|
+
"""
|
|
275
|
+
local_path = Path(project_root) / ".forest" / "local.yaml"
|
|
276
|
+
save_local_config_file(config, local_path)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def save_local_config_file(config: LocalConfig, local_path: Path) -> None:
|
|
280
|
+
"""Save LocalConfig to an explicit local.yaml path."""
|
|
281
|
+
data: dict[str, object] = {
|
|
282
|
+
"active_remote": config.active_remote,
|
|
283
|
+
"stage_paths": {name: str(path) for name, path in config.stage_paths.items()},
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
atomic_write(Path(local_path), yaml.dump(data, default_flow_style=False, sort_keys=False))
|
forest/flags.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Feature flags for forest, read from the ``FOREST_FLAGS`` environment variable.
|
|
2
|
+
|
|
3
|
+
``FOREST_FLAGS`` is a comma-separated list of flag names, e.g.
|
|
4
|
+
``FOREST_FLAGS=raw-logs``. Unknown names are harmless; unset means no flags.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def enabled_flags() -> frozenset[str]:
|
|
13
|
+
"""Return the set of flag names enabled via ``FOREST_FLAGS``."""
|
|
14
|
+
raw = os.environ.get("FOREST_FLAGS", "")
|
|
15
|
+
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_enabled(name: str) -> bool:
|
|
19
|
+
"""Return True when the named feature flag is enabled."""
|
|
20
|
+
return name in enabled_flags()
|
forest/flow.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Mermaid graph helpers for the ``forest flow`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from forest.config import ProjectConfig, StageConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FlowNode:
|
|
13
|
+
"""A Mermaid node."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
label: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class FlowEdge:
|
|
21
|
+
"""A Mermaid edge."""
|
|
22
|
+
|
|
23
|
+
source: str
|
|
24
|
+
target: str
|
|
25
|
+
label: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_node(nodes: dict[str, FlowNode], raw_id: str, label: str) -> str:
|
|
29
|
+
node_id = _node_id(raw_id)
|
|
30
|
+
nodes.setdefault(node_id, FlowNode(node_id, label))
|
|
31
|
+
return node_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _add_stage_flow(
|
|
35
|
+
nodes: dict[str, FlowNode],
|
|
36
|
+
edges: list[FlowEdge],
|
|
37
|
+
config: ProjectConfig,
|
|
38
|
+
stage_name: str,
|
|
39
|
+
stage: StageConfig,
|
|
40
|
+
*,
|
|
41
|
+
project_id: str,
|
|
42
|
+
manifest_id: str | None,
|
|
43
|
+
state_id: str | None,
|
|
44
|
+
remote_ids: dict[str, str],
|
|
45
|
+
active_remote: str | None,
|
|
46
|
+
stage_outputs: dict[str, str],
|
|
47
|
+
stage_units: list[tuple[str, str | None, str | None]],
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Add one stage's nodes and edges (manifest, sync, outputs, units)."""
|
|
50
|
+
stage_label = f"stage: {stage_name}\npath: {stage.path}\nschema: {stage.schema_ or 'none'}"
|
|
51
|
+
stage_id = _add_node(nodes, f"stage_{stage_name}", stage_label)
|
|
52
|
+
edges.append(FlowEdge(project_id, stage_id))
|
|
53
|
+
|
|
54
|
+
if manifest_id is not None and config.manifest is not None and stage_name in config.manifest.stage_columns:
|
|
55
|
+
edges.append(FlowEdge(manifest_id, stage_id, config.manifest.stage_columns[stage_name]))
|
|
56
|
+
|
|
57
|
+
if active_remote is not None and active_remote in remote_ids:
|
|
58
|
+
edges.append(FlowEdge(stage_id, remote_ids[active_remote], "sync"))
|
|
59
|
+
if state_id is not None:
|
|
60
|
+
edges.append(FlowEdge(stage_id, state_id, "records"))
|
|
61
|
+
|
|
62
|
+
for output_name, output_path in stage_outputs.items():
|
|
63
|
+
output_id = _add_node(nodes, f"output_{stage_name}_{output_name}", f"output: {output_name}\n{output_path}")
|
|
64
|
+
edges.append(FlowEdge(stage_id, output_id))
|
|
65
|
+
|
|
66
|
+
_add_stage_units(nodes, edges, stage_id, stage_name, stage_units)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _add_stage_units(
|
|
70
|
+
nodes: dict[str, FlowNode],
|
|
71
|
+
edges: list[FlowEdge],
|
|
72
|
+
stage_id: str,
|
|
73
|
+
stage_name: str,
|
|
74
|
+
stage_units: list[tuple[str, str | None, str | None]],
|
|
75
|
+
) -> None:
|
|
76
|
+
for uid, local_path, remote_path in stage_units:
|
|
77
|
+
unit_id = _add_node(nodes, f"unit_{stage_name}_{uid}", f"unit: {uid}")
|
|
78
|
+
edges.append(FlowEdge(stage_id, unit_id))
|
|
79
|
+
if local_path is not None:
|
|
80
|
+
local_id = _add_node(nodes, f"local_{stage_name}_{uid}", f"local: {local_path}")
|
|
81
|
+
edges.append(FlowEdge(unit_id, local_id))
|
|
82
|
+
if remote_path is not None:
|
|
83
|
+
remote_path_id = _add_node(nodes, f"remote_path_{stage_name}_{uid}", f"remote: {remote_path}")
|
|
84
|
+
edges.append(FlowEdge(unit_id, remote_path_id))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_graph(
|
|
88
|
+
config: ProjectConfig,
|
|
89
|
+
*,
|
|
90
|
+
active_remote: str | None = None,
|
|
91
|
+
has_state: bool = False,
|
|
92
|
+
expanded_units: dict[str, list[tuple[str, str | None, str | None]]] | None = None,
|
|
93
|
+
key_outputs: dict[str, dict[str, str]] | None = None,
|
|
94
|
+
) -> tuple[list[FlowNode], list[FlowEdge]]:
|
|
95
|
+
"""Build a project data-flow graph from resolved config state."""
|
|
96
|
+
nodes: dict[str, FlowNode] = {}
|
|
97
|
+
edges: list[FlowEdge] = []
|
|
98
|
+
|
|
99
|
+
project_id = _add_node(nodes, "project", f"project: {config.project}")
|
|
100
|
+
|
|
101
|
+
manifest_id: str | None = None
|
|
102
|
+
if config.manifest is not None:
|
|
103
|
+
manifest_id = _add_node(nodes, "manifest", f"manifest: {config.manifest.file}")
|
|
104
|
+
edges.append(FlowEdge(manifest_id, project_id, config.manifest.id_column))
|
|
105
|
+
|
|
106
|
+
state_id = _add_node(nodes, "sync_state", ".forest/sync_state.json") if has_state else None
|
|
107
|
+
|
|
108
|
+
remote_ids: dict[str, str] = {}
|
|
109
|
+
for name, remote in sorted(config.remotes.items()):
|
|
110
|
+
label = f"remote: {name}\n{remote.url}"
|
|
111
|
+
if name == active_remote:
|
|
112
|
+
label += "\nactive"
|
|
113
|
+
remote_ids[name] = _add_node(nodes, f"remote_{name}", label)
|
|
114
|
+
|
|
115
|
+
for stage_name, stage in sorted(config.stages.items()):
|
|
116
|
+
_add_stage_flow(
|
|
117
|
+
nodes,
|
|
118
|
+
edges,
|
|
119
|
+
config,
|
|
120
|
+
stage_name,
|
|
121
|
+
stage,
|
|
122
|
+
project_id=project_id,
|
|
123
|
+
manifest_id=manifest_id,
|
|
124
|
+
state_id=state_id,
|
|
125
|
+
remote_ids=remote_ids,
|
|
126
|
+
active_remote=active_remote,
|
|
127
|
+
stage_outputs=(key_outputs or {}).get(stage_name, {}),
|
|
128
|
+
stage_units=(expanded_units or {}).get(stage_name, []),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return list(nodes.values()), edges
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_mermaid(nodes: list[FlowNode], edges: list[FlowEdge], *, direction: str = "LR") -> str:
|
|
135
|
+
"""Render Mermaid flowchart text."""
|
|
136
|
+
lines = [f"flowchart {direction}"]
|
|
137
|
+
for node in nodes:
|
|
138
|
+
lines.append(f' {node.id}["{_escape_label(node.label)}"]')
|
|
139
|
+
for edge in edges:
|
|
140
|
+
label = f"|{_escape_label(edge.label)}|" if edge.label else ""
|
|
141
|
+
lines.append(f" {edge.source} -->{label} {edge.target}")
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _node_id(value: str) -> str:
|
|
146
|
+
node_id = re.sub(r"[^0-9A-Za-z_]", "_", value).strip("_")
|
|
147
|
+
if not node_id:
|
|
148
|
+
return "node"
|
|
149
|
+
if node_id[0].isdigit():
|
|
150
|
+
return f"n_{node_id}"
|
|
151
|
+
return node_id
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _escape_label(label: str | None) -> str:
|
|
155
|
+
if label is None:
|
|
156
|
+
return ""
|
|
157
|
+
return label.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "<br/>")
|
forest/fsutil.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Filesystem primitives shared across forest modules.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free (stdlib only) so ``config`` and ``sync_state`` can import
|
|
4
|
+
``atomic_write`` at module top level without a circular import back through
|
|
5
|
+
``checkout``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def atomic_write(path: str | Path, text: str) -> None:
|
|
17
|
+
"""Atomically write text to *path* via same-directory temp file and os.replace."""
|
|
18
|
+
target = Path(path)
|
|
19
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
existing_mode = None
|
|
21
|
+
if target.exists():
|
|
22
|
+
existing_mode = target.stat().st_mode & 0o777
|
|
23
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent))
|
|
24
|
+
temp_path = Path(temp_name)
|
|
25
|
+
try:
|
|
26
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
27
|
+
handle.write(text)
|
|
28
|
+
handle.flush()
|
|
29
|
+
os.fsync(handle.fileno())
|
|
30
|
+
if existing_mode is not None:
|
|
31
|
+
os.chmod(temp_path, existing_mode)
|
|
32
|
+
os.replace(temp_path, target)
|
|
33
|
+
except BaseException:
|
|
34
|
+
with suppress(FileNotFoundError):
|
|
35
|
+
temp_path.unlink()
|
|
36
|
+
raise
|
forest/logger.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Structured logging with run-scoped trace IDs for forest.
|
|
2
|
+
|
|
3
|
+
Every CLI invocation gets a short ``run_id`` that is stamped onto every log
|
|
4
|
+
record emitted anywhere in the package (see :class:`_RunIdFilter`), onto every
|
|
5
|
+
metric sample (``forest.metrics``), and onto every alert/error report
|
|
6
|
+
(``forest.monitoring``). Grep one run_id across all three sinks to follow a
|
|
7
|
+
single invocation end to end, including through the rclone subprocess calls it
|
|
8
|
+
spawned.
|
|
9
|
+
|
|
10
|
+
Logging is silent by default so the click-based UX and stdout/stderr contracts
|
|
11
|
+
stay untouched. It activates only through environment variables:
|
|
12
|
+
|
|
13
|
+
- ``FOREST_LOG_FILE``: append logs to this file (JSON lines by default).
|
|
14
|
+
- ``FOREST_LOG_FORMAT``: ``json`` or ``text``; setting it without
|
|
15
|
+
``FOREST_LOG_FILE`` sends logs to stderr.
|
|
16
|
+
- ``FOREST_LOG_LEVEL``: standard level name, default ``INFO``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import uuid
|
|
27
|
+
from contextvars import ContextVar
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from forest import flags
|
|
32
|
+
|
|
33
|
+
_RUN_ID: ContextVar[str] = ContextVar("forest_run_id", default="")
|
|
34
|
+
|
|
35
|
+
_ROOT_LOGGER_NAME = "forest"
|
|
36
|
+
|
|
37
|
+
# LogRecord attributes that are not user-supplied "extra" fields.
|
|
38
|
+
_STANDARD_RECORD_ATTRS = frozenset(vars(logging.LogRecord("", 0, "", 0, "", (), None)).keys()) | {
|
|
39
|
+
"message",
|
|
40
|
+
"asctime",
|
|
41
|
+
"taskName",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_REDACTED = "[REDACTED]"
|
|
45
|
+
|
|
46
|
+
# Ordered secret-shaped substring patterns applied by scrub(). Order matters:
|
|
47
|
+
# PEM blocks and URL credentials go first so the generic key=value rule never
|
|
48
|
+
# mangles them halfway.
|
|
49
|
+
_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
50
|
+
(
|
|
51
|
+
re.compile(
|
|
52
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)",
|
|
53
|
+
re.DOTALL,
|
|
54
|
+
),
|
|
55
|
+
"[REDACTED PRIVATE KEY]",
|
|
56
|
+
),
|
|
57
|
+
# URL credentials, including sentry DSN key@host forms.
|
|
58
|
+
(re.compile(r"://[^/\s@]+@"), "://[REDACTED]@"),
|
|
59
|
+
(re.compile(r"(?i)\b(authorization|x-api-key)\b\s*[:=]\s*(?:basic |bearer )?\S+"), r"\1: [REDACTED]"),
|
|
60
|
+
(
|
|
61
|
+
re.compile(
|
|
62
|
+
r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key(?:[_-]?id)?"
|
|
63
|
+
r"|session[_-]?token|private[_-]?key)\b\s*[=:]\s*\S+"
|
|
64
|
+
),
|
|
65
|
+
r"\1=[REDACTED]",
|
|
66
|
+
),
|
|
67
|
+
# Bare AWS access key ids.
|
|
68
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), _REDACTED),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_SECRET_KEY_RE = re.compile(
|
|
72
|
+
r"(?i)(password|passwd|secret|token|api[_-]?key|access[_-]?key|authorization|dsn|private[_-]?key|credential)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scrub(text: str) -> str:
|
|
77
|
+
"""Redact secret-shaped substrings (keys, tokens, URL credentials) from text."""
|
|
78
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
79
|
+
text = pattern.sub(replacement, text)
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scrub_obj(value: Any) -> Any:
|
|
84
|
+
"""Recursively scrub strings; redact dict values whose key looks secret."""
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
return scrub(value)
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {
|
|
89
|
+
key: _REDACTED if isinstance(key, str) and _SECRET_KEY_RE.search(key) else scrub_obj(val)
|
|
90
|
+
for key, val in value.items()
|
|
91
|
+
}
|
|
92
|
+
if isinstance(value, (list, tuple)):
|
|
93
|
+
return [scrub_obj(item) for item in value]
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def new_run_id() -> str:
|
|
98
|
+
"""Create and activate a fresh trace ID for this invocation."""
|
|
99
|
+
run_id = uuid.uuid4().hex[:12]
|
|
100
|
+
_RUN_ID.set(run_id)
|
|
101
|
+
return run_id
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_run_id() -> str:
|
|
105
|
+
"""Return the active trace ID, creating one lazily if needed."""
|
|
106
|
+
run_id = _RUN_ID.get()
|
|
107
|
+
if not run_id:
|
|
108
|
+
run_id = new_run_id()
|
|
109
|
+
return run_id
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _RunIdFilter(logging.Filter):
|
|
113
|
+
"""Stamp the active run_id onto every record so traces survive fan-out."""
|
|
114
|
+
|
|
115
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
116
|
+
record.run_id = get_run_id()
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _ScrubFilter(logging.Filter):
|
|
121
|
+
"""Scrub the message and every string ``extra`` field on each record.
|
|
122
|
+
|
|
123
|
+
Known limitation: tracebacks rendered by the text formatter are not
|
|
124
|
+
scrubbed; the JSON formatter scrubs ``exc_info`` itself.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
128
|
+
record.msg = scrub(record.getMessage())
|
|
129
|
+
record.args = None
|
|
130
|
+
for key, value in record.__dict__.items():
|
|
131
|
+
if key not in _STANDARD_RECORD_ATTRS and isinstance(value, str):
|
|
132
|
+
setattr(record, key, scrub(value))
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class JsonFormatter(logging.Formatter):
|
|
137
|
+
"""Render records as single-line JSON objects (one event per line)."""
|
|
138
|
+
|
|
139
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
140
|
+
payload: dict[str, Any] = {
|
|
141
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
142
|
+
"level": record.levelname,
|
|
143
|
+
"logger": record.name,
|
|
144
|
+
"event": record.getMessage(),
|
|
145
|
+
"run_id": getattr(record, "run_id", ""),
|
|
146
|
+
}
|
|
147
|
+
for key, value in record.__dict__.items():
|
|
148
|
+
if key in _STANDARD_RECORD_ATTRS or key in payload:
|
|
149
|
+
continue
|
|
150
|
+
payload[key] = value if isinstance(value, (str, int, float, bool, type(None))) else repr(value)
|
|
151
|
+
if record.exc_info:
|
|
152
|
+
payload["exc_info"] = scrub(self.formatException(record.exc_info))
|
|
153
|
+
return json.dumps(payload, default=str)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_logger(name: str) -> logging.Logger:
|
|
157
|
+
"""Return a logger under the ``forest`` hierarchy (e.g. ``forest.rclone``)."""
|
|
158
|
+
if name == _ROOT_LOGGER_NAME or name.startswith(_ROOT_LOGGER_NAME + "."):
|
|
159
|
+
return logging.getLogger(name)
|
|
160
|
+
return logging.getLogger(f"{_ROOT_LOGGER_NAME}.{name}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def configure_logging(*, verbose: bool = False) -> None:
|
|
164
|
+
"""(Re)configure the package logger from the environment. Idempotent.
|
|
165
|
+
|
|
166
|
+
Without ``FOREST_LOG_FILE`` or ``FOREST_LOG_FORMAT`` the logger stays on a
|
|
167
|
+
NullHandler: no output, no behavioral change for existing users or tests.
|
|
168
|
+
"""
|
|
169
|
+
root = logging.getLogger(_ROOT_LOGGER_NAME)
|
|
170
|
+
root.propagate = False
|
|
171
|
+
for existing in list(root.handlers):
|
|
172
|
+
root.removeHandler(existing)
|
|
173
|
+
existing.close()
|
|
174
|
+
|
|
175
|
+
log_file = os.environ.get("FOREST_LOG_FILE")
|
|
176
|
+
log_format = os.environ.get("FOREST_LOG_FORMAT")
|
|
177
|
+
if not log_file and not log_format:
|
|
178
|
+
root.addHandler(logging.NullHandler())
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
handler: logging.Handler
|
|
182
|
+
handler = logging.FileHandler(log_file, encoding="utf-8") if log_file else logging.StreamHandler(sys.stderr)
|
|
183
|
+
|
|
184
|
+
if (log_format or "json").lower() == "text":
|
|
185
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(run_id)s] %(message)s"))
|
|
186
|
+
else:
|
|
187
|
+
handler.setFormatter(JsonFormatter())
|
|
188
|
+
handler.addFilter(_RunIdFilter())
|
|
189
|
+
if not flags.is_enabled("raw-logs"):
|
|
190
|
+
handler.addFilter(_ScrubFilter())
|
|
191
|
+
root.addHandler(handler)
|
|
192
|
+
|
|
193
|
+
level_name: str | None = os.environ.get("FOREST_LOG_LEVEL")
|
|
194
|
+
if level_name:
|
|
195
|
+
root.setLevel(getattr(logging, level_name.upper(), logging.INFO))
|
|
196
|
+
else:
|
|
197
|
+
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|