diff --git a/conftest.py b/conftest.py index 9a5b07cb7..377724ea1 100644 --- a/conftest.py +++ b/conftest.py @@ -24,7 +24,7 @@ @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, - doctest_namespace: dict[str, t.Any], + doctest_namespace: dict[str, object], ) -> None: """Harness pytest fixtures to doctests namespace.""" from _pytest.doctest import DoctestItem diff --git a/src/vcspull/_internal/private_path.py b/src/vcspull/_internal/private_path.py index 367d3dfd6..4d5013938 100644 --- a/src/vcspull/_internal/private_path.py +++ b/src/vcspull/_internal/private_path.py @@ -34,7 +34,11 @@ class PrivatePath(PrivatePathBase): '~/notes.txt' """ - def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath: + def __new__( + cls, + *args: str | os.PathLike[str], + **kwargs: object, + ) -> PrivatePath: return super().__new__(cls, *args, **kwargs) @classmethod diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index b595a7e47..d82af4c62 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -28,6 +28,20 @@ log = logging.getLogger(__name__) +SubparserTuple: t.TypeAlias = tuple[ + argparse.ArgumentParser, # sync + argparse.ArgumentParser, # list + argparse.ArgumentParser, # status + argparse.ArgumentParser, # search + argparse.ArgumentParser, # add + argparse.ArgumentParser, # discover + argparse.ArgumentParser, # fmt + argparse.ArgumentParser, # migrate + argparse.ArgumentParser, # import + argparse.ArgumentParser, # worktree +] + + def build_description( intro: str, example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], @@ -323,7 +337,7 @@ def build_description( @t.overload def create_parser( return_subparsers: t.Literal[True], -) -> tuple[argparse.ArgumentParser, t.Any]: ... +) -> tuple[argparse.ArgumentParser, SubparserTuple]: ... @t.overload @@ -332,7 +346,7 @@ def create_parser(return_subparsers: t.Literal[False]) -> argparse.ArgumentParse def create_parser( return_subparsers: bool = False, -) -> argparse.ArgumentParser | tuple[argparse.ArgumentParser, t.Any]: +) -> argparse.ArgumentParser | tuple[argparse.ArgumentParser, SubparserTuple]: """Create CLI argument parser for vcspull.""" parser = argparse.ArgumentParser( prog="vcspull", diff --git a/src/vcspull/cli/_output.py b/src/vcspull/cli/_output.py index c2add1d2d..d98b7043b 100644 --- a/src/vcspull/cli/_output.py +++ b/src/vcspull/cli/_output.py @@ -8,6 +8,72 @@ from dataclasses import dataclass, field from enum import Enum +JsonPrimitive: t.TypeAlias = str | int | float | bool | None +JsonValue: t.TypeAlias = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"] +JsonObject: t.TypeAlias = t.Mapping[str, JsonValue] +OutputPayload: t.TypeAlias = "JsonObject | PlanEntryPayload | PlanSummaryPayload" + + +class _PlanEntryPayloadRequired(t.TypedDict): + """Required keys for a plan-entry JSON payload.""" + + format_version: str + type: str + name: str + path: str + workspace_root: str + action: str + + +class PlanEntryPayload(_PlanEntryPayloadRequired, total=False): + """Typed JSON payload for a plan entry (optional keys omitted when unset).""" + + detail: str + url: str + branch: str + remote_branch: str + current_rev: str + target_rev: str + ahead: int + behind: int + dirty: bool + error: str + diagnostics: list[str] + + +class _PlanSummaryPayloadRequired(t.TypedDict): + """Required keys for a plan-summary JSON payload.""" + + format_version: str + type: str + clone: int + update: int + unchanged: int + blocked: int + errors: int + total: int + + +class PlanSummaryPayload(_PlanSummaryPayloadRequired, total=False): + """Typed JSON payload for a plan summary (optional keys omitted when unset).""" + + duration_ms: int + + +class PlanWorkspacePayload(t.TypedDict): + """Typed JSON payload for a workspace grouping.""" + + path: str + operations: list[PlanEntryPayload] + + +class PlanResultPayload(t.TypedDict): + """Typed JSON payload for a plan result.""" + + format_version: str + workspaces: list[PlanWorkspacePayload] + summary: PlanSummaryPayload + class OutputMode(Enum): """Output format modes.""" @@ -47,7 +113,7 @@ class PlanEntry: error: str | None = None diagnostics: list[str] = field(default_factory=list) - def to_payload(self) -> dict[str, t.Any]: + def to_payload(self) -> PlanEntryPayload: """Convert the plan entry into a serialisable payload. Examples @@ -67,7 +133,7 @@ def to_payload(self) -> dict[str, t.Any]: >>> payload["format_version"] '1' """ - payload: dict[str, t.Any] = { + payload: PlanEntryPayload = { "format_version": "1", "type": "operation", "name": self.name, @@ -122,7 +188,7 @@ def total(self) -> int: """ return self.clone + self.update + self.unchanged + self.blocked + self.errors - def to_payload(self) -> dict[str, t.Any]: + def to_payload(self) -> PlanSummaryPayload: """Convert the summary to a serialisable payload. Examples @@ -134,7 +200,7 @@ def to_payload(self) -> dict[str, t.Any]: >>> payload["total"] 3 """ - payload: dict[str, t.Any] = { + payload: PlanSummaryPayload = { "format_version": "1", "type": "summary", "clone": self.clone, @@ -167,6 +233,29 @@ class PlanResult: entries: list[PlanEntry] summary: PlanSummary + def to_workspace_mapping(self) -> dict[str, list[PlanEntry]]: + """Group plan entries by workspace root.""" + grouped: dict[str, list[PlanEntry]] = {} + for entry in self.entries: + grouped.setdefault(entry.workspace_root, []).append(entry) + return grouped + + def to_json_object(self) -> PlanResultPayload: + """Return the JSON structure for ``--json`` output.""" + workspaces: list[PlanWorkspacePayload] = [] + for workspace_root, entries in self.to_workspace_mapping().items(): + workspaces.append( + { + "path": workspace_root, + "operations": [entry.to_payload() for entry in entries], + }, + ) + return { + "format_version": "1", + "workspaces": workspaces, + "summary": self.summary.to_payload(), + } + class OutputFormatter: """Manages output formatting for different modes (human, JSON, NDJSON).""" @@ -186,17 +275,18 @@ def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None: """ self.mode = mode - self._json_buffer: list[dict[str, t.Any]] = [] + self._json_buffer: list[OutputPayload] = [] - def emit(self, data: dict[str, t.Any] | PlanEntry | PlanSummary) -> None: + def emit(self, data: OutputPayload | PlanEntry | PlanSummary) -> None: """Emit a data event. Parameters ---------- - data : dict | PlanEntry | PlanSummary + data : OutputPayload | PlanEntry | PlanSummary Event data to emit. PlanEntry and PlanSummary instances are serialised automatically. """ + payload: OutputPayload if isinstance(data, (PlanEntry, PlanSummary)): payload = data.to_payload() else: diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index 849b1bacd..434f085a6 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -37,6 +37,13 @@ log = logging.getLogger(__name__) +class _OrderedItem(t.TypedDict): + """Ordered config entry preserving label/section pairs.""" + + label: str + section: object + + class AddAction(enum.Enum): """Action resolved for a single repo during ``vcspull add``.""" @@ -251,37 +258,40 @@ def _normalize_detected_url(remote: str | None) -> tuple[str, str]: def _build_ordered_items( - top_level_items: list[tuple[str, t.Any]] | None, - raw_config: dict[str, t.Any], -) -> list[dict[str, t.Any]]: + top_level_items: list[tuple[str, object]] | None, + raw_config: dict[str, object], +) -> list[_OrderedItem]: """Return deep-copied top-level items preserving original ordering.""" - source: list[tuple[str, t.Any]] = top_level_items or list(raw_config.items()) + source: list[tuple[str, object]] = top_level_items or list(raw_config.items()) - ordered: list[dict[str, t.Any]] = [] + ordered: list[_OrderedItem] = [] for label, section in source: ordered.append({"label": label, "section": copy.deepcopy(section)}) return ordered def _aggregate_from_ordered_items( - items: list[dict[str, t.Any]], -) -> dict[str, t.Any]: + items: list[_OrderedItem], +) -> dict[str, object]: """Collapse ordered top-level items into a mapping grouped by label.""" - aggregated: dict[str, t.Any] = {} + aggregated: dict[str, object] = {} for entry in items: label = entry["label"] section = entry["section"] if isinstance(section, dict): - workspace_section = aggregated.setdefault(label, {}) + workspace_section = t.cast( + "dict[str, object]", + aggregated.setdefault(label, {}), + ) for repo_name, repo_config in section.items(): - workspace_section[repo_name] = copy.deepcopy(repo_config) + workspace_section[str(repo_name)] = copy.deepcopy(repo_config) else: aggregated[label] = copy.deepcopy(section) return aggregated def _collapse_ordered_items_to_dict( - ordered_items: list[dict[str, t.Any]], + ordered_items: list[_OrderedItem], ) -> dict[str, t.Any]: """Collapse ordered items into a flat dict for JSON serialization. @@ -326,10 +336,10 @@ def _collapse_ordered_items_to_dict( def _collect_duplicate_sections( - items: list[dict[str, t.Any]], -) -> dict[str, list[t.Any]]: + items: list[_OrderedItem], +) -> dict[str, list[object]]: """Return mapping of labels to their repeated sections (>= 2 occurrences).""" - occurrences: dict[str, list[t.Any]] = {} + occurrences: dict[str, list[object]] = {} for entry in items: label = entry["label"] occurrences.setdefault(label, []).append(copy.deepcopy(entry["section"])) @@ -341,7 +351,7 @@ def _collect_duplicate_sections( def _save_ordered_items( config_file_path: pathlib.Path, - ordered_items: list[dict[str, t.Any]], + ordered_items: list[_OrderedItem], ) -> None: """Persist ordered items in the format matching the config file extension. @@ -610,9 +620,9 @@ def add_repo( config_file_path = home_configs[0] # Load existing config - raw_config: dict[str, t.Any] - duplicate_root_occurrences: dict[str, list[t.Any]] - top_level_items: list[tuple[str, t.Any]] + raw_config: dict[str, object] + duplicate_root_occurrences: dict[str, list[object]] + top_level_items: list[tuple[str, object]] display_config_path = str(PrivatePath(config_file_path)) if config_file_path.exists() and config_file_path.is_file(): @@ -666,7 +676,7 @@ def add_repo( new_repo_entry = build_repo_entry(url, rev=rev, shallow=shallow, depth=depth) def _ensure_workspace_label_for_merge( - config_data: dict[str, t.Any], + config_data: dict[str, object], ) -> tuple[str, bool]: workspace_map: dict[pathlib.Path, str] = {} for label, section in config_data.items(): @@ -700,7 +710,7 @@ def _ensure_workspace_label_for_merge( return workspace_label, relabelled def _prepare_no_merge_items( - items: list[dict[str, t.Any]], + items: list[_OrderedItem], ) -> tuple[str, int, bool]: matching_indexes: list[int] = [] for idx, entry in enumerate(items): diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 5d91b7a6b..8b769dc5a 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -416,8 +416,8 @@ def discover_repos( config_scope = _classify_config_scope(config_file_path, cwd=cwd, home=home) allow_relative_workspace = config_scope == "project" - raw_config: dict[str, t.Any] - duplicate_root_occurrences: dict[str, list[t.Any]] + raw_config: dict[str, object] + duplicate_root_occurrences: dict[str, list[object]] if config_file_path.exists() and config_file_path.is_file(): try: ( @@ -844,9 +844,11 @@ def discover_repos( ) workspace_map[workspace_path] = workspace_label - if workspace_label not in raw_config: - raw_config[workspace_label] = {} - elif not isinstance(raw_config[workspace_label], dict): + section = raw_config.get(workspace_label) + if section is None: + section = {} + raw_config[workspace_label] = section + elif not isinstance(section, dict): log.warning( "Workspace root '%s' in config is not a dictionary. Skipping repo %s.", workspace_label, @@ -854,8 +856,9 @@ def discover_repos( ) continue - if repo_name not in raw_config[workspace_label]: - raw_config[workspace_label][repo_name] = build_repo_entry( + workspace_section = t.cast("dict[str, object]", raw_config[workspace_label]) + if repo_name not in workspace_section: + workspace_section[repo_name] = build_repo_entry( repo_url, rev=rev, shallow=repo_shallow, diff --git a/src/vcspull/cli/fmt.py b/src/vcspull/cli/fmt.py index 858c32a74..6a012d4e4 100644 --- a/src/vcspull/cli/fmt.py +++ b/src/vcspull/cli/fmt.py @@ -26,6 +26,8 @@ log = logging.getLogger(__name__) +RepoConfigData: t.TypeAlias = str | pathlib.Path | t.Mapping[str, object] + class FmtAction(enum.Enum): """Action resolved for each repo entry during ``vcspull fmt``.""" @@ -64,12 +66,12 @@ def create_fmt_subparser(parser: argparse.ArgumentParser) -> None: parser.set_defaults(merge_roots=True) -def normalize_repo_config(repo_data: t.Any) -> dict[str, t.Any]: +def normalize_repo_config(repo_data: RepoConfigData) -> dict[str, object]: """Normalize repository configuration to verbose format. Parameters ---------- - repo_data : Any + repo_data : str | pathlib.Path | Mapping[str, object] Repository configuration (string URL or dict) Returns @@ -104,16 +106,16 @@ def normalize_repo_config(repo_data: t.Any) -> dict[str, t.Any]: if isinstance(repo_data, str): # Convert compact format to verbose format return {"repo": repo_data} - if isinstance(repo_data, dict): - # If it has 'url' key but not 'repo', convert to use 'repo' - if "url" in repo_data and "repo" not in repo_data: - normalized = repo_data.copy() - normalized["repo"] = normalized.pop("url") - return normalized - # Already in correct format or has other fields - return repo_data - # Return as-is for other types - return t.cast("dict[str, t.Any]", repo_data) + if isinstance(repo_data, pathlib.Path): + return {"repo": str(repo_data)} + repo_map = dict(repo_data) + # If it has 'url' key but not 'repo', convert to use 'repo' + if "url" in repo_map and "repo" not in repo_map: + normalized = repo_map.copy() + normalized["repo"] = normalized.pop("url") + return normalized + # Already in correct format or has other fields + return repo_map def _classify_fmt_action(repo_data: t.Any) -> tuple[FmtAction, t.Any]: @@ -181,7 +183,9 @@ def _classify_fmt_action(repo_data: t.Any) -> tuple[FmtAction, t.Any]: return FmtAction.NO_CHANGE, repo_data -def format_config(config_data: dict[str, t.Any]) -> tuple[dict[str, t.Any], int]: +def format_config( + config_data: t.Mapping[str, object], +) -> tuple[dict[str, object], int]: """Format vcspull configuration for consistency. Parameters @@ -195,7 +199,7 @@ def format_config(config_data: dict[str, t.Any]) -> tuple[dict[str, t.Any], int] Formatted configuration and count of changes made """ changes = 0 - formatted: dict[str, t.Any] = {} + formatted: dict[str, object] = {} for directory in sorted(config_data.keys()): repos = config_data[directory] @@ -204,10 +208,11 @@ def format_config(config_data: dict[str, t.Any]) -> tuple[dict[str, t.Any], int] formatted[directory] = repos continue - formatted_dir: dict[str, t.Any] = {} + repos_map = t.cast("dict[str, object]", repos) + formatted_dir: dict[str, object] = {} - for repo_name in sorted(repos.keys()): - repo_data = repos[repo_name] + for repo_name in sorted(repos_map.keys()): + repo_data = repos_map[repo_name] action, result = _classify_fmt_action(repo_data) if action == FmtAction.SKIP_PINNED: formatted_dir[repo_name] = copy.deepcopy(result) diff --git a/src/vcspull/cli/status.py b/src/vcspull/cli/status.py index 55bf8d324..7817684bf 100644 --- a/src/vcspull/cli/status.py +++ b/src/vcspull/cli/status.py @@ -19,7 +19,7 @@ from vcspull.types import ConfigDict from ._colors import Colors, get_color_mode -from ._output import OutputFormatter, get_output_mode +from ._output import JsonObject, OutputFormatter, get_output_mode from ._workspaces import filter_by_workspace log = logging.getLogger(__name__) @@ -28,6 +28,20 @@ ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") +class StatusResult(t.TypedDict): + """Typed status payload for a single repository.""" + + name: str + path: str + workspace_root: str + exists: bool + is_git: bool + clean: bool | None + branch: str | None + ahead: int | None + behind: int | None + + @dataclass class StatusCheckConfig: """Configuration options for status checking.""" @@ -174,7 +188,7 @@ async def _check_repos_status_async( *, config: StatusCheckConfig, progress: StatusProgressPrinter | None, -) -> list[dict[str, t.Any]]: +) -> list[StatusResult]: """Check repository status concurrently using asyncio. Parameters @@ -188,18 +202,18 @@ async def _check_repos_status_async( Returns ------- - list[dict[str, t.Any]] + list[StatusResult] List of status dictionaries in completion order """ if not repos: return [] semaphore = asyncio.Semaphore(min(config.max_concurrent, len(repos))) - results: list[dict[str, t.Any]] = [] + results: list[StatusResult] = [] exists_count = 0 missing_count = 0 - async def check_with_limit(repo: ConfigDict) -> dict[str, t.Any]: + async def check_with_limit(repo: ConfigDict) -> StatusResult: async with semaphore: return await asyncio.to_thread( check_repo_status, @@ -214,7 +228,7 @@ async def check_with_limit(repo: ConfigDict) -> dict[str, t.Any]: results.append(status) # Update counts for progress - if status.get("exists"): + if status["exists"]: exists_count += 1 else: missing_count += 1 @@ -242,7 +256,7 @@ def _run_git_command( return None -def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.Any]: +def check_repo_status(repo: ConfigDict, detailed: bool = False) -> StatusResult: """Check the status of a single repository. Parameters @@ -261,7 +275,7 @@ def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.A repo_name = repo.get("name", "unknown") workspace_root = repo.get("workspace_root", "") - status: dict[str, t.Any] = { + status: StatusResult = { "name": repo_name, "path": str(PrivatePath(repo_path)), "workspace_root": workspace_root, @@ -451,25 +465,28 @@ def status_repos( summary["missing"] += 1 # Emit status - formatter.emit( + status_payload = t.cast( + "JsonObject", { "reason": "status", **status, }, ) + formatter.emit(status_payload) # Human output _format_status_line(status, formatter, colors, detailed) # Emit summary - summary_data: dict[str, t.Any] = { - "reason": "summary", - **summary, - } - if duration_ms is not None: - summary_data["duration_ms"] = duration_ms - - formatter.emit(summary_data) + summary_payload = t.cast( + "JsonObject", + { + "reason": "summary", + **summary, + **({"duration_ms": duration_ms} if duration_ms is not None else {}), + }, + ) + formatter.emit(summary_payload) # Human summary formatter.emit_text( @@ -482,7 +499,7 @@ def status_repos( def _format_status_line( - status: dict[str, t.Any], + status: StatusResult, formatter: OutputFormatter, colors: Colors, detailed: bool, diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 73ec66ca7..86dfc814a 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -42,6 +42,8 @@ from ._colors import Colors, get_color_mode from ._output import ( + JsonObject, + JsonValue, OutputFormatter, OutputMode, PlanAction, @@ -53,7 +55,7 @@ ) from ._progress import SyncStatusIndicator, build_indicator from ._workspaces import filter_by_workspace -from .status import check_repo_status +from .status import StatusResult, check_repo_status log = logging.getLogger(__name__) @@ -224,7 +226,7 @@ def _maybe_fetch( def _determine_plan_action( - status: dict[str, t.Any], + status: StatusResult, *, config: SyncPlanConfig, ) -> tuple[PlanAction, str | None]: @@ -1767,7 +1769,7 @@ def _run_sync_loop( summary["total"] += 1 indicator.heartbeat() - event: dict[str, t.Any] = { + event: dict[str, JsonValue] = { "reason": "sync", "name": repo_name, "path": display_repo_path, @@ -1966,7 +1968,7 @@ def _emit_summary( summary: dict[str, int], ) -> None: """Emit the structured summary event and optional human-readable text.""" - formatter.emit({"reason": "summary", **summary}) + formatter.emit(t.cast("JsonObject", {"reason": "summary", **summary})) if formatter.mode == OutputMode.HUMAN: previewed = summary.get("previewed", 0) unmatched = summary.get("unmatched", 0) @@ -2029,7 +2031,7 @@ def guess_vcs(url: str) -> VCSLiteral | None: class CouldNotGuessVCSFromURL(exc.VCSPullException): """Raised when no VCS could be guessed from a URL.""" - def __init__(self, repo_url: str, *args: object, **kwargs: object) -> None: + def __init__(self, repo_url: str) -> None: return super().__init__(f"Could not automatically determine VCS for {repo_url}") diff --git a/src/vcspull/log.py b/src/vcspull/log.py index beb9b3c04..0d1217910 100644 --- a/src/vcspull/log.py +++ b/src/vcspull/log.py @@ -237,8 +237,23 @@ def template(self, record: logging.LogRecord) -> str: return "".join(reset + levelname + asctime + name + reset) - def __init__(self, color: bool = True, **kwargs: t.Any) -> None: - logging.Formatter.__init__(self, **kwargs) + def __init__( + self, + color: bool = True, + fmt: str | None = None, + datefmt: str | None = None, + style: t.Literal["%", "{", "$"] = "%", + validate: bool = True, + defaults: t.Mapping[str, object] | None = None, + ) -> None: + logging.Formatter.__init__( + self, + fmt=fmt, + datefmt=datefmt, + style=style, + validate=validate, + defaults=defaults, + ) def format(self, record: logging.LogRecord) -> str: """Format log record.""" diff --git a/src/vcspull/util.py b/src/vcspull/util.py index 96e6aec13..b45e8df29 100644 --- a/src/vcspull/util.py +++ b/src/vcspull/util.py @@ -5,7 +5,7 @@ import os import pathlib import typing as t -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping LEGACY_CONFIG_DIR = pathlib.Path("~/.vcspull/").expanduser() # remove dupes of this @@ -42,12 +42,12 @@ def get_config_dir() -> pathlib.Path: return path -T = t.TypeVar("T", bound=dict[str, t.Any]) +T = t.TypeVar("T", bound=MutableMapping[str, object]) def update_dict( d: T, - u: T, + u: Mapping[str, object], ) -> T: """Return updated dict. @@ -67,7 +67,13 @@ def update_dict( """ for k, v in u.items(): if isinstance(v, Mapping): - r = update_dict(d.get(k, {}), v) + current = d.get(k) + if isinstance(current, MutableMapping): + r = update_dict(current, t.cast("Mapping[str, object]", v)) + elif isinstance(current, Mapping): + r = update_dict(dict(current), t.cast("Mapping[str, object]", v)) + else: + r = update_dict({}, t.cast("Mapping[str, object]", v)) d[k] = r else: d[k] = v diff --git a/src/vcspull/validator.py b/src/vcspull/validator.py index b209566dd..aa47ac1fc 100644 --- a/src/vcspull/validator.py +++ b/src/vcspull/validator.py @@ -8,7 +8,7 @@ from vcspull.types import RawConfigDict -def is_valid_config(config: dict[str, t.Any]) -> t.TypeGuard[RawConfigDict]: +def is_valid_config(config: dict[str, object]) -> t.TypeGuard[RawConfigDict]: """Return true and upcast if vcspull configuration file is valid.""" if not isinstance(config, dict): return False diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 0e844f194..f04509d09 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -20,6 +20,7 @@ AddAction, _classify_add_action, _collapse_ordered_items_to_dict, + _OrderedItem, add_repo, create_add_subparser, handle_add_command, @@ -30,6 +31,9 @@ from syrupy.assertion import SnapshotAssertion +ConfigData: t.TypeAlias = dict[str, dict[str, dict[str, object]]] + + class AddRepoFixture(t.NamedTuple): """Fixture for add repo test cases.""" @@ -40,8 +44,8 @@ class AddRepoFixture(t.NamedTuple): path_relative: str | None dry_run: bool use_default_config: bool - preexisting_config: dict[str, t.Any] | None - expected_in_config: dict[str, t.Any] + preexisting_config: ConfigData | None + expected_in_config: ConfigData expected_log_messages: list[str] rev: str | None = None shallow: bool = False @@ -261,8 +265,8 @@ def test_add_repo( path_relative: str | None, dry_run: bool, use_default_config: bool, - preexisting_config: dict[str, t.Any] | None, - expected_in_config: dict[str, t.Any], + preexisting_config: ConfigData | None, + expected_in_config: ConfigData, expected_log_messages: list[str], rev: str | None, shallow: bool, @@ -1665,7 +1669,7 @@ class CollapseOrderedItemsFixture(t.NamedTuple): """Fixture for _collapse_ordered_items_to_dict unit tests.""" test_id: str - ordered_items: list[dict[str, t.Any]] + ordered_items: list[_OrderedItem] expected_labels: list[str] expected_repo_keys: dict[str, set[str]] @@ -1702,7 +1706,7 @@ class CollapseOrderedItemsFixture(t.NamedTuple): ) def test_collapse_ordered_items_to_dict( test_id: str, - ordered_items: list[dict[str, t.Any]], + ordered_items: list[_OrderedItem], expected_labels: list[str], expected_repo_keys: dict[str, set[str]], ) -> None: diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index 8456be8f4..e2682fb02 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -41,7 +41,7 @@ class DiscoverFixture(t.NamedTuple): yes: bool expected_repo_count: int config_relpath: str | None - preexisting_config: dict[str, t.Any] | None + preexisting_config: dict[str, object] | None user_input: str | None expected_workspace_labels: set[str] | None merge_duplicates: bool @@ -410,7 +410,7 @@ def test_discover_repos( yes: bool, expected_repo_count: int, config_relpath: str | None, - preexisting_config: dict[str, t.Any] | None, + preexisting_config: dict[str, object] | None, user_input: str | None, expected_workspace_labels: set[str] | None, merge_duplicates: bool, @@ -997,9 +997,9 @@ def test_discover_normalization_only_save( config_file = tmp_path / ".vcspull.yaml" config_file.write_text(yaml.dump(preexisting_config), encoding="utf-8") - save_calls: list[tuple[pathlib.Path, dict[str, t.Any]]] = [] + save_calls: list[tuple[pathlib.Path, dict[str, object]]] = [] - def _fake_save(path: pathlib.Path, data: dict[str, t.Any]) -> None: + def _fake_save(path: pathlib.Path, data: dict[str, object]) -> None: save_calls.append((path, data)) monkeypatch.setattr("vcspull.cli.discover.save_config", _fake_save) @@ -1190,7 +1190,7 @@ def test_discover_skips_non_dict_workspace( encoding="utf-8", ) - def _fail_save(path: pathlib.Path, data: dict[str, t.Any]) -> None: + def _fail_save(path: pathlib.Path, data: dict[str, object]) -> None: error_message = "save_config should not be called when skipping repo" raise AssertionError(error_message) diff --git a/tests/cli/test_fmt.py b/tests/cli/test_fmt.py index 946f5ec25..3ec1edc3f 100644 --- a/tests/cli/test_fmt.py +++ b/tests/cli/test_fmt.py @@ -28,7 +28,7 @@ class WorkspaceRootFixture(t.NamedTuple): """Fixture for workspace root normalization cases.""" test_id: str - config_factory: t.Callable[[pathlib.Path], dict[str, t.Any]] + config_factory: t.Callable[[pathlib.Path], dict[str, object]] WORKSPACE_ROOT_FIXTURES: list[WorkspaceRootFixture] = [ @@ -81,7 +81,7 @@ class WorkspaceRootFixture(t.NamedTuple): ) def test_workspace_root_normalization( test_id: str, - config_factory: t.Callable[[pathlib.Path], dict[str, t.Any]], + config_factory: t.Callable[[pathlib.Path], dict[str, object]], snapshot_json: SnapshotAssertion, ) -> None: """Ensure format_config merges duplicate workspace roots.""" diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py index e81b9ec42..93abff738 100644 --- a/tests/cli/test_list.py +++ b/tests/cli/test_list.py @@ -16,7 +16,10 @@ from _pytest.monkeypatch import MonkeyPatch -def create_test_config(config_path: pathlib.Path, repos: dict[str, t.Any]) -> None: +ConfigData: t.TypeAlias = dict[str, dict[str, dict[str, str]]] + + +def create_test_config(config_path: pathlib.Path, repos: ConfigData) -> None: """Create a test config file.""" with config_path.open("w", encoding="utf-8") as f: yaml.dump(repos, f) @@ -26,7 +29,7 @@ class ListReposFixture(t.NamedTuple): """Fixture for list repos test cases.""" test_id: str - config_data: dict[str, t.Any] + config_data: ConfigData patterns: list[str] tree: bool output_json: bool @@ -122,7 +125,7 @@ class ListReposFixture(t.NamedTuple): ) def test_list_repos( test_id: str, - config_data: dict[str, t.Any], + config_data: ConfigData, patterns: list[str], tree: bool, output_json: bool, diff --git a/tests/cli/test_plan_output_helpers.py b/tests/cli/test_plan_output_helpers.py index c4f691a1e..ce5859db3 100644 --- a/tests/cli/test_plan_output_helpers.py +++ b/tests/cli/test_plan_output_helpers.py @@ -8,6 +8,7 @@ from contextlib import redirect_stdout import pytest +import typing_extensions as te from vcspull.cli._colors import ColorMode, Colors from vcspull.cli._output import ( @@ -20,12 +21,32 @@ from vcspull.cli.sync import PlanProgressPrinter +class PlanEntryKwargs(t.TypedDict): + """Typed kwargs for PlanEntry construction in tests.""" + + name: str + path: str + workspace_root: str + action: PlanAction + detail: te.NotRequired[str] + url: te.NotRequired[str] + branch: te.NotRequired[str] + remote_branch: te.NotRequired[str] + current_rev: te.NotRequired[str] + target_rev: te.NotRequired[str] + ahead: te.NotRequired[int] + behind: te.NotRequired[int] + dirty: te.NotRequired[bool] + error: te.NotRequired[str] + diagnostics: te.NotRequired[list[str]] + + class PlanEntryPayloadFixture(t.NamedTuple): """Fixture for PlanEntry payload serialization.""" test_id: str - kwargs: dict[str, t.Any] - expected_keys: dict[str, t.Any] + kwargs: PlanEntryKwargs + expected_keys: dict[str, object] unexpected_keys: set[str] @@ -85,19 +106,20 @@ class PlanEntryPayloadFixture(t.NamedTuple): ) def test_plan_entry_to_payload( test_id: str, - kwargs: dict[str, t.Any], - expected_keys: dict[str, t.Any], + kwargs: PlanEntryKwargs, + expected_keys: dict[str, object], unexpected_keys: set[str], ) -> None: """Ensure PlanEntry serialises optional fields correctly.""" entry = PlanEntry(**kwargs) payload = entry.to_payload() + payload_map = t.cast(dict[str, object], payload) for key, value in expected_keys.items(): - assert payload[key] == value + assert payload_map[key] == value for key in unexpected_keys: - assert key not in payload + assert key not in payload_map assert payload["format_version"] == "1" assert payload["type"] == "operation" diff --git a/tests/cli/test_status.py b/tests/cli/test_status.py index 216f2e7ba..c80556f09 100644 --- a/tests/cli/test_status.py +++ b/tests/cli/test_status.py @@ -11,6 +11,7 @@ from vcspull.cli.status import ( StatusCheckConfig, + StatusResult, _check_repos_status_async, check_repo_status, status_repos, @@ -24,7 +25,10 @@ from vcspull.types import ConfigDict -def create_test_config(config_path: pathlib.Path, repos: dict[str, t.Any]) -> None: +ConfigData: t.TypeAlias = dict[str, dict[str, dict[str, str]]] + + +def create_test_config(config_path: pathlib.Path, repos: ConfigData) -> None: """Create a test config file.""" with config_path.open("w", encoding="utf-8") as f: yaml.dump(repos, f) @@ -223,7 +227,16 @@ def test_check_repo_status( else: repo_path.mkdir(parents=True) - repo_dict: t.Any = {"name": "test-repo", "path": str(repo_path)} + repo_dict = t.cast( + "ConfigDict", + { + "vcs": None, + "name": "test-repo", + "path": repo_path, + "url": str(repo_path), + "workspace_root": str(tmp_path), + }, + ) status = check_repo_status(repo_dict, detailed=False) @@ -630,13 +643,24 @@ async def test_check_repos_status_async_concurrency_limit( ) -> None: """Test that semaphore limits concurrent operations.""" # Create multiple repos - repos_list = [] + repos_list: list[ConfigDict] = [] for i in range(10): repo_path = tmp_path / f"repo{i}" init_git_repo(repo_path) - repos_list.append({"name": f"repo{i}", "path": str(repo_path)}) + repos_list.append( + t.cast( + "ConfigDict", + { + "vcs": None, + "name": f"repo{i}", + "path": repo_path, + "url": str(repo_path), + "workspace_root": str(tmp_path), + }, + ), + ) - repos = t.cast("list[ConfigDict]", repos_list) + repos = repos_list # Track concurrent calls concurrent_calls = [] @@ -644,7 +668,7 @@ async def test_check_repos_status_async_concurrency_limit( original_check = check_repo_status - def tracked_check(repo: t.Any, detailed: bool = False) -> dict[str, t.Any]: + def tracked_check(repo: ConfigDict, detailed: bool = False) -> StatusResult: concurrent_calls.append(1) nonlocal max_concurrent_seen current = len(concurrent_calls) diff --git a/tests/cli/test_sync_plan_helpers.py b/tests/cli/test_sync_plan_helpers.py index 5d097dee3..c643f336c 100644 --- a/tests/cli/test_sync_plan_helpers.py +++ b/tests/cli/test_sync_plan_helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import collections.abc as cabc import os import subprocess import typing as t @@ -14,6 +15,35 @@ if t.TYPE_CHECKING: import pathlib + from vcspull.cli.status import StatusResult + + +StatusOverride: t.TypeAlias = dict[str, bool | int | None] +CompletedProcessArgs: t.TypeAlias = ( + str + | bytes + | os.PathLike[str] + | os.PathLike[bytes] + | cabc.Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes]] +) + + +def _build_status(overrides: StatusOverride) -> StatusResult: + """Build a StatusResult with defaults for required keys.""" + base: dict[str, object] = { + "name": "repo", + "path": "/tmp/repo", + "workspace_root": "/tmp", + "exists": False, + "is_git": False, + "clean": None, + "branch": None, + "ahead": None, + "behind": None, + } + base.update(overrides) + return t.cast("StatusResult", base) + class MaybeFetchFixture(t.NamedTuple): """Fixture for _maybe_fetch behaviours.""" @@ -120,8 +150,8 @@ def test_maybe_fetch_behaviour( if subprocess_behavior: def _patched_run( - *args: t.Any, - **kwargs: t.Any, + args: CompletedProcessArgs, + **kwargs: object, ) -> subprocess.CompletedProcess[str]: if subprocess_behavior == "file-not-found": error_message = "git executable not found" @@ -130,16 +160,16 @@ def _patched_run( error_message = "Permission denied" raise OSError(error_message) if subprocess_behavior == "timeout": - raise subprocess.TimeoutExpired(cmd=args[0], timeout=120) + raise subprocess.TimeoutExpired(cmd=args, timeout=120) if subprocess_behavior == "non-zero": return subprocess.CompletedProcess( - args=args[0], + args=args, returncode=1, stdout="", stderr="remote rejected", ) return subprocess.CompletedProcess( - args=args[0], + args=args, returncode=0, stdout="", stderr="", @@ -165,7 +195,7 @@ class DeterminePlanActionFixture(t.NamedTuple): """Fixture for _determine_plan_action outcomes.""" test_id: str - status: dict[str, t.Any] + status: StatusOverride config: SyncPlanConfig expected_action: PlanAction expected_detail: str @@ -257,13 +287,16 @@ class DeterminePlanActionFixture(t.NamedTuple): ) def test_determine_plan_action( test_id: str, - status: dict[str, t.Any], + status: StatusOverride, config: SyncPlanConfig, expected_action: PlanAction, expected_detail: str, ) -> None: """Verify _determine_plan_action handles edge cases.""" - action, detail = _determine_plan_action(status, config=config) + action, detail = _determine_plan_action( + _build_status(status), + config=config, + ) assert action is expected_action assert detail == expected_detail diff --git a/tests/helpers.py b/tests/helpers.py index b88b727dd..faf6f2ef0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -58,6 +58,6 @@ def write_config(config_path: pathlib.Path, content: str) -> pathlib.Path: return config_path -def load_raw(data: str, fmt: t.Literal["yaml", "json"]) -> dict[str, t.Any]: +def load_raw(data: str, fmt: t.Literal["yaml", "json"]) -> dict[str, object]: """Load configuration data via string value. Accepts yaml or json.""" return ConfigReader._load(fmt=fmt, content=data) diff --git a/tests/test_cli.py b/tests/test_cli.py index 670f08b1a..6f28d21c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,13 +24,24 @@ from vcspull.__about__ import __version__ from vcspull._internal.private_path import PrivatePath from vcspull.cli import cli -from vcspull.cli._output import PlanAction, PlanEntry, PlanResult, PlanSummary +from vcspull.cli._output import ( + JsonValue, + PlanAction, + PlanEntry, + PlanResult, + PlanSummary, +) from vcspull.cli.sync import EXIT_ON_ERROR_MSG, NO_REPOS_FOR_TERM_MSG from .helpers import write_config sync_module = importlib.import_module("vcspull.cli.sync") +RepoConfig: t.TypeAlias = dict[str, str | dict[str, str]] +ConfigData: t.TypeAlias = dict[str, dict[str, RepoConfig]] +OperationSubset: t.TypeAlias = dict[str, JsonValue] +RepoRecord: t.TypeAlias = dict[str, str] + if t.TYPE_CHECKING: from typing import TypeAlias @@ -1740,7 +1751,7 @@ def test_sync_dry_run_plan_human( if set_no_color: monkeypatch.setenv("NO_COLOR", "1") - config: dict[str, dict[str, dict[str, t.Any]]] = {"~/github_projects/": {}} + config: ConfigData = {"~/github_projects/": {}} for name in repository_names: config["~/github_projects/"][name] = { "url": f"git+file://{git_repo.path}", @@ -1782,7 +1793,7 @@ def test_sync_dry_run_plan_human( errors=sum(entry.action is PlanAction.ERROR for entry in plan_entries), ) - async def _fake_plan(*args: t.Any, **kwargs: t.Any) -> PlanResult: + async def _fake_plan(*args: object, **kwargs: object) -> PlanResult: return PlanResult(entries=plan_entries, summary=computed_summary) monkeypatch.setattr(sync_module, "_build_plan_result_async", _fake_plan) @@ -1813,7 +1824,7 @@ class DryRunPlanMachineFixture(t.NamedTuple): pre_sync: bool = True plan_entries: list[PlanEntry] | None = None plan_summary: PlanSummary | None = None - expected_operation_subset: dict[str, t.Any] | None = None + expected_operation_subset: OperationSubset | None = None DRY_RUN_PLAN_MACHINE_FIXTURES: list[DryRunPlanMachineFixture] = [ @@ -1920,7 +1931,7 @@ def test_sync_dry_run_plan_machine( pre_sync: bool, plan_entries: list[PlanEntry] | None, plan_summary: PlanSummary | None, - expected_operation_subset: dict[str, t.Any] | None, + expected_operation_subset: OperationSubset | None, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1931,7 +1942,7 @@ def test_sync_dry_run_plan_machine( """Validate machine-readable plan parity.""" monkeypatch.setenv("NO_COLOR", "1") - config: dict[str, dict[str, dict[str, t.Any]]] = {"~/github_projects/": {}} + config: ConfigData = {"~/github_projects/": {}} for name in repository_names: config["~/github_projects/"][name] = { "url": f"git+file://{git_repo.path}", @@ -1971,7 +1982,7 @@ def test_sync_dry_run_plan_machine( errors=sum(entry.action is PlanAction.ERROR for entry in plan_entries), ) - async def _fake_plan(*args: t.Any, **kwargs: t.Any) -> PlanResult: + async def _fake_plan(*args: object, **kwargs: object) -> PlanResult: return PlanResult(entries=plan_entries, summary=computed_summary) monkeypatch.setattr(sync_module, "_build_plan_result_async", _fake_plan) @@ -2313,12 +2324,12 @@ def test_sync_human_output_redacts_repo_paths( ) def _fake_filter_repos( - _configs: list[dict[str, t.Any]], + _configs: list[RepoRecord], *, path: str | None = None, vcs_url: str | None = None, name: str | None = None, - ) -> list[dict[str, t.Any]]: + ) -> list[RepoRecord]: if name and name != repo_config["name"]: return [] if path and path != repo_config["path"]: diff --git a/tests/test_config.py b/tests/test_config.py index 7f7c0299f..b4bd43820 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -36,7 +36,7 @@ def __call__( content: str, path: str = "randomdir", filename: str = "randomfilename.yaml", - ) -> tuple[pathlib.Path, list[t.Any | pathlib.Path], list[ConfigDict]]: + ) -> tuple[pathlib.Path, list[pathlib.Path], list[ConfigDict]]: """Callable function type signature for load_yaml pytest fixture.""" ... diff --git a/tests/test_config_writer.py b/tests/test_config_writer.py index b596b1d4d..af89e47f7 100644 --- a/tests/test_config_writer.py +++ b/tests/test_config_writer.py @@ -18,7 +18,7 @@ if t.TYPE_CHECKING: import pathlib -FixtureEntry = tuple[str, dict[str, t.Any]] +FixtureEntry = tuple[str, dict[str, dict[str, str]]] @pytest.mark.parametrize(