diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ee72e..eac407b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and versions are tracked in the repo-root `VERSION` file. - Bound the core Click and PyYAML dependency windows, publish the tested compatibility matrix, and document the dependency update policy. +- Move PyYAML behind the optional `base-cli[yaml]` extra and provide an + actionable installation hint when YAML configuration or output is selected. ### Added diff --git a/docs/adopter-readiness.md b/docs/adopter-readiness.md index 6da2b4d..59f6dbd 100644 --- a/docs/adopter-readiness.md +++ b/docs/adopter-readiness.md @@ -11,7 +11,8 @@ Before the first production pilot, the adopter should be able to check every box below: - [ ] Pin a supported `base-cli` minor release (for example, `~=0.4.0`) and - record Click, PyYAML, and any optional integration versions in a lock file. + record Click, the optional YAML extra (when used), and any other integration + versions in a lock file. - [ ] Run the adopter's command suite on CPython 3.10--3.14 on every platform the product supports; retain at least one installed-wheel smoke job. - [ ] Use only the documented `base_cli` facade and module `__all__` exports; diff --git a/docs/api-stability.md b/docs/api-stability.md index 14bb6ab..367ebae 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -48,9 +48,10 @@ warning where practical, a migration path, and a changelog entry. Consumers that need a frozen API should pin a minor release (for example, `~=0.4.0`). The core package requires Python `>=3.10` and currently tests CPython 3.10 -through 3.14 on Linux, macOS, and Windows. Core runtime dependencies are -Click `>=8.1,<9` and PyYAML `>=6.0,<7`. Optional integrations are -independently versioned and constrained in `pyproject.toml`: Typer +through 3.14 on Linux, macOS, and Windows. The core runtime dependency is +Click `>=8.1,<9`; YAML configuration and YAML output are provided by the +optional `base-cli[yaml]` extra, which supplies PyYAML `>=6.0,<7`. Other +optional integrations are independently versioned and constrained in `pyproject.toml`: Typer `>=0.12,<0.28`, Rich `>=13.7,<15`, and OpenTelemetry API `>=1.24,<2`. The lower bounds are the minimum supported versions; a dependency major release is supported only after it passes the compatibility suite. The tested core diff --git a/docs/dependency-support.md b/docs/dependency-support.md index 7e1caef..321e4d2 100644 --- a/docs/dependency-support.md +++ b/docs/dependency-support.md @@ -10,11 +10,10 @@ documents the versions covered by CI and the process for widening a window. | --- | --- | --- | --- | | Python | `>=3.10,<4` (CPython 3.10--3.14) | Every OS test job | Drop an end-of-life line only in a documented compatibility release | | Click | `>=8.1,<9` | 8.1 and 8.2 lines on Python 3.10 and 3.14 | Review the next major before widening the upper bound | -| PyYAML | `>=6.0,<7` | 6.0 line on Python 3.10 and 3.14 | Keep YAML optionality and parser behavior covered by profile tests | +| YAML extra | `PyYAML>=6.0,<7` | 6.0 line on Python 3.10 and 3.14 | Install `base-cli[yaml]`; keep parser behavior covered by profile tests | -The `base-cli[yaml]` extra is planned as the minimal installation for YAML -profiles. Until that extra is released, PyYAML remains part of the core -runtime metadata. +The `base-cli[yaml]` extra is the minimal installation for YAML profiles. +Generic consumers can install the core package without PyYAML. ## Optional integrations diff --git a/docs/local-config.md b/docs/local-config.md index 207b919..af852ab 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -15,7 +15,8 @@ The consumer owns the configuration schema, merge semantics, and operational choice of whether to back up or synchronize its machine-local files. Applications that prefer conventional policy can opt into -`CliProfile.batteries_included("tool")`. It loads optional platform-aware user, +`CliProfile.batteries_included("tool")` after installing the `base-cli[yaml]` +extra. It loads optional platform-aware user, project, environment, and explicit YAML layers with documented precedence and records the winning source for each key in `Context.config_provenance`. Its reserved lifecycle keys are validated separately as `Context.framework_config`; diff --git a/docs/output-contracts.md b/docs/output-contracts.md index 6949406..880e0ae 100644 --- a/docs/output-contracts.md +++ b/docs/output-contracts.md @@ -1,7 +1,8 @@ # Output contracts `base_cli.output.render_records()` supports `text`, `csv`, `tsv`, `yaml`, and -`json` formats. The requested `text` format is presentation-aware: it renders +`json` formats. Install `base-cli[yaml]` before selecting `yaml`; the other +formats are available from the core package. The requested `text` format is presentation-aware: it renders a table on a TTY and tab-delimited rows when stdout is redirected or piped. Delimited output is intentionally automation-friendly: diff --git a/docs/platform-support.md b/docs/platform-support.md index 74a3833..a3ceebe 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -36,7 +36,8 @@ filesystem. ## Dependency support -The core runtime dependency contract is Click `>=8.1,<9` and PyYAML +The core runtime dependency contract is Click `>=8.1,<9`. YAML configuration +and YAML output use the optional `base-cli[yaml]` extra, which supplies PyYAML `>=6.0,<7`. The lower bound is the oldest supported line; the upper bound prevents an unreviewed major release from entering a production install. The CI [dependency matrix](https://github.com/basefoundry/base-cli/actions/workflows/dependency-matrix.yml) diff --git a/examples/nested_click_app/README.md b/examples/nested_click_app/README.md index c06e63c..227e205 100644 --- a/examples/nested_click_app/README.md +++ b/examples/nested_click_app/README.md @@ -22,7 +22,8 @@ silently read global files or environment variables. ## Output and errors `base-nested status --format json` emits stable records suitable for automation; -`text`, `csv`, `tsv`, and `yaml` are also supported. Click retains its normal +`text`, `csv`, and `tsv` are available from the core package. Install +`base-cli[yaml]` to enable `yaml` output. Click retains its normal usage errors and exit codes. Plugin import failures are reported per plugin so a broken optional extension cannot hide healthy ones. diff --git a/lib/python/base_cli/_dependencies.py b/lib/python/base_cli/_dependencies.py index ea970a8..32346e0 100644 --- a/lib/python/base_cli/_dependencies.py +++ b/lib/python/base_cli/_dependencies.py @@ -6,10 +6,12 @@ def require_yaml(error_message: str) -> Any: - """Import PyYAML or raise the caller's feature-specific error.""" + """Import PyYAML or explain how to enable the optional YAML feature.""" try: import yaml except ImportError as exc: - raise RuntimeError(error_message) from exc + raise RuntimeError( + f"{error_message} Install the optional dependency with `python -m pip install 'base-cli[yaml]'`." + ) from exc return yaml diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 87a9f92..3de0f29 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -232,7 +232,10 @@ def load_yaml_file(path: Path, *, required: bool = False) -> dict[str, Any]: elif not path.is_file(): return {} - yaml = require_yaml("PyYAML is required to load the explicit CLI configuration file.") + try: + yaml = require_yaml("PyYAML is required to load the explicit CLI configuration file.") + except RuntimeError as exc: + raise ConfigurationError(str(exc)) from exc try: contents = path.read_text(encoding="utf-8") diff --git a/lib/python/base_cli/output.py b/lib/python/base_cli/output.py index eeea218..3962d3c 100644 --- a/lib/python/base_cli/output.py +++ b/lib/python/base_cli/output.py @@ -106,7 +106,10 @@ def render_records( return resolved if resolved == "yaml": - yaml = require_yaml("PyYAML is required for YAML output.") + try: + yaml = require_yaml("PyYAML is required for YAML output.") + except RuntimeError as exc: + raise OutputFormatError(str(exc)) from exc target.write(yaml.safe_dump(record_list, sort_keys=False, allow_unicode=True)) return resolved @@ -148,7 +151,10 @@ def render_document( target.write("\n") return resolved if resolved == "yaml": - yaml = require_yaml("PyYAML is required for YAML output.") + try: + yaml = require_yaml("PyYAML is required for YAML output.") + except RuntimeError as exc: + raise OutputFormatError(str(exc)) from exc target.write(yaml.safe_dump(dict(document), sort_keys=False, allow_unicode=True)) return resolved diff --git a/pyproject.toml b/pyproject.toml index d5ace10..7a0d782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,14 +42,17 @@ classifiers = [ ] dependencies = [ "click>=8.1,<9", - "PyYAML>=6.0,<7", ] [project.optional-dependencies] +yaml = [ + "PyYAML>=6.0,<7", +] dev = [ "build>=1.2", "hypothesis>=6.100,<7", "mypy>=1.17,<2", + "PyYAML>=6.0,<7", "pytest>=8.0", "types-PyYAML>=6.0,<7", ] diff --git a/scripts/validate_package_artifact.py b/scripts/validate_package_artifact.py index 6965ae2..6e2020c 100644 --- a/scripts/validate_package_artifact.py +++ b/scripts/validate_package_artifact.py @@ -15,7 +15,8 @@ PACKAGE_NAME = "base-cli" IMPORT_NAME = "base_cli" MINIMUM_PYTHON = ">=3.10" -REQUIRED_DEPENDENCIES = ("click<9,>=8.1", "PyYAML<7,>=6.0") +REQUIRED_DEPENDENCIES = ("click<9,>=8.1",) +YAML_DEPENDENCY_PREFIX = "PyYAML<7,>=6.0" DOCUMENTATION_URL = "Documentation, https://basefoundry.github.io/base-cli/" ALLOWED_WHEEL_DIST_INFO_FILES = frozenset({"METADATA", "RECORD", "WHEEL", "top_level.txt", "entry_points.txt"}) ALLOWED_SDIST_FILES = frozenset( @@ -88,6 +89,19 @@ def validate_wheel(path: Path, expected_version: str, package_files: set[str]) - for dependency in REQUIRED_DEPENDENCIES: if dependency not in dependencies: fail(f"{path.name} is missing runtime dependency {dependency!r}") + if metadata.get("Provides-Extra") != "yaml": + extras = metadata.get_all("Provides-Extra", []) + if "yaml" not in extras: + fail(f"{path.name} does not advertise the yaml optional extra") + yaml_dependencies = { + dependency + for dependency in dependencies + if dependency.startswith(YAML_DEPENDENCY_PREFIX) and 'extra == "yaml"' in dependency + } + if not yaml_dependencies: + fail(f"{path.name} does not bind PyYAML to the yaml optional extra") + if any(dependency == YAML_DEPENDENCY_PREFIX for dependency in dependencies): + fail(f"{path.name} makes PyYAML a core dependency instead of an optional extra") if DOCUMENTATION_URL not in metadata.get_all("Project-URL", []): fail(f"{path.name} is missing the canonical Documentation project URL") diff --git a/tests/test_optional_yaml_dependency.py b/tests/test_optional_yaml_dependency.py new file mode 100644 index 0000000..9e5b1d7 --- /dev/null +++ b/tests/test_optional_yaml_dependency.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import io +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import base_cli +from base_cli.config import load_yaml_file +from base_cli.errors import ConfigurationError +from base_cli.output import OutputFormatError, render_records + + +class OptionalYamlDependencyTests(unittest.TestCase): + def test_yaml_output_explains_optional_install_when_yaml_is_missing(self) -> None: + stream = io.StringIO() + with mock.patch.dict(sys.modules, {"yaml": None}): + with self.assertRaisesRegex(OutputFormatError, r"base-cli\[yaml\]"): + render_records( + ({"name": "value"},), + requested_format="yaml", + columns=(("NAME", "name"),), + stream=stream, + ) + + def test_yaml_config_explains_optional_install_when_yaml_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.yaml" + path.write_text("answer: 42\n", encoding="utf-8") + with mock.patch.dict(sys.modules, {"yaml": None}): + with self.assertRaisesRegex(ConfigurationError, r"base-cli\[yaml\]"): + load_yaml_file(path, required=True) + + def test_core_facade_import_does_not_import_yaml(self) -> None: + self.assertIn("base_cli", sys.modules) + self.assertTrue(hasattr(base_cli, "App")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/validate.sh b/tests/validate.sh index 483d3d3..96fcbf7 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -23,6 +23,7 @@ required_files=( docs/releasing.md docs/index.md docs/api-stability.md + docs/dependency-support.md docs/user-config-typing.md docs/migrations.md docs/security-threat-model.md