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/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 []
|