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.
@@ -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
+ ]
@@ -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())