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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and versions are tracked in the repo-root `VERSION` file.

## [Unreleased]

### Changed

- Make `base_cli.App()` use the consumer-neutral profile by default; the
temporary Base compatibility profile is now explicit.

### Added

- Initialized the repository with the Base-managed repo baseline.
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ The package follows these rules:
between applications: project discovery, configuration, runtime placement, and
optional history persistence.

Standalone consumers should opt into the generic profile explicitly:
Standalone consumers use the generic profile by default. It can also be passed
explicitly when making the policy boundary visible:

```python
app = base_cli.App(
Expand All @@ -71,10 +72,11 @@ app = base_cli.App(
The generic profile has no manifest filename convention, no product-owned
configuration directory, and no implicit history writer. Applications can
provide those policies through callbacks or build their own profile. The
temporary default, `CliProfile.legacy_base()`, preserves the historical
Base behavior for existing callers while Base migrates to an explicit adapter.
See [`docs/consumer-profiles.md`](docs/consumer-profiles.md) for the boundary
and migration plan.
temporary compatibility profile, `CliProfile.legacy_base()`, preserves the
historical Base behavior only for callers that opt into it explicitly while
Base completes its adapter extraction. See
[`docs/consumer-profiles.md`](docs/consumer-profiles.md) for the boundary and
migration plan.

## Public API

Expand Down
19 changes: 9 additions & 10 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ command aliases.

## Compatibility profile

For the migration period, `App()` without an explicit profile selects
`CliProfile.legacy_base()`. This preserves existing Base consumers while they
move their integration code out of the generic package. New standalone
consumers should pass `CliProfile.generic()` explicitly so their behavior does
not depend on the compatibility default.
`App()` uses `CliProfile.generic()` when no profile is supplied. This keeps the
standalone default consumer-neutral. During the migration, Base and other
existing integrations that still need these conventions can opt into
`CliProfile.legacy_base()` explicitly while they move their adapters out of the
generic package.

The legacy profile contains the current Base conventions, including:

Expand All @@ -108,13 +108,12 @@ The following behaviors should not be added to generic lifecycle modules:
- product-specific command lists or history schema;
- assumptions about a downstream repository's directory layout.

The next migration phases are:
The remaining migration phases are:

1. Change Base command engines to construct and pass an explicit legacy profile.
2. Move Base discovery, config, runtime, and history adapters into Base.
3. Generalize the remaining context/config types where their names still encode
1. Move Base discovery, config, runtime, and history adapters into Base.
2. Generalize the remaining context/config types where their names still encode
Base concepts.
4. Remove the compatibility default and keep `base_cli` focused on the generic
3. Remove the compatibility profile and keep `base_cli` focused on the generic
lifecycle.

The package rename is deliberately separate from this refactor. Names can be
Expand Down
5 changes: 4 additions & 1 deletion lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ def __init__(
self.help = help
self.log_to_file = log_to_file
self.max_log_files = max_log_files
self.profile = profile or CliProfile.legacy_base()
# Standalone applications must not inherit a consumer's product
# conventions. Consumers with an existing integration should pass an
# explicit profile, such as Base's temporary legacy adapter.
self.profile = profile or CliProfile.generic()
self._click_command = None
self._command_func: Callable[..., Any] | None = None
self._command_args: tuple[Any, ...] = ()
Expand Down
20 changes: 12 additions & 8 deletions tests/test_app_log_retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from base_cli.testing import invoke


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


def write_log_file(path: Path, mtime: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("old log\n", encoding="utf-8")
Expand Down Expand Up @@ -56,7 +60,7 @@ def test_ignores_stale_paths_in_retention_index(self) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_uses_retention_index_after_initial_discovery(self) -> None:
app = base_cli.App(name="retention-index", max_log_files=2)
app = legacy_app(name="retention-index", max_log_files=2)

@app.command()
def main(ctx: base_cli.Context) -> None:
Expand All @@ -77,7 +81,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_prunes_oldest_default_logs(self) -> None:
app = base_cli.App(name="retention-demo", max_log_files=2)
app = legacy_app(name="retention-demo", max_log_files=2)
seen = {}

@app.command()
Expand All @@ -104,7 +108,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_prunes_by_filename_when_mtimes_disagree(self) -> None:
app = base_cli.App(name="retention-filename", max_log_files=2)
app = legacy_app(name="retention-filename", max_log_files=2)
seen = {}

@app.command()
Expand All @@ -131,7 +135,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_preserves_current_log_file(self) -> None:
app = base_cli.App(name="retention-current", max_log_files=1)
app = legacy_app(name="retention-current", max_log_files=1)
seen = {}

@app.command()
Expand All @@ -158,7 +162,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_keeps_logs_when_count_is_within_limit(self) -> None:
app = base_cli.App(name="retention-within-limit", max_log_files=3)
app = legacy_app(name="retention-within-limit", max_log_files=3)
seen = {}

@app.command()
Expand All @@ -185,7 +189,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_is_disabled_by_default(self) -> None:
app = base_cli.App(name="retention-unset")
app = legacy_app(name="retention-unset")
seen = {}

@app.command()
Expand All @@ -212,7 +216,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_skips_no_durable_write_modes(self) -> None:
dry_run_app = base_cli.App(name="retention-dry-run", max_log_files=1)
dry_run_app = legacy_app(name="retention-dry-run", max_log_files=1)
dry_seen = {}

@dry_run_app.command()
Expand All @@ -222,7 +226,7 @@ def dry_run_main(ctx: base_cli.Context, dry_run: bool) -> None:
dry_seen["log_file"] = ctx.log_file
ctx.log.info("dry retention")

no_file_app = base_cli.App(
no_file_app = legacy_app(
name="retention-no-file",
log_to_file=False,
max_log_files=1,
Expand Down
8 changes: 6 additions & 2 deletions tests/test_app_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
from base_cli.config import user_config_path


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


class RunAppTests(unittest.TestCase):
@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_reports_config_errors_without_traceback(self) -> None:
app = base_cli.App(name="bad-config", log_to_file=False)
app = legacy_app(name="bad-config", log_to_file=False)
seen = {}

@app.command()
Expand Down Expand Up @@ -122,7 +126,7 @@ def main(ctx: base_cli.Context, name: str) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_uses_delegated_display_command_for_usage_errors(self) -> None:
app = base_cli.App(name="internal-cli", log_to_file=False)
app = legacy_app(name="internal-cli", log_to_file=False)

@app.command(context_settings={"help_option_names": ["-h", "--help"]})
def main(ctx: base_cli.Context) -> None:
Expand Down
6 changes: 5 additions & 1 deletion tests/test_app_runtime_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from base_cli.testing import invoke


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


class AppRuntimeErrorTests(unittest.TestCase):
def test_missing_click_error_recommends_pip_install(self) -> None:
app = base_cli.App(name="missing-click")
Expand All @@ -39,7 +43,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_reports_unwritable_cache_root_without_traceback(self) -> None:
app = base_cli.App(name="cache-failure", version="0.1.0")
app = legacy_app(name="cache-failure", version="0.1.0")

@app.command()
def main(ctx: base_cli.Context) -> None:
Expand Down
16 changes: 10 additions & 6 deletions tests/test_base_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def change_directory(path: Path):
os.chdir(original)


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


class BaseCliTests(unittest.TestCase):
@staticmethod
def make_context(tmpdir: str) -> tuple[base_cli.Context, mock.Mock]:
Expand Down Expand Up @@ -509,7 +513,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_context_exposes_populated_typed_user_config(self) -> None:
app = base_cli.App(name="typed-config-populated", log_to_file=False)
app = legacy_app(name="typed-config-populated", log_to_file=False)
seen = {}

@app.command()
Expand Down Expand Up @@ -587,7 +591,7 @@ def second(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_app_runs_with_context_and_cleans_temp_dir(self) -> None:
app = base_cli.App(name="demo", version="0.1.0")
app = legacy_app(name="demo", version="0.1.0")
seen = {}

@app.command()
Expand Down Expand Up @@ -774,7 +778,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_testing_invoke_uses_supplied_cwd_for_manifest_discovery(self) -> None:
app = base_cli.App(name="project-aware", log_to_file=False)
app = legacy_app(name="project-aware", log_to_file=False)
seen = {}

@app.command()
Expand Down Expand Up @@ -855,7 +859,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_testing_invoke_defaults_base_cache_dir_under_home(self) -> None:
app = base_cli.App(name="cache-default")
app = legacy_app(name="cache-default")
seen = {}

@app.command()
Expand All @@ -881,7 +885,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_testing_invoke_preserves_explicit_environment_overrides(self) -> None:
app = base_cli.App(name="cache-override")
app = legacy_app(name="cache-override")
seen = {}

@app.command()
Expand Down Expand Up @@ -917,7 +921,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_standard_options_manifest_context_and_sensitive_redaction(self) -> None:
app = base_cli.App(name="secret-tool", version="0.1.0")
app = legacy_app(name="secret-tool", version="0.1.0")
seen = {}

@app.command()
Expand Down
6 changes: 5 additions & 1 deletion tests/test_context_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
from base_cli.config import user_config_path


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
class ContextWorkspaceRootTests(unittest.TestCase):
def test_context_exposes_workspace_root_when_configured(self) -> None:
app = base_cli.App(name="workspace-root-configured", log_to_file=False)
app = legacy_app(name="workspace-root-configured", log_to_file=False)
seen: dict[str, Path | None] = {}

@app.command()
Expand Down
16 changes: 10 additions & 6 deletions tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from base_cli import history as history_helpers


def legacy_app(**kwargs: object) -> base_cli.App:
return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs)


def read_history_records(cache_root: Path) -> list[dict]:
history_path = cache_root / "base" / "history" / "runs.jsonl"
return [json.loads(line) for line in history_path.read_text(encoding="utf-8").splitlines()]
Expand Down Expand Up @@ -185,7 +189,7 @@ def test_write_primary_record_preserves_user_command_and_project_metadata(self)

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_app_records_successful_command_history_with_redacted_metadata(self) -> None:
app = base_cli.App(name="history-demo", version="0.1.0")
app = legacy_app(name="history-demo", version="0.1.0")
seen = {}

@app.command()
Expand Down Expand Up @@ -255,7 +259,7 @@ def main(ctx: base_cli.Context, endpoint: str, token: str) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_app_reuses_parent_run_and_does_not_record_internal_history(self) -> None:
app = base_cli.App(name="history-internal")
app = legacy_app(name="history-internal")

seen = {}

Expand Down Expand Up @@ -291,7 +295,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_project_owner_uses_checkout_scoped_run_bundle(self) -> None:
app = base_cli.App(name="project-native")
app = legacy_app(name="project-native")

@app.command()
def main(ctx: base_cli.Context) -> None:
Expand Down Expand Up @@ -329,7 +333,7 @@ def main(ctx: base_cli.Context) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_uses_explicit_argv_for_history_and_log_metadata(self) -> None:
app = base_cli.App(name="history-explicit", version="0.1.0")
app = legacy_app(name="history-explicit", version="0.1.0")
seen = {}
current_endpoint = "https://current.example/path"
stale_endpoint = "https://stale.invalid/path"
Expand Down Expand Up @@ -380,7 +384,7 @@ def main(ctx: base_cli.Context, endpoint: str, token: str) -> None:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_app_records_failed_command_history(self) -> None:
app = base_cli.App(name="failing-history")
app = legacy_app(name="failing-history")

@app.command()
def main(ctx: base_cli.Context) -> int:
Expand All @@ -405,7 +409,7 @@ def main(ctx: base_cli.Context) -> int:

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_app_history_write_failures_do_not_fail_command(self) -> None:
app = base_cli.App(name="history-best-effort")
app = legacy_app(name="history-best-effort")

@app.command()
def main(ctx: base_cli.Context) -> None:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ def main(ctx: base_cli.Context) -> None:
self.assertFalse((home / ".base.d").exists())
self.assertFalse((home / ".cache" / "base").exists())

def test_app_defaults_to_generic_profile(self) -> None:
app = base_cli.App(name="plain-tool", log_to_file=False)

self.assertIsNone(app.profile.history_writer)
self.assertIsNone(app.profile.display_command())
self.assertEqual(app.profile.resolve_runtime("plain-tool", None).runtime_owner, "default")

def test_generic_profile_accepts_consumer_project_and_config_policies(self) -> None:
seen: dict[str, object] = {}

Expand Down
Loading