Skip to content

Commit 3cf7b2c

Browse files
Send console errors to stderr instead of stdout (#1792)
- fixes #1749 --- `print_error` and `print_exception` rendered onto whatever console the command passed in, and every command builds a plain stdout `Console()`. So the reporter's case does this: ``` $ frictionless describe doesnotexist.csv --json > out.txt 2> err.txt before: stdout 324 bytes (the red error panel), stderr 0 bytes after: stdout 0 bytes, stderr 324 bytes ``` Both helpers now print to a module-level `Console(stderr=True)`, including the `debug=True` path, which called `console.print_exception()` and would otherwise have kept leaking tracebacks to stdout. There is one call site in there that must **not** move: `extract.py:233` calls `print_error(console, note="No rows found", title="Empty")` from inside the table-rendering loop, after `console.rule("[bold]Tables")` and followed by `continue`. That is report content on a **successful** run (exit 0) using `print_error` purely for the red panel styling. Sending it to stderr would tear it out of the sequence of tables it sits between. So it moves to a new `print_panel` helper that renders on the passed console. Verified: `frictionless extract empty.csv` still prints "No rows found" to stdout, exit 0, with stderr empty. **Tests.** Five console tests asserted error text on `result.stdout`; they now assert on `result.stderr`, which is precisely the behaviour under change: - `test_describe.py::test_console_describe_error_not_found` - `test_transform.py::test_console_transform_error_not_found_source_issue_814` - `test_validate.py::test_console_validate_single_invalid_resource_221` - `test_extract.py::test_console_extract_single_invalid_resource` - `test_extract.py::test_console_extract_single_valid_resource_invalid_package` Two new tests pin the separation directly: an error run must leave stdout completely empty while stderr carries the message, and a successful `--json` run must still produce parseable JSON on stdout with stderr empty. Reverting the source while keeping the tests fails the first of those; the second passes either way, deliberately, so it catches over-correction. --------- Co-authored-by: Pierre Camilleri <pierre.camilleri@multi.coop> Co-authored-by: Pierre Camilleri <22995923+pierrecamilleri@users.noreply.github.com>
1 parent 445b3e0 commit 3cf7b2c

18 files changed

Lines changed: 91 additions & 57 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
from typer.testing import CliRunner
4+
5+
6+
def create_runner() -> CliRunner:
7+
"""Build a runner capturing stdout and stderr separately.
8+
9+
click >= 8.2 always separates the two streams and dropped the "mix_stderr"
10+
argument, while earlier versions merge them unless it is passed. Python 3.9
11+
is stuck on click 8.1 because 8.2 requires Python 3.10.
12+
"""
13+
try:
14+
return CliRunner(mix_stderr=False) # type: ignore[call-arg]
15+
except TypeError:
16+
return CliRunner()

frictionless/console/commands/__spec__/test_describe.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import pytest
44
import yaml
5-
from typer.testing import CliRunner
65

76
from frictionless import Detector, Dialect, describe, formats, platform
87
from frictionless.console import console
98

10-
runner = CliRunner()
9+
from .conftest import create_runner
10+
11+
runner = create_runner()
1112

1213

1314
# General
@@ -106,8 +107,8 @@ def test_console_describe_json():
106107
def test_console_describe_error_not_found():
107108
actual = runner.invoke(console, "describe data/bad.csv")
108109
assert actual.exit_code == 1
109-
assert actual.stdout.count("[Errno 2]")
110-
assert actual.stdout.count("data/bad.csv")
110+
assert actual.stderr.count("[Errno 2]")
111+
assert actual.stderr.count("data/bad.csv")
111112

112113

113114
def test_console_describe_basepath():
@@ -199,3 +200,17 @@ def test_console_describe_package_with_glob_having_one_incorrect_dialect_1126():
199200
assert output["resources"][1]["schema"] == {
200201
"fields": [{"type": "string", "name": "# Author: the scientist"}]
201202
}
203+
204+
205+
def test_console_describe_error_goes_to_stderr_not_stdout_issue_1749():
206+
actual = runner.invoke(console, "describe data/bad.csv --json")
207+
assert actual.exit_code == 1
208+
assert "[Errno 2]" in actual.stderr
209+
assert actual.stdout == ""
210+
211+
212+
def test_console_describe_success_still_writes_to_stdout_issue_1749():
213+
actual = runner.invoke(console, "describe data/table.csv --json")
214+
assert actual.exit_code == 0
215+
assert json.loads(actual.stdout)
216+
assert actual.stderr == ""

frictionless/console/commands/__spec__/test_extract.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import pytest
44
import yaml
5-
from typer.testing import CliRunner
65

76
from frictionless import Detector, Dialect, extract, formats, platform
87
from frictionless.console import console
98

10-
runner = CliRunner()
9+
from .conftest import create_runner
10+
11+
runner = create_runner()
1112

1213

1314
# General
@@ -275,15 +276,15 @@ def test_console_extract_single_invalid_resource():
275276
console, "extract data/datapackage.json --resource-name number-twoo"
276277
)
277278
assert actual.exit_code == 1
278-
assert actual.stdout.count("number-twoo")
279+
assert actual.stderr.count("number-twoo")
279280

280281

281282
def test_console_extract_single_valid_resource_invalid_package():
282283
actual = runner.invoke(
283284
console, "extract data/bad/datapackage.json --resource-name number-two"
284285
)
285286
assert actual.exit_code == 1
286-
assert actual.stdout.count("No such file or directory")
287+
assert actual.stderr.count("No such file or directory")
287288

288289

289290
def test_console_extract_single_resource_yaml():

frictionless/console/commands/__spec__/test_transform.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from typer.testing import CliRunner
2-
31
from frictionless.console import console
42

