Skip to content

Commit 4e3af9a

Browse files
authored
Merge pull request #112 from SMI-Lab-Inha/fix-cli-ascii-help
fix(cli): make --help ASCII-safe for non-UTF-8 Windows consoles
2 parents 7bf8899 + be551b8 commit 4e3af9a

7 files changed

Lines changed: 75 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
## [Unreleased]
1010

11+
## [1.14.1] — 2026-05-23
12+
13+
### Fixed
14+
15+
- **CLI ``--help`` crashed on a non-UTF-8 Windows console.** The
16+
``windio`` subcommand help carried a rightwards-arrow glyph, so
17+
``pybmodes --help`` raised ``UnicodeEncodeError`` when argparse wrote
18+
the formatted help to a legacy Windows console (cp1252 / cp437) where
19+
that character has no mapping. The CLI help and description strings are
20+
now plain ASCII, and a test asserts every parser's formatted help is
21+
ASCII-encodable so it prints on any code page. Caught by the
22+
conda-forge Windows build.
23+
1124
## [1.14.0] — 2026-05-23
1225

1326
An engineering-hardening pass that makes the library fail closed on

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Check the installed version, and pin one if you need reproducibility:
6767

6868
```bash
6969
python -c "import pybmodes; print(pybmodes.__version__)"
70-
pip install "pybmodes==1.14.0" # install / pin a specific release
70+
pip install "pybmodes==1.14.1" # install / pin a specific release
7171
```
7272

7373
For a source checkout, pull and reinstall:

‎docs/installation.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Check the installed version, and pin one if you need reproducibility.
142142
.. code-block:: bash
143143
144144
python -c "import pybmodes; print(pybmodes.__version__)"
145-
pip install "pybmodes==1.14.0" # install or pin a specific release
145+
pip install "pybmodes==1.14.1" # install or pin a specific release
146146
147147
For a source checkout, pull and reinstall so new dependencies are
148148
picked up too.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pybmodes"
7-
version = "1.14.0"
7+
version = "1.14.1"
88
description = "Python finite-element library for wind turbine blade and tower modal analysis (OpenFAST/ElastoDyn)"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

‎src/pybmodes/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
except PackageNotFoundError:
185185
# Fallback for an uninstalled source tree (no package metadata).
186186
# Keep in step with ``pyproject.toml`` ``[project] version``.
187-
__version__ = "1.14.0"
187+
__version__ = "1.14.1"
188188

189189
__all__ = [
190190
"CheckOptions",

‎src/pybmodes/cli.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _cmd_patch(args: argparse.Namespace) -> int:
105105
print(
106106
"error: --output and --output-dir were given different "
107107
f"paths ({args.output!r} vs {args.output_dir!r}); they are "
108-
"aliases — pass only one (or the same value)",
108+
"aliases. Pass only one (or the same value)",
109109
file=sys.stderr,
110110
)
111111
return 2
@@ -327,7 +327,7 @@ def _build_parser() -> argparse.ArgumentParser:
327327
parser = argparse.ArgumentParser(
328328
prog="pybmodes",
329329
description=(
330-
"pyBmodes — pure-Python finite-element library for "
330+
"pyBmodes, a pure-Python finite-element library for "
331331
"wind-turbine blade and tower modal analysis."
332332
),
333333
)
@@ -441,14 +441,14 @@ def _build_parser() -> argparse.ArgumentParser:
441441
"--n-blade-modes",
442442
type=int,
443443
default=4,
444-
help="number of blade modes to track across the sweep (default: 4 — "
444+
help="number of blade modes to track across the sweep (default: 4, "
445445
"1st/2nd flap and 1st/2nd edge)",
446446
)
447447
p_camp.add_argument(
448448
"--n-tower-modes",
449449
type=int,
450450
default=4,
451-
help="number of tower modes to overlay as horizontal lines (default: 4 — "
451+
help="number of tower modes to overlay as horizontal lines (default: 4, "
452452
"1st/2nd FA and 1st/2nd SS); set to 0 to suppress",
453453
)
454454
p_camp.add_argument(
@@ -511,7 +511,7 @@ def _build_parser() -> argparse.ArgumentParser:
511511
"combined with --validate, also writes a "
512512
"<deck>_validate_after.txt report alongside the "
513513
"before-patch one. Default-safe in 1.8.0: each side-deck "
514-
"is .bak-copied before the rewrite — pass --no-backup to "
514+
"is .bak-copied before the rewrite; pass --no-backup to "
515515
"opt out; pass --dry-run for a no-write preview; pass "
516516
"--output-dir DIR to write patched copies elsewhere.",
517517
)
@@ -643,13 +643,13 @@ def _default_report_out(args: argparse.Namespace) -> argparse.Namespace:
643643
)
644644

