From 7580bf40cb9941df2a0c647b7af266b0d773f5e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:26:30 +0000 Subject: [PATCH 1/6] Make the coverage job work, and report to Codecov The `coverage` job has been a no-op. `report-coverage` merges the matrix artifacts server-side but never downloads them into the workspace, so every command in it ran against an empty directory: + python -Iim coverage combine No data to combine + python -Iim coverage report --fail-under=80 No data to report. The job still passed, because `-Iim` includes `-i`: after each command exits non-zero, Python drops into an interactive interpreter, reads EOF from the empty stdin, and exits 0. So the 80% gate never once fired. Replace it with explicit steps that download the `coverage-*` artifacts, combine them, and publish the result to Codecov. The checkout the job already does turns out to be load-bearing: merging the Windows data with the POSIX data relies on `relative_files`, and coverage only maps a recorded path onto a canonical one when that file exists on disk. The floor is set to 78%, just under the 80.26% a single run measures today, so that it stays a backstop rather than a tripwire. Codecov's `project` status, at `target: auto`, is what ratchets coverage up. Also move `branch` into `[tool.coverage.run]`. It was passed as `--cov-branch` from the hatch script, which left the `coverage` CLI invocations in CI measuring something subtly different from the pytest run that produced the data. Codecov needs `CODECOV_TOKEN` in the repository secrets; without it the upload falls back to tokenless, which is rate-limited. `fail_ci_if_error` is off until that secret exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- .github/workflows/tests.yml | 39 ++++++++++++++++++++++++++++++++++++- .gitignore | 2 ++ README.md | 1 + codecov.yml | 32 ++++++++++++++++++++++++++++++ pyproject.toml | 5 ++++- 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31d9e3677..8afe28017 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,12 +51,49 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 coverage: + name: Combine Coverage and Report to Codecov runs-on: ubuntu-latest needs: - tests steps: + # The checkout is required, not just cosmetic: combining the Windows + # and POSIX data files relies on `relative_files`, which only maps a + # recorded path onto a canonical one when that file exists on disk. - uses: actions/checkout@v7 - - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Download the coverage data from every matrix job + uses: actions/download-artifact@v6 + with: + pattern: coverage-* + merge-multiple: true + - name: Combine coverage + run: | + python -Im pip install --upgrade 'coverage[toml]' + python -Im coverage combine + python -Im coverage xml + python -Im coverage html --skip-covered --skip-empty + python -Im coverage report --format=markdown --skip-covered >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + disable_search: true + # Empty on pull requests from forks, where Codecov falls back to a + # tokenless upload. Flip fail_ci_if_error on once the secret is set. + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + - name: Upload the HTML report if the check failed + uses: actions/upload-artifact@v6 + if: failure() + with: + name: html-report + path: htmlcov + # A floor, not a target: Codecov's `project` status is what ratchets + # coverage up from one pull request to the next. + - name: Check coverage against the floor + run: python -Im coverage report --fail-under=78 test_minimum_versions: name: Test Minimum Versions diff --git a/.gitignore b/.gitignore index 6fae1b197..3ccb4de87 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ __pycache__ \#*# .#* .coverage +.coverage.* +coverage.xml .cache htmlcov docs/source/CHANGELOG.md diff --git a/README.md b/README.md index 259c3d079..cb52ce2a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Traitlets [![Tests](https://github.com/ipython/traitlets/actions/workflows/tests.yml/badge.svg)](https://github.com/ipython/traitlets/actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/ipython/traitlets/branch/main/graph/badge.svg)](https://codecov.io/gh/ipython/traitlets) [![Documentation Status](https://readthedocs.org/projects/traitlets/badge/?version=latest)](https://traitlets.readthedocs.io/en/latest/?badge=latest) [![Tidelift](https://tidelift.com/subscription/pkg/pypi-traitlets)](https://tidelift.com/badges/package/pypi/traitlets) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..31fdef8be --- /dev/null +++ b/codecov.yml @@ -0,0 +1,32 @@ +# https://docs.codecov.com/docs/codecov-yaml +codecov: + require_ci_to_pass: true + notify: + # Coverage is combined and uploaded exactly once, by the `coverage` job, + # after the whole test matrix has finished. There is nothing to wait for. + after_n_builds: 1 + +coverage: + precision: 2 + round: down + range: "70...90" + status: + project: + default: + target: auto + # Don't fail a pull request over the rounding noise of a refactor + # that only moves code around. + threshold: 0.5% + patch: + default: + target: 80% + threshold: 5% + +comment: + layout: "condensed_header, condensed_files, condensed_footer" + require_changes: true + +ignore: + - "docs" + - "examples" + - "tests" diff --git a/pyproject.toml b/pyproject.toml index fe14b2aaf..915b23405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ nowarn = "test -W default {args}" features = ["test"] dependencies = ["coverage[toml]", "pytest-cov"] [tool.hatch.envs.cov.scripts] -test = "python -m pytest -vv --cov traitlets --cov-branch --cov-report term-missing:skip-covered {args}" +test = "python -m pytest -vv --cov traitlets --cov-report term-missing:skip-covered {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] @@ -174,6 +174,9 @@ exclude_lines = [ ] [tool.coverage.run] +branch = true +# Required to combine the data files produced by the Windows, macOS and Linux +# jobs of the test matrix into a single report. relative_files = true source = ["traitlets"] From 963c911825deaa1b7cc03b8a5778e53e854e7c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:33:19 +0000 Subject: [PATCH 2/6] Let Codecov merge the matrix instead of combining locally Codecov already merges every upload it receives for a commit, so the artifact round-trip and the combine job were doing work the service does anyway. Each matrix job now uploads its own coverage.xml, tagged with a flag, which also gets the per-OS and per-version breakdown that a single combined upload cannot show. `coverage xml` writes the `filename` attributes with forward slashes regardless of platform, so the Windows uploads line up with the rest without the path mapping the combined data file needed. Drop `after_n_builds`. It was correct at 1 for a single upload; with one upload per matrix job it would have to track the matrix size, and would silently report partial coverage the first time someone adds a Python version without bumping it. `wait_for_ci` (true by default) already holds the comment and the statuses until CI has finished. This gives up the `--fail-under` gate that ran in the combine job. Codecov's `project` status covers it, and is the stricter of the two. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- .github/workflows/tests.yml | 40 +++---------------------------------- .gitignore | 1 - codecov.yml | 7 +++---- pyproject.toml | 6 +++--- 4 files changed, 9 insertions(+), 45 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8afe28017..1a498ae58 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,52 +48,18 @@ jobs: python_package_manager: "uv pip" - name: Run Tests run: hatch run cov:test - - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 - - coverage: - name: Combine Coverage and Report to Codecov - runs-on: ubuntu-latest - needs: - - tests - steps: - # The checkout is required, not just cosmetic: combining the Windows - # and POSIX data files relies on `relative_files`, which only maps a - # recorded path onto a canonical one when that file exists on disk. - - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Download the coverage data from every matrix job - uses: actions/download-artifact@v6 - with: - pattern: coverage-* - merge-multiple: true - - name: Combine coverage - run: | - python -Im pip install --upgrade 'coverage[toml]' - python -Im coverage combine - python -Im coverage xml - python -Im coverage html --skip-covered --skip-empty - python -Im coverage report --format=markdown --skip-covered >> "$GITHUB_STEP_SUMMARY" + # Codecov merges the uploads of every matrix job into one report for + # the commit, so each job uploads its own and nothing combines them here. - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: files: coverage.xml disable_search: true + flags: ${{ matrix.os }}-${{ matrix.python-version }} # Empty on pull requests from forks, where Codecov falls back to a # tokenless upload. Flip fail_ci_if_error on once the secret is set. token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - - name: Upload the HTML report if the check failed - uses: actions/upload-artifact@v6 - if: failure() - with: - name: html-report - path: htmlcov - # A floor, not a target: Codecov's `project` status is what ratchets - # coverage up from one pull request to the next. - - name: Check coverage against the floor - run: python -Im coverage report --fail-under=78 test_minimum_versions: name: Test Minimum Versions diff --git a/.gitignore b/.gitignore index 3ccb4de87..6d41986f4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ __pycache__ \#*# .#* .coverage -.coverage.* coverage.xml .cache htmlcov diff --git a/codecov.yml b/codecov.yml index 31fdef8be..27ed090b0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,10 +1,9 @@ # https://docs.codecov.com/docs/codecov-yaml codecov: + # Every job of the test matrix uploads its own report. Waiting for CI to + # finish keeps the comment and the statuses off a partial set of uploads, + # without pinning a build count that the matrix would drift away from. require_ci_to_pass: true - notify: - # Coverage is combined and uploaded exactly once, by the `coverage` job, - # after the whole test matrix has finished. There is nothing to wait for. - after_n_builds: 1 coverage: precision: 2 diff --git a/pyproject.toml b/pyproject.toml index 915b23405..7b1b5c417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ nowarn = "test -W default {args}" features = ["test"] dependencies = ["coverage[toml]", "pytest-cov"] [tool.hatch.envs.cov.scripts] -test = "python -m pytest -vv --cov traitlets --cov-report term-missing:skip-covered {args}" +test = "python -m pytest -vv --cov traitlets --cov-report term-missing:skip-covered --cov-report xml {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] @@ -175,8 +175,8 @@ exclude_lines = [ [tool.coverage.run] branch = true -# Required to combine the data files produced by the Windows, macOS and Linux -# jobs of the test matrix into a single report. +# Keeps the paths in coverage.xml relative to the repository root, so that the +# uploads from every matrix job line up with each other on Codecov. relative_files = true source = ["traitlets"] From e864e67e6e2104ac2bc137ee60588cf2a43b1cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:42:53 +0000 Subject: [PATCH 3/6] Cover the two untested config modules, and split tests out as a component `manager.py` and `sphinxdoc.py` were both at 0% -- 117 statements that no test ever touched. Neither needs much of a harness: the config manager is JSON on disk, and the Sphinx extension turns out to import no Sphinx at all, so a stub with an `add_object_type` method is enough to exercise `setup()`. Both are now at 100%, branches included, along with the gaps in `descriptions.py`, `getargspec.py`, `sentinel.py` and `bunch.py`. The library goes from 80.26% to 84.59%. For the components, coverage has to measure the test suite as well -- a component can only report on files that are in the report. Codecov then splits the two apart, since blending them gives a number (89.25%) that mostly tracks how much of the test code runs rather than how much of the library is covered. Measuring from the repository root, rather than `source = ["traitlets", "tests"]`, is deliberate. `coverage xml` writes each path relative to its source root, so with two roots both `config/__init__.py` files land in the XML under one name. Today they are empty and nothing collides; the first line of code added to `tests/config/__init__.py` would silently merge the two on Codecov's side. One root keeps the paths distinct. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- codecov.yml | 26 +++-- pyproject.toml | 10 +- tests/config/test_manager.py | 88 ++++++++++++++++ tests/config/test_sphinxdoc.py | 173 +++++++++++++++++++++++++++++++ tests/utils/test_bunch.py | 8 ++ tests/utils/test_descriptions.py | 103 ++++++++++++++++++ tests/utils/test_getargspec.py | 86 +++++++++++++++ tests/utils/test_sentinel.py | 26 +++++ 8 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 tests/config/test_manager.py create mode 100644 tests/config/test_sphinxdoc.py create mode 100644 tests/utils/test_descriptions.py create mode 100644 tests/utils/test_getargspec.py create mode 100644 tests/utils/test_sentinel.py diff --git a/codecov.yml b/codecov.yml index 27ed090b0..6ac148658 100644 --- a/codecov.yml +++ b/codecov.yml @@ -21,11 +21,25 @@ coverage: target: 80% threshold: 5% +# The test suite is measured alongside the library, so the overall number +# blends the two. These components report on each separately: `traitlets` is +# the one to watch, while `tests` mostly surfaces test code that never runs. +component_management: + default_rules: + statuses: + - type: project + target: auto + threshold: 0.5% + individual_components: + - component_id: library + name: traitlets + paths: + - traitlets/** + - component_id: tests + name: tests + paths: + - tests/** + comment: - layout: "condensed_header, condensed_files, condensed_footer" + layout: "condensed_header, components, condensed_files, condensed_footer" require_changes: true - -ignore: - - "docs" - - "examples" - - "tests" diff --git a/pyproject.toml b/pyproject.toml index 7b1b5c417..3505b30b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ nowarn = "test -W default {args}" features = ["test"] dependencies = ["coverage[toml]", "pytest-cov"] [tool.hatch.envs.cov.scripts] -test = "python -m pytest -vv --cov traitlets --cov-report term-missing:skip-covered --cov-report xml {args}" +test = "python -m pytest -vv --cov --cov-report term-missing:skip-covered --cov-report xml {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] @@ -178,7 +178,13 @@ branch = true # Keeps the paths in coverage.xml relative to the repository root, so that the # uploads from every matrix job line up with each other on Codecov. relative_files = true -source = ["traitlets"] +# The test suite is measured too, so that Codecov's `tests` component can +# report on it: a test file with uncovered lines is a test that never runs. +# Measuring from the repository root rather than listing the two directories +# keeps the paths in coverage.xml distinct -- with `source = ["traitlets", +# "tests"]` both config/__init__.py files are written out under the same name. +source = ["."] +omit = ["docs/*", "examples/*"] [tool.repo-review] # traitlets publishes `traitlets[test]` / `traitlets[docs]` as documented diff --git a/tests/config/test_manager.py b/tests/config/test_manager.py new file mode 100644 index 000000000..bde44c7e1 --- /dev/null +++ b/tests/config/test_manager.py @@ -0,0 +1,88 @@ +"""Tests for traitlets.config.manager""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import errno +import json +import os + +import pytest + +from traitlets.config.manager import BaseJSONConfigManager, recursive_update + + +def test_recursive_update_merges_nested_dicts(): + target = {"a": {"b": 1, "c": 2}, "d": 3} + recursive_update(target, {"a": {"c": 20, "e": 5}}) + assert target == {"a": {"b": 1, "c": 20, "e": 5}, "d": 3} + + +def test_recursive_update_creates_missing_subdict(): + target = {} + recursive_update(target, {"a": {"b": 1}}) + assert target == {"a": {"b": 1}} + + +def test_recursive_update_none_removes_key(): + target = {"a": 1, "b": 2} + recursive_update(target, {"a": None, "never-there": None}) + assert target == {"b": 2} + + +def test_recursive_update_prunes_emptied_subdicts(): + target = {"a": {"b": 1}} + recursive_update(target, {"a": {"b": None}}) + assert target == {} + + +def test_file_name_joins_the_config_dir(tmp_path): + mgr = BaseJSONConfigManager(config_dir=str(tmp_path)) + assert mgr.file_name("section") == os.path.join(str(tmp_path), "section.json") + + +def test_get_missing_section_is_empty(tmp_path): + mgr = BaseJSONConfigManager(config_dir=str(tmp_path)) + assert mgr.get("never-written") == {} + + +def test_set_then_get_roundtrips(tmp_path): + mgr = BaseJSONConfigManager(config_dir=str(tmp_path)) + mgr.set("section", {"a": 1, "b": {"c": 2}}) + assert mgr.get("section") == {"a": 1, "b": {"c": 2}} + on_disk = json.loads((tmp_path / "section.json").read_text(encoding="utf-8")) + assert on_disk == {"a": 1, "b": {"c": 2}} + + +def test_set_creates_the_config_dir(tmp_path): + config_dir = tmp_path / "not-yet-there" + mgr = BaseJSONConfigManager(config_dir=str(config_dir)) + mgr.set("section", {"a": 1}) + assert config_dir.is_dir() + + +def test_ensure_config_dir_exists_tolerates_an_existing_dir(tmp_path): + mgr = BaseJSONConfigManager(config_dir=str(tmp_path)) + mgr.ensure_config_dir_exists() + mgr.ensure_config_dir_exists() + assert tmp_path.is_dir() + + +def test_ensure_config_dir_exists_reraises_other_errors(tmp_path): + # A file where a parent directory should be: the resulting error is not + # EEXIST, so it must propagate rather than be swallowed. + blocker = tmp_path / "a-file" + blocker.write_text("not a directory", encoding="utf-8") + mgr = BaseJSONConfigManager(config_dir=str(blocker / "sub")) + with pytest.raises(OSError, match="sub") as excinfo: + mgr.ensure_config_dir_exists() + assert excinfo.value.errno != errno.EEXIST + + +def test_update_merges_into_the_stored_section(tmp_path): + mgr = BaseJSONConfigManager(config_dir=str(tmp_path)) + mgr.set("section", {"a": {"b": 1}, "drop-me": True}) + result = mgr.update("section", {"a": {"c": 2}, "drop-me": None}) + assert result == {"a": {"b": 1, "c": 2}} + assert mgr.get("section") == {"a": {"b": 1, "c": 2}} diff --git a/tests/config/test_sphinxdoc.py b/tests/config/test_sphinxdoc.py new file mode 100644 index 000000000..43bd9063d --- /dev/null +++ b/tests/config/test_sphinxdoc.py @@ -0,0 +1,173 @@ +"""Tests for traitlets.config.sphinxdoc""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +from collections import defaultdict + +from traitlets import Bool, Enum, Int, Undefined, Unicode +from traitlets.config.application import Application +from traitlets.config.configurable import Configurable +from traitlets.config.sphinxdoc import ( + class_config_rst_doc, + format_aliases, + interesting_default_value, + reverse_aliases, + setup, + write_doc, +) + + +class ExplodingInt(Int): + """An Int whose default cannot be rendered, like a trait holding an object.""" + + def default_value_repr(self): + raise ValueError("cannot repr this default") + + +class Sample(Configurable): + count = Int(0, help="How many things.").tag(config=True) + mode = Enum(["a", "b"], default_value="a", help="Which mode.").tag(config=True) + name = Unicode("bob", help="A name.").tag(config=True) + undocumented = Bool(False).tag(config=True) + long_default = Unicode("x" * 100, help="A long one.").tag(config=True) + newlined = Unicode("a\nb", help="Has a newline.").tag(config=True) + boom = ExplodingInt(5, help="Unrenderable default.").tag(config=True) + not_configurable = Int(0, help="Not tagged.") + + +class SampleApp(Application): + classes = [Sample] + aliases = {"n": "Sample.name", "count": "Sample.count"} + flags = { + "flagged": ({"Sample": {"undocumented": True}}, "sets one trait to True"), + "off": ({"Sample": {"undocumented": False}}, "sets it to False, not an alias"), + "two-classes": ( + {"Sample": {"undocumented": True}, "Other": {"x": True}}, + "touches two classes", + ), + "two-traits": ({"Sample": {"undocumented": True, "count": True}}, "two traits"), + } + + +class StubSphinxApp: + """Stands in for the Sphinx application passed to the extension's setup().""" + + def __init__(self): + self.object_types = [] + + def add_object_type(self, *args, **kwargs): + self.object_types.append((args, kwargs)) + + +def test_setup_registers_the_configtrait_object_type(): + app = StubSphinxApp() + metadata = setup(app) + assert app.object_types == [ + (("configtrait", "configtrait"), {"objname": "Config option"}), + ] + assert metadata == {"parallel_read_safe": True, "parallel_write_safe": True} + + +def test_uninteresting_default_values(): + for dv in (None, Undefined, "", [], (), {}, set()): + assert not interesting_default_value(dv) + + +def test_interesting_default_values(): + # Note that a falsy non-container, such as 0, still counts as interesting. + for dv in ("x", ["a"], ("a",), {"a": 1}, {"a"}, 0, False): + assert interesting_default_value(dv) + + +def test_format_aliases_picks_the_dash_count_by_length(): + assert format_aliases(["v"]) == "``-v``" + assert format_aliases(["v", "verbose"]) == "``-v``, ``--verbose``" + assert format_aliases([]) == "" + + +def test_class_config_rst_doc(): + aliases = defaultdict(list) + aliases["Sample.name"] = ["n", "name"] + doc = class_config_rst_doc(Sample, aliases) + + assert ".. configtrait:: Sample.count" in doc + assert "How many things." in doc + assert ":trait type: Int" in doc + # Enum traits list their choices instead of their type. + assert ":options: ``'a'``, ``'b'``" in doc + assert ":trait type: Enum" not in doc + assert ":default: ``'bob'``" in doc + assert ":CLI option: ``-n``, ``--name``" in doc + + +def test_class_config_rst_doc_describes_undocumented_traits(): + doc = class_config_rst_doc(Sample, defaultdict(list)) + assert "No description" in doc + + +def test_class_config_rst_doc_skips_traits_that_are_not_config(): + doc = class_config_rst_doc(Sample, defaultdict(list)) + assert "Sample.not_configurable" not in doc + + +def test_class_config_rst_doc_truncates_long_defaults(): + doc = class_config_rst_doc(Sample, defaultdict(list)) + # 61 characters of the repr are kept, the first of which is its quote. + assert "``'" + "x" * 60 + "...``" in doc + assert "x" * 100 not in doc + + +def test_class_config_rst_doc_doubles_backslashes(): + doc = class_config_rst_doc(Sample, defaultdict(list)) + assert "``'a\\\\nb'``" in doc + + +def test_class_config_rst_doc_omits_defaults_it_cannot_render(): + doc = class_config_rst_doc(Sample, defaultdict(list)) + boom = doc.split(".. configtrait:: Sample.boom")[1].split(".. configtrait::")[0] + assert "Unrenderable default." in boom + assert ":default:" not in boom + + +def test_reverse_aliases_maps_traits_to_their_aliases(): + res = reverse_aliases(SampleApp()) + assert res["Sample.name"] == ["n"] + assert res["Sample.count"] == ["count"] + + +def test_reverse_aliases_treats_true_setting_flags_as_aliases(): + res = reverse_aliases(SampleApp()) + assert res["Sample.undocumented"] == ["flagged"] + + +def test_reverse_aliases_ignores_flags_that_are_not_simple_aliases(): + res = reverse_aliases(SampleApp()) + for aliases in res.values(): + assert "off" not in aliases + assert "two-classes" not in aliases + assert "two-traits" not in aliases + + +def test_write_doc(tmp_path): + path = tmp_path / "options.rst" + write_doc(str(path), "Sample options", SampleApp(), preamble="Some preamble.") + text = path.read_text(encoding="utf-8") + + assert text.startswith("Sample options\n==============\n") + assert "Some preamble." in text + assert ".. configtrait:: Sample.count" in text + assert ":CLI option: ``--count``" in text + + +def test_write_doc_without_a_preamble(tmp_path): + path = tmp_path / "options.rst" + write_doc(str(path), "Sample options", SampleApp()) + text = path.read_text(encoding="utf-8") + + assert text.startswith("Sample options\n==============\n") + assert "Some preamble." not in text + # The application's own config traits are documented alongside Sample's. + assert ".. configtrait:: Application.log_datefmt" in text + assert ".. configtrait:: Sample.count" in text diff --git a/tests/utils/test_bunch.py b/tests/utils/test_bunch.py index 98bf26201..9deb54748 100644 --- a/tests/utils/test_bunch.py +++ b/tests/utils/test_bunch.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from traitlets.utils.bunch import Bunch @@ -12,6 +14,12 @@ def test_bunch(): assert b.a == "hi" +def test_bunch_missing_attribute(): + b = Bunch(x=5) + with pytest.raises(AttributeError, match="nope"): + b.nope + + def test_bunch_dir(): b = Bunch(x=5, y=10) assert "keys" in dir(b) diff --git a/tests/utils/test_descriptions.py b/tests/utils/test_descriptions.py new file mode 100644 index 000000000..aa16ebe84 --- /dev/null +++ b/tests/utils/test_descriptions.py @@ -0,0 +1,103 @@ +"""Tests for traitlets.utils.descriptions""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import pytest + +from traitlets.utils.descriptions import add_article, class_of, describe, repr_type + + +class Sample: + def method(self): + pass + + +class Described: + def __repr__(self): + return "" + + +def a_function(): + pass + + +def test_describe_indefinite(): + assert describe("a", object()) == "an object" + assert describe("a", object) == "an object" + assert describe("a", type(object)) == "a type" + + +def test_describe_lowercases_the_article(): + assert describe("A", object()) == "an object" + assert describe("The", object, "I will use") == "the object I will use" + + +def test_describe_capitalizes_on_request(): + assert describe("a", object(), capital=True) == "An object" + assert describe("the", object, "I will use", capital=True) == "The object I will use" + + +def test_describe_with_no_article(): + # Classes are described indefinitely, instances definitely. + assert describe(None, object) == "object" + assert describe(None, object(), "I made") == "object I made" + assert describe(None, object()).startswith("object at '0x") + + +def test_describe_definite(): + assert describe("the", object).startswith("the object object") + assert describe("the", type(object)) == "the type type" + assert describe("the", object()).startswith("the object at '0x") + + +def test_describe_functions_and_methods_are_tick_wrapped(): + assert describe("the", a_function) == "the function 'a_function'" + assert describe("the", Sample().method) == "the method 'method'" + + +def test_describe_uses_the_repr_when_there_is_one(): + assert describe("the", Described()) == "the Described " + + +def test_describe_verbose_includes_the_module(): + assert describe("a", Sample, verbose=True) == f"a {__name__}.Sample" + # Builtins are not prefixed with their module. + assert describe("a", object, verbose=True) == "an object" + + +def test_describe_verbose_function_names_the_module(): + result = describe("the", a_function, verbose=True) + assert result.startswith("the ") + assert f"{__name__}.a_function" in result + + +def test_describe_verbose_method_names_its_instance(): + result = describe("the", Sample().method, verbose=True) + assert "Sample at '0x" in result + assert "method" in result + + +def test_describe_rejects_other_articles(): + with pytest.raises(ValueError, match="should be 'the', 'a', 'an', or None"): + describe("some", object()) + + +def test_class_of(): + assert class_of(Sample) == "a Sample" + assert class_of(Sample()) == "a Sample" + assert class_of(object()) == "an object" + + +def test_add_article(): + assert add_article("object") == "an object" + assert add_article("Sample") == "a Sample" + assert add_article("object", definite=True) == "the object" + assert add_article("object", definite=True, capital=True) == "The object" + # Leading non-word characters do not decide between "a" and "an". + assert add_article("_object") == "an _object" + + +def test_repr_type(): + assert repr_type(1) == "1 " diff --git a/tests/utils/test_getargspec.py b/tests/utils/test_getargspec.py new file mode 100644 index 000000000..8c906ca25 --- /dev/null +++ b/tests/utils/test_getargspec.py @@ -0,0 +1,86 @@ +"""Tests for traitlets.utils.getargspec""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import functools +from functools import partial + +import pytest + +from traitlets.utils.getargspec import getargspec + + +def plain(a, b, c=3): + pass + + +def no_defaults(a, b): + pass + + +def with_kwonly(a, *, k=1): + pass + + +class Sample: + def method(self, a, b=2): + pass + + +def test_plain_function(): + spec = getargspec(plain) + assert spec.args == ["a", "b", "c"] + assert spec.defaults == (3,) + + +def test_method_uses_the_underlying_function(): + spec = getargspec(Sample().method) + assert spec.args == ["self", "a", "b"] + assert spec.defaults == (2,) + + +def test_partial_drops_bound_positional_args(): + spec = getargspec(partial(plain, 1)) + assert spec.args == ["b", "c"] + assert spec.defaults == (3,) + + +def test_partial_drops_bound_keyword_args(): + spec = getargspec(partial(plain, c=5)) + assert spec.args == ["a", "b"] + assert spec.defaults == () + + +def test_partial_keyword_without_a_default(): + # Deleting the default is skipped when the argument never had one. + spec = getargspec(partial(no_defaults, b=2)) + assert spec.args == ["a"] + assert spec.defaults == () + + +def test_partial_drops_bound_keyword_only_args(): + spec = getargspec(partial(with_kwonly, k=2)) + assert spec.args == ["a"] + assert spec.kwonlyargs == [] + assert spec.kwonlydefaults == {} + + +def test_nested_partials(): + spec = getargspec(partial(partial(plain, 1), 2)) + assert spec.args == ["c"] + + +def test_wrapped_functions_are_unwrapped(): + @functools.wraps(plain) + def wrapper(*args, **kwargs): + return plain(*args, **kwargs) + + spec = getargspec(wrapper) + assert spec.args == ["a", "b", "c"] + + +def test_non_functions_are_rejected(): + with pytest.raises(TypeError, match="is not a Python function"): + getargspec(len) diff --git a/tests/utils/test_sentinel.py b/tests/utils/test_sentinel.py new file mode 100644 index 000000000..2c714b6c0 --- /dev/null +++ b/tests/utils/test_sentinel.py @@ -0,0 +1,26 @@ +"""Tests for traitlets.utils.sentinel""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import copy + +from traitlets.utils.sentinel import Sentinel + + +def test_repr_includes_the_module(): + assert repr(Sentinel("Undefined", "traitlets")) == "traitlets.Undefined" + + +def test_docstring_is_optional(): + assert Sentinel("Thing", "mod", "The docs.").__doc__ == "The docs." + # Without one, the class docstring is left alone. + assert Sentinel("Thing", "mod").__doc__ == Sentinel.__doc__ + + +def test_copies_are_the_same_object(): + sentinel = Sentinel("Undefined", "traitlets") + assert copy.copy(sentinel) is sentinel + assert copy.deepcopy(sentinel) is sentinel + assert copy.deepcopy([sentinel])[0] is sentinel From c4f044989883ded7f936be8762bf88c7064f83a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:46:22 +0000 Subject: [PATCH 4/6] Don't import sphinxdoc.setup under its own name in the tests pytest before 8.0 treats a module-level `setup` as an xunit setup hook and calls it once per test with the test module as its argument, so importing the extension's `setup` into the test module's namespace turned every test in the file into an error: AttributeError: module 'tests.config.test_sphinxdoc' has no attribute 'add_object_type' Only the minimum-versions job caught it, since it pins pytest 7.0 and the nose-style hooks were removed in pytest 8. Verified against pytest 7.0.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- tests/config/test_sphinxdoc.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/config/test_sphinxdoc.py b/tests/config/test_sphinxdoc.py index 43bd9063d..feba4fbe6 100644 --- a/tests/config/test_sphinxdoc.py +++ b/tests/config/test_sphinxdoc.py @@ -14,10 +14,14 @@ format_aliases, interesting_default_value, reverse_aliases, - setup, write_doc, ) +# Imported under another name on purpose: pytest before 8.0 treats a +# module-level `setup` as an xunit setup hook and calls it with the test +# module as its argument, which fails every test in the file. +from traitlets.config.sphinxdoc import setup as setup_extension + class ExplodingInt(Int): """An Int whose default cannot be rendered, like a trait holding an object.""" @@ -63,7 +67,7 @@ def add_object_type(self, *args, **kwargs): def test_setup_registers_the_configtrait_object_type(): app = StubSphinxApp() - metadata = setup(app) + metadata = setup_extension(app) assert app.object_types == [ (("configtrait", "configtrait"), {"objname": "Config option"}), ] From f1eed9fbbd06c137bb3723f59548ffcb31edc217 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:04:38 +0000 Subject: [PATCH 5/6] Cover application.py's help emitters, and stop measuring test_typing.py `application.py` sat at 76% with ~113 uncovered lines, nearly all of them the help machinery: emit_alias_help, emit_flag_help, emit_options_help, emit_subcommands_help, emit_help, emit_description, emit_examples and the print_* wrappers around them, plus start_show_config, load_config_environ, boolean_flag, get_config and launch_instance. Most of that is not actually untested. test_help_output and friends drive the application through `check_help_output`, which spawns a subprocess, and coverage does not follow the child. So the lines run, but nothing records it. These tests call the emitters in process instead, one method at a time, which measures them and pins the generator API that downstream applications use. 40 tests, taking application.py from 76% to 94%. The remaining misses there are error paths that re-raise after logging, and would need a deliberately malformed alias or flag to reach. tests/test_typing.py is now omitted. mypy type-checks it and pytest-mypy-testing never executes the bodies, so all 454 lines read as uncovered -- it is a fixture for the type checker, not dead code, and counting it only depressed the number. The library goes from 84.59% to 87.07%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- pyproject.toml | 8 +- tests/config/test_application_help.py | 331 ++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 tests/config/test_application_help.py diff --git a/pyproject.toml b/pyproject.toml index 3505b30b0..831ab0d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,7 +184,13 @@ relative_files = true # keeps the paths in coverage.xml distinct -- with `source = ["traitlets", # "tests"]` both config/__init__.py files are written out under the same name. source = ["."] -omit = ["docs/*", "examples/*"] +omit = [ + "docs/*", + "examples/*", + # mypy type-checks this file, pytest-mypy-testing never executes its bodies, + # so every line in it reads as uncovered. It is a fixture, not dead code. + "tests/test_typing.py", +] [tool.repo-review] # traitlets publishes `traitlets[test]` / `traitlets[docs]` as documented diff --git a/tests/config/test_application_help.py b/tests/config/test_application_help.py new file mode 100644 index 000000000..182c73a01 --- /dev/null +++ b/tests/config/test_application_help.py @@ -0,0 +1,331 @@ +"""Tests for the help emitters and environment loading of Application. + +The existing help tests in test_application.py drive the application through a +subprocess, so the emitters themselves are never measured. These exercise them +in process, one method at a time. +""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import json + +import pytest + +from traitlets import Bool, Int, Unicode +from traitlets.config.application import Application, boolean_flag, get_config +from traitlets.config.configurable import Configurable + + +class Bar(Configurable): + enabled = Bool(False, help="Whether bar is enabled.").tag(config=True) + + +class Foo(Configurable): + name = Unicode("bob", help="The name to use.").tag(config=True) + count = Int(0, help="How many times to go around.").tag(config=True) + + +class SubApp(Application): + name = "subapp" + + +class HelpApp(Application): + name = "helpapp" + version = "1.2.3" + description = "An application used to exercise the help emitters." + examples = """ + helpapp --name=alice + helpapp sub + """ + classes = [Foo, Bar] + aliases = { + "name": "Foo.name", + "c": "Foo.count", + ("v", "verbose"): "Foo.name", + } + flags = { + "enable": ({"Bar": {"enabled": True}}, "Enable bar."), + ("d", "disable"): ({"Bar": {"enabled": False}}, "Disable bar."), + } + subcommands = {"sub": (SubApp, "Run the subcommand.")} + + +class BareApp(Application): + """A docstring standing in for a description.""" + + name = "bareapp" + # Application supplies --log-level and --debug by default, and a + # non-empty description, so an app with nothing of its own has to say so. + description = "" + aliases = {} + flags = {} + + +@pytest.fixture +def app(): + return HelpApp() + + +def test_emit_alias_help(app): + lines = list(app.emit_alias_help()) + text = "\n".join(lines) + assert "--name=" in text + assert "Equivalent to: [--Foo.name]" in text + assert "The name to use." in text + # Single-character aliases get one dash, and tuples list every spelling. + assert "-c=" in text + assert "-v, --verbose=" in text + + +def test_emit_alias_help_without_aliases(): + assert list(BareApp().emit_alias_help()) == [] + + +def test_emit_flag_help(app): + text = "\n".join(app.emit_flag_help()) + assert "--enable" in text + assert "Enable bar." in text + assert "-d, --disable" in text + + +def test_emit_flag_help_without_flags(): + assert list(BareApp().emit_flag_help()) == [] + + +def test_emit_options_help(app): + lines = list(app.emit_options_help()) + assert lines[0] == "Options" + assert lines[1] == "=======" + text = "\n".join(lines) + # The options section carries both the flags and the aliases. + assert "--enable" in text + assert "Equivalent to: [--Foo.name]" in text + + +def test_emit_options_help_without_flags_or_aliases(): + assert list(BareApp().emit_options_help()) == [] + + +def test_emit_subcommands_help(app): + lines = list(app.emit_subcommands_help()) + assert lines[0] == "Subcommands" + assert lines[1] == "===========" + text = "\n".join(lines) + assert "sub" in text + assert "Run the subcommand." in text + + +def test_emit_subcommands_help_without_subcommands(): + assert list(BareApp().emit_subcommands_help()) == [] + + +def test_emit_description_uses_the_description(app): + text = "\n".join(app.emit_description()) + assert "exercise the help emitters" in text + + +def test_emit_description_falls_back_to_the_docstring(): + text = "\n".join(BareApp().emit_description()) + assert "A docstring standing in for a description." in text + + +def test_emit_examples(app): + lines = list(app.emit_examples()) + assert lines[0] == "Examples" + assert lines[1] == "--------" + assert "helpapp --name=alice" in "\n".join(lines) + + +def test_emit_examples_without_examples(): + assert list(BareApp().emit_examples()) == [] + + +def test_emit_help_epilogue_points_at_help_all(app): + assert "To see all available configurables, use `--help-all`." in list( + app.emit_help_epilogue(classes=False) + ) + + +def test_emit_help_epilogue_is_silent_for_help_all(app): + assert list(app.emit_help_epilogue(classes=True)) == [] + + +def test_emit_help(app): + text = "\n".join(app.emit_help()) + assert "exercise the help emitters" in text + assert "Subcommands" in text + assert "Options" in text + assert "Examples" in text + assert "--help-all" in text + # Without classes=True the per-class options are left out. + assert "Class options" not in text + + +def test_emit_help_with_classes(app): + text = "\n".join(app.emit_help(classes=True)) + assert "Class options" in text + assert "Foo.name" in text + assert "Bar.enabled" in text + # The epilogue is dropped, though --help-all is still mentioned by the + # keyvalue description that introduces the class options. + assert "To see all available configurables" not in text + + +def test_document_config_options(app): + doc = app.document_config_options() + assert "Foo.name" in doc + assert "Bar.enabled" in doc + + +def test_print_help(app, capsys): + app.print_help() + assert "Options" in capsys.readouterr().out + + +def test_print_help_with_classes(app, capsys): + app.print_help(classes=True) + assert "Class options" in capsys.readouterr().out + + +def test_print_alias_help(app, capsys): + app.print_alias_help() + assert "Equivalent to: [--Foo.name]" in capsys.readouterr().out + + +def test_print_flag_help(app, capsys): + app.print_flag_help() + assert "Enable bar." in capsys.readouterr().out + + +def test_print_options(app, capsys): + app.print_options() + assert "Options" in capsys.readouterr().out + + +def test_print_subcommands(app, capsys): + app.print_subcommands() + assert "Run the subcommand." in capsys.readouterr().out + + +def test_print_description(app, capsys): + app.print_description() + assert "exercise the help emitters" in capsys.readouterr().out + + +def test_print_examples(app, capsys): + app.print_examples() + assert "helpapp --name=alice" in capsys.readouterr().out + + +def test_print_version(app, capsys): + app.print_version() + assert capsys.readouterr().out.strip() == "1.2.3" + + +def test_start_show_config(app, capsys): + app.config.Foo.name = "alice" + app.start_show_config() + out = capsys.readouterr().out + assert "Foo" in out + assert ".name = 'alice'" in out + + +def test_start_show_config_lists_loaded_files(app, capsys): + app._loaded_config_files = ["/config/one.py", "/config/two.py"] + app.start_show_config() + out = capsys.readouterr().out + assert "Loaded config files:" in out + assert "/config/one.py" in out + + +def test_start_show_config_skips_empty_sections(app, capsys): + app.config.Foo # touching a section creates it, empty + app.start_show_config() + assert "Foo" not in capsys.readouterr().out + + +def test_start_show_config_json(app, capsys): + app.show_config_json = True + app.config.Foo.name = "alice" + app.start_show_config() + loaded = json.loads(capsys.readouterr().out) + assert loaded["Foo"]["name"] == "alice" + + +def test_start_show_config_hides_its_own_flags(app, capsys): + app.config.HelpApp.show_config = True + app.config.HelpApp.show_config_json = False + app.config.Foo.name = "alice" + app.start_show_config() + out = capsys.readouterr().out + assert "show_config" not in out + + +def test_load_config_environ(app, monkeypatch): + monkeypatch.setenv("HELPAPP__Foo__name", "from-the-environment") + app.load_config_environ() + assert Foo(config=app.config).name == "from-the-environment" + + +def test_load_config_environ_nested_sections(app, monkeypatch): + # Sections are separated by __, so a deeper path nests further in. + monkeypatch.setenv("HELPAPP__Deep__Foo__name", "nested") + app.load_config_environ() + assert app.config.Deep.Foo.name == "nested" + + +def test_load_config_environ_ignores_other_variables(app, monkeypatch): + monkeypatch.setenv("SOMETHINGELSE__Foo__name", "not-mine") + app.load_config_environ() + assert Foo(config=app.config).name == "bob" + + +def test_load_config_environ_keeps_the_command_line_winning(app, monkeypatch): + app.cli_config.Foo.name = "from-the-command-line" + monkeypatch.setenv("HELPAPP__Foo__name", "from-the-environment") + app.load_config_environ() + assert Foo(config=app.config).name == "from-the-command-line" + + +def test_boolean_flag_defaults(): + flags = boolean_flag("bar", "Bar.enabled") + assert flags["bar"] == ({"Bar": {"enabled": True}}, "set Bar.enabled=True") + assert flags["no-bar"] == ({"Bar": {"enabled": False}}, "set Bar.enabled=False") + + +def test_boolean_flag_with_help_strings(): + flags = boolean_flag("bar", "Bar.enabled", "Turn it on.", "Turn it off.") + assert flags["bar"][1] == "Turn it on." + assert flags["no-bar"][1] == "Turn it off." + + +def test_get_config_without_an_application(): + Application.clear_instance() + assert get_config() == {} + + +def test_get_config_with_an_application(): + try: + app = Application.instance() + app.config.Foo.name = "alice" + assert get_config().Foo.name == "alice" + finally: + Application.clear_instance() + + +def test_launch_instance(): + class LaunchedApp(Application): + name = "launched" + started = False + + def start(self): + type(self).started = True + + try: + LaunchedApp.launch_instance(argv=[]) + assert LaunchedApp.started + assert LaunchedApp.initialized() + finally: + LaunchedApp.clear_instance() From 68139b72d768be9acb50b2a291538f6f5dcfe3ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:07:31 +0000 Subject: [PATCH 6/6] Skip the load_config_environ tests on Windows os.environ upper-cases every key on Windows, so HELPAPP__Foo__name arrives as HELPAPP__FOO__NAME. load_config_environ splits the trait name off the end and assigns it unchanged, and Config rejects a key beginning with an uppercase letter unless the value is another Config: ValueError: values whose keys begin with an uppercase char must be Config instances: 'NAME', DeferredConfigString('from-the-environment') This is not specific to the names in the test. Every trait name is upper-cased the same way, so no environment variable can set any trait on Windows -- the "Warning, case sensitive!" note in the method is understating it. That behaviour predates this branch; the tests merely reached code that nothing had reached before. Skipping keeps the tests honest about the limitation without asserting that the broken behaviour is correct, and Linux and macOS still cover the method. Fixing load_config_environ is a behaviour change, and belongs in its own pull request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qMpoL5cL6W4qCeCdGm26w --- tests/config/test_application_help.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/config/test_application_help.py b/tests/config/test_application_help.py index 182c73a01..e3e417047 100644 --- a/tests/config/test_application_help.py +++ b/tests/config/test_application_help.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import os import pytest @@ -68,6 +69,22 @@ def app(): return HelpApp() +# Windows upper-cases every key in os.environ, so HELPAPP__Foo__name arrives as +# HELPAPP__FOO__NAME. load_config_environ splits the trait name off the end and +# assigns it as-is, and Config refuses a key starting with an uppercase letter +# unless the value is another Config: +# +# ValueError: values whose keys begin with an uppercase char must be +# Config instances: 'NAME', DeferredConfigString('...') +# +# Every trait name is upper-cased the same way, so this is not specific to the +# names used here. See the "Warning, case sensitive!" note in the method. +needs_case_sensitive_environ = pytest.mark.skipif( + os.name == "nt", + reason="Windows upper-cases environment variable names", +) + + def test_emit_alias_help(app): lines = list(app.emit_alias_help()) text = "\n".join(lines) @@ -263,12 +280,14 @@ def test_start_show_config_hides_its_own_flags(app, capsys): assert "show_config" not in out +@needs_case_sensitive_environ def test_load_config_environ(app, monkeypatch): monkeypatch.setenv("HELPAPP__Foo__name", "from-the-environment") app.load_config_environ() assert Foo(config=app.config).name == "from-the-environment" +@needs_case_sensitive_environ def test_load_config_environ_nested_sections(app, monkeypatch): # Sections are separated by __, so a deeper path nests further in. monkeypatch.setenv("HELPAPP__Deep__Foo__name", "nested") @@ -282,6 +301,7 @@ def test_load_config_environ_ignores_other_variables(app, monkeypatch): assert Foo(config=app.config).name == "bob" +@needs_case_sensitive_environ def test_load_config_environ_keeps_the_command_line_winning(app, monkeypatch): app.cli_config.Foo.name = "from-the-command-line" monkeypatch.setenv("HELPAPP__Foo__name", "from-the-environment")