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
3 changes: 2 additions & 1 deletion .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
ref: beb99953e5df66dea207a31cea3c00332aaadcad
# Keep the consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Set up Python ${{ matrix.python-version }}
Expand Down
26 changes: 22 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
ref: beb99953e5df66dea207a31cea3c00332aaadcad
# Keep consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Set up Python
Expand All @@ -56,6 +57,7 @@ jobs:
timeout-minutes: 20
env:
BASE_BASH_LIBS_DIR: ${{ github.workspace }}/.dependencies/base-bash-libs/lib/bash
BASE_CLI_SOURCE_DIR: ${{ github.workspace }}/.dependencies/base-cli/lib/python

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
Expand All @@ -69,7 +71,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
ref: beb99953e5df66dea207a31cea3c00332aaadcad
# Keep consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Expose standalone Python package checkout as sibling
Expand Down Expand Up @@ -121,7 +124,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
ref: beb99953e5df66dea207a31cea3c00332aaadcad
# Keep consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Expose standalone Python package checkout as sibling
Expand Down Expand Up @@ -199,6 +203,7 @@ jobs:
timeout-minutes: 25
env:
BASE_BASH_LIBS_DIR: ${{ github.workspace }}/.dependencies/base-bash-libs/lib/bash
BASE_CLI_SOURCE_DIR: ${{ github.workspace }}/.dependencies/base-cli/lib/python

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
Expand All @@ -209,6 +214,17 @@ jobs:
ref: 2c5ef2c3a9edfbe2cf68d0645be65b920255abff
path: .dependencies/base-bash-libs

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
# Keep consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Expose standalone Python package checkout as sibling
run: |
ln -s "$GITHUB_WORKSPACE/.dependencies/base-cli" "$GITHUB_WORKSPACE/../base-cli"

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
Expand Down Expand Up @@ -250,6 +266,7 @@ jobs:
timeout-minutes: 35
env:
BASE_BASH_LIBS_DIR: ${{ github.workspace }}/.dependencies/base-bash-libs/lib/bash
BASE_CLI_SOURCE_DIR: ${{ github.workspace }}/.dependencies/base-cli/lib/python

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
Expand All @@ -263,7 +280,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
repository: basefoundry/base-cli
ref: beb99953e5df66dea207a31cea3c00332aaadcad
# Keep consumer validation paired with base-cli #25 until it merges.
ref: 03c50fb65cb0a52d5e2f58e208d2fd8c4dee5130
path: .dependencies/base-cli

- name: Expose reusable Bash library checkout as sibling
Expand Down
2 changes: 1 addition & 1 deletion cli/python/base_clean/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import base_cli
from base_cli_profile import base_cli_app
from base_cli.paths import base_cache_root
from base_cli_adapters.paths import base_cache_root


