Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/adopter-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions docs/dependency-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
3 changes: 2 additions & 1 deletion docs/output-contracts.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion examples/nested_click_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions lib/python/base_cli/_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 8 additions & 2 deletions lib/python/base_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
16 changes: 15 additions & 1 deletion scripts/validate_package_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")

Expand Down
42 changes: 42 additions & 0 deletions tests/test_optional_yaml_dependency.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions tests/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading