You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/19 10:46:26 UTC
(juneau) branch master updated: Count only leading poms on the Setup row, close the pager subprocess on every exit path, and make the none console load juneau-run, default a full log and send git output to it
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 7973735355 Count only leading poms on the Setup row, close the pager subprocess on every exit path, and make the none console load juneau-run, default a full log and send git output to it
7973735355 is described below
commit 797373535521b3382f4d19697f8ed87080badd5f
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Oct 19 06:46:20 2026 -0400
Count only leading poms on the Setup row, close the pager subprocess on every exit path, and make the none console load juneau-run, default a full log and send git output to it
---
juneau-run/README.md | 4 +-
juneau-run/src/main/python/bootstrap.py | 7 +-
juneau-run/src/main/python/juneau_run.py | 81 ++++++++------
juneau-run/src/main/python/juneau_scripts/git.py | 30 ++++-
juneau-run/src/main/python/juneau_scripts/run.py | 7 +-
.../test/python/fixtures/maven-trailing-pom.log | 49 ++++++++
juneau-run/src/test/python/test_maven_setup_row.py | 123 ++++++++++++++++++++-
juneau-run/src/test/python/test_parsers.py | 18 +++
juneau-run/src/test/python/test_runner.py | 19 ++++
.../src/test/python/test_scripts_bootstrap.py | 17 +++
juneau-run/src/test/python/test_scripts_git.py | 48 ++++++++
juneau-run/src/test/python/test_scripts_run.py | 42 +++++++
juneau-run/src/test/python/test_sinks.py | 9 +-
13 files changed, 408 insertions(+), 46 deletions(-)
diff --git a/juneau-run/README.md b/juneau-run/README.md
index 698e3e7d85..73cb059b49 100644
--- a/juneau-run/README.md
+++ b/juneau-run/README.md
@@ -86,9 +86,9 @@ screen. `full` (the default) prints raw output as before; `none` draws nothing.
| Variable | Effect |
|---|---|
-| `JUNEAU_RUN_CONSOLE=full\|condensed\|none` | Where tool output goes. `condensed` turns the view on. |
+| `JUNEAU_RUN_CONSOLE=full\|condensed\|none` | Where tool output goes. `condensed` turns the view on. `condensed` and `none` also make `bootstrap.load()` load juneau_run, so a tool's output goes to the logs and not the terminal; `none` draws nothing and emits no markers unless `RUN_MARKERS=1`. |
| `JUNEAU_RUN_DETAIL=summary\|actionable\|modules\|all` | How much the view shows (default `actionable`). `--detail` on `push.py`, `test.py` and `release.py` wins. |
-| `JUNEAU_RUN_FULL_LOG`, `JUNEAU_RUN_CONDENSED_LOG` | The raw log, and the view's rows as plain text. The header prints `tail -f <full log>` to paste into another terminal. With `condensed` and no full log, the full log defaults to `~/.cache/juneau-run/<script>-<YYYYMMDD-HHMMSS>.log` (the newest 20 are kept); nested scripts share it. |
+| `JUNEAU_RUN_FULL_LOG`, `JUNEAU_RUN_CONDENSED_LOG` | The raw log, and the view's rows as plain text. The header prints `tail -f <full log>` to paste into another terminal. With `condensed` or `none` and no full log, the full log defaults to `~/.cache/juneau-run/<script>-<YYYYMMDD-HHMMSS>.log` (the newest 20 are kept); nested scripts share it. |
| `JUNEAU_RUN_LOG_DIR` | Where that default full log goes (default `~/.cache/juneau-run`). The tests point it at a temp dir. |
| `JUNEAU_RUN_LIVE=0` | Plain view: each row printed once, when it ends. Also used when stdout isn't a terminal or `TERM=dumb`. |
| `NO_COLOR` | The live view without colour. |
diff --git a/juneau-run/src/main/python/bootstrap.py b/juneau-run/src/main/python/bootstrap.py
index f2a7589f2b..3303e898e0 100644
--- a/juneau-run/src/main/python/bootstrap.py
+++ b/juneau-run/src/main/python/bootstrap.py
@@ -26,7 +26,7 @@ Where the toolkit comes from, in order:
3. ~/.m2/repository/org/apache/juneau/juneau-run/<v>/juneau-run-<v>.{py,zip}, in place when both are there;
otherwise `mvn dependency:copy` of both into cache_dir (a SNAPSHOT once per process, a release when missing).
-juneau_run is loaded only when run=True, or when run is None and RUN_MARKERS=1 or JUNEAU_RUN_CONSOLE=condensed.
+juneau_run is loaded only when run=True, or when run is None and RUN_MARKERS=1 or JUNEAU_RUN_CONSOLE=condensed or none.
"""
import importlib
@@ -49,8 +49,9 @@ class BootstrapError(Exception):
def run_requested():
- """True when the environment asks for juneau_run: RUN_MARKERS=1 or JUNEAU_RUN_CONSOLE=condensed."""
- return os.environ.get("RUN_MARKERS") == "1" or os.environ.get("JUNEAU_RUN_CONSOLE") == "condensed"
+ """True when the environment asks for juneau_run: RUN_MARKERS=1 or JUNEAU_RUN_CONSOLE=condensed or none. Under
+ none juneau_run is loaded so a tool's output goes to the sinks and not the terminal."""
+ return os.environ.get("RUN_MARKERS") == "1" or os.environ.get("JUNEAU_RUN_CONSOLE") in ("condensed", "none")
def _pom_version(pom):
diff --git a/juneau-run/src/main/python/juneau_run.py b/juneau-run/src/main/python/juneau_run.py
index 73026dcbdd..df1f6d60d2 100644
--- a/juneau-run/src/main/python/juneau_run.py
+++ b/juneau-run/src/main/python/juneau_run.py
@@ -614,7 +614,8 @@ class _Mod:
class _Setup:
- """The run's one Setup row: a box per pom module of the first Maven reactor that has any (spec 2.1)."""
+ """The run's one Setup row: a box per pom module built before the first real module of the first Maven reactor
+ that has any (spec 2.1). A trailing pom (an aggregator root built last) is not on it."""
def __init__(self, row, ids, t0):
self.row = row # id of the step row that owns it
@@ -677,7 +678,8 @@ class _Row:
self.held_failure = False # failure text is among the hints: they print however the row ends
self.failed_mods = [] # titles of its failed modules, in failure order, for the row's text
self.size = None # reactor size N
- self.poms = set()
+ self.poms = set() # the Setup poms: built before the reactor's first real module
+ self.tail = set() # poms built after a real module: on neither the Setup row nor the bar
self.tests_mode = False
self.quiet = 0 # seconds the tool has been silent, from the watchdog
self.units_total = 0 # announced unit count (tests, files...) for the unit bar
@@ -693,6 +695,11 @@ class _Row:
def running(self):
return self.status is None
+ @property
+ def unbarred(self):
+ """Modules that never get a cell in the module bar: the Setup poms and the trailing ones."""
+ return self.poms | self.tail
+
def boxes(self):
return [self.modules[m].box for m in self.order]
@@ -704,7 +711,7 @@ class _Row:
if self.unit_kind != "modules" or not self.running:
return self.unit_cells
running = sum(1 for m in self.modules.values()
- if m.started and m.status is None and m.unit_index is None and not m.dropped and m.id not in self.poms)
+ if m.started and m.status is None and m.unit_index is None and not m.dropped and m.id not in self.unbarred)
view = bytearray(self.unit_cells)
for i in range(self.units_done, min(self.units_total, self.units_done + running)):
view[i] = UNIT_RUN
@@ -939,15 +946,17 @@ class Board:
def _on_reactor(self, f):
"""Sent at the first line (pom-tree size), again at the Reactor Build Order block and at the first Building
- line. The module bar counts the non-pom modules; the pom modules go to the run's Setup row (spec 2.1)."""
+ line. The module bar counts the non-pom modules; the pom modules built before the first real module go to the
+ run's Setup row (spec 2.1). A pom built after a real module (`tail`, an aggregator root) is on neither."""
r = self._adopt(f["step"])
if r is not None:
r.size = f["size"]
r.poms = set(f["poms"])
+ r.tail = set(f.get("tail") or ()) # absent from an older publisher: no trailing pom
r.tests_mode = f["tests"]
if r.size > 0: # a reactor of no size says nothing: the row keeps the box bar
r.unit_kind = "modules"
- r.units_total = max(0, r.size - len(r.poms) - r.units_dropped, r.units_done) # set, not grown: the block shrinks the tree's size
+ r.units_total = max(0, r.size - len(r.poms) - len(r.tail) - r.units_dropped, r.units_done) # set, not grown: the block shrinks the tree's size
del r.unit_cells[r.units_total:]
r.unit_cells.extend(bytes(r.units_total - len(r.unit_cells)))
for pom in f["poms"]:
@@ -965,7 +974,7 @@ class Board:
def _count_unit(self, r, m, outcome):
"""A real module of a Maven row counts once toward its module bar; a later failure turns its cell red."""
- if r.unit_kind != "modules" or m.id in r.poms:
+ if r.unit_kind != "modules" or m.id in r.unbarred:
return
if m.dropped:
if outcome != "fail":
@@ -987,7 +996,7 @@ class Board:
The count of modules that will run tests is only known as modules end (--also-make pulls in many upstream
modules with none), so the live bar starts at the reactor's size and N shrinks as those drop out. The n/N
and the bar therefore only ever lose cells that were never going to say anything."""
- if r.unit_kind != "modules" or m.id in r.poms or m.dropped or m.unit_index is not None:
+ if r.unit_kind != "modules" or m.id in r.unbarred or m.dropped or m.unit_index is not None:
return
m.dropped = True
r.units_dropped += 1
@@ -1056,8 +1065,8 @@ class Board:
r, m = self._mod(f, create=True)
if m is None:
return []
- if m.id in r.poms:
- return [] # pom modules live on the Setup row, never in a bar
+ if m.id in r.unbarred:
+ return [] # pom modules live on the Setup row (or, trailing, nowhere), never in a bar
if not f["ok"]:
self._grant(r, m, "fail")
if not r.tests_mode:
@@ -1069,7 +1078,7 @@ class Board:
def _on_module_tested(self, f):
r, m = self._mod(f, create=True)
- if m is not None and m.id not in r.poms and m.box != "fail":
+ if m is not None and m.id not in r.unbarred and m.box != "fail":
outcome = self._tests_outcome(m)
self._grant(r, m, outcome)
if r.tests_mode: # under -T module_end waits for the Reactor Summary: count the unit when its tests end
@@ -1106,6 +1115,10 @@ class Board:
blocks = [("setup",)] # it went permanent green at the pom's start: print it again, red
else:
blocks = self._setup_settled()
+ elif m.id in r.tail: # a trailing pom: no Setup box, no bar cell; its failure still fails the row and is named
+ m.box = "fail" if failed else "pass"
+ if failed and m.title not in r.failed_mods:
+ r.failed_mods.append(m.title)
else:
if failed:
self._grant(r, m, "fail")
@@ -1159,7 +1172,7 @@ class Board:
if f.get("totals"):
r.totals = _totals(f["totals"])
for m in r.modules.values(): # never ended: it ran nothing we saw (close_children ends the open ones)
- if m.box is None and m.id not in r.poms:
+ if m.box is None and m.id not in r.unbarred:
self._grant(r, m, "fail" if "fail" in m.outcomes else "pass" if m.outcomes else "skip")
if m.box == "fail" and m.title not in r.failed_mods:
r.failed_mods.append(m.title)
@@ -1446,7 +1459,7 @@ class PlainRenderer:
totals = [r.totals for r in running if r.totals]
total = sum(t[0] for t in totals)
failed = sum(t[1] + t[2] for t in totals)
- k = sum(1 for r in running for m in r.modules.values() if m.in_progress and m.id not in r.poms)
+ k = sum(1 for r in running for m in r.modules.values() if m.in_progress and m.id not in r.unbarred)
overall = board.overall()
figure = (f" · {overall.percent}% · ~{fmt_elapsed(overall.remaining * 1000)}"
f"{'+' if overall.lower_bound else ''} left") if overall else ""
@@ -1659,13 +1672,13 @@ class LiveRenderer:
kept while anything is pending), then the module sub-lines (+N more running), then the lines above Overall
are cut: Overall itself is never dropped."""
running = board.running()
- inflight = [(r, m) for r in running for m in r.modules.values() if m.in_progress and m.id not in r.poms]
+ inflight = [(r, m) for r in running for m in r.modules.values() if m.in_progress and m.id not in r.unbarred]
subs = [(r, m) for r, m in inflight if m.outcomes or m.totals]
collapsed = {}
for r in running:
names = [m.title for owner, m in inflight if owner is r and not (m.outcomes or m.totals)]
if names:
- prefix = common_prefix([m.title for m in r.modules.values() if m.id not in r.poms])
+ prefix = common_prefix([m.title for m in r.modules.values() if m.id not in r.unbarred])
collapsed[r.id] = " " + collapse_names([t[len(prefix):] or t for t in names], cols - 2)
blocks = {id(m): self._sub_lines(m, cols) for _, m in subs}
setup_running = (board.setup is not None and not board.setup.ended
@@ -2120,15 +2133,11 @@ def _page(text):
except ValueError: # not the main thread
previous = None
try:
- try:
- proc.stdin.write(text)
- except BrokenPipeError: # the pager was quit before it read everything
- pass
- try:
- proc.stdin.close()
- except BrokenPipeError:
- pass
- proc.wait()
+ with proc: # on every exit path, closes the pipe and waits for the pager, still under SIG_IGN
+ try:
+ proc.stdin.write(text)
+ except BrokenPipeError: # the pager was quit before it read everything
+ pass
finally:
if previous is not None:
signal.signal(signal.SIGINT, previous)
@@ -2576,15 +2585,15 @@ def prune_full_logs(keep=FULL_LOG_KEEP):
class Sinks:
"""Where the full and condensed streams go. Arguments beat JUNEAU_RUN_* environment variables.
- A condensed console with no full log gets default_full_log()."""
+ A condensed or none console with no full log gets default_full_log()."""
def __init__(self, console=None, full_log=None, condensed_log=None):
self.console = console or os.environ.get("JUNEAU_RUN_CONSOLE") or "full"
if self.console not in CONSOLE_MODES:
raise ValueError(f"console must be one of {CONSOLE_MODES}, not {self.console!r}")
full = full_log or os.environ.get("JUNEAU_RUN_FULL_LOG")
- if not full and self.console == "condensed":
- # Condensed mode always has a log to tail. Exported, so later Sinks() and child scripts write the same one.
+ if not full and self.console in ("condensed", "none"):
+ # Condensed and none always have a log: to tail, and to recover the output nothing else shows. Exported, so later Sinks() and child scripts write the same one.
full = str(default_full_log())
os.environ["JUNEAU_RUN_FULL_LOG"] = full
self.full_path = self._resolve(full)
@@ -2658,7 +2667,7 @@ class Sinks:
# ("tests", id, total, fail, err, skip) ("report", id, kind, path)
# ("note", level, text, id) ("cond", text)
# ("class_done", id, outcome) ("compiled", id, ok) ("tested", id)
-# ("reactor", size, [pom sub ids], tests_mode) ("failure", text, id)
+# ("reactor", size, [pom sub ids], tests_mode[, [trailing pom sub ids]]) ("failure", text, id)
# ("progress", id, text) bus only (step_progress), never a marker
# ("units", id, total, kind) ("unit", id, done, outcome) bus only (units / unit), never a marker
# ("unit_fix", id, index, outcome) ("series", id, name, sub, value) bus only (unit_fix / series)
@@ -2841,7 +2850,8 @@ class MavenParser(Parser):
self.surefire_s = {} # sub id -> summed class seconds, for the history `series` entry
self.class_cache = {} # test class FQN -> artifactIds of the modules containing it
self.packaging = {} # artifactId -> packaging
- self.order_poms = [] # sub ids of the pom-packaged members of the Reactor Build Order block
+ self.order_poms = [] # sub ids of the pom-packaged members built before the block's first real module
+ self.order_tail = [] # sub ids of the pom-packaged members built after it (an aggregator root)
self.in_order = False # inside the Reactor Build Order block
self.order_members = [] # (title, packaging) rows read so far
self.phase = {} # artifactId -> compile, testCompile, compiled, surefire, tested or done
@@ -2877,6 +2887,8 @@ class MavenParser(Parser):
return cmd, env
def _reactor(self, size):
+ if self.order_tail: # the optional fifth field: absent when there is no trailing pom
+ return ("reactor", size, list(self.order_poms), self.tests_mode, list(self.order_tail))
return ("reactor", size, list(self.order_poms), self.tests_mode)
def _has_tests(self, artifact):
@@ -3057,8 +3069,14 @@ class MavenParser(Parser):
if self.order_members and line.rstrip() == "[INFO]":
self.in_order = False
# a pom title absent from the pom tree stays in the module count but cannot be mapped to a sub id
- self.order_poms = list(dict.fromkeys(f"{self.step_id}/{self.names[t]}" for t, pkg in self.order_members
- if pkg == "pom" and t in self.names))
+ # only the poms built before the first real module are Setup poms; one built after it (an aggregator
+ # root, last in the order) is on neither the Setup row nor the module bar
+ first_real = next((i for i, (_, pkg) in enumerate(self.order_members) if pkg != "pom"),
+ len(self.order_members))
+ poms = [(i < first_real, f"{self.step_id}/{self.names[t]}")
+ for i, (t, pkg) in enumerate(self.order_members) if pkg == "pom" and t in self.names]
+ self.order_poms = list(dict.fromkeys(i for lead, i in poms if lead))
+ self.order_tail = list(dict.fromkeys(i for lead, i in poms if not lead))
events.append(self._reactor(len(self.order_members)))
return events
if not self.order_members and line.rstrip() == "[INFO]":
@@ -3805,7 +3823,8 @@ class _Dispatcher:
elif kind == "tested":
publish("module_tested", step=self.step_id, module=ev[1])
elif kind == "reactor":
- publish("reactor", step=self.step_id, size=ev[1], poms=list(ev[2]), tests=ev[3])
+ publish("reactor", step=self.step_id, size=ev[1], poms=list(ev[2]), tests=ev[3],
+ **({"tail": list(ev[4])} if len(ev) > 4 else {})) # `tail` only when there is one
elif kind == "failure":
publish("failure", text=ev[1], step=ev[2])
elif kind == "progress":
diff --git a/juneau-run/src/main/python/juneau_scripts/git.py b/juneau-run/src/main/python/juneau_scripts/git.py
index 561c42e9d4..56022c1006 100644
--- a/juneau-run/src/main/python/juneau_scripts/git.py
+++ b/juneau-run/src/main/python/juneau_scripts/git.py
@@ -17,8 +17,9 @@ isn't a repository they raise GitError, so an empty list means "nothing there" a
"git failed".
Writers return a Result and relay git's output as Foundry's push.py does: inherited by the terminal in a plain run
-(out and err stay empty), captured and shown as notes under a console view (info on success, error on failure), and
-captured into the Result in either mode with capture=True.
+(out and err stay empty), captured and shown as notes under a console view (info on success, error on failure),
+captured and written to the full log alone under the none console, and captured into the Result in every mode with
+capture=True.
Under an active console every command is polled rather than waited on: the helpers call juneau_run's tick() every
POLL_SECONDS, so the spinner and the elapsed time keep moving during a `git fetch` or `git push`.
@@ -290,9 +291,30 @@ def upstream_verdict(repo=".", *, git_cmd="git", fetch=True):
f" {git_cmd} merge --ff-only @{{upstream}}"))
+def _quiet(jr):
+ """True when juneau_run is loaded and the console is none: nothing is drawn and output goes to the sinks."""
+ return jr is not None and os.environ.get("JUNEAU_RUN_CONSOLE") == "none"
+
+
+def _to_sinks(jr, text):
+ """Append text to the full log (the default one when none was given); nowhere else."""
+ if not text:
+ return
+ sinks = jr.Sinks(console="none")
+ sinks.open()
+ try:
+ sinks.write_full(text.encode("utf-8", errors="replace"))
+ finally:
+ sinks.close()
+
+
def _write(cmd, repo, *, input=None, capture=False):
_base.remember(" ".join(cmd[:2]))
jr = _base.jr()
+ if _quiet(jr) and not capture:
+ result = _run(cmd, cwd=repo, input=input, capture=True)
+ _to_sinks(jr, result.out + result.err)
+ return Result(result.ok, result.code)
console = jr is not None and jr.console_active()
result = _run(cmd, cwd=repo, input=input, capture=capture or console)
if console and not capture:
@@ -306,7 +328,9 @@ def _write(cmd, repo, *, input=None, capture=False):
def _warn(text):
"""A warning: a warn note under a console view, else a line on stderr."""
jr = _base.jr()
- if jr is not None and jr.console_active():
+ if _quiet(jr):
+ _to_sinks(jr, text + "\n")
+ elif jr is not None and jr.console_active():
jr.say(text, "warn")
else:
print(text, file=sys.stderr)
diff --git a/juneau-run/src/main/python/juneau_scripts/run.py b/juneau-run/src/main/python/juneau_scripts/run.py
index bb0b765a4d..8f915064c2 100644
--- a/juneau-run/src/main/python/juneau_scripts/run.py
+++ b/juneau-run/src/main/python/juneau_scripts/run.py
@@ -56,10 +56,11 @@ def _next_n(n=None, count=1):
def routed():
- """True when commands should go through juneau_run's run_tool: markers on, or a console view active. Public
- because Foundry's run_tests also needs it (Decision Q4, revised in the review triage)."""
+ """True when commands should go through juneau_run's run_tool: markers on, a console view active, or the none
+ console, where a child's output goes to the sinks and nothing is drawn. Public because Foundry's run_tests also
+ needs it (Decision Q4, revised in the review triage)."""
jr = _base.jr()
- return jr is not None and (jr.enabled() or jr.console_active())
+ return jr is not None and (jr.enabled() or jr.console_active() or os.environ.get(CONSOLE_ENV) == "none")
def console_requested():
diff --git a/juneau-run/src/test/python/fixtures/maven-trailing-pom.log b/juneau-run/src/test/python/fixtures/maven-trailing-pom.log
new file mode 100644
index 0000000000..23d9580ca5
--- /dev/null
+++ b/juneau-run/src/test/python/fixtures/maven-trailing-pom.log
@@ -0,0 +1,49 @@
+[INFO] Scanning for projects...
+[INFO] ------------------------------------------------------------------------
+[INFO] Reactor Build Order:
+[INFO]
+[INFO] app-api [jar]
+[INFO] app-server [war]
+[INFO] app [pom]
+[INFO]
+[INFO] -------------------------< demo:app-api >--------------------------
+[INFO] Building app-api 1.0 [1/3]
+[INFO] from app-api/pom.xml
+[INFO] --------------------------------[ jar ]---------------------------------
+[INFO]
+[INFO] --- compiler:3.15.0:compile (default-compile) @ app-api ---
+[INFO] Nothing to compile - all classes are up to date.
+[INFO]
+[INFO] --- surefire:3.6.0:test (default-test) @ app-api ---
+[INFO] Running demo.dto.CellStatusTest
+[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s -- in demo.dto.CellStatusTest
+[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
+[INFO]
+[INFO]
+[INFO] ----------------------< demo:app-server >-----------------------
+[INFO] Building app-server 1.0 [2/3]
+[INFO] from app-server/pom.xml
+[INFO] --------------------------------[ war ]---------------------------------
+[INFO]
+[INFO] --- compiler:3.15.0:compile (default-compile) @ app-server ---
+[INFO] Nothing to compile - all classes are up to date.
+[INFO]
+[INFO] --- surefire:3.6.0:test (default-test) @ app-server ---
+[INFO] Running demo.server.RouteTest
+[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in demo.server.RouteTest
+[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
+[INFO]
+[INFO] --------------------------< demo:app >--------------------------
+[INFO] Building app 1.0 [3/3]
+[INFO] from pom.xml
+[INFO] --------------------------------[ pom ]---------------------------------
+[INFO] ------------------------------------------------------------------------
+[INFO] Reactor Summary for app 1.0:
+[INFO]
+[INFO] app-api ............................................ SUCCESS [ 0.869 s]
+[INFO] app-server ......................................... SUCCESS [ 3.215 s]
+[INFO] app ................................................ SUCCESS [ 0.000 s]
+[INFO] ------------------------------------------------------------------------
+[INFO] BUILD SUCCESS
+[INFO] ------------------------------------------------------------------------
+[INFO] Total time: 4.1 s
diff --git a/juneau-run/src/test/python/test_maven_setup_row.py b/juneau-run/src/test/python/test_maven_setup_row.py
index fa0c0a0716..8858cc77c4 100644
--- a/juneau-run/src/test/python/test_maven_setup_row.py
+++ b/juneau-run/src/test/python/test_maven_setup_row.py
@@ -314,13 +314,131 @@ class SetupRowTest(unittest.TestCase):
self.assertIn("1/3", rest)
+class TrailingPomTest(unittest.TestCase):
+ """A pom built after a real module (an aggregator root) is on neither the Setup row nor the module bar."""
+
+ def board(self, poms, tail, total):
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ b = jr.Board("actionable", nested=False, full_log=None, clock=lambda: 100.0)
+ b.apply("step_start", dict(id="mvn", n=1, title="mvn", parent=None))
+ b.apply("reactor", dict(step="mvn", size=total, poms=poms, tail=tail, tests=False))
+ return b
+
+ def run_module(self, b, module, status="ok"):
+ b.apply("module_start", dict(step="mvn", module=module, title=module))
+ return b.apply("module_end", dict(step="mvn", module=module, status=status, ms=1, totals=None))
+
+ def test_jar_war_then_a_trailing_pom_has_no_setup_row_and_a_bar_of_two(self):
+ b = self.board([], ["mvn/app"], 3)
+ self.assertIsNone(b.setup)
+ self.assertEqual((b.rows["mvn"].units_total, b.rows["mvn"].unit_kind), (2, "modules"))
+ for m in ("mvn/app-api", "mvn/app-server", "mvn/app"):
+ self.run_module(b, m)
+ b.apply("step_end", dict(id="mvn", status="ok", ms=2000, exit=0, summary=None, totals=None))
+ r = b.rows["mvn"]
+ self.assertIsNone(b.setup)
+ self.assertEqual((r.units_total, r.units_done), (2, 2))
+ self.assertNotIn("mvn/app", r.order)
+
+ def test_a_trailing_pom_never_shows_as_in_flight_in_the_bar(self):
+ b = self.board([], ["mvn/app"], 3)
+ b.apply("module_start", dict(step="mvn", module="mvn/app", title="app"))
+ self.assertEqual(b.rows["mvn"].unit_view(), bytearray(2))
+
+ def test_leading_parent_and_trailing_aggregator_give_a_setup_row_of_one(self):
+ b = self.board(["mvn/parent"], ["mvn/agg"], 4)
+ self.assertEqual((b.setup.total, jr.setup_text(b.setup)), (1, "1 pom module"))
+ self.assertEqual(b.rows["mvn"].units_total, 2)
+ self.run_module(b, "mvn/parent")
+ self.assertEqual((b.setup.done, b.setup.ended), (1, True))
+
+ def test_a_resent_reactor_event_keeps_the_trailing_pom_out(self):
+ b = self.board(["mvn/parent"], ["mvn/agg"], 4)
+ b.apply("reactor", dict(step="mvn", size=4, poms=["mvn/parent"], tail=["mvn/agg"], tests=False))
+ self.assertEqual((b.setup.ids, b.rows["mvn"].units_total), (["mvn/parent"], 2))
+
+ def test_an_event_without_tail_behaves_as_before(self):
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ b = jr.Board("actionable", nested=False, full_log=None, clock=lambda: 100.0)
+ b.apply("step_start", dict(id="mvn", n=1, title="mvn", parent=None))
+ b.apply("reactor", dict(step="mvn", size=4, poms=["mvn/parent"], tests=False))
+ self.assertEqual((b.setup.total, b.rows["mvn"].units_total), (1, 3))
+
+ def test_a_failing_trailing_pom_fails_the_row_and_is_named(self):
+ b = self.board([], ["mvn/app"], 3)
+ self.run_module(b, "mvn/app-api")
+ self.run_module(b, "mvn/app-server")
+ self.run_module(b, "mvn/app", status="fail")
+ b.apply("step_end", dict(id="mvn", status="fail", ms=2000, exit=1, summary=None, totals=None))
+ r = b.rows["mvn"]
+ self.assertIsNone(b.setup)
+ self.assertEqual(r.failed_mods, ["mvn/app"])
+ self.assertEqual((r.units_total, r.units_done), (2, 2))
+ self.assertEqual(r.modules["mvn/app"].box, "fail")
+
+
+class TrailingPomGoldenTest(unittest.TestCase):
+ """The same rule end to end: the recorded jar, war, pom reactor order through the Maven parser."""
+
+ def tree(self, root):
+ from pathlib import Path
+ root = Path(root)
+
+ def write(d, artifact, packaging, modules=""):
+ (root / d).mkdir(exist_ok=True)
+ (root / d / "pom.xml").write_text(f"<project><artifactId>{artifact}</artifactId><name>{artifact}</name>"
+ f"<packaging>{packaging}</packaging>{modules}</project>", encoding="utf-8")
+ write(".", "app", "pom", "<modules><module>app-api</module><module>app-server</module></modules>")
+ write("app-api", "app-api", "jar")
+ write("app-server", "app-server", "war")
+
+ def run_log(self, text):
+ import tempfile
+ from test_parsers import feed
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ with tempfile.TemporaryDirectory() as tmp:
+ self.tree(tmp)
+ parser = jr.MavenParser()
+ parser.step_id = "mvn"
+ parser.prepare(["mvn", "test"], None, tmp)
+ events = feed(parser, text)
+ board = jr.Board("actionable", nested=False, full_log=None, clock=lambda: 100.0)
+ board.apply("step_start", dict(id="mvn", n=1, title="mvn", parent=None))
+ pump(board, events)
+ return board, board.rows["mvn"]
+
+ def fixture(self):
+ from support import FIXTURES
+ return (FIXTURES / "maven-trailing-pom.log").read_text(encoding="utf-8")
+
+ def test_the_recorded_order_has_no_setup_row_and_a_full_bar_of_two(self):
+ board, r = self.run_log(self.fixture())
+ board.apply("step_end", dict(id="mvn", status="ok", ms=3000, exit=0, summary=None, totals=None))
+ self.assertIsNone(board.setup)
+ self.assertEqual((r.units_total, r.units_done), (2, 2))
+
+ def test_the_recorded_order_with_a_failing_aggregator_names_it(self):
+ text = self.fixture().replace("app ................................................ SUCCESS",
+ "app ................................................ FAILURE").replace(
+ "BUILD SUCCESS", "BUILD FAILURE")
+ board, r = self.run_log(text)
+ board.apply("step_end", dict(id="mvn", status="fail", ms=3000, exit=1, summary=None, totals=None))
+ self.assertIsNone(board.setup)
+ self.assertEqual(r.failed_mods, ["app"])
+ self.assertEqual((r.units_total, r.units_done), (2, 2))
+
+
def pump(board, events, step="mvn"):
"""Feed parser events to a board the way the dispatcher publishes them; returns the bar progress after each event."""
seen, totals = [], {}
for ev in events:
kind = ev[0]
if kind == "reactor":
- board.apply("reactor", dict(step=step, size=ev[1], poms=list(ev[2]), tests=ev[3]))
+ board.apply("reactor", dict(step=step, size=ev[1], poms=list(ev[2]), tests=ev[3],
+ tail=list(ev[4]) if len(ev) > 4 else []))
elif kind == "sub_start":
board.apply("module_start", dict(step=step, module=ev[1], title=ev[3]))
elif kind == "tests":
@@ -401,7 +519,8 @@ class MavenBoardGoldenTest(unittest.TestCase):
# 96 modules less 16 poms is 80, but only juneau-commons ran tests: the other 79 dropped out of the bar
self.assertEqual((r.units_total, r.unit_kind, r.units_dropped), (1, "modules", 79))
self.assertEqual(r.units_done, 1)
- self.assertEqual((board.setup.total, board.setup.done, jr.setup_text(board.setup)), (16, 16, "16 pom modules"))
+ # only the 3 poms ahead of the first jar are Setup poms; the 13 built after it are on neither the row nor the bar
+ self.assertEqual((board.setup.total, board.setup.done, jr.setup_text(board.setup)), (3, 3, "3 pom modules"))
first_end = next(i for i, e in enumerate(seen) if e[0] == "sub_end")
self.assertGreater(max(e[1] for e in seen[:first_end]), 0)
diff --git a/juneau-run/src/test/python/test_parsers.py b/juneau-run/src/test/python/test_parsers.py
index 345f51a49e..5434dbc0d1 100644
--- a/juneau-run/src/test/python/test_parsers.py
+++ b/juneau-run/src/test/python/test_parsers.py
@@ -947,6 +947,24 @@ class ReactorBuildOrderTest(unittest.TestCase):
self.assertEqual(last[2], ["mvn/demo-parent"])
+ def test_a_trailing_pom_is_not_a_setup_pom(self):
+ order = ["[INFO] Reactor Build Order:", "[INFO] ", "[INFO] Demo Module A [jar]", "[INFO] Demo Module B [war]",
+ "[INFO] Demo Parent [pom]", "[INFO] ", "[INFO] Building Demo Module A 1.0 [1/3]"]
+ last = self.reactor(order, packaging="pom")[-1]
+ self.assertEqual(last, ("reactor", 3, [], True, ["mvn/demo-parent"])) # it rides in the optional fifth field
+
+ def test_only_the_poms_before_the_first_real_module_are_setup_poms(self):
+ order = ["[INFO] Reactor Build Order:", "[INFO] ", "[INFO] Demo Parent [pom]", "[INFO] Demo Module A [jar]",
+ "[INFO] Demo Module B [pom]", "[INFO] ", "[INFO] Building Demo Parent 1.0 [1/3]"]
+ last = self.reactor(order, packaging="pom")[-1]
+ self.assertEqual(last[2], ["mvn/demo-parent"])
+ self.assertEqual(last[4], ["mvn/mod-b"])
+
+ def test_the_reactor_event_keeps_four_fields_without_a_trailing_pom(self):
+ last = self.reactor(self.ORDER, packaging="pom")[-1]
+ self.assertEqual(len(last), 4)
+
+
class ParserContractDefaultsTest(unittest.TestCase):
def test_new_members_are_inert(self):
p = jr.Parser()
diff --git a/juneau-run/src/test/python/test_runner.py b/juneau-run/src/test/python/test_runner.py
index 632ce53b4b..efcc794ac1 100644
--- a/juneau-run/src/test/python/test_runner.py
+++ b/juneau-run/src/test/python/test_runner.py
@@ -1068,6 +1068,25 @@ class ScriptApiTest(unittest.TestCase):
jr.show("x" * (1 << 20), pager=True)
self.assertEqual(out.getvalue(), "")
+ def test_a_failed_write_to_the_pager_still_closes_and_waits_for_it(self):
+ pager, _ = self.pager("sys.stdin.read()")
+ procs = []
+ real_popen = subprocess.Popen
+
+ def spawn(*args, **kwargs):
+ procs.append(real_popen(*args, **kwargs))
+ return procs[-1]
+
+ previous = signal.getsignal(signal.SIGINT)
+ self.addCleanup(lambda: [p.kill() or p.wait() for p in procs if p.poll() is None])
+ with mock.patch.object(sys, "stdout", TTYOut()), mock.patch.dict(os.environ, {"PAGER": pager}), \
+ mock.patch.object(jr.subprocess, "Popen", side_effect=spawn):
+ with self.assertRaises(UnicodeEncodeError):
+ jr.show("bad \udcff text", pager=True)
+ self.assertEqual(len(procs), 1)
+ self.assertIsNotNone(procs[0].returncode, "the pager was left running")
+ self.assertIs(signal.getsignal(signal.SIGINT), previous)
+
def test_show_with_a_missing_or_unparseable_PAGER_prints_the_text(self):
for pager in ("/nonexistent/juneau-run-pager", 'less "unterminated'):
with self.subTest(pager=pager):
diff --git a/juneau-run/src/test/python/test_scripts_bootstrap.py b/juneau-run/src/test/python/test_scripts_bootstrap.py
index 124bec5a37..790f299cce 100644
--- a/juneau-run/src/test/python/test_scripts_bootstrap.py
+++ b/juneau-run/src/test/python/test_scripts_bootstrap.py
@@ -20,6 +20,7 @@ import sys
import tempfile
import textwrap
import unittest
+import unittest.mock
from pathlib import Path
import scripts_support # noqa: F401 (puts src/main/python on sys.path)
@@ -305,6 +306,17 @@ class ImportTest(BootstrapCase):
self.assertNotIn("older than", self.stderr)
+class RunRequestedTest(unittest.TestCase):
+ def test_true_for_markers_condensed_and_none(self):
+ sys.path.insert(0, str(PYTHON_SRC))
+ self.addCleanup(sys.path.remove, str(PYTHON_SRC))
+ import bootstrap
+ for env, expected in (({}, False), ({"RUN_MARKERS": "1"}, True), ({"JUNEAU_RUN_CONSOLE": "condensed"}, True),
+ ({"JUNEAU_RUN_CONSOLE": "none"}, True), ({"JUNEAU_RUN_CONSOLE": "full"}, False)):
+ with self.subTest(env=env), unittest.mock.patch.dict(os.environ, env, clear=True):
+ self.assertEqual(bootstrap.run_requested(), expected)
+
+
class RunDecisionTest(BootstrapCase):
def setUp(self):
super().setUp()
@@ -321,6 +333,11 @@ class RunDecisionTest(BootstrapCase):
self.env = {}
self.assertIsNone(self.load(run=False)["jr"])
+ def test_console_none_loads_juneau_run(self):
+ self.env = {"JUNEAU_RUN_CONSOLE": "none"}
+ self.assertIsNotNone(self.load()["jr"])
+ self.assertIsNone(self.load(run=False)["jr"])
+
def test_missing_juneau_run_warns_when_requested_and_raises_when_forced(self):
z = build_zip(self.tmp / "toolkit.zip")
(self.home / ".m2").rename(self.tmp / "no-m2")
diff --git a/juneau-run/src/test/python/test_scripts_git.py b/juneau-run/src/test/python/test_scripts_git.py
index e262355092..f5c18912db 100644
--- a/juneau-run/src/test/python/test_scripts_git.py
+++ b/juneau-run/src/test/python/test_scripts_git.py
@@ -25,6 +25,7 @@ from pathlib import Path
from unittest import mock
import scripts_support # noqa: F401
+from scripts_support import jr
from juneau_scripts import git, testing
from juneau_scripts.git import GitError
@@ -289,6 +290,53 @@ class ConsoleRelayTest(GitCase):
self.assertIn("nothing to commit", r.out + r.err)
+class ConsoleNoneTest(GitCase):
+ """Under JUNEAU_RUN_CONSOLE=none with juneau_run loaded, a write's output goes to the full log, not the terminal."""
+
+ def setUp(self):
+ super().setUp()
+ self.log = self.tmp / "full.log"
+ patcher = mock.patch.dict(os.environ, {"JUNEAU_RUN_CONSOLE": "none", "JUNEAU_RUN_FULL_LOG": str(self.log)})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ modules = mock.patch.dict(sys.modules, {"juneau_run": jr})
+ modules.start()
+ self.addCleanup(modules.stop)
+
+ def test_failed_write_is_captured_and_logged(self):
+ real = git._run
+ captures = []
+
+ def spy(cmd, **kw):
+ captures.append(kw.get("capture"))
+ return real(cmd, **kw)
+
+ with mock.patch.object(git, "_run", spy):
+ r = git.commit_index("msg", self.work)
+ self.assertFalse(r.ok)
+ self.assertEqual((r.out, r.err), ("", ""))
+ self.assertEqual(captures, [True])
+ self.assertIn(b"nothing to commit", self.log.read_bytes())
+
+ def test_successful_write_is_logged(self):
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ self.assertTrue(git.commit_index("logged commit", self.work).ok)
+ self.assertIn(b"logged commit", self.log.read_bytes())
+
+ def test_capture_still_returns_the_output_and_logs_nothing(self):
+ r = git.commit_index("msg", self.work, capture=True)
+ self.assertIn("nothing to commit", r.out + r.err)
+ self.assertFalse(self.log.exists() and b"nothing to commit" in self.log.read_bytes())
+
+ def test_warning_goes_to_the_log_not_stderr(self):
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ git._warn("stash could not be dropped")
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertIn(b"stash could not be dropped", self.log.read_bytes())
+
+
class NeverAddOrForceTest(GitCase):
def test_no_helper_runs_add_force_no_verify_or_a_config_write(self):
log = self.tmp / "git-argv.jsonl"
diff --git a/juneau-run/src/test/python/test_scripts_run.py b/juneau-run/src/test/python/test_scripts_run.py
index 3bea577f71..f2bb249a56 100644
--- a/juneau-run/src/test/python/test_scripts_run.py
+++ b/juneau-run/src/test/python/test_scripts_run.py
@@ -160,6 +160,48 @@ class ParserForTest(unittest.TestCase):
self.assertEqual(run._parser_for(jr, ["bazel", "query", "//..."], None), "generic")
self.assertEqual(run._parser_for(jr, ["x"], "bazel"), "bazel")
+class ConsoleNoneCase(unittest.TestCase):
+ def setUp(self):
+ isolate_run_env(self)
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ sys.modules["juneau_run"] = jr
+ self.log = Path(tempfile.mkdtemp()) / "full.log"
+ os.environ["JUNEAU_RUN_CONSOLE"] = "none"
+ os.environ["JUNEAU_RUN_FULL_LOG"] = str(self.log)
+ run._n = 0
+
+
+class CmdConsoleNoneTest(ConsoleNoneCase):
+ def test_routed_under_none(self):
+ self.assertTrue(run.routed())
+
+ def test_child_output_goes_to_the_full_log_and_not_stdout(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ r = run.cmd([PY, "-c", "print('child-line')"], title="Child")
+ self.assertTrue(r.ok)
+ self.assertEqual(out.getvalue(), "")
+ self.assertIn(b"child-line", self.log.read_bytes())
+
+ def test_default_full_log_when_none_is_given(self):
+ del os.environ["JUNEAU_RUN_FULL_LOG"]
+ os.environ["JUNEAU_RUN_LOG_DIR"] = str(self.log.parent)
+ out = io.StringIO()
+ with redirect_stdout(out):
+ run.cmd([PY, "-c", "print('child-line')"], title="Child")
+ self.assertEqual(out.getvalue(), "")
+ logs = list(self.log.parent.glob("*.log"))
+ self.assertEqual(len(logs), 1)
+ self.assertIn(b"child-line", logs[0].read_bytes())
+
+ def test_no_markers_under_none(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ run.cmd([PY, "-c", "pass"], title="Quiet")
+ self.assertNotIn("##run", out.getvalue())
+
+
class CmdMarkersTest(MarkerCase):
def test_runs_through_run_tool_with_the_step_number(self):
r, got, _ = self.capture(run.cmd, [PY, "-c", "print('x')"], title="Build it", n=4)
diff --git a/juneau-run/src/test/python/test_sinks.py b/juneau-run/src/test/python/test_sinks.py
index d6ab688ebc..0c8820af68 100644
--- a/juneau-run/src/test/python/test_sinks.py
+++ b/juneau-run/src/test/python/test_sinks.py
@@ -41,13 +41,18 @@ class SinksTest(unittest.TestCase):
self.assertEqual(os.environ["JUNEAU_RUN_FULL_LOG"], str(first.full_path))
self.assertEqual(jr.Sinks(console="condensed").full_path, first.full_path) # a nested run's Sinks
- def test_only_condensed_gets_a_default_and_a_given_log_wins(self):
+ def test_condensed_and_none_get_a_default_and_a_given_log_wins(self):
os.environ["JUNEAU_RUN_LOG_DIR"] = str(self.tmp)
self.assertIsNone(jr.Sinks(console="full").full_path)
- self.assertIsNone(jr.Sinks(console="none").full_path)
self.assertEqual(jr.Sinks(console="condensed", full_log=str(self.tmp / "mine.log")).full_path,
self.tmp / "mine.log")
self.assertNotIn("JUNEAU_RUN_FULL_LOG", os.environ)
+ self.assertEqual(jr.Sinks(console="none", full_log=str(self.tmp / "mine.log")).full_path,
+ self.tmp / "mine.log")
+ self.assertNotIn("JUNEAU_RUN_FULL_LOG", os.environ)
+ default = jr.Sinks(console="none").full_path
+ self.assertRegex(str(default), rf"^{re.escape(str(self.tmp))}/.+-\d{{8}}-\d{{6}}\.log$")
+ self.assertEqual(os.environ["JUNEAU_RUN_FULL_LOG"], str(default))
def test_safe_name_replaces_whitespace_and_punctuation(self):
for raw, want in (("python3 -m unittest", "python3-m-unittest"),