app = base_cli_app(name="base_clean")
Expand Down
3 changes: 3 additions & 0 deletions cli/python/base_cli_adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Base-owned adapters for the shared :mod:`base_cli` lifecycle."""

from __future__ import annotations
223 changes: 223 additions & 0 deletions cli/python/base_cli_adapters/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

import base_cli
from base_cli.config import UserConfig
from base_cli.config import UserGithubConfig
from base_cli.config import UserIdeConfig
from base_cli.config import UserIdePreference
from base_cli.config import UserWorkspaceConfig
from base_cli.ide_schema import parse_ide_extensions
from base_cli.ide_schema import parse_ide_settings

from base_setup.ide_schema import SUPPORTED_IDES

from .paths import base_state_root


def user_config_path(home: Path | None = None) -> Path:
return base_state_root(home) / "config.yaml"


def load_yaml_file(path: Path) -> dict[str, Any]:
return base_cli.config.load_yaml_file(path)


def load_user_config(home: Path | None = None) -> dict[str, Any]:
return load_yaml_file(user_config_path(home))


def read_user_config(
home: Path | None = None,
*,
supported_ides: frozenset[str] | None = SUPPORTED_IDES,
) -> UserConfig:
raw = load_user_config(home)
path = user_config_path(home)
return UserConfig(
raw=raw,
workspace=_read_user_workspace_config(path, raw.get("workspace")),
github=_read_user_github_config(path, raw.get("github")),
ide=_read_user_ide_config(path, raw.get("ide"), supported_ides=supported_ides),
)


def _read_user_workspace_config(path: Path, workspace_data: Any) -> UserWorkspaceConfig:
if workspace_data is None:
return UserWorkspaceConfig(root=None)
if not isinstance(workspace_data, dict):
raise ValueError(f"{path}: workspace must be a mapping when provided.")

allowed_keys = {"root", "manifest", "manifest_source"}
unknown_keys = sorted(set(workspace_data) - allowed_keys)
if unknown_keys:
raise ValueError(f"{path}: workspace has unsupported keys: {', '.join(unknown_keys)}.")

return UserWorkspaceConfig(
root=_optional_path(path, "workspace.root", workspace_data.get("root")),
manifest=_optional_path(path, "workspace.manifest", workspace_data.get("manifest")),
manifest_source=_optional_non_empty_string(
path,
"workspace.manifest_source",
workspace_data.get("manifest_source"),
),
)


def _read_user_github_config(path: Path, github_data: Any) -> UserGithubConfig:
if github_data is None:
return UserGithubConfig(default_owner=None, clone_protocol=None)
if not isinstance(github_data, dict):
raise ValueError(f"{path}: github must be a mapping when provided.")

allowed_keys = {"default_owner", "clone_protocol"}
unknown_keys = sorted(set(github_data) - allowed_keys)
if unknown_keys:
raise ValueError(f"{path}: github has unsupported keys: {', '.join(unknown_keys)}.")

return UserGithubConfig(
default_owner=_optional_github_owner(path, github_data.get("default_owner")),
clone_protocol=_optional_github_clone_protocol(path, github_data.get("clone_protocol")),
)


def _optional_github_owner(path: Path, value: Any) -> str | None:
owner = _optional_non_empty_string(path, "github.default_owner", value)
if owner is None:
return None
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", owner):
raise ValueError(
f"{path}: github.default_owner must start with a letter or digit and contain only "
"letters, digits, and dash."
)
return owner


def _optional_github_clone_protocol(path: Path, value: Any) -> str | None:
protocol = _optional_non_empty_string(path, "github.clone_protocol", value)
if protocol is None:
return None
if protocol not in {"ssh", "https"}:
raise ValueError(f"{path}: github.clone_protocol must be 'ssh' or 'https'.")
return protocol


def _optional_path(path: Path, key: str, value: Any) -> Path | None:
if value is None:
return None
candidate = _optional_non_empty_string(path, key, value)
if candidate is None:
return None

candidate_path = Path(candidate).expanduser()
if not candidate_path.is_absolute():
raise ValueError(f"{path}: {key} must be an absolute path or start with '~'.")
return candidate_path.resolve(strict=False)


def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{path}: {key} must be a non-empty string when provided.")
return value.strip()


def _read_user_ide_config(
path: Path,
ide_data: Any,
*,
supported_ides: frozenset[str] | None,
) -> UserIdeConfig:
if ide_data is None:
return UserIdeConfig(enabled=None, preferences={})
if not isinstance(ide_data, dict):
raise ValueError(f"{path}: ide must be a mapping when provided.")

invalid_keys = sorted(str(key) for key in ide_data if not isinstance(key, str) or not key.strip())
if invalid_keys:
raise ValueError(f"{path}: ide keys must be non-empty strings.")

unknown_keys = sorted(set(ide_data) - supported_ides - {"enabled"}) if supported_ides is not None else []
if unknown_keys:
raise ValueError(f"{path}: unsupported ide keys: {', '.join(unknown_keys)}.")

enabled = _optional_bool(path, "ide.enabled", ide_data.get("enabled"))
preferences = {
ide_name: _read_user_ide_preference(path, ide_name, ide_data.get(ide_name))
for ide_name in sorted(set(ide_data) - {"enabled"})
if ide_name in ide_data
}
return UserIdeConfig(enabled=enabled, preferences=preferences)


def _read_user_ide_preference(path: Path, ide_name: str, preference_data: Any) -> UserIdePreference:
if preference_data is None:
preference_data = {}
if not isinstance(preference_data, dict):
raise ValueError(f"{path}: ide.{ide_name} must be a mapping when provided.")

allowed_keys = {"enabled", "install", "extra_extensions", "settings"}
unknown_keys = sorted(set(preference_data) - allowed_keys)
if unknown_keys:
raise ValueError(f"{path}: ide.{ide_name} has unsupported keys: {', '.join(unknown_keys)}.")

return UserIdePreference(
enabled=_optional_bool(path, f"ide.{ide_name}.enabled", preference_data.get("enabled")),
install=_optional_bool(path, f"ide.{ide_name}.install", preference_data.get("install")),
extra_extensions=parse_ide_extensions(
f"{path}: ide.{ide_name}.extra_extensions",
preference_data.get("extra_extensions", []),
),
settings=parse_ide_settings(
f"{path}: ide.{ide_name}.settings",
preference_data.get("settings", {}),
),
)


def _optional_bool(path: Path, key: str, value: Any) -> bool | None:
if value is None:
return None
if not isinstance(value, bool):
raise ValueError(f"{path}: {key} must be a boolean when provided.")
return value


def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_dicts(merged[key], value)
else:
merged[key] = value
return merged


def load_config(
project_root: Path | None,
explicit_config: Path | None,
home: Path | None = None,
) -> dict[str, Any]:
root = home or Path.home()
config: dict[str, Any] = {}
config = merge_dicts(config, load_user_config(root))
if project_root is not None:
config = merge_dicts(config, load_yaml_file(project_root / ".base" / "config.yaml"))
if explicit_config is not None:
config = merge_dicts(config, load_yaml_file(explicit_config))

env_config: dict[str, Any] = {}
if "BASE_CLI_ENVIRONMENT" in os.environ:
env_config["environment"] = os.environ["BASE_CLI_ENVIRONMENT"]
if "BASE_CLI_LOG_LEVEL" in os.environ:
env_config["log_level"] = os.environ["BASE_CLI_LOG_LEVEL"]
elif os.environ.get("LOG_DEBUG", "").lower() in ("1", "true"):
env_config["log_level"] = "debug"
if "BASE_CLI_KEEP_TEMP" in os.environ:
env_config["keep_temp"] = os.environ["BASE_CLI_KEEP_TEMP"].lower() == "true"
return merge_dicts(config, env_config)
Loading
Loading