From 64f9c647ed6164202b88cce1e249514f9deee45e Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:39:00 -0700 Subject: [PATCH] refactor: make context configuration consumer-owned --- CHANGELOG.md | 5 ++++ README.md | 16 +++++++------ docs/consumer-profiles.md | 8 +++++-- lib/python/base_cli/__init__.py | 6 ----- lib/python/base_cli/app.py | 6 ++--- lib/python/base_cli/config.py | 41 --------------------------------- lib/python/base_cli/context.py | 15 +----------- lib/python/base_cli/profile.py | 16 +++++++++---- tests/test_context_workspace.py | 23 ++++++++++-------- tests/test_profile.py | 2 ++ tests/test_public_api.py | 12 +--------- 11 files changed, 52 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acea8a6..e7e5a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and versions are tracked in the repo-root `VERSION` file. - Move manifest discovery, implicit configuration, owner-aware runtime layout, and history persistence out of the generic package. Consumers now provide those policies through an explicit `CliProfile`. +- Make `Context.user_config` an opaque consumer-owned value and remove the + Base-shaped `UserConfig` types from the public package facade. ### Migration notes @@ -23,6 +25,9 @@ and versions are tracked in the repo-root `VERSION` file. - `base_cli.history.write_history_record()` and `base_cli.history.write_primary_record()` now require a consumer-selected history path; they never choose an application cache location themselves. +- Consumers that need a workspace root should provide the optional + `CliProfile.resolve_workspace_root` projection; the generic lifecycle no + longer reads fields from a prescribed user-configuration schema. ### Added diff --git a/README.md b/README.md index 3f1ccf7..9dac14b 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,9 @@ migration guidance. The supported facade is `import base_cli`. It exports the command lifecycle (`App`, `Context`, `run_app`, decorators, and logging helpers), command filters, -the structured command protocol helpers, and the user configuration types used -by `Context.user_config`. The corresponding modules are also available as +and the structured command protocol helpers. Consumer-owned user configuration +is passed through `Context.user_config`; the library does not impose a schema. +The corresponding modules are also available as `base_cli.command_filters`, `base_cli.command_protocol`, and `base_cli.history`. @@ -301,7 +302,8 @@ Important fields include: - `ctx.log_file`: the run's shared `logs/primary.log`, or `None` when persistent logging is disabled. - `ctx.config`: merged configuration dictionary. -- `ctx.user_config`: typed user configuration returned by the profile. +- `ctx.user_config`: opaque consumer-owned user configuration returned by the + profile, or `None` for the generic default. - `ctx.environment`: active environment, defaulting to `dev`. - `ctx.debug`: whether debug logging is enabled for the stderr stream. - `ctx.quiet`: whether INFO logs are suppressed on the stderr stream. @@ -414,10 +416,10 @@ configuration file. `ctx.config` exposes the dictionary returned by the profile. `ctx.user_config` -exposes the user-configuration value returned by the profile. Consumers that -need user files, project files, environment variables, or a merge precedence -must implement those policies in `CliProfile.load_config` and -`CliProfile.load_user_config`. +exposes the opaque user-configuration value returned by the profile. Consumers +that need user files, project files, environment variables, or a merge +precedence must implement those policies in `CliProfile.load_config` and +`CliProfile.load_user_config`; `base_cli` does not define the value's fields. ## Project Discovery diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index d433c39..eff306f 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -15,6 +15,7 @@ base_cli.App + Context + logging + cleanup | +-- project discovery -> CliProfile.discover_project +-- user configuration -> CliProfile.load_user_config + +-- workspace projection -> CliProfile.resolve_workspace_root +-- project/explicit config -> CliProfile.load_config +-- runtime placement -> CliProfile.resolve_runtime +-- history command labels -> CliProfile.history_display_command @@ -68,8 +69,11 @@ profile = base_cli.CliProfile.generic( ``` The callback types are deliberately small. A consumer can wrap an existing -project library, use a different serialization format, or return no project -metadata at all. +project library, use a different serialization format, return no project +metadata, or keep its typed user-configuration object entirely in the consumer. +`Context.user_config` is opaque to `base_cli`; `resolve_workspace_root` is an +optional projection used when commands need a workspace root without exposing +the consumer's configuration schema to the generic lifecycle. If a consumer persists history, it can provide `history_display_command` to translate internal entry-point names into user-facing labels. The generic diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 36b5d0b..5824275 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -44,7 +44,6 @@ def _resolve_version() -> str: loads_records, register_record_schema, ) -from .config import UserConfig, UserGithubConfig, UserIdeConfig, UserIdePreference, UserWorkspaceConfig from .context import Context, get_current_context from .exit_codes import ExitCode from .inspection import inspection_envelope, render_inspection_json @@ -71,11 +70,6 @@ def _resolve_version() -> str: "FieldSpec", "NULLABLE_STRING", "STRING", - "UserConfig", - "UserGithubConfig", - "UserIdeConfig", - "UserIdePreference", - "UserWorkspaceConfig", "command_filters", "command_matches", "command_protocol", diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index 798a155..6807849 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -188,6 +188,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], manifest_path = project.manifest if project is not None else None explicit_config = Path(standard["config"]).expanduser() if standard.get("config") else None user_config = self.profile.load_user_config() + workspace_root = self.profile.resolve_workspace_root(user_config) config = self.profile.load_config(project, explicit_config) environment = standard.get("environment") or config.get("environment") or "dev" @@ -228,7 +229,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], "project": selected_project_name, "project_root": str(selected_project_root) if selected_project_root else None, "manifest": str(manifest_path) if manifest_path else None, - "workspace_root": str(user_config.workspace.root) if user_config.workspace.root else None, + "workspace_root": str(workspace_root) if workspace_root else None, } run_metadata_path = layout.run_root / "run.json" write_private_json(run_metadata_path, run_metadata) @@ -262,10 +263,9 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], runtime_owner=runtime_owner, owner_root=layout.owner_root, run_root=layout.run_root, - base_home=runtime.application_home, application_home=runtime.application_home, project_root=selected_project_root, - workspace_root=user_config.workspace.root, + workspace_root=workspace_root, manifest_path=manifest_path, project_name=selected_project_name, state_dir=layout.state_dir, diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 6d0b371..058a701 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from pathlib import Path from typing import Any @@ -8,50 +7,10 @@ __all__ = [ - "UserConfig", - "UserGithubConfig", - "UserIdeConfig", - "UserIdePreference", - "UserWorkspaceConfig", "load_yaml_file", ] -@dataclass(frozen=True) -class UserIdePreference: - enabled: bool | None - install: bool | None - extra_extensions: tuple[str, ...] - settings: dict[str, Any] - - -@dataclass(frozen=True) -class UserIdeConfig: - enabled: bool | None - preferences: dict[str, UserIdePreference] - - -@dataclass(frozen=True) -class UserWorkspaceConfig: - root: Path | None - manifest: Path | None = None - manifest_source: str | None = None - - -@dataclass(frozen=True) -class UserGithubConfig: - default_owner: str | None - clone_protocol: str | None - - -@dataclass(frozen=True) -class UserConfig: - raw: dict[str, Any] - ide: UserIdeConfig - workspace: UserWorkspaceConfig = UserWorkspaceConfig(root=None) - github: UserGithubConfig = UserGithubConfig(default_owner=None, clone_protocol=None) - - def load_yaml_file(path: Path) -> dict[str, Any]: if not path.is_file(): return {} diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 9484a08..376f3a0 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -7,8 +7,6 @@ from pathlib import Path from typing import Any, Callable -from .config import UserConfig, UserIdeConfig - _current_context: contextvars.ContextVar[Context | None] = contextvars.ContextVar( "base_cli_current_context", @@ -16,10 +14,6 @@ ) -def _default_user_config() -> UserConfig: - return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={})) - - def _default_history_display_command(cli_name: str, _argv: list[str]) -> str: return cli_name.replace("_", "-") @@ -41,14 +35,13 @@ class Context: keep_temp: bool log: logging.Logger dry_run: bool = False - base_home: Path | None = None application_home: Path | None = None project_root: Path | None = None manifest_path: Path | None = None project_name: str | None = None history_scope: str = "primary" history_parent_run_id: str | None = None - user_config: UserConfig = field(default_factory=_default_user_config) + user_config: object | None = None history_display_command: Callable[[str, list[str]], str] = _default_history_display_command cleanup_hooks: list[Callable[[], None]] = field(default_factory=list) workspace_root: Path | None = None @@ -57,12 +50,6 @@ class Context: owner_root: Path | None = None run_root: Path | None = None - def __post_init__(self) -> None: - if self.application_home is None: - self.application_home = self.base_home - if self.base_home is None: - self.base_home = self.application_home - def on_cleanup(self, hook: Callable[[], None]) -> None: self.cleanup_hooks.append(hook) diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 2e7d8e1..496a1f3 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -9,7 +9,7 @@ from typing import Any from ._runtime import RuntimeLayout, runtime_layout -from .config import UserConfig, UserIdeConfig, load_yaml_file +from .config import load_yaml_file from .paths import make_run_id @@ -41,9 +41,10 @@ class RuntimeBinding: ProjectDiscovery = Callable[[Path], ProjectInfo | None] -UserConfigLoader = Callable[[], UserConfig] +UserConfigLoader = Callable[[], object | None] ConfigLoader = Callable[[ProjectInfo | None, Path | None], dict[str, Any]] RuntimeResolver = Callable[[str, ProjectInfo | None], RuntimeBinding] +WorkspaceRootResolver = Callable[[object | None], Path | None] HistoryWriter = Callable[[Any, list[str], set[str], datetime, int], None] DisplayCommandResolver = Callable[[], str | None] HistoryDisplayResolver = Callable[[str, list[str]], str] @@ -53,6 +54,10 @@ def _no_display_command() -> str | None: return None +def _no_workspace_root(_user_config: object | None) -> Path | None: + return None + + def _generic_history_display_command(cli_name: str, _argv: list[str]) -> str: return cli_name.replace("_", "-") @@ -73,6 +78,7 @@ class CliProfile: history_writer: HistoryWriter | None = None display_command: DisplayCommandResolver = _no_display_command history_display_command: HistoryDisplayResolver = _generic_history_display_command + resolve_workspace_root: WorkspaceRootResolver = _no_workspace_root @classmethod def generic( @@ -84,6 +90,7 @@ def generic( load_user_config: UserConfigLoader | None = None, load_config: ConfigLoader | None = None, history_display_command: HistoryDisplayResolver | None = None, + resolve_workspace_root: WorkspaceRootResolver | None = None, ) -> CliProfile: """Create a profile with consumer-neutral defaults. @@ -97,14 +104,15 @@ def generic( resolve_runtime=_generic_runtime_resolver(cache_root, application_home), display_command=_no_display_command, history_display_command=history_display_command or _generic_history_display_command, + resolve_workspace_root=resolve_workspace_root or _no_workspace_root, ) def _discover_no_project(_cwd: Path) -> ProjectInfo | None: return None -def _empty_user_config() -> UserConfig: - return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={})) +def _empty_user_config() -> None: + return None def _load_explicit_config(_project: ProjectInfo | None, explicit: Path | None) -> dict[str, Any]: diff --git a/tests/test_context_workspace.py b/tests/test_context_workspace.py index 74bf1a9..08a10b8 100644 --- a/tests/test_context_workspace.py +++ b/tests/test_context_workspace.py @@ -3,19 +3,22 @@ import importlib.util import tempfile import unittest +from dataclasses import dataclass from pathlib import Path import base_cli -from base_cli.config import UserConfig, UserIdeConfig, UserWorkspaceConfig + + +@dataclass(frozen=True) +class ConsumerSettings: + workspace_root: Path | None def configured_app(workspace: Path | None, **kwargs: object) -> base_cli.App: + settings = ConsumerSettings(workspace.resolve() if workspace is not None else None) profile = base_cli.CliProfile.generic( - load_user_config=lambda: UserConfig( - raw={}, - ide=UserIdeConfig(enabled=None, preferences={}), - workspace=UserWorkspaceConfig(root=workspace.resolve() if workspace is not None else None), - ) + load_user_config=lambda: settings, + resolve_workspace_root=lambda value: value.workspace_root if isinstance(value, ConsumerSettings) else None, ) return base_cli.App(profile=profile, **kwargs) @@ -35,7 +38,7 @@ def test_context_exposes_workspace_root_when_configured(self) -> None: @app.command() def main(ctx: base_cli.Context) -> None: seen["workspace_root"] = ctx.workspace_root - seen["user_config_workspace_root"] = ctx.user_config.workspace.root + seen["user_config"] = ctx.user_config from base_cli.testing import invoke @@ -43,7 +46,7 @@ def main(ctx: base_cli.Context) -> None: self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(seen["workspace_root"], workspace.resolve()) - self.assertEqual(seen["user_config_workspace_root"], workspace.resolve()) + self.assertEqual(seen["user_config"], ConsumerSettings(workspace.resolve())) def test_context_workspace_root_is_none_without_configured_root(self) -> None: app = configured_app(None, name="workspace-root-default", log_to_file=False) @@ -52,7 +55,7 @@ def test_context_workspace_root_is_none_without_configured_root(self) -> None: @app.command() def main(ctx: base_cli.Context) -> None: seen["workspace_root"] = ctx.workspace_root - seen["user_config_workspace_root"] = ctx.user_config.workspace.root + seen["user_config"] = ctx.user_config with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) @@ -63,4 +66,4 @@ def main(ctx: base_cli.Context) -> None: self.assertEqual(result.exit_code, 0, result.output) self.assertIsNone(seen["workspace_root"]) - self.assertIsNone(seen["user_config_workspace_root"]) + self.assertEqual(seen["user_config"], ConsumerSettings(None)) diff --git a/tests/test_profile.py b/tests/test_profile.py index 7559134..9a7cc0e 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -58,6 +58,8 @@ def test_app_defaults_to_generic_profile(self) -> None: self.assertIsNone(app.profile.history_writer) self.assertIsNone(app.profile.display_command()) + self.assertIsNone(app.profile.load_user_config()) + self.assertIsNone(app.profile.resolve_workspace_root(None)) self.assertEqual(app.profile.resolve_runtime("plain-tool", None).runtime_owner, "default") def test_generic_profile_accepts_consumer_project_and_config_policies(self) -> None: diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 975f32b..a4f359c 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -7,7 +7,6 @@ import base_cli from base_cli import command_filters, command_protocol, history -from base_cli.config import UserConfig, UserGithubConfig, UserIdeConfig, UserIdePreference, UserWorkspaceConfig class PublicApiTests(unittest.TestCase): @@ -33,11 +32,6 @@ def test_version_resolution_ignores_unrelated_ancestor_version_files(self) -> No def test_facade_exports_supported_modules_functions_and_types(self) -> None: expected = { "CommandProtocolError", - "UserConfig", - "UserGithubConfig", - "UserIdeConfig", - "UserIdePreference", - "UserWorkspaceConfig", "command_filters", "command_matches", "command_protocol", @@ -50,11 +44,7 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: } self.assertTrue(expected.issubset(base_cli.__all__)) - self.assertIs(base_cli.UserConfig, UserConfig) - self.assertIs(base_cli.UserGithubConfig, UserGithubConfig) - self.assertIs(base_cli.UserIdeConfig, UserIdeConfig) - self.assertIs(base_cli.UserIdePreference, UserIdePreference) - self.assertIs(base_cli.UserWorkspaceConfig, UserWorkspaceConfig) + self.assertFalse(hasattr(base_cli, "UserConfig")) self.assertIs(base_cli.command_filters, command_filters) self.assertIs(base_cli.command_protocol, command_protocol)