diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31d9e3677..1a498ae58 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,15 +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: - runs-on: ubuntu-latest - needs: - - tests - steps: - - uses: actions/checkout@v7 - - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 + # 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 test_minimum_versions: name: Test Minimum Versions diff --git a/.gitignore b/.gitignore index 6fae1b197..6d41986f4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ __pycache__ \#*# .#* .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..6ac148658 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,45 @@ +# 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 + +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% + +# 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, components, condensed_files, condensed_footer" + require_changes: true diff --git a/pyproject.toml b/pyproject.toml index fe14b2aaf..831ab0d8d 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 --cov-report term-missing:skip-covered --cov-report xml {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] @@ -174,8 +174,23 @@ exclude_lines = [ ] [tool.coverage.run] +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/*", + # 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..e3e417047 --- /dev/null +++ b/tests/config/test_application_help.py @@ -0,0 +1,351 @@ +"""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 os + +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() + + +# 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) + 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 + + +@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") + 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" + + +@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") + 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() 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..feba4fbe6 --- /dev/null +++ b/tests/config/test_sphinxdoc.py @@ -0,0 +1,177 @@ +"""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, + 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.""" + + 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_extension(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