645645
# -----------------------------------------------------------------
646-
# windio — one-click WindIO ontology → tower + blade + (floating)
646+
# windio - one-click WindIO ontology to tower + blade + (floating)
647647
# coupled platform + Campbell + bundled report
648648
# -----------------------------------------------------------------
649649
p_windio = sub.add_parser(
650650
"windio",
651651
help="one-click: a WindIO ontology .yaml (or an RWT directory) "
652-
"→ tower + blade + (floating) coupled-platform modes + "
652+
"to tower + blade + (floating) coupled-platform modes + "
653653
"optional Campbell + a bundled report. Companion "
654654
"HydroDyn/MoorDyn/ElastoDyn decks are auto-discovered so "
655655
"a floating platform is industry-grade by default; "
@@ -675,7 +675,7 @@ def _default_report_out(args: argparse.Namespace) -> argparse.Namespace:
675675
)
676676
p_windio.add_argument(
677677
"--water-depth", type=float, default=None,
678-
help="site water depth (m) — only needed for the yaml-only "
678+
help="site water depth (m), only needed for the yaml-only "
679679
"floating screening preview when no MoorDyn deck is found",
680680
)
681681
p_windio.add_argument(
@@ -691,7 +691,7 @@ def _default_report_out(args: argparse.Namespace) -> argparse.Namespace:
691691
"--min-rpm", type=float, default=0.0,
692692
help="lower end of the operating rotor-speed range used for "
693693
"the 1P/3P design bands on the environmental-spectra "
694-
"figure (default: 0.0 — the figure is then labelled a "
694+
"figure (default: 0.0; the figure is then labelled a "
695695
"SCREENING envelope since no real cut-in is given)",
696696
)
697697
p_windio.add_argument(

‎tests/test_cli_help.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""The CLI help text must be plain ASCII.
2+
3+
A non-ASCII glyph in an argparse help / description string crashes
4+
``pybmodes <cmd> --help`` on a legacy Windows console (cp1252 / cp437)
5+
with a ``UnicodeEncodeError`` when argparse writes the formatted help.
6+
A rightwards arrow in the ``windio`` subcommand help did exactly that
7+
and was caught by conda-forge's Windows build. These tests assert every
8+
parser's formatted help is ASCII-encodable so it prints on any code
9+
page, and guard against the regression returning.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
16+
import pytest
17+
18+
from pybmodes.cli import _build_parser
19+
20+
21+
def _iter_parsers():
22+
"""Yield (label, parser) for the main parser and every subparser."""
23+
parser = _build_parser()
24+
yield "pybmodes", parser
25+
for action in parser._actions:
26+
if isinstance(action, argparse._SubParsersAction):
27+
for name, sub in action.choices.items():
28+
yield f"pybmodes {name}", sub
29+
30+
31+
def test_all_cli_help_is_ascii() -> None:
32+
"""Every parser's formatted help encodes as ASCII, so it never trips
33+
the console-encoding crash on a non-UTF-8 Windows terminal."""
34+
offenders: list[str] = []
35+
for label, parser in _iter_parsers():
36+
try:
37+
parser.format_help().encode("ascii")
38+
except UnicodeEncodeError as exc:
39+
offenders.append(f"{label}: {exc}")
40+
assert not offenders, "non-ASCII CLI help:\n " + "\n ".join(offenders)
41+
42+
43+
@pytest.mark.parametrize("encoding", ["cp1252", "cp437"])
44+
def test_cli_help_encodes_on_legacy_windows_codepages(encoding: str) -> None:
45+
"""Belt and braces: the help also encodes on the two common legacy
46+
Windows console code pages, the environments where the original
47+
crash happened."""
48+
for _label, parser in _iter_parsers():
49+
parser.format_help().encode(encoding) # must not raise

0 commit comments

Comments
 (0)