ms-moe-maker 0.4.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.
- ms_moe_maker/__init__.py +28 -0
- ms_moe_maker/__main__.py +253 -0
- ms_moe_maker/_describe.py +47 -0
- ms_moe_maker/_version.py +24 -0
- ms_moe_maker/events.py +70 -0
- ms_moe_maker/levers.py +285 -0
- ms_moe_maker/manifest.py +285 -0
- ms_moe_maker/recipe.py +573 -0
- ms_moe_maker/runner.py +448 -0
- ms_moe_maker/stages.py +123 -0
- ms_moe_maker-0.4.0.dist-info/METADATA +175 -0
- ms_moe_maker-0.4.0.dist-info/RECORD +15 -0
- ms_moe_maker-0.4.0.dist-info/WHEEL +5 -0
- ms_moe_maker-0.4.0.dist-info/entry_points.txt +2 -0
- ms_moe_maker-0.4.0.dist-info/top_level.txt +1 -0
ms_moe_maker/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Ms.MoE - Multi-Specified Mixture of Experts.
|
|
2
|
+
|
|
3
|
+
Five deliberate experts instead of a hundred lottery tickets. The design thesis
|
|
4
|
+
is the INVERSE of a frontier MoE: hand-assign the domains so every expert has a
|
|
5
|
+
guaranteed constituency, which eliminates dead and collapsed experts by
|
|
6
|
+
construction rather than fighting them with a load-balancing auxiliary loss.
|
|
7
|
+
Because each expert does exactly one thing, you can retrain ONE and re-splice
|
|
8
|
+
without touching the others - which is what makes it maintainable by one
|
|
9
|
+
person.
|
|
10
|
+
|
|
11
|
+
The real product is the factory, not the model. Swap the expert list and
|
|
12
|
+
someone else gets their own Ms.MoE, shaped like THEIR stack.
|
|
13
|
+
|
|
14
|
+
This package deliberately depends on nothing of Seren's. seren-theatre can
|
|
15
|
+
watch a run, and seren-theatre[stagehand] can start one, but the arrow only
|
|
16
|
+
points that way - and even then the two never speak, they share a directory.
|
|
17
|
+
Opt in, never opt out.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from ._version import version as __version__
|
|
23
|
+
except Exception: # noqa: BLE001 - source checkout without a build
|
|
24
|
+
__version__ = "0.0.0+unknown"
|
|
25
|
+
|
|
26
|
+
from ._describe import DESCRIBE, NAME # noqa: F401 (stdlib-only, safe here)
|
|
27
|
+
|
|
28
|
+
__all__ = ["DESCRIBE", "NAME", "__version__"]
|
ms_moe_maker/__main__.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The ms-moe-maker CLI - the command stagehand forks and a person types.
|
|
2
|
+
|
|
3
|
+
Three verbs:
|
|
4
|
+
|
|
5
|
+
ms-moe-maker describe one line of JSON, exit 0, no side effects
|
|
6
|
+
ms-moe-maker validate recipe.yaml parse + check, touching nothing
|
|
7
|
+
ms-moe-maker build recipe.yaml translate, fork the pipeline, report
|
|
8
|
+
|
|
9
|
+
`ms-moe-maker build recipe.yaml` is deliberately the literal string in the README and
|
|
10
|
+
the literal string seren-theatre[stagehand] forks. Not a Python API call, not
|
|
11
|
+
an internal entry point with different defaults - the same command. If the two
|
|
12
|
+
ever diverge, the hand-run path is the one that rots, because it is the one
|
|
13
|
+
with no automated users; making them identical removes the possibility.
|
|
14
|
+
|
|
15
|
+
--describe is scanned before argparse for the same reason every Seren installer
|
|
16
|
+
does it: it has to answer on a broken install, so nothing may run first.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ._describe import DESCRIBE
|
|
27
|
+
from .events import Events
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _force_utf8_stdio() -> None:
|
|
31
|
+
"""UTF-8 regardless of console codepage. Windows defaults to legacy, and
|
|
32
|
+
the pipeline prints emoji milestones - a UnicodeEncodeError mid-build would
|
|
33
|
+
kill a run over a decorative character."""
|
|
34
|
+
for stream in (sys.stdout, sys.stderr):
|
|
35
|
+
try:
|
|
36
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
37
|
+
except (AttributeError, ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_pipeline(explicit: str | None, recipe_path: Path,
|
|
42
|
+
required: bool) -> Path | None:
|
|
43
|
+
"""Locate fraunkenstein_universal.py. None if absent and not required.
|
|
44
|
+
|
|
45
|
+
Looked up rather than assumed, and reported when missing, because "wrapped
|
|
46
|
+
a script that isn't there" should be one clear error and not a traceback
|
|
47
|
+
from subprocess. Order: --pipeline, beside the recipe, cwd, then upward.
|
|
48
|
+
|
|
49
|
+
`required` is the difference between the two verbs, and it is not a
|
|
50
|
+
convenience:
|
|
51
|
+
|
|
52
|
+
build - REQUIRED. There is nothing to fork without it.
|
|
53
|
+
validate - OPTIONAL. The README promises `ms-moe-maker validate` runs on a
|
|
54
|
+
laptop with no GPU so you can check a recipe BEFORE going
|
|
55
|
+
near a machine that can run it. Demanding the pipeline made
|
|
56
|
+
that promise false: a stranger with a recipe and no checkout
|
|
57
|
+
got "could not find fraunkenstein_universal.py" and no
|
|
58
|
+
validation at all. Recipe SHAPE is checkable on its own; only
|
|
59
|
+
the refusal analysis needs a pipeline to compare against.
|
|
60
|
+
|
|
61
|
+
An explicit --pipeline that does not exist is always an error, for either
|
|
62
|
+
verb. Being told where it is and being wrong is different from not saying.
|
|
63
|
+
"""
|
|
64
|
+
from .levers import DEFAULT_PIPELINE
|
|
65
|
+
|
|
66
|
+
if explicit:
|
|
67
|
+
p = Path(explicit)
|
|
68
|
+
if not p.is_file():
|
|
69
|
+
raise SystemExit(f"--pipeline {p} does not exist")
|
|
70
|
+
return p.resolve()
|
|
71
|
+
|
|
72
|
+
for candidate in (recipe_path.parent / DEFAULT_PIPELINE,
|
|
73
|
+
Path.cwd() / DEFAULT_PIPELINE):
|
|
74
|
+
if candidate.is_file():
|
|
75
|
+
return candidate.resolve()
|
|
76
|
+
for parent in [Path.cwd(), *Path.cwd().parents]:
|
|
77
|
+
candidate = parent / DEFAULT_PIPELINE
|
|
78
|
+
if candidate.is_file():
|
|
79
|
+
return candidate.resolve()
|
|
80
|
+
if required:
|
|
81
|
+
raise SystemExit(
|
|
82
|
+
f"could not find {DEFAULT_PIPELINE}. Pass --pipeline PATH, or run "
|
|
83
|
+
f"from the directory that holds it.")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
_force_utf8_stdio()
|
|
89
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
90
|
+
|
|
91
|
+
# Before argparse: zero side effects, works half-installed.
|
|
92
|
+
if "--describe" in argv or (argv and argv[0] == "describe"):
|
|
93
|
+
print(json.dumps(DESCRIBE))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
ap = argparse.ArgumentParser(
|
|
97
|
+
prog="ms-moe-maker",
|
|
98
|
+
description="Build a mixture of experts from a recipe.")
|
|
99
|
+
ap.add_argument("command", choices=["build", "validate", "describe"])
|
|
100
|
+
ap.add_argument("recipe", nargs="?", help="path to the recipe .yaml")
|
|
101
|
+
ap.add_argument("--pipeline", default=None,
|
|
102
|
+
help="path to fraunkenstein_universal.py (default: found "
|
|
103
|
+
"beside the recipe, then upward from cwd)")
|
|
104
|
+
ap.add_argument("--json", action="store_true",
|
|
105
|
+
help="JSON Lines events on stdout, prose on stderr")
|
|
106
|
+
ap.add_argument("--python", default=None,
|
|
107
|
+
help="interpreter to run the pipeline with (default: the "
|
|
108
|
+
"one running ms-moe-maker). Use this when the trainer lives "
|
|
109
|
+
"in a different venv - which is the normal case, "
|
|
110
|
+
"since ms-moe-maker is deliberately small and torch is not.")
|
|
111
|
+
ap.add_argument("--dryrun", action="store_true",
|
|
112
|
+
help="FRAUNK_DRYRUN=1 - the whole pipeline, small")
|
|
113
|
+
ap.add_argument("--force", action="store_true",
|
|
114
|
+
help="FRAUNK_FORCE=1 - redo stages whose artifacts exist")
|
|
115
|
+
ap.add_argument("--allow-refusals", action="store_true",
|
|
116
|
+
help="run even though some recipe fields cannot be "
|
|
117
|
+
"honoured. They are recorded in the manifest either "
|
|
118
|
+
"way; this only removes the stop.")
|
|
119
|
+
a = ap.parse_args(argv)
|
|
120
|
+
|
|
121
|
+
ev = Events(enabled=a.json)
|
|
122
|
+
|
|
123
|
+
if a.command == "describe":
|
|
124
|
+
print(json.dumps(DESCRIBE))
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
if not a.recipe:
|
|
128
|
+
ap.error("a recipe path is required")
|
|
129
|
+
|
|
130
|
+
from .recipe import load, resolve, validate
|
|
131
|
+
|
|
132
|
+
recipe_path = Path(a.recipe).resolve()
|
|
133
|
+
try:
|
|
134
|
+
rec, parse_warns = load(str(recipe_path))
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - the message IS the product
|
|
136
|
+
ev.error("parse", str(exc))
|
|
137
|
+
ev.say(f"FAILED to parse {recipe_path}: {exc}")
|
|
138
|
+
return 2
|
|
139
|
+
|
|
140
|
+
errs, warns = validate(rec)
|
|
141
|
+
warns = parse_warns + warns
|
|
142
|
+
for w in warns:
|
|
143
|
+
ev.warning(w)
|
|
144
|
+
ev.say(f" WARN {w}")
|
|
145
|
+
for e in errs:
|
|
146
|
+
ev.error("validate", e)
|
|
147
|
+
ev.say(f" ERROR {e}")
|
|
148
|
+
if errs:
|
|
149
|
+
ev.done(ok=False, stage="validate")
|
|
150
|
+
return 1
|
|
151
|
+
|
|
152
|
+
# RESOLVED BEFORE THE PIPELINE LOOKUP, on purpose. Both are explicit
|
|
153
|
+
# arguments, and an explicit argument that is wrong should say so no matter
|
|
154
|
+
# what else is also missing - same rule as --pipeline. Reporting "could not
|
|
155
|
+
# find fraunkenstein_universal.py" to someone who mistyped --python sends
|
|
156
|
+
# them to fix the wrong thing.
|
|
157
|
+
#
|
|
158
|
+
# MSMOE_PYTHON as well as --python: the interpreter is a property of the
|
|
159
|
+
# BOX, not of the run, so it belongs somewhere you set once. Same shape as
|
|
160
|
+
# the family's SEREN_<X>_* levers - a flag for the one-off, an env var for
|
|
161
|
+
# the machine.
|
|
162
|
+
interpreter = a.python or os.environ.get("MSMOE_PYTHON") or None
|
|
163
|
+
if interpreter:
|
|
164
|
+
ipath = Path(interpreter)
|
|
165
|
+
if not ipath.is_file():
|
|
166
|
+
raise SystemExit(f"--python {ipath} does not exist")
|
|
167
|
+
# abspath, NEVER resolve(). A venv's bin/python is a SYMLINK to the
|
|
168
|
+
# base interpreter, and resolving it throws the venv away: you asked
|
|
169
|
+
# for /lab/bin/python and got /usr/bin/python3.12, whose sys.prefix is
|
|
170
|
+
# /usr and whose site-packages has none of your training deps. The
|
|
171
|
+
# failure then reads as "No module named 'torch'" from an interpreter
|
|
172
|
+
# you never named, which is about as misleading as it gets.
|
|
173
|
+
#
|
|
174
|
+
# Measured: running the symlink gives sys.prefix=/tmp/venvtest;
|
|
175
|
+
# running its target gives sys.prefix=/usr. Same file, different venv.
|
|
176
|
+
# abspath normalises the path without following the link.
|
|
177
|
+
interpreter = os.path.abspath(str(ipath))
|
|
178
|
+
|
|
179
|
+
pipeline = _find_pipeline(a.pipeline, recipe_path,
|
|
180
|
+
required=(a.command == "build"))
|
|
181
|
+
|
|
182
|
+
from .levers import Translation, translate
|
|
183
|
+
|
|
184
|
+
if pipeline is None:
|
|
185
|
+
# Recipe-only validation. Say so LOUDLY rather than reporting a clean
|
|
186
|
+
# bill of health: "valid" and "valid, and nothing checked whether the
|
|
187
|
+
# pipeline can honour it" are different answers, and quietly giving
|
|
188
|
+
# the first when you mean the second is how a document that lies gets
|
|
189
|
+
# blessed on its way out the door.
|
|
190
|
+
tr = Translation()
|
|
191
|
+
no_pipeline_note = (
|
|
192
|
+
"no pipeline found, so ONLY the recipe's own shape was checked. "
|
|
193
|
+
"Refusals could not be computed - run this again beside "
|
|
194
|
+
"fraunkenstein_universal.py, or pass --pipeline PATH, to find out "
|
|
195
|
+
"whether a build would actually honour these fields.")
|
|
196
|
+
ev.warning(no_pipeline_note)
|
|
197
|
+
else:
|
|
198
|
+
no_pipeline_note = ""
|
|
199
|
+
tr = translate(rec, pipeline, force=a.force)
|
|
200
|
+
|
|
201
|
+
if tr.refusals:
|
|
202
|
+
ev.refused(tr.refusals)
|
|
203
|
+
ev.say("")
|
|
204
|
+
ev.say(f" {len(tr.refusals)} recipe field(s) cannot be honoured by "
|
|
205
|
+
f"{pipeline.name}:")
|
|
206
|
+
for r in tr.refusals:
|
|
207
|
+
ev.say(f" · {r}")
|
|
208
|
+
ev.say("")
|
|
209
|
+
ev.say(" These are not warnings. A recipe is a document you hand to "
|
|
210
|
+
"someone so they get YOUR run, and a field that is silently "
|
|
211
|
+
"ignored makes it a document that lies. Fix the recipe, carve "
|
|
212
|
+
"the stage out, or pass --allow-refusals to proceed knowing "
|
|
213
|
+
"the build will not match the file.")
|
|
214
|
+
|
|
215
|
+
if a.command == "validate":
|
|
216
|
+
eff = resolve(rec)
|
|
217
|
+
ev.emit("resolved", **eff)
|
|
218
|
+
ev.say("")
|
|
219
|
+
ev.say(f"Ms.MoE recipe {rec.name} [{eff['recipe_id']}]")
|
|
220
|
+
ev.say(f" pipeline {pipeline if pipeline else '(none found)'}")
|
|
221
|
+
ev.say(f" honoured {len(tr.agreed)} field(s), "
|
|
222
|
+
f"{len(tr.env)} env lever(s) set")
|
|
223
|
+
ev.say(f" refused {len(tr.refusals)} field(s)")
|
|
224
|
+
if no_pipeline_note:
|
|
225
|
+
ev.say("")
|
|
226
|
+
ev.say(f" NOTE {no_pipeline_note}")
|
|
227
|
+
ok = not tr.refusals
|
|
228
|
+
ev.done(ok=ok, refusals=len(tr.refusals), agreed=len(tr.agreed),
|
|
229
|
+
env=tr.env, pipeline=str(pipeline) if pipeline else None,
|
|
230
|
+
# The consumer needs to be able to tell "no refusals" from
|
|
231
|
+
# "refusals were never computed". Same key set either way, one
|
|
232
|
+
# honest flag - the alternative is a caller inferring depth of
|
|
233
|
+
# analysis from an empty list, which it cannot do.
|
|
234
|
+
refusals_checked=pipeline is not None)
|
|
235
|
+
return 0 if ok else 1
|
|
236
|
+
|
|
237
|
+
# build
|
|
238
|
+
if tr.refusals and not a.allow_refusals:
|
|
239
|
+
ev.done(ok=False, stage="translate", refusals=len(tr.refusals))
|
|
240
|
+
ev.say(" REFUSED - nothing was run.")
|
|
241
|
+
return 3
|
|
242
|
+
|
|
243
|
+
from .runner import Runner
|
|
244
|
+
|
|
245
|
+
if interpreter:
|
|
246
|
+
ev.say(f" pipeline interpreter: {interpreter}")
|
|
247
|
+
runner = Runner(rec, pipeline, tr, ev, cwd=pipeline.parent,
|
|
248
|
+
dryrun=a.dryrun, python=interpreter)
|
|
249
|
+
return runner.run()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ms-moe-maker's identity card. STDLIB ONLY - nothing imported, nothing read.
|
|
2
|
+
|
|
3
|
+
Same contract and the same reason as every Seren service's `_describe`, even
|
|
4
|
+
though ms-moe-maker is NOT a Seren package and never imports one: `--describe` has to
|
|
5
|
+
answer on a half-installed tool, so it cannot need torch, pydantic, or yaml.
|
|
6
|
+
The moment you most want something to be able to say its own name is when its
|
|
7
|
+
install is broken.
|
|
8
|
+
|
|
9
|
+
ms-moe-maker is deliberately Seren-agnostic. No seren-* dependency, no assumption
|
|
10
|
+
that Lodestar exists, no Seren in the name. It is a pipeline for building a
|
|
11
|
+
specified mixture of experts, usable by someone who has never heard of any of
|
|
12
|
+
this. seren-theatre[stagehand] depends on ms-moe-maker; ms-moe-maker depends on nothing of
|
|
13
|
+
Chad's. Mandate is not ethos - the connection is opt-in from the Seren side,
|
|
14
|
+
and the run DIRECTORY is the only thing the two ever share.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
NAME = "ms-moe-maker"
|
|
19
|
+
DESCRIPTION = ("Build a mixture of experts from deliberately chosen "
|
|
20
|
+
"specialists. Not a coding model - a coding model shaped like "
|
|
21
|
+
"your stack.")
|
|
22
|
+
|
|
23
|
+
# The three verbs. Named here so a front-end can offer them without parsing
|
|
24
|
+
# --help, and so `stagehand` can check the tool it forked speaks the version of
|
|
25
|
+
# the contract it expects.
|
|
26
|
+
COMMANDS = ("build", "validate", "describe")
|
|
27
|
+
|
|
28
|
+
# The event vocabulary emitted under --json. A consumer that does not know an
|
|
29
|
+
# event kind must ignore it, so adding one is not a breaking change; removing
|
|
30
|
+
# or renaming one is.
|
|
31
|
+
EVENTS = ("started", "stage", "progress", "refused", "warning", "error", "done")
|
|
32
|
+
|
|
33
|
+
DESCRIBE = {
|
|
34
|
+
"name": NAME,
|
|
35
|
+
"kind": "pipeline",
|
|
36
|
+
"description": DESCRIPTION,
|
|
37
|
+
"commands": list(COMMANDS),
|
|
38
|
+
"events": list(EVENTS),
|
|
39
|
+
# The manifest schema this build writes into a run directory. A reader
|
|
40
|
+
# (seren-theatre) can check compatibility before it trusts a file.
|
|
41
|
+
"manifest_schema_version": 1,
|
|
42
|
+
"recipe_schema_version": 1,
|
|
43
|
+
# Requires nothing, of anyone. The heavy deps (torch, transformers,
|
|
44
|
+
# datasets) belong to the PIPELINE it forks, not to this CLI - which is
|
|
45
|
+
# what lets `ms-moe-maker validate` run on a laptop with no GPU and no CUDA.
|
|
46
|
+
"requires": [],
|
|
47
|
+
}
|
ms_moe_maker/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g9c59d38f4'
|
ms_moe_maker/events.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""JSON Lines events - the machine-readable twin of the human output.
|
|
2
|
+
|
|
3
|
+
The Starwright contract, reused: human prose on stderr, one JSON object per
|
|
4
|
+
line on stdout when --json is on. Two channels, never interleaved, so a
|
|
5
|
+
consumer can parse stdout without a heuristic for "is this line prose".
|
|
6
|
+
|
|
7
|
+
WHY STDERR FOR THE PROSE. The other direction - prose on stdout, events on a
|
|
8
|
+
side channel - means anything piping the tool has to know about the side
|
|
9
|
+
channel. Putting the machine stream on stdout makes `ms-moe-maker build r.yaml --json
|
|
10
|
+
| jq` work with no ceremony, which is the whole point of having it.
|
|
11
|
+
|
|
12
|
+
ONE RULE, and it is the one that gets broken: flush every line. A consumer
|
|
13
|
+
following a build is reading a pipe, and Python block-buffers a pipe by
|
|
14
|
+
default. Without the flush, a run that takes six hours emits its first event
|
|
15
|
+
when the buffer fills or the process exits, and a dashboard watching it shows
|
|
16
|
+
nothing at all for hours while everything is completely fine. That failure
|
|
17
|
+
looks exactly like a hang.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Any, TextIO
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Events:
|
|
27
|
+
"""Emitter. Inert unless --json was passed, so call sites need no branch."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, enabled: bool = False, stream: TextIO | None = None,
|
|
30
|
+
prose: TextIO | None = None) -> None:
|
|
31
|
+
self.enabled = enabled
|
|
32
|
+
self._out = stream or sys.stdout
|
|
33
|
+
self._prose = prose or sys.stderr
|
|
34
|
+
|
|
35
|
+
def emit(self, kind: str, **fields: Any) -> None:
|
|
36
|
+
if not self.enabled:
|
|
37
|
+
return
|
|
38
|
+
# default=str so a Path or a dataclass never turns a progress report
|
|
39
|
+
# into a crash. An event stream that can kill the build it is
|
|
40
|
+
# describing is worse than no event stream.
|
|
41
|
+
self._out.write(json.dumps({"event": kind, **fields}, default=str) + "\n")
|
|
42
|
+
self._out.flush()
|
|
43
|
+
|
|
44
|
+
def say(self, message: str) -> None:
|
|
45
|
+
"""Human line. Always stderr, never the event stream."""
|
|
46
|
+
self._prose.write(message + "\n")
|
|
47
|
+
self._prose.flush()
|
|
48
|
+
|
|
49
|
+
# -- the vocabulary, as methods so typos are import errors not silence ---
|
|
50
|
+
|
|
51
|
+
def started(self, **kw: Any) -> None:
|
|
52
|
+
self.emit("started", **kw)
|
|
53
|
+
|
|
54
|
+
def stage(self, id: str, status: str, **kw: Any) -> None:
|
|
55
|
+
self.emit("stage", id=id, status=status, **kw)
|
|
56
|
+
|
|
57
|
+
def progress(self, id: str, **kw: Any) -> None:
|
|
58
|
+
self.emit("progress", id=id, **kw)
|
|
59
|
+
|
|
60
|
+
def refused(self, reasons: list[str]) -> None:
|
|
61
|
+
self.emit("refused", reasons=reasons, count=len(reasons))
|
|
62
|
+
|
|
63
|
+
def warning(self, message: str) -> None:
|
|
64
|
+
self.emit("warning", message=message)
|
|
65
|
+
|
|
66
|
+
def error(self, stage: str, message: str) -> None:
|
|
67
|
+
self.emit("error", stage=stage, message=message)
|
|
68
|
+
|
|
69
|
+
def done(self, ok: bool, **kw: Any) -> None:
|
|
70
|
+
self.emit("done", ok=ok, **kw)
|