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/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)