5-
runner = CliRunner()
3+
from .conftest import create_runner
4+
5+
runner = create_runner()
66

77

88
def test_console_transform():
@@ -35,5 +35,5 @@ def test_console_transform_error_not_found_source_issue_814():
3535
"transform data/bad.csv --pipeline data/issue-814.yaml",
3636
)
3737
assert result.exit_code == 1
38-
assert result.stdout.count("[Errno 2]")
39-
assert result.stdout.count("bad.csv")
38+
assert result.stderr.count("[Errno 2]")
39+
assert result.stderr.count("bad.csv")

frictionless/console/commands/__spec__/test_validate.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import pytest
44
import yaml
5-
from typer.testing import CliRunner
65

76
from frictionless import Detector, Dialect, validate
87
from frictionless.console import console
98

10-
runner = CliRunner()
9+
from .conftest import create_runner
10+
11+
runner = create_runner()
1112

1213

1314
# General
@@ -222,7 +223,7 @@ def test_console_validate_single_invalid_resource_221():
222223
console, "validate data/datapackage.json --resource-name number-twoo"
223224
)
224225
assert actual.exit_code == 1
225-
assert actual.stdout.count("number-twoo")
226+
assert actual.stderr.count("number-twoo")
226227

227228

228229
def test_console_validate_multipart_resource_1140():

frictionless/console/commands/convert.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def console_convert(
6363
source = helpers.create_source(source, path=path)
6464
if not source and not path:
6565
note = 'Providing "source" or "path" is required'
66-
helpers.print_error(console, note=note)
66+
helpers.print_error(note=note)
6767
raise typer.Exit(code=1)
6868

6969
try:
@@ -124,7 +124,7 @@ def console_convert(
124124
)
125125

126126
except Exception as exception:
127-
helpers.print_exception(console, debug=debug, exception=exception)
127+
helpers.print_exception(debug=debug, exception=exception)
128128
raise typer.Exit(code=1)
129129

130130
# Print result

frictionless/console/commands/describe.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def console_describe(
7676
source = helpers.create_source(source, path=path)
7777
if not source and not path:
7878
note = 'Providing "source" or "path" is required'
79-
helpers.print_error(console, note=note)
79+
helpers.print_error(note=note)
8080
raise typer.Exit(code=1)
8181

8282
try:
@@ -121,7 +121,7 @@ def console_describe(
121121
stats=stats,
122122
)
123123
except Exception as exception:
124-
helpers.print_exception(console, debug=debug, exception=exception)
124+
helpers.print_exception(debug=debug, exception=exception)
125125
raise typer.Exit(code=1)
126126

127127
# Json mode

frictionless/console/commands/explore.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import List
55

66
import typer
7-
from rich.console import Console
87

98
from ...resource import Resource
109
from ...system import system
@@ -29,8 +28,6 @@ def console_explore(
2928
Please read the commands reference:
3029
- https://www.visidata.org/man/
3130
"""
32-
console = Console()
33-
3431
# Setup system
3532
if trusted:
3633
system.trusted = trusted
@@ -41,7 +38,7 @@ def console_explore(
4138
source = helpers.create_source(source, path=path)
4239
if not source and not path:
4340
note = 'Providing "source" or "path" is required'
44-
helpers.print_error(console, note=note)
41+
helpers.print_error(note=note)
4542
raise typer.Exit(code=1)
4643

4744
# Get paths
@@ -55,7 +52,7 @@ def console_explore(
5552
resources = resource.list(name=name)
5653
paths = [resource.normpath for resource in resources if resource.normpath]
5754
except Exception as exception:
58-
helpers.print_exception(console, debug=debug, exception=exception)
55+
helpers.print_exception(debug=debug, exception=exception)
5956
raise typer.Exit(code=1)
6057

6158
# Enter editor

frictionless/console/commands/extract.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def console_extract(
9090
source = helpers.create_source(source, path=path)
9191
if not source and not path:
9292
note = 'Providing "source" or "path" is required'
93-
helpers.print_error(console, note=note)
93+
helpers.print_error(note=note)
9494
raise typer.Exit(code=1)
9595

9696
try:
@@ -173,7 +173,7 @@ def console_extract(
173173
# List resources
174174
resources = resource.list()
175175
except Exception as exception:
176-
helpers.print_exception(console, debug=debug, exception=exception)
176+
helpers.print_exception(debug=debug, exception=exception)
177177
raise typer.Exit(code=1)
178178

179179
# Yaml mode
@@ -191,7 +191,7 @@ def console_extract(
191191
# No data
192192
if not data:
193193
note = "No tabular data have been found in the source"
194-
helpers.print_error(console, note=note)
194+
helpers.print_error(note=note)
195195
raise typer.Exit(code=1)
196196

197197
# TODO: rework
@@ -230,7 +230,7 @@ def console_extract(
230230
for title, items in data.items():
231231
# Empty
232232
if not items:
233-
helpers.print_error(console, note="No rows found", title="Empty")
233+
helpers.print_panel(console, note="No rows found", title="Empty")
234234
continue
235235

236236
# General

frictionless/console/commands/index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def console_index(
4141
source = helpers.create_source(source, path=path)
4242
if not source and not path:
4343
note = 'Providing "source" or "path" is required'
44-
helpers.print_error(console, note=note)
44+
helpers.print_error(note=note)
4545
raise typer.Exit(code=1)
4646

4747
# Index resource
@@ -71,7 +71,7 @@ def console_index(
7171
)
7272
)
7373
except Exception as exception:
74-
helpers.print_exception(console, debug=debug, exception=exception)
74+
helpers.print_exception(debug=debug, exception=exception)
7575
raise typer.Exit(code=1)
7676

7777
# Print result

0 commit comments

Comments
 (0)