pangea-flows 0.3.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.
- pangea_flows/__init__.py +48 -0
- pangea_flows/capture.py +144 -0
- pangea_flows/cli.py +240 -0
- pangea_flows/client.py +340 -0
- pangea_flows/errors.py +33 -0
- pangea_flows/keys.py +149 -0
- pangea_flows/models.py +128 -0
- pangea_flows/planner.py +69 -0
- pangea_flows/runner.py +540 -0
- pangea_flows/serve/__init__.py +298 -0
- pangea_flows/serve/api.py +88 -0
- pangea_flows/serve/config.py +133 -0
- pangea_flows/serve/sources.py +270 -0
- pangea_flows/simple.py +143 -0
- pangea_flows/template.py +123 -0
- pangea_flows/workflow.py +284 -0
- pangea_flows-0.3.0.dist-info/METADATA +306 -0
- pangea_flows-0.3.0.dist-info/RECORD +20 -0
- pangea_flows-0.3.0.dist-info/WHEEL +4 -0
- pangea_flows-0.3.0.dist-info/entry_points.txt +2 -0
pangea_flows/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Write PANGEA processing workflows in Python.
|
|
2
|
+
|
|
3
|
+
from pangea_flows import each_file
|
|
4
|
+
|
|
5
|
+
@each_file(inputs="pollyxt/raw", pattern="*.nc", output="pollyxt/quicklooks")
|
|
6
|
+
def quicklook(path, out_dir):
|
|
7
|
+
plot(path, out_dir / f"{path.stem}.png")
|
|
8
|
+
|
|
9
|
+
See README.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.3.0"
|
|
13
|
+
|
|
14
|
+
from .client import PangeaClient
|
|
15
|
+
from .errors import ClaimLost, FailPermanently, MissingSecrets, PangeaError
|
|
16
|
+
from .keys import KeyScheme, TimeWindows, Window, daily, hourly, per_datum
|
|
17
|
+
from .models import DataFile, Dataset, Datum
|
|
18
|
+
from .runner import RunReport, connect, dry_run, run_once
|
|
19
|
+
from .simple import each_file, each_window
|
|
20
|
+
from .workflow import Context, Item, Output, Workflow, workflow
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ClaimLost",
|
|
24
|
+
"Context",
|
|
25
|
+
"DataFile",
|
|
26
|
+
"Dataset",
|
|
27
|
+
"Datum",
|
|
28
|
+
"FailPermanently",
|
|
29
|
+
"Item",
|
|
30
|
+
"KeyScheme",
|
|
31
|
+
"MissingSecrets",
|
|
32
|
+
"Output",
|
|
33
|
+
"PangeaClient",
|
|
34
|
+
"PangeaError",
|
|
35
|
+
"RunReport",
|
|
36
|
+
"TimeWindows",
|
|
37
|
+
"Window",
|
|
38
|
+
"Workflow",
|
|
39
|
+
"connect",
|
|
40
|
+
"daily",
|
|
41
|
+
"dry_run",
|
|
42
|
+
"each_file",
|
|
43
|
+
"each_window",
|
|
44
|
+
"hourly",
|
|
45
|
+
"per_datum",
|
|
46
|
+
"run_once",
|
|
47
|
+
"workflow",
|
|
48
|
+
]
|
pangea_flows/capture.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Keeps the end of what a run, and each item, logs and prints, to send to the
|
|
2
|
+
platform with the run's and the attempt's result.
|
|
3
|
+
|
|
4
|
+
Researchers' code logs in all sorts of ways: ``ctx.log``, its own loggers,
|
|
5
|
+
``print``, warnings. A handler on the root logger catches the loggers, and
|
|
6
|
+
stdout and stderr are teed while an item runs. Output of programs started with
|
|
7
|
+
``subprocess`` goes straight to the file descriptors and isn't caught; the
|
|
8
|
+
runner that started the process keeps that.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Iterable, Iterator, Optional, TextIO
|
|
18
|
+
|
|
19
|
+
#: The API keeps at most this many characters of a log
|
|
20
|
+
MAX_LOG_CHARS = 64 * 1024
|
|
21
|
+
|
|
22
|
+
#: Shorter secret values aren't masked: they would blank out whole logs
|
|
23
|
+
MIN_MASKED_LENGTH = 6
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mask(text: str, values: Iterable[str]) -> str:
|
|
27
|
+
"""``text`` with each of ``values`` of at least MIN_MASKED_LENGTH characters replaced by ``***``"""
|
|
28
|
+
for value in sorted((v for v in values if len(v) >= MIN_MASKED_LENGTH), key=len, reverse=True):
|
|
29
|
+
text = text.replace(value, "***")
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
_FORMAT = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Tail:
|
|
36
|
+
"""Text that keeps only its last ``limit`` characters"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, limit: int = MAX_LOG_CHARS):
|
|
39
|
+
self.limit = limit
|
|
40
|
+
self._parts: list[str] = []
|
|
41
|
+
self._size = 0
|
|
42
|
+
self._dropped = False
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
def write(self, text: str) -> None:
|
|
46
|
+
if not text:
|
|
47
|
+
return
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._parts.append(text)
|
|
50
|
+
self._size += len(text)
|
|
51
|
+
if self._size > 2 * self.limit:
|
|
52
|
+
joined = "".join(self._parts)[-self.limit :]
|
|
53
|
+
self._parts, self._size, self._dropped = [joined], len(joined), True
|
|
54
|
+
|
|
55
|
+
def text(self) -> str:
|
|
56
|
+
with self._lock:
|
|
57
|
+
joined = "".join(self._parts)
|
|
58
|
+
if len(joined) <= self.limit and not self._dropped:
|
|
59
|
+
return joined
|
|
60
|
+
marker = "[...]\n"
|
|
61
|
+
return marker + joined[-(self.limit - len(marker)) :]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Capture(logging.Handler):
|
|
65
|
+
"""Copies log records into the run's tail, and into the current item's while one runs"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, level: int = logging.INFO):
|
|
68
|
+
super().__init__(level)
|
|
69
|
+
self.setFormatter(_FORMAT)
|
|
70
|
+
self.run = Tail()
|
|
71
|
+
self.item: Optional[Tail] = None
|
|
72
|
+
#: Values to mask in everything captured
|
|
73
|
+
self.secrets: list[str] = []
|
|
74
|
+
|
|
75
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
76
|
+
# The handler sits on the root logger and on the run's logger, whose
|
|
77
|
+
# records may also reach the root: write each once
|
|
78
|
+
if getattr(record, "_pangea_captured", None) is self:
|
|
79
|
+
return
|
|
80
|
+
record._pangea_captured = self # type: ignore[attr-defined]
|
|
81
|
+
try:
|
|
82
|
+
line = self.format(record) + "\n"
|
|
83
|
+
except Exception:
|
|
84
|
+
self.handleError(record)
|
|
85
|
+
return
|
|
86
|
+
self.write(line)
|
|
87
|
+
|
|
88
|
+
def write(self, text: str) -> None:
|
|
89
|
+
if self.secrets:
|
|
90
|
+
text = mask(text, self.secrets)
|
|
91
|
+
self.run.write(text)
|
|
92
|
+
item = self.item
|
|
93
|
+
if item is not None:
|
|
94
|
+
item.write(text)
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def attached(self, *loggers: logging.Logger) -> Iterator[Capture]:
|
|
98
|
+
"""Catches records of the root logger and ``loggers`` meanwhile"""
|
|
99
|
+
targets = [logging.getLogger(), *(lg for lg in loggers if lg is not logging.getLogger())]
|
|
100
|
+
# The run's own messages are kept even where logging isn't set up, e.g.
|
|
101
|
+
# when run_once is called from a notebook, where loggers default to WARNING
|
|
102
|
+
levels = {lg: lg.level for lg in loggers if lg.getEffectiveLevel() > self.level}
|
|
103
|
+
for lg in levels:
|
|
104
|
+
lg.setLevel(self.level)
|
|
105
|
+
for target in targets:
|
|
106
|
+
target.addHandler(self)
|
|
107
|
+
try:
|
|
108
|
+
yield self
|
|
109
|
+
finally:
|
|
110
|
+
for target in targets:
|
|
111
|
+
target.removeHandler(self)
|
|
112
|
+
for lg, level in levels.items():
|
|
113
|
+
lg.setLevel(level)
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def item_log(self) -> Iterator[Tail]:
|
|
117
|
+
"""Collects an item's log, including what it prints, meanwhile"""
|
|
118
|
+
tail = Tail()
|
|
119
|
+
self.item = tail
|
|
120
|
+
out, err = sys.stdout, sys.stderr
|
|
121
|
+
sys.stdout, sys.stderr = _Tee(out, self), _Tee(err, self)
|
|
122
|
+
try:
|
|
123
|
+
yield tail
|
|
124
|
+
finally:
|
|
125
|
+
sys.stdout, sys.stderr = out, err
|
|
126
|
+
self.item = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Tee:
|
|
130
|
+
"""Writes to a stream and to the capture"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, stream: TextIO, capture: Capture):
|
|
133
|
+
self._stream = stream
|
|
134
|
+
self._capture = capture
|
|
135
|
+
|
|
136
|
+
def write(self, text: str) -> int:
|
|
137
|
+
self._capture.write(text)
|
|
138
|
+
return self._stream.write(text)
|
|
139
|
+
|
|
140
|
+
def flush(self) -> None:
|
|
141
|
+
self._stream.flush()
|
|
142
|
+
|
|
143
|
+
def __getattr__(self, name: str):
|
|
144
|
+
return getattr(self._stream, name)
|
pangea_flows/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""``pangea-flows``: run workflows from the command line.
|
|
2
|
+
|
|
3
|
+
pangea-flows new my-flows # Start a project
|
|
4
|
+
pangea-flows list # The workflows in flows.py
|
|
5
|
+
pangea-flows try quicklook # Run on yesterday's data, into ./out
|
|
6
|
+
pangea-flows run flows.py:quicklook # Run for real, against the ledger
|
|
7
|
+
|
|
8
|
+
A target is a workflow's name in ``flows.py``, ``module``, ``module:name``, or
|
|
9
|
+
a file such as ``flows.py:name``; ``flows.py`` by default. The API comes from
|
|
10
|
+
``$PANGEA_URL``, and tokens from ``$PANGEA_TOKEN_<NAME>`` or ``$PANGEA_TOKEN``,
|
|
11
|
+
which can also be set in a ``.env`` file in the current directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import importlib
|
|
18
|
+
import importlib.util
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import ModuleType
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from . import __version__, template
|
|
29
|
+
from .errors import MissingSecrets, PangeaError
|
|
30
|
+
from .runner import connect, dry_run, run_once
|
|
31
|
+
from .workflow import Workflow
|
|
32
|
+
|
|
33
|
+
DEFAULT_FILE = "flows.py"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load(target: Optional[str]) -> list[Workflow]:
|
|
37
|
+
"""The workflows a target names"""
|
|
38
|
+
target = target or DEFAULT_FILE
|
|
39
|
+
if target.isidentifier() and Path(DEFAULT_FILE).is_file():
|
|
40
|
+
# A bare name: a workflow in flows.py, if it has one by that name
|
|
41
|
+
if isinstance(getattr(_load_file(Path(DEFAULT_FILE)), target, None), Workflow):
|
|
42
|
+
target = f"{DEFAULT_FILE}:{target}"
|
|
43
|
+
module_name, _, attr = target.partition(":")
|
|
44
|
+
if module_name.endswith(".py"):
|
|
45
|
+
if not Path(module_name).is_file():
|
|
46
|
+
raise SystemExit(f"No file {module_name} here. Run this in your project's folder, or name a module.")
|
|
47
|
+
module = _load_file(Path(module_name))
|
|
48
|
+
else:
|
|
49
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
50
|
+
module = importlib.import_module(module_name)
|
|
51
|
+
|
|
52
|
+
if attr:
|
|
53
|
+
wf = getattr(module, attr, None)
|
|
54
|
+
if not isinstance(wf, Workflow):
|
|
55
|
+
raise SystemExit(f"{target} is not a @workflow")
|
|
56
|
+
return [wf]
|
|
57
|
+
found = [v for v in vars(module).values() if isinstance(v, Workflow)]
|
|
58
|
+
if not found:
|
|
59
|
+
raise SystemExit(f"No workflows in {module_name}")
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def describe(wf: Workflow) -> dict:
|
|
64
|
+
"""What a runner needs to know about a workflow"""
|
|
65
|
+
return {
|
|
66
|
+
"name": wf.name,
|
|
67
|
+
"keys": wf.keys.name,
|
|
68
|
+
"inputs": wf.inputs,
|
|
69
|
+
"outputs": wf.outputs,
|
|
70
|
+
"first": wf.first,
|
|
71
|
+
"secrets": wf.secrets,
|
|
72
|
+
"schedule": wf.schedule,
|
|
73
|
+
"timezone": wf.timezone,
|
|
74
|
+
"memory": wf.memory,
|
|
75
|
+
"cpus": wf.cpus,
|
|
76
|
+
"timeoutSeconds": int(wf.timeout.total_seconds()) if wf.timeout else None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_file(path: Path) -> ModuleType:
|
|
81
|
+
path = path.resolve()
|
|
82
|
+
name = path.stem
|
|
83
|
+
loaded = sys.modules.get(name)
|
|
84
|
+
if loaded is not None and getattr(loaded, "__file__", None) == str(path):
|
|
85
|
+
return loaded
|
|
86
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
87
|
+
if spec is None or spec.loader is None:
|
|
88
|
+
raise SystemExit(f"Can't load {path}")
|
|
89
|
+
module = importlib.util.module_from_spec(spec)
|
|
90
|
+
sys.modules[name] = module
|
|
91
|
+
# Let flows.py import its neighbours, like a script would
|
|
92
|
+
sys.path.insert(0, str(path.parent))
|
|
93
|
+
spec.loader.exec_module(module)
|
|
94
|
+
return module
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _one(target: Optional[str]) -> Workflow:
|
|
98
|
+
workflows = load(target)
|
|
99
|
+
if len(workflows) > 1:
|
|
100
|
+
names = ", ".join(w.name for w in workflows)
|
|
101
|
+
raise SystemExit(f"{target} has several workflows ({names}); name one as module:name")
|
|
102
|
+
return workflows[0]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_dotenv(path: Path = Path(".env")) -> None:
|
|
106
|
+
"""Sets variables from a ``.env`` file, without overriding ones already set"""
|
|
107
|
+
if not path.is_file():
|
|
108
|
+
return
|
|
109
|
+
for line in path.read_text().splitlines():
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
112
|
+
continue
|
|
113
|
+
name, _, value = line.removeprefix("export ").partition("=")
|
|
114
|
+
value = value.strip()
|
|
115
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
116
|
+
value = value[1:-1]
|
|
117
|
+
os.environ.setdefault(name.strip(), value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _since(value: str) -> datetime:
|
|
121
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
122
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
126
|
+
parser = argparse.ArgumentParser(prog="pangea-flows", description="Run PANGEA workflows")
|
|
127
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Log debug messages")
|
|
128
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
129
|
+
|
|
130
|
+
new_cmd = commands.add_parser("new", help="Start a project of workflows in a new folder")
|
|
131
|
+
new_cmd.add_argument("folder", type=Path)
|
|
132
|
+
|
|
133
|
+
list_cmd = commands.add_parser("list", help="List the workflows in flows.py, or a module")
|
|
134
|
+
list_cmd.add_argument("target", nargs="?")
|
|
135
|
+
list_cmd.add_argument("--json", action="store_true", help="As JSON, for runners")
|
|
136
|
+
|
|
137
|
+
run_cmd = commands.add_parser("run", help="Plan new items and process them, once")
|
|
138
|
+
run_cmd.add_argument("target", nargs="?")
|
|
139
|
+
run_cmd.add_argument("--max-items", type=int, help="Process at most this many items")
|
|
140
|
+
run_cmd.add_argument("--no-plan", action="store_true", help="Only process items already in the ledger")
|
|
141
|
+
run_cmd.add_argument("--run-ref", help="An ID for this run, shown with each attempt")
|
|
142
|
+
run_cmd.add_argument("--trigger", default="manual", help="What started the run, shown on the platform")
|
|
143
|
+
run_cmd.add_argument("--run-id", type=int, help="Carry on with a run a runner started, instead of starting one")
|
|
144
|
+
run_cmd.add_argument(
|
|
145
|
+
"--accept-changes", action="store_true", help="Accept changed inputs or outputs since the last run"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
serve_cmd = commands.add_parser("serve", help="Run workflows from their repositories, in containers")
|
|
149
|
+
serve_cmd.add_argument("--config", type=Path, required=True, help="The runner's TOML settings")
|
|
150
|
+
serve_cmd.add_argument("--once", action="store_true", help="Check once, wait for the runs, and stop")
|
|
151
|
+
|
|
152
|
+
dry_cmd = commands.add_parser(
|
|
153
|
+
"try",
|
|
154
|
+
aliases=["dry-run"],
|
|
155
|
+
help="Try a workflow on real data, writing outputs to ./out. Only reads from the API.",
|
|
156
|
+
)
|
|
157
|
+
dry_cmd.add_argument("target", nargs="?")
|
|
158
|
+
dry_cmd.add_argument(
|
|
159
|
+
"--since",
|
|
160
|
+
type=_since,
|
|
161
|
+
default=None,
|
|
162
|
+
help="Plan from files uploaded since this time (default: a day ago)",
|
|
163
|
+
)
|
|
164
|
+
dry_cmd.add_argument("--key", action="append", help="Run this item; can be repeated. By default the first few.")
|
|
165
|
+
dry_cmd.add_argument("--limit", type=int, default=5, help="Run at most this many items, without --key")
|
|
166
|
+
dry_cmd.add_argument("--out", type=Path, default=Path("out"), help="Where to write outputs (default: ./out)")
|
|
167
|
+
|
|
168
|
+
args = parser.parse_args(argv)
|
|
169
|
+
load_dotenv()
|
|
170
|
+
logging.basicConfig(
|
|
171
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
172
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
173
|
+
)
|
|
174
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
175
|
+
try:
|
|
176
|
+
return _run(args)
|
|
177
|
+
except (PangeaError, LookupError, ValueError, MissingSecrets) as err:
|
|
178
|
+
print(f"pangea-flows: {err}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run(args: argparse.Namespace) -> int:
|
|
183
|
+
if args.command == "new":
|
|
184
|
+
created = template.create(args.folder)
|
|
185
|
+
print(f"Created {args.folder}/ with {', '.join(p.name for p in created)}")
|
|
186
|
+
print(f"Next: cd {args.folder} && uv sync, then see README.md")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
if args.command == "serve":
|
|
190
|
+
from .serve import Server
|
|
191
|
+
from .serve.config import Config
|
|
192
|
+
|
|
193
|
+
server = Server(Config.load(args.config))
|
|
194
|
+
if args.once:
|
|
195
|
+
server.api.report(f"pangea-flows {__version__}")
|
|
196
|
+
server.tick()
|
|
197
|
+
server.wait()
|
|
198
|
+
else:
|
|
199
|
+
server.serve_forever()
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
if args.command == "list":
|
|
203
|
+
if args.json:
|
|
204
|
+
print(json.dumps([describe(wf) for wf in load(args.target)]))
|
|
205
|
+
return 0
|
|
206
|
+
for wf in load(args.target):
|
|
207
|
+
print(f"{wf.name}\tkeys={wf.keys.name}\tinputs={wf.inputs}\toutputs={wf.outputs}\ttoken=${wf.token_variable}")
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
if args.command == "run":
|
|
211
|
+
report = run_once(
|
|
212
|
+
_one(args.target),
|
|
213
|
+
max_items=args.max_items,
|
|
214
|
+
plan=not args.no_plan,
|
|
215
|
+
run_ref=args.run_ref,
|
|
216
|
+
accept_changes=args.accept_changes,
|
|
217
|
+
trigger=args.trigger,
|
|
218
|
+
run_id=args.run_id,
|
|
219
|
+
)
|
|
220
|
+
print(report)
|
|
221
|
+
return 1 if report.failed else 0
|
|
222
|
+
|
|
223
|
+
if args.command in ("try", "dry-run"):
|
|
224
|
+
wf = _one(args.target)
|
|
225
|
+
since = args.since or datetime.now(timezone.utc) - timedelta(days=1)
|
|
226
|
+
with connect(wf) as client:
|
|
227
|
+
written = dry_run(wf, client, since=since, keys=args.key, limit=args.limit, out_dir=args.out)
|
|
228
|
+
if not written:
|
|
229
|
+
print(f"Nothing uploaded to the inputs since {since:%Y-%m-%d %H:%M} UTC. Try an earlier --since.")
|
|
230
|
+
for key, paths in written.items():
|
|
231
|
+
print(f"{key}: {len(paths)} file(s)")
|
|
232
|
+
for path in paths:
|
|
233
|
+
print(f" {path}")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
return 2
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|