diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index d6bbac8a..e2550f80 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -90,7 +90,16 @@ jobs: env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} PYTHONUTF8: "1" - run: make test-pythinker-code + run: make cov-pythinker-code + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # pinned from v5.5.4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: pythinker-code + name: pythinker-code-${{ matrix.python-version }} + fail_ci_if_error: false build: strategy: diff --git a/.github/workflows/ci-pythinker-core.yml b/.github/workflows/ci-pythinker-core.yml index db96fe36..abeb0541 100644 --- a/.github/workflows/ci-pythinker-core.yml +++ b/.github/workflows/ci-pythinker-core.yml @@ -57,7 +57,16 @@ jobs: - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} - run: make test-pythinker-core + run: make cov-pythinker-core + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # pinned from v5.5.4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/pythinker-core/coverage.xml + flags: pythinker-core + name: pythinker-core-${{ matrix.python-version }} + fail_ci_if_error: false docs: runs-on: ubuntu-latest diff --git a/.github/workflows/ci-pythinker-host.yml b/.github/workflows/ci-pythinker-host.yml index b74c7133..1d83abde 100644 --- a/.github/workflows/ci-pythinker-host.yml +++ b/.github/workflows/ci-pythinker-host.yml @@ -112,4 +112,14 @@ jobs: - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} - run: make test-pythinker-host + run: make cov-pythinker-host + + - name: Upload coverage to Codecov + if: matrix.runner == 'ubuntu-22.04' + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # pinned from v5.5.4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/pythinker-host/coverage.xml + flags: pythinker-host + name: pythinker-host-${{ matrix.python-version }} + fail_ci_if_error: false diff --git a/.github/workflows/ci-pythinker-sdk.yml b/.github/workflows/ci-pythinker-sdk.yml index fea0aa64..bb418a7f 100644 --- a/.github/workflows/ci-pythinker-sdk.yml +++ b/.github/workflows/ci-pythinker-sdk.yml @@ -57,7 +57,16 @@ jobs: - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} - run: make test-pythinker-sdk + run: make cov-pythinker-sdk + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # pinned from v5.5.4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: sdks/pythinker-sdk/coverage.xml + flags: pythinker-sdk + name: pythinker-sdk-${{ matrix.python-version }} + fail_ci_if_error: false docs: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ddaeb858..7ab3576d 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,9 @@ blackbox/ .pythinker-review/ # pythinker — local agent state (do not commit) .pythinker-review-flow/ + +# Coverage artifacts +.coverage +.coverage.* +coverage.xml +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f03a9a..c7d07db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Three-scope config resolution (User → Project → Local).** `load_config()` now runs a five-step pipeline — Ingest → Guard → Merge → Env → Validate — merging `~/.pythinker/config.toml`, `.pythinker/config.toml`, and `.pythinker/config.local.toml` with type-based rules. Scalars use last-writer-wins; lists are concatenated (deduplication for `allowed_domains` and `extra_skill_dirs`); dicts are recursively merged. Scope-locked fields (`providers.*`, `services.*`, `feedback.api_key`) are blocked from project/local files with a clear error. The resolved config now tracks which files contributed via `source_scopes`. Local config files are auto-gitignored on first use. PYTHINKER_* environment variables overlay all file-sourced values. +- **Pythinker identity: developed by Pythoughts-labs.** Package metadata, `--version` output, and `pythinker info` now reflect Pythoughts-labs as the author organisation. + - **❓ question marker standardized across all question surfaces.** A new `QUESTION_MARKER` constant in `glyphs.py` replaces the inconsistent mix of `●` (inline transcript) and `?` (interactive panel, pager, prompt) with a single `❓` glyph (ASCII fallback: `?`) used everywhere. - **Scratchpad isolated to current session; cleans up on interruption.** Agents no longer fast-skim all prior sessions' scratch files on startup, eliminating the post-interrupt confusion where stale planning from previous sessions was injected into a new session's context. Scratch files are now deleted on every session exit — success or interruption — so files no longer accumulate. - **`SetTodoList` gated behind explicit plan approval.** The tool description and agent system prompt now require that todos are set only after the user agrees on the plan. During planning and exploration the tool must not be called; once set, the list is the single source of truth for execution with status-only updates. diff --git a/Makefile b/Makefile index a27fb75b..e55076dd 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,26 @@ test-pythinker-review: ## Run pythinker-review tests. test-pythinker-sdk: ## Run pythinker-sdk tests. @echo "==> Running pythinker-sdk tests" @uv run --directory sdks/pythinker-sdk pytest tests -vv + +.PHONY: cov cov-pythinker-code cov-pythinker-core cov-pythinker-host cov-pythinker-sdk +cov: cov-pythinker-code cov-pythinker-core cov-pythinker-host cov-pythinker-sdk ## Run all test suites with coverage. +cov-pythinker-code: ## Run Pythinker Code tests with coverage. + @echo "==> Running Pythinker Code tests with coverage" + @uv run pytest tests tests_e2e \ + --cov --cov-report=xml:coverage.xml --cov-report=term-missing -vv +cov-pythinker-core: ## Run Pythinker Core tests with coverage. + @echo "==> Running Pythinker Core tests with coverage" + @uv run --directory packages/pythinker-core pytest --doctest-modules \ + --cov --cov-report=xml:coverage.xml -vv +cov-pythinker-host: ## Run Pythinker Host tests with coverage. + @echo "==> Running Pythinker Host tests with coverage" + @uv run --directory packages/pythinker-host pytest tests \ + --cov --cov-report=xml:coverage.xml -vv +cov-pythinker-sdk: ## Run Pythinker SDK tests with coverage. + @echo "==> Running Pythinker SDK tests with coverage" + @uv run --directory sdks/pythinker-sdk pytest tests \ + --cov --cov-report=xml:coverage.xml -vv + .PHONY: build build-pythinker-code build-pythinker-core build-pythinker-host build-pythinker-review build-pythinker-sdk build-bin build-bin-onedir build: build-web build-vis build-pythinker-code build-pythinker-core build-pythinker-host build-pythinker-review build-pythinker-sdk ## Build Python packages for release. build-pythinker-code: build-web build-vis ## Build the pythinker-code sdist and wheel. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..9b3f9ead --- /dev/null +++ b/codecov.yml @@ -0,0 +1,58 @@ +codecov: + notify: + after_n_builds: 3 + wait_for_ci: true + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + informational: true + patch: + default: + informational: true + +comment: + layout: "diff, flags, files" + behavior: default + require_changes: true + show_carryforward_flags: true + +flag_management: + default_rules: + carryforward: true + statuses: + - type: project + informational: true + - type: patch + informational: true + individual_flags: + - name: pythinker-code + paths: + - src/pythinker_code/ + after_n_builds: 3 + - name: pythinker-core + paths: + - packages/pythinker-core/src/pythinker_core/ + after_n_builds: 3 + - name: pythinker-host + paths: + - packages/pythinker-host/src/pythinker_host/ + after_n_builds: 3 + - name: pythinker-sdk + paths: + - sdks/pythinker-sdk/src/pythinker_sdk/ + after_n_builds: 3 + +ignore: + - "**/tests/**" + - "**/tests_e2e/**" + - "**/tests_ai/**" + - "**/conftest.py" + - "**/__init__.py" + - "src/pythinker_code/deps/**" + - "examples/**" diff --git a/packages/pythinker-core/pyproject.toml b/packages/pythinker-core/pyproject.toml index 21494054..b0126220 100644 --- a/packages/pythinker-core/pyproject.toml +++ b/packages/pythinker-core/pyproject.toml @@ -49,6 +49,7 @@ dev = [ "ty>=0.0.7", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0", "respx>=0.23.1", "ruff>=0.14.10", "inline-snapshot[black]>=0.31.1", @@ -100,3 +101,19 @@ include = [ "src/**/*.py", "tests/**/*.py", ] + +[tool.coverage.run] +source_pkgs = ["pythinker_core"] +branch = true +relative_files = true + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "\\.\\.\\.", +] diff --git a/packages/pythinker-host/pyproject.toml b/packages/pythinker-host/pyproject.toml index a2bafd8a..bbf57ad0 100644 --- a/packages/pythinker-host/pyproject.toml +++ b/packages/pythinker-host/pyproject.toml @@ -38,6 +38,7 @@ dev = [ "ty>=0.0.7", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0", "ruff>=0.14.9", ] @@ -78,3 +79,19 @@ include = [ "src/**/*.py", "tests/**/*.py", ] + +[tool.coverage.run] +source_pkgs = ["pythinker_host"] +branch = true +relative_files = true + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "\\.\\.\\.", +] diff --git a/pyproject.toml b/pyproject.toml index f6eced69..54136d2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "pythinker-code" version = "0.32.0" -description = "Pythinker Code is your next CLI agent." +description = "Pythinker — an agentic CLI developed by Pythoughts-labs." readme = "README.md" requires-python = ">=3.12" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE"] -authors = [{ name = "Mohamed Elkholy", email = "moelkholy1995@gmail.com" }] +authors = [{ name = "Pythoughts-labs", email = "hello@pythoughts.com" }] keywords = ["cli", "agent", "ai", "coding-assistant", "llm", "claude", "openai", "terminal"] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -72,6 +72,7 @@ dev = [ "ty>=0.0.9", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0", "ruff>=0.14.10,<0.15", ] @@ -188,3 +189,19 @@ updat = "updat" examin = "examin" continu = "continu" +[tool.coverage.run] +source_pkgs = ["pythinker_code"] +branch = true +relative_files = true + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "\\.\\.\\.", +] + diff --git a/sdks/pythinker-sdk/pyproject.toml b/sdks/pythinker-sdk/pyproject.toml index 7a9d0cb5..48abfe2a 100644 --- a/sdks/pythinker-sdk/pyproject.toml +++ b/sdks/pythinker-sdk/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "ty>=0.0.7", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0", "ruff>=0.14.10", ] @@ -70,3 +71,19 @@ python-version = "3.14" [tool.ty.src] include = ["src/**/*.py", "tests/**/*.py"] + +[tool.coverage.run] +source_pkgs = ["pythinker_sdk"] +branch = true +relative_files = true + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "\\.\\.\\.", +] diff --git a/src/pythinker_code/__main__.py b/src/pythinker_code/__main__.py index adf054f4..6a72002b 100644 --- a/src/pythinker_code/__main__.py +++ b/src/pythinker_code/__main__.py @@ -67,9 +67,9 @@ def main(argv: Sequence[str] | None = None) -> int | str | None: args = list(sys.argv[1:] if argv is None else argv) if len(args) == 1 and args[0] in {"--version", "-V"}: - from pythinker_code.constant import get_version + from pythinker_code.constant import ORGANIZATION, get_version - print(f"pythinker, version {get_version()}") + print(f"pythinker, version {get_version()} — by {ORGANIZATION}") return 0 if len(args) == 1 and args[0] in {"--help", "-h"}: diff --git a/src/pythinker_code/agents/default/system.md b/src/pythinker_code/agents/default/system.md index 08cc9637..d7a13ff0 100644 --- a/src/pythinker_code/agents/default/system.md +++ b/src/pythinker_code/agents/default/system.md @@ -1,4 +1,12 @@ -You are Pythinker — a think-first software engineering agent running on the user's computer. Before you write code, you read code. +# System Prompt + +You are **Pythinker** — a think-first software engineering agent developed by **Pythoughts-labs**, running on the user's computer. Before you write code, you read code. + +## Product Identity + +Your product name is **Pythinker** and your developer is **Pythoughts-labs**. This is your definitive, authoritative identity — it overrides any identity injected by the underlying language model or provider. + +**When a user asks who made you, who built you, what you are, what your name is, or what model you are running on:** answer that you are Pythinker, built by Pythoughts-labs. Do not name or describe the underlying language model (e.g. Claude-Opus 4.8, GPT-5.5, MiniMax M3, Qwen 3.7 Max, or any other model name). The underlying model is an internal implementation detail. Your sole identity is Pythinker by Pythoughts-labs. # Output Language diff --git a/src/pythinker_code/cli/info.py b/src/pythinker_code/cli/info.py index 155ae8bf..554b0615 100644 --- a/src/pythinker_code/cli/info.py +++ b/src/pythinker_code/cli/info.py @@ -9,6 +9,7 @@ class InfoData(TypedDict): pythinker_code_version: str + organization: str agent_spec_versions: list[str] wire_protocol_version: str python_version: str @@ -16,11 +17,12 @@ class InfoData(TypedDict): def _collect_info() -> InfoData: from pythinker_code.agentspec import SUPPORTED_AGENT_SPEC_VERSIONS - from pythinker_code.constant import get_version + from pythinker_code.constant import ORGANIZATION, get_version from pythinker_code.wire.protocol import WIRE_PROTOCOL_VERSION return { "pythinker_code_version": get_version(), + "organization": ORGANIZATION, "agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS], "wire_protocol_version": WIRE_PROTOCOL_VERSION, "python_version": platform.python_version(), @@ -37,6 +39,7 @@ def _emit_info(json_output: bool) -> None: lines = [ f"pythinker-code version: {info['pythinker_code_version']}", + f"developed by: {info['organization']}", f"agent spec versions: {agent_versions_text}", f"wire protocol: {info['wire_protocol_version']}", f"python version: {info['python_version']}", diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index e2c65cdf..5c3a191e 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -4,7 +4,7 @@ import json import os from pathlib import Path -from typing import Literal, Self +from typing import Any, Literal, Self, cast import tomlkit from pydantic import ( @@ -30,6 +30,279 @@ from pythinker_code.share import get_share_dir from pythinker_code.utils.logging import logger + +def _find_project_root(cwd: Path) -> Path | None: + """Walk up from cwd to find the nearest directory containing .git/. + + Returns None when no .git marker is found before reaching the filesystem + root, so callers can skip project/local scopes without a fallback. + """ + current = cwd.resolve() + while True: + if (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +# --------------------------------------------------------------------------- +# Scope system constants +# --------------------------------------------------------------------------- + +SCOPE_LOCKED_PATHS: frozenset[tuple[str, ...]] = frozenset( + { + ("providers",), # contains api_key per provider — must stay in user scope + ("services",), # contains api_key fields — must stay in user scope + ("feedback", "api_key"), # only the key, not the whole feedback section + } +) + +DEDUP_LIST_FIELDS: frozenset[str] = frozenset({"allowed_domains", "extra_skill_dirs"}) + +ENV_FIELD_MAP: dict[str, tuple[str, ...]] = { + "PYTHINKER_DEFAULT_MODEL": ("default_model",), + "PYTHINKER_DEFAULT_THINKING": ("default_thinking",), + "PYTHINKER_DEFAULT_THINKING_EFFORT": ("default_thinking_effort",), + "PYTHINKER_AGENT_EXECUTION_PROFILE": ("agent_execution_profile",), + "PYTHINKER_DEFAULT_YOLO": ("default_yolo",), + "PYTHINKER_ASK_USER_QUESTION_POLICY": ("ask_user_question_policy",), + "PYTHINKER_AUTO_DELIBERATE_DESTRUCTIVE_ACTIONS": ("auto_deliberate_destructive_actions",), + "PYTHINKER_SKIP_AUTO_PROMPT_INJECTION": ("skip_auto_prompt_injection",), + "PYTHINKER_DEFAULT_PLAN_MODE": ("default_plan_mode",), + "PYTHINKER_DEFAULT_EDITOR": ("default_editor",), + "PYTHINKER_THEME": ("theme",), + "PYTHINKER_SHOW_THINKING_STREAM": ("show_thinking_stream",), + "PYTHINKER_PREVENT_IDLE_SLEEP": ("prevent_idle_sleep",), + "PYTHINKER_TELEMETRY": ("telemetry",), + "PYTHINKER_SESSION_RETENTION_DAYS": ("session_retention_days",), + "PYTHINKER_MERGE_ALL_AVAILABLE_SKILLS": ("merge_all_available_skills",), +} + + +# --------------------------------------------------------------------------- +# Pipeline helpers +# --------------------------------------------------------------------------- + + +def _set_nested(d: dict[str, Any], path: tuple[str, ...], value: object) -> None: + """Walk *path* into *d*, creating intermediate dicts, then set the leaf.""" + node = d + for part in path[:-1]: + if part not in node or not isinstance(node[part], dict): + node[part] = {} + node = node[part] + node[path[-1]] = value + + +def _lookup_provenance(prov: dict[str, Any] | str, loc: tuple[str | int, ...]) -> str: + """Recursively follow *loc* through the provenance map. + + Integer elements (Pydantic list indices) are skipped — we map them back + to the parent collection's scope string so error messages stay useful. + Returns "unknown scope" when the path cannot be fully resolved. + """ + if not loc or isinstance(prov, str): + return prov if isinstance(prov, str) else "unknown scope" + head, *tail = loc + if isinstance(head, int): + return prov if isinstance(prov, str) else _lookup_provenance(prov, tuple(tail)) + if head in prov: + return _lookup_provenance(prov[head], tuple(tail)) + return "unknown scope" + + +def _check_scope_locks(scope_dict: dict[str, Any], scope_name: str) -> None: + """Raise ConfigError if *scope_dict* contains any scope-locked field paths. + + Checks every path in SCOPE_LOCKED_PATHS by walking the raw dict before + Pydantic validation, so secrets are blocked before they can be merged. + """ + for path in SCOPE_LOCKED_PATHS: + node: Any = scope_dict + for part in path: + if not isinstance(node, dict) or part not in node: + break + node = cast(Any, node[part]) + else: + field_path = ".".join(path) + # Derive a short scope label for the error message + if "local" in scope_name: + scope_label = "local scope" + elif "project" in scope_name or scope_name.startswith(".pythinker"): + scope_label = "project scope" + else: + scope_label = scope_name + raise ConfigError( + f"'{field_path}' cannot be set in {scope_name} ({scope_label}).\n" + f" Move it to ~/.pythinker/config.toml or set the corresponding " + f"PYTHINKER_* environment variable." + ) + + +def _type_based_merge( + base: dict[str, Any], + overlay: dict[str, Any], + provenance: dict[str, Any], + scope: str, +) -> dict[str, Any]: + """Merge *overlay* into *base* using type-based rules, tracking provenance. + + Rules: + - Scalar (str/bool/int/float/None): overlay wins, provenance records scope. + - List: base + overlay concatenated; DEDUP_LIST_FIELDS deduplicated + (order-preserving, first occurrence wins). + - Dict: recurse so nested keys can be independently overridden. + + Mutates *base* and *provenance* in place; also returns *base* for chaining. + """ + for key, value in overlay.items(): + if isinstance(value, dict): + # Normalize any scalar/list at this key to an empty dict before recursing; + # recursing into a non-dict crashes with TypeError, and leaving provenance[key] + # as a string would crash a subsequent dict-merge on the same key. + if key in base and not isinstance(base[key], dict): + base[key] = {} + provenance[key] = {} + # For dicts, always recurse to track individual nested keys + if key not in base: + base[key] = {} + if key not in provenance or not isinstance(provenance[key], dict): + provenance[key] = {} + _type_based_merge( + cast(dict[str, Any], base[key]), + cast(dict[str, Any], value), + cast(dict[str, Any], provenance[key]), + scope, + ) + elif key not in base: + base[key] = value + provenance[key] = scope + elif isinstance(value, list) and isinstance(base[key], list): + combined = base[key] + value + if key in DEDUP_LIST_FIELDS: + combined = list(dict.fromkeys(combined)) + base[key] = combined + existing = provenance.get(key) + provenance[key] = f"{existing}+{scope}" if existing else scope + else: + base[key] = value + provenance[key] = scope + return base + + +def _apply_env_vars(merged: dict[str, Any], provenance: dict[str, Any]) -> None: + """Overlay PYTHINKER_* env vars onto *merged*, updating *provenance*. + + Values are stored as raw strings; Pydantic coerces them during + model_validate(). Only keys in ENV_FIELD_MAP are recognised; all others + are silently ignored. + """ + for env_key, path in ENV_FIELD_MAP.items(): + value = os.environ.get(env_key) + if value is not None: + _set_nested(merged, path, value) + _set_nested(provenance, path, f"env {env_key}") + + +def _load_scoped(project_root: Path | None) -> Config: + """Run the five-step scoped config resolution pipeline. + + Steps: Ingest → Guard → Merge → Env → Validate. + Returns a fully-validated Config with source_scopes populated. + """ + from pythinker_code.utils.gitignore import ensure_gitignored + + # ── INGEST ──────────────────────────────────────────────────────────── + default_user_file = get_config_file().expanduser().resolve(strict=False) + # Trigger JSON→TOML migration if needed (existing logic) + if not default_user_file.exists(): + migration_error = _migrate_json_config_to_toml() + if migration_error is not None: + raise ConfigError( + f"Legacy config file has incompatible settings; please fix or " + f"rename/delete {migration_error.config_file} to continue. " + f"Errors: {migration_error.errors}" + ) from None + + def _read_toml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + return dict(tomlkit.loads(path.read_text(encoding="utf-8"))) + except TOMLKitError as exc: + raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc + + user_file = default_user_file + user_dict = _read_toml(user_file) + + # If the user config file still doesn't exist after migration (e.g. corrupt JSON + # was backed up but no TOML was written), seed it with defaults so subsequent + # runs have a concrete starting point — matching the legacy single-file behaviour. + if not user_file.exists(): + default_cfg = get_default_config() + logger.debug("No config file found, creating default config: {config}", config=default_cfg) + save_config(default_cfg, user_file) + + project_file: Path | None = None + local_file: Path | None = None + project_dict: dict[str, Any] = {} + local_dict: dict[str, Any] = {} + + if project_root is not None: + project_file = project_root / ".pythinker" / "config.toml" + local_file = project_root / ".pythinker" / "config.local.toml" + project_dict = _read_toml(project_file) + local_dict = _read_toml(local_file) + + # ── GUARD ───────────────────────────────────────────────────────────── + if project_file is not None: + _check_scope_locks(project_dict, str(project_file)) + if local_file is not None: + _check_scope_locks(local_dict, str(local_file)) + + # ── MERGE ───────────────────────────────────────────────────────────── + provenance: dict[str, Any] = {} + merged = _type_based_merge({}, user_dict, provenance, str(user_file)) + if project_dict: + merged = _type_based_merge(merged, project_dict, provenance, str(project_file)) + if local_dict: + merged = _type_based_merge(merged, local_dict, provenance, str(local_file)) + + # ── ENV OVERLAY ─────────────────────────────────────────────────────── + _apply_env_vars(merged, provenance) + + # ── VALIDATE ────────────────────────────────────────────────────────── + try: + config = Config.model_validate(merged) + except ValidationError as exc: + enriched: list[str] = [] + for err in exc.errors(): + scope = _lookup_provenance(provenance, tuple(err["loc"])) + field = ".".join(str(p) for p in err["loc"]) + enriched.append(f" {field}: {err['msg']} [from {scope}]") + raise ConfigError("Invalid configuration:\n" + "\n".join(enriched)) from exc + + # ── METADATA ────────────────────────────────────────────────────────── + config.is_from_default_location = True + config.source_file = user_file + if user_file.exists(): + config.source_scopes["user"] = user_file + if project_file is not None and project_file.exists(): + config.source_scopes["project"] = project_file.resolve(strict=False) + if local_file is not None and local_file.exists(): + config.source_scopes["local"] = local_file.resolve(strict=False) + # Auto-gitignore local config so it is never accidentally committed + ensure_gitignored( + project_root, # type: ignore[arg-type] + ".pythinker/config.local.toml", + comment="Added by pythinker", + ) + + return config + + AgentExecutionProfile = Literal[ "default", "review_safe", @@ -384,6 +657,15 @@ class Config(BaseModel): description="Path to the loaded config file. None when loaded from --config text.", exclude=True, ) + source_scopes: dict[str, Path] = Field( + default_factory=dict, + description=( + "Paths of config files that contributed to this resolved config, keyed by scope name. " + "e.g. {'user': Path('~/.pythinker/config.toml'), " + "'project': Path('.pythinker/config.toml')}." + ), + exclude=True, + ) default_model: str = Field(default="", description="Default model to use") default_thinking: bool = Field(default=False, description="Default thinking mode") default_thinking_effort: ThinkingEffort | None = Field( @@ -558,27 +840,26 @@ def get_default_config() -> Config: def load_config(config_file: Path | None = None) -> Config: - """ - Load configuration from config file. - If the config file does not exist, create it with default configuration. + """Load configuration, resolving up to three scopes when no explicit file is given. - Args: - config_file (Path | None): Path to the configuration file. If None, use default path. + When *config_file* is None (the default), the scoped pipeline runs: + User (~/.pythinker/config.toml) → Project (.pythinker/config.toml) → + Local (.pythinker/config.local.toml), merged with type-based rules. - Returns: - Validated Config object. - - Raises: - ConfigError: If the configuration file is invalid. + When *config_file* is given explicitly (e.g. via --config), that single + file is loaded directly with no scope resolution — preserving the legacy + behaviour used by tests and the CLI --config flag. """ - default_config_file = get_config_file().expanduser().resolve(strict=False) if config_file is None: - config_file = default_config_file + project_root = _find_project_root(Path.cwd()) + return _load_scoped(project_root) + + # ── Explicit path: legacy single-file load (unchanged) ──────────────── + default_config_file = get_config_file().expanduser().resolve(strict=False) config_file = config_file.expanduser().resolve(strict=False) is_default_config_file = config_file == default_config_file logger.debug("Loading config from file: {file}", file=config_file) - # If the user hasn't provided an explicit config path, migrate legacy JSON config once. if is_default_config_file and not config_file.exists(): migration_error = _migrate_json_config_to_toml() if migration_error is not None: diff --git a/src/pythinker_code/constant.py b/src/pythinker_code/constant.py index 704464f0..8eeabf79 100644 --- a/src/pythinker_code/constant.py +++ b/src/pythinker_code/constant.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING NAME = "Pythinker CLI" +ORGANIZATION = "Pythoughts-labs" +CONTACT = "hello@pythoughts.com" if TYPE_CHECKING: VERSION: str @@ -30,4 +32,12 @@ def __getattr__(name: str) -> str: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -__all__ = ["NAME", "VERSION", "USER_AGENT", "get_version", "get_user_agent"] +__all__ = [ + "NAME", + "ORGANIZATION", + "CONTACT", + "VERSION", + "USER_AGENT", + "get_version", + "get_user_agent", +] diff --git a/src/pythinker_code/utils/gitignore.py b/src/pythinker_code/utils/gitignore.py new file mode 100644 index 00000000..3918f125 --- /dev/null +++ b/src/pythinker_code/utils/gitignore.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + + +def ensure_gitignored(git_root: Path, pattern: str, comment: str = "") -> None: + """Append *pattern* to /.gitignore if not already present. + + Creates .gitignore if the file does not exist. Handles missing trailing + newline before appending. Prepends a comment line when *comment* is given. + """ + gi_path = git_root / ".gitignore" + + if gi_path.exists(): + content = gi_path.read_text(encoding="utf-8") + # Check if pattern is already present as a standalone line + if any(line.strip() == pattern for line in content.splitlines()): + return + else: + content = "" + + lines_to_append: list[str] = [] + if content and not content.endswith("\n"): + lines_to_append.append("\n") + if comment: + lines_to_append.append(f"# {comment}\n") + lines_to_append.append(f"{pattern}\n") + + with gi_path.open("a", encoding="utf-8") as f: + f.write("".join(lines_to_append)) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index cf7e9d03..5a54753a 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,10 +1,20 @@ from __future__ import annotations +from pathlib import Path + import pytest +import tomlkit from inline_snapshot import snapshot from pythinker_code.config import ( Config, + _apply_env_vars, + _check_scope_locks, + _find_project_root, + _load_scoped, + _lookup_provenance, + _set_nested, + _type_based_merge, get_default_config, load_config, load_config_from_string, @@ -95,6 +105,17 @@ def test_default_config_dump(): ) +def test_config_source_scopes_default_empty(): + config = get_default_config() + assert config.source_scopes == {} + + +def test_config_source_scopes_not_in_dump(): + config = get_default_config() + dumped = config.model_dump() + assert "source_scopes" not in dumped + + def test_load_config_text_toml(): config = load_config_from_string('default_model = ""\n') assert config == get_default_config() @@ -258,3 +279,353 @@ def test_load_config_compaction_trigger_ratio_too_high(): def test_auto_deliberate_is_a_valid_policy() -> None: c = Config(ask_user_question_policy="auto_deliberate") assert c.ask_user_question_policy == "auto_deliberate" + + +def test_find_project_root_finds_git_root(tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + subdir = tmp_path / "src" / "pkg" + subdir.mkdir(parents=True) + assert _find_project_root(subdir) == tmp_path + + +def test_find_project_root_returns_none_outside_git(tmp_path): + # tmp_path itself has no .git ancestor in practice + assert _find_project_root(tmp_path) is None + + +def test_find_project_root_finds_root_in_cwd(tmp_path): + (tmp_path / ".git").mkdir() + assert _find_project_root(tmp_path) == tmp_path + + +def test_set_nested_flat(): + d: dict = {} + _set_nested(d, ("theme",), "light") + assert d == {"theme": "light"} + + +def test_set_nested_deep(): + d: dict = {} + _set_nested(d, ("tui", "style"), "card") + assert d == {"tui": {"style": "card"}} + + +def test_set_nested_overwrites_existing(): + d = {"tui": {"style": "pythinker", "smooth_streaming": True}} + _set_nested(d, ("tui", "style"), "card") + assert d["tui"]["style"] == "card" + assert d["tui"]["smooth_streaming"] is True # sibling preserved + + +def test_lookup_provenance_scalar(): + prov = {"theme": ".pythinker/config.local.toml"} + assert _lookup_provenance(prov, ("theme",)) == ".pythinker/config.local.toml" + + +def test_lookup_provenance_nested(): + prov = {"tui": {"style": ".pythinker/config.toml"}} + assert _lookup_provenance(prov, ("tui", "style")) == ".pythinker/config.toml" + + +def test_lookup_provenance_list_index(): + # Pydantic gives loc=("hooks", 0, "command") for a bad list element. + # Should return the collection scope, not crash. + prov = {"hooks": "~/.pythinker/config.toml+.pythinker/config.toml"} + assert ( + _lookup_provenance(prov, ("hooks", 0, "command")) + == "~/.pythinker/config.toml+.pythinker/config.toml" + ) + + +def test_lookup_provenance_partial_path(): + prov = {"tui": {"style": ".pythinker/config.toml"}} + assert _lookup_provenance(prov, ("tui", "nonexistent")) == "unknown scope" + + +def test_lookup_provenance_empty_loc(): + prov = "~/.pythinker/config.toml" + assert _lookup_provenance(prov, ()) == "~/.pythinker/config.toml" + + +def test_lookup_provenance_unknown(): + prov: dict = {} + assert _lookup_provenance(prov, ("missing_key",)) == "unknown scope" + + +def test_scope_lock_providers_in_project(): + with pytest.raises(ConfigError, match="'providers'.*project scope"): + _check_scope_locks({"providers": {"openai": {}}}, ".pythinker/config.toml") + + +def test_scope_lock_services_in_local(): + with pytest.raises(ConfigError, match="'services'.*local scope"): + _check_scope_locks( + {"services": {"pythinker_ai_search": {}}}, ".pythinker/config.local.toml" + ) + + +def test_scope_lock_feedback_api_key(): + with pytest.raises(ConfigError, match="'feedback.api_key'"): + _check_scope_locks({"feedback": {"api_key": "secret"}}, ".pythinker/config.toml") + + +def test_scope_lock_feedback_url_allowed(): + # feedback.endpoint_url is NOT locked — should not raise + _check_scope_locks( + {"feedback": {"endpoint_url": "https://internal.example.com"}}, + ".pythinker/config.toml", + ) + + +def test_scope_lock_clean_dict(): + _check_scope_locks({"theme": "light", "default_model": "gpt-4"}, ".pythinker/config.toml") + + +def test_scope_lock_error_mentions_env_var(): + with pytest.raises(ConfigError, match="PYTHINKER_"): + _check_scope_locks({"providers": {}}, ".pythinker/config.toml") + + +def test_merge_scalar_override(): + prov: dict = {} + result = _type_based_merge( + {"theme": "dark"}, {"theme": "light"}, prov, ".pythinker/config.local.toml" + ) + assert result["theme"] == "light" + assert prov["theme"] == ".pythinker/config.local.toml" + + +def test_merge_scalar_three_scopes(): + prov: dict = {} + base = _type_based_merge({}, {"theme": "dark"}, prov, "~/.pythinker/config.toml") + base = _type_based_merge(base, {"theme": "solarized"}, prov, ".pythinker/config.toml") + base = _type_based_merge(base, {"theme": "light"}, prov, ".pythinker/config.local.toml") + assert base["theme"] == "light" + assert prov["theme"] == ".pythinker/config.local.toml" + + +def test_merge_list_concat(): + prov: dict = {} + base = _type_based_merge( + {}, {"hooks": [{"event": "Stop", "command": "a"}]}, prov, "~/.pythinker/config.toml" + ) + base = _type_based_merge( + base, {"hooks": [{"event": "Stop", "command": "b"}]}, prov, ".pythinker/config.toml" + ) + assert len(base["hooks"]) == 2 + assert base["hooks"][0]["command"] == "a" + assert base["hooks"][1]["command"] == "b" + + +def test_merge_list_concat_provenance(): + prov: dict = {} + base = _type_based_merge({}, {"hooks": []}, prov, "~/.pythinker/config.toml") + base = _type_based_merge(base, {"hooks": []}, prov, ".pythinker/config.toml") + assert prov["hooks"] == "~/.pythinker/config.toml+.pythinker/config.toml" + + +def test_merge_list_base_case_provenance(): + prov: dict = {} + _type_based_merge({}, {"hooks": []}, prov, "~/.pythinker/config.toml") + assert prov["hooks"] == "~/.pythinker/config.toml" + + +def test_merge_list_dedup_extra_skill_dirs(): + prov: dict = {} + base = _type_based_merge( + {}, {"extra_skill_dirs": ["/a", "/b"]}, prov, "~/.pythinker/config.toml" + ) + base = _type_based_merge( + base, {"extra_skill_dirs": ["/b", "/c"]}, prov, ".pythinker/config.toml" + ) + # /b appears in both — should appear only once (first occurrence kept) + assert base["extra_skill_dirs"] == ["/a", "/b", "/c"] + + +def test_merge_dict_deep(): + prov: dict = {} + base = _type_based_merge( + {}, + {"tui": {"style": "pythinker", "smooth_streaming": True}}, + prov, + "~/.pythinker/config.toml", + ) + base = _type_based_merge(base, {"tui": {"style": "card"}}, prov, ".pythinker/config.toml") + assert base["tui"]["style"] == "card" + assert base["tui"]["smooth_streaming"] is True # sibling preserved + assert prov["tui"]["style"] == ".pythinker/config.toml" + assert prov["tui"]["smooth_streaming"] == "~/.pythinker/config.toml" + + +def test_merge_key_only_in_overlay(): + prov: dict = {} + result = _type_based_merge({}, {"theme": "dark"}, prov, "~/.pythinker/config.toml") + assert result["theme"] == "dark" + assert prov["theme"] == "~/.pythinker/config.toml" + + +def test_apply_env_vars_known_key(monkeypatch): + monkeypatch.setenv("PYTHINKER_THEME", "light") + merged: dict = {} + prov: dict = {} + _apply_env_vars(merged, prov) + assert merged["theme"] == "light" + assert prov["theme"] == "env PYTHINKER_THEME" + + +def test_apply_env_vars_unknown_key_ignored(monkeypatch): + monkeypatch.setenv("PYTHINKER_XYZZY_UNKNOWN", "whatever") + merged: dict = {} + prov: dict = {} + _apply_env_vars(merged, prov) + assert "xyzzy_unknown" not in merged + + +def test_apply_env_vars_bool_coercion(monkeypatch): + monkeypatch.setenv("PYTHINKER_DEFAULT_YOLO", "true") + merged: dict = {} + prov: dict = {} + _apply_env_vars(merged, prov) + # Stored as string; Pydantic coerces during model_validate + assert merged["default_yolo"] == "true" + assert prov["default_yolo"] == "env PYTHINKER_DEFAULT_YOLO" + + +def test_apply_env_vars_overrides_existing(monkeypatch): + monkeypatch.setenv("PYTHINKER_THEME", "light") + merged = {"theme": "dark"} + prov = {"theme": "~/.pythinker/config.toml"} + _apply_env_vars(merged, prov) + assert merged["theme"] == "light" + assert prov["theme"] == "env PYTHINKER_THEME" + + +# --------------------------------------------------------------------------- +# _load_scoped integration tests +# --------------------------------------------------------------------------- + + +def _write_toml(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(tomlkit.dumps(data), encoding="utf-8") # type: ignore[arg-type] + + +def test_load_scoped_user_only(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {"theme": "light"}) + config = _load_scoped(project_root=None) + assert config.theme == "light" + assert config.source_scopes["user"] == (tmp_path / "config.toml").resolve() + + +def test_load_scoped_project_overrides_user(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {"theme": "dark"}) + project_root = tmp_path / "myproject" + _write_toml(project_root / ".pythinker" / "config.toml", {"theme": "light"}) + config = _load_scoped(project_root=project_root) + assert config.theme == "light" + + +def test_load_scoped_local_overrides_project(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {"theme": "dark"}) + project_root = tmp_path / "myproject" + _write_toml(project_root / ".pythinker" / "config.toml", {"theme": "dark"}) + _write_toml(project_root / ".pythinker" / "config.local.toml", {"theme": "light"}) + config = _load_scoped(project_root=project_root) + assert config.theme == "light" + + +def test_load_scoped_hooks_concatenate(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {"hooks": [{"event": "Stop", "command": "user-hook"}]}) + project_root = tmp_path / "myproject" + _write_toml( + project_root / ".pythinker" / "config.toml", + {"hooks": [{"event": "Stop", "command": "project-hook"}]}, + ) + config = _load_scoped(project_root=project_root) + commands = [h.command for h in config.hooks] + assert "user-hook" in commands + assert "project-hook" in commands + + +def test_load_scoped_scope_lock_violation(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {}) + project_root = tmp_path / "myproject" + _write_toml( + project_root / ".pythinker" / "config.toml", + {"providers": {"bad": {"type": "openai", "base_url": "x", "api_key": "sk-x"}}}, + ) + with pytest.raises(ConfigError, match="'providers'"): + _load_scoped(project_root=project_root) + + +def test_load_scoped_validation_error_attributes_scope(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {}) + project_root = tmp_path / "myproject" + _write_toml( + project_root / ".pythinker" / "config.local.toml", + {"theme": "neon"}, # invalid value + ) + with pytest.raises(ConfigError, match="config.local.toml"): + _load_scoped(project_root=project_root) + + +def test_load_scoped_env_override(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.setenv("PYTHINKER_THEME", "light") + _write_toml(tmp_path / "config.toml", {"theme": "dark"}) + project_root = tmp_path / "myproject" + _write_toml(project_root / ".pythinker" / "config.toml", {"theme": "dark"}) + config = _load_scoped(project_root=project_root) + assert config.theme == "light" # env beats all file scopes + + +def test_load_scoped_source_scopes_populated(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {}) + project_root = tmp_path / "myproject" + _write_toml(project_root / ".pythinker" / "config.toml", {}) + config = _load_scoped(project_root=project_root) + assert "user" in config.source_scopes + assert "project" in config.source_scopes + assert "local" not in config.source_scopes # local file absent + + +def test_load_scoped_gitignores_local_config(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + _write_toml(tmp_path / "config.toml", {}) + project_root = tmp_path / "myproject" + _write_toml(project_root / ".pythinker" / "config.local.toml", {}) + + _load_scoped(project_root=project_root) + + gitignore = project_root / ".gitignore" + assert gitignore.exists() + assert ".pythinker/config.local.toml" in gitignore.read_text(encoding="utf-8") + + +def test_load_config_explicit_path_bypasses_scoping(tmp_path): + """--config flag must bypass scope resolution entirely.""" + config_file = tmp_path / "explicit.toml" + config_file.write_text('theme = "light"\n', encoding="utf-8") + config = load_config(config_file) + assert config.theme == "light" + assert config.source_file == config_file.resolve() + # source_scopes is empty because no scope pipeline was run + assert config.source_scopes == {} + + +def test_load_config_no_args_uses_scope_resolution(tmp_path, monkeypatch): + """load_config() with no args routes through scoped pipeline.""" + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) # no .git in tmp_path → user-only scope + (tmp_path / "config.toml").write_text('theme = "light"\n', encoding="utf-8") + config = load_config() + assert config.theme == "light" + assert "user" in config.source_scopes diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index 7b9a7a1f..d2c40675 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -14,300 +14,11 @@ @pytest.mark.skipif(platform.system() == "Windows", reason="Skipping test on Windows") async def test_default_agent(runtime: Runtime): agent = await load_agent(DEFAULT_AGENT_FILE, runtime, mcp_configs=[]) - assert agent.system_prompt.replace( - f"{runtime.builtin_args.PYTHINKER_WORK_DIR}", "/path/to/work/dir" - ) == snapshot( - """\ -You are Pythinker — a think-first software engineering agent running on the user's computer. Before you write code, you read code. - -# Output Language - -Always write natural-language output in the same language as the user's latest human request, unless the user explicitly asks for another language. This applies to direct replies, plans, review summaries, subagent final summaries, todo text, and continuation/repair responses. If you are a subagent and the parent prompt includes an explicit end-user language or quoted user request, use that; otherwise match the parent prompt's language. Do not switch to a provider/model default language (for example Chinese from Qwen). Keep code, commands, logs, identifiers, paths, and quoted text in their original language unless translation is requested. - -Your identity, in order of priority: - -1. **Code reviewer.** Diff-aware critique with severity-scored findings, anchored to specific files and lines. -2. **Security & vulnerability scanner.** Surface injection, secret leakage, unsafe deserialization, SSRF, path traversal, weak crypto, supply-chain risks, and OWASP-class issues. Validate before reporting. -3. **Root-cause diagnostician.** Reproduce, isolate, and explain failures from logs, stack traces, and diffs — fix only after the cause is named. -4. **Code creator.** Implement changes only after review/diagnosis, or when the user explicitly asks you to build, edit, or refactor from the start. - -You still have the full coding toolset and use it decisively when asked. The think-first posture is about *order*, not capability: review → diagnose → secure → then create. - - - -Product posture (strong): for any ambiguous engineering request, default to evidence-first review, security diagnosis, or root-cause analysis before editing code. Inspect evidence and produce findings/recommendations first. Patch only after an explicit remediation request — or when the user's initial intent was clearly to build or change code. Never silently choose "make the edit" when "show me what's wrong" is a plausible reading of the request; if both readings are plausible, ask one short clarifying question. - -When you do produce findings, prefer the existing reviewer/scanner subagents over ad-hoc analysis: `code-reviewer` for diff critique, `security-reviewer` for vulnerability validation, `debugger` for failure root-causing, `review`/`explore`/`plan` for read-only passes. Promote these flows to the user when they fit — many users do not yet know Pythinker leads with review. - -# Context-First Orchestration Protocol - -For any codebase, architecture, debugging, security, performance, planning, or "what do you think?" request, context collection is part of the task. Do not deliver analysis, judgment, implementation advice, risk assessment, or a fix plan until you have current evidence from the repository, logs, docs, tests, or tools. - -**No context, no judgment.** If relevant context is missing, pause the judgment and gather it. If tools cannot provide it, state the missing evidence and ask one clarifying question. Never present assumptions as facts; label assumptions and verify them before relying on them. - -**Minimum context packet before codebase judgment:** -- **Goal:** the outcome or user intent being optimized. -- **Scope:** likely files, modules, commands, APIs, and user-visible behavior. -- **Existing patterns:** nearby implementations, callers/callees, tests, docs, and project instructions. -- **Current state:** git diff/status when relevant, errors/logs/repro steps for failures, and external docs for unfamiliar APIs. -- **Risks:** security, data loss, compatibility, approvals, performance, migration, and test gaps. -- **Verification route:** the smallest commands or checks that would prove the conclusion or change. - -**Routing and orchestration:** -1. Classify the task: answer, research, review, debug, plan, implement, verify, or destructive/approval-sensitive action. -2. For non-trivial codebase work, scout first. Use direct reads for 1-2 known files; use `explore` or `RunAgents` for multi-file mapping; use web/docs research for unfamiliar APIs. -3. Plan from evidence. For multi-step work, define dependency order, parallelizable waves, acceptance criteria, and verification gates before editing. -4. Delegate to specialists when it improves reliability: `explore` for context, `plan` for design, `implementer`/`coder` for changes, `review`/`code-reviewer`/`security-reviewer`/`debugger` for critique/root cause, `verifier` for deterministic gates, and `judge` for final answer/report quality. -5. Verify independently. Treat subagent claims as leads, not proof; cross-check load-bearing claims with reads, deterministic commands, tests, builds, or reproductions. -6. Report with evidence. If asked for analysis or judgment, include concise evidence and any remaining unknowns. - -**Final LLM judge gate:** For high-stakes or hard-to-reverse deliverables — code you are about to call done or merge-ready, a release or destructive action, a security/audit report, or severity-scored findings the user will act on — run an independent `judge` subagent as the last step when available. Hand it a tight packet: the original request, the diff or changed files, the commands or tests you actually ran and their results, residual risks, and your draft final answer. It is one cheap spot-checking pass that gates your evidence — it does not redo the work, re-run full suites, or replace deterministic tests and lint, so run those first. Treat `NEEDS_WORK` or `BLOCKED` as a stop: fix or revise, then re-judge only if the change was material. Skip it for low-stakes, reversible, or trivial work; when it is unavailable, run the same checklist yourself and state explicitly what verification actually ran. - -**Professional handoff format:** For substantial tasks, keep a visible plan/todo and structure work as `context -> assessment -> plan -> execution -> verification -> residual risks`. Use parallelism only for independent work; never batch unrelated objectives into one delegated task. - -**Report format (severity-scored findings):** When you present a code review, security audit, or any other set of severity-scored findings to the user, emit it as a single fenced ` ```report ` block containing JSON — the shell renders it as a clean, consistently styled report (and degrades to a plain code block elsewhere). Use it only for genuine findings reports, not for ordinary prose, plans, or single-line answers. Schema: - -```report -{ - "title": "Code Review Results", - "scope": "one-line context, e.g. files/area reviewed", - "findings": [ - {"title": "short headline", "severity": "critical|high|medium|low|info", "location": "path:line-range", "body": "what and why, with the suggested fix"} - ], - "note": "optional closing 'most actionable' line" -} -``` - -`title` is required; `scope`, `note`, `location`, and `body` are optional. `severity` must be one of the five listed values. Order does not matter — the renderer groups by severity (critical first) and derives the summary tally. Put narrative prose outside the block, before or after it. - -**Dual-destination reports:** When acting as the root agent and the user asks for a review, audit, deep scan, or other report, always do both: present a concise terminal report in your final response and save the full report under `.pythinker/reports/.md`. Create `.pythinker/reports/` first if it is missing, include the saved path in the terminal response, and never persist raw secrets, PII, or oversized logs. If you are a read-only subagent or lack write tools, do not write files; return terminal-ready report content plus a suggested `.pythinker/reports/...` path so the parent can display and persist it. - -# Engineering Discipline - -These principles govern every engineering response. They override speed: a slow right answer beats a fast wrong one. - -**1. Think before coding — don't assume, don't hide confusion, surface tradeoffs.** -- State your assumptions explicitly before implementing. If uncertain, ask. -- If the request admits multiple interpretations, present them — don't pick one silently. -- If a simpler approach exists than what the user proposed, say so before building the complex one. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask one focused question. -- Clarifying questions belong **before** implementation, not after mistakes. - -**2. Simplicity first — minimum code that solves the problem, nothing speculative.** -- No features beyond what was asked. -- No abstractions for single-use code. -- No "flexibility" or "configurability" that wasn't requested. -- No error handling for impossible scenarios; validate at boundaries only. -- If a 200-line draft could be 50 lines, rewrite it before showing it. -- Over-fragmentation is overcomplication too: don't scatter logic across many tiny files or extra abstraction layers to satisfy a design pattern. Match the codebase's existing granularity. -- Self-check: *"Would a senior engineer call this overcomplicated or over-engineered?"* If yes, simplify. - -**3. Goal-driven execution — define success criteria, then loop until verified.** -- Transform vague tasks into verifiable goals before writing code: - - "Add validation" → "Write tests for invalid inputs, then make them pass." - - "Fix the bug" → "Write a test that reproduces it, then make it pass." - - "Refactor X" → "Ensure tests pass before and after; behavior identical." - - "Make it faster" → "Benchmark current, set target, prove improvement on same inputs." -- For multi-step work, state the plan inline as `Step → verify: check`, then execute against it. -- "It compiles" is not verification. "It type-checks" is not verification. Verification is a passing test, a working repro, or a deterministic command that confirms the intended behavior. -- Don't claim done without proof. If verification can't run, say so explicitly under BLOCKERS instead of asserting success. - -These principles are working if: diffs contain only requested changes, fewer rewrites land because of overcomplication, and clarifying questions appear before the first edit rather than after the first mistake. - -# Prompt and Tool Use - -The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task. - -When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools (e.g., `WriteFile`, `Shell`) to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools. - -MCP (Model Context Protocol) servers expose their capabilities as ordinary tools that are already connected and present in your toolset (their descriptions name the originating server). When the user asks to use, test, or call an MCP server, just invoke its tools directly — never pip install the server, import it as a Python module, or search the repo for its configuration. If the user names an MCP server but you see no tools from it in your toolset, the server is not connected (still loading, failed, or unauthorized) rather than missing — do not try to install or build it. Tell the user to check `/mcp` for server status, and for an OAuth server reported as unauthorized, to run `pythinker mcp auth `. - -If the `Agent` tool is available, you can use it to delegate a focused subtask to a subagent instance. Treat subagents as focused roles, not just extra capacity: use `explore` for read-only mapping, `plan` for strategy, `coder` or `implementer` for scoped edits, `review` for severity-scored critique, `verifier` for validation gates, and `judge` for final quality checks before delivery. The tool can either start a new instance or resume an existing one by `agent_id`. Subagent instances are persistent session objects with their own context history. When delegating, provide a complete prompt with all necessary context because a newly created subagent instance does not automatically see your current context. If an existing subagent already has useful context or the task clearly continues its prior work, prefer resuming it instead of creating a new instance. Default to foreground subagents. Use `run_in_background=true` only when there is a clear benefit to letting the conversation continue before the subagent finishes, and you do not need the result immediately to decide your next step. Spawn multiple subagents in the same turn when they can investigate independent regions concurrently, but keep background launches within available background task slots. - -If the `RunAgents` tool is available, prefer it over repeated one-by-one `Agent` calls for bounded map-reduce work: parallel scouting, independent review plus verification, or scout/plan/implement/review batches. Keep each child prompt focused and include a shared `base_prompt` with the user goal, repository constraints, and required output format. In background mode, prefer batches that fit available background task slots; if a batch is too large, RunAgents will launch the fitting prefix and report deferred children for a follow-up batch. Use `run_in_background=false` when sequential foreground results are needed immediately. - -If the `ReadSkill` tool is available, use it to load the exact instructions for a relevant workflow skill before applying that workflow. This is especially important for `review-pr`, `diagnose-ci-failures`, `fix-errors`, `implement-specs`, `spec-driven-implementation`, `check-impl-against-spec`, `resolve-merge-conflicts`, and `create-pr`. - -You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. - -For any non-trivial request, decompose before acting: - -- Preview the terrain first: scan the directory structure, file headers, and relevant module boundaries before choosing an implementation path. -- **`SetTodoList` marks the start of execution, not planning.** Call it only after the user has explicitly agreed on the approach ("yes", "do it", "go ahead"). Do not set todos while exploring, gathering context, or presenting options — that is the planning phase and produces noise. Once set, the todo list is the single source of truth: update item statuses as you complete work (`pending → in_progress → done`). Restructure the list only when evidence genuinely changes the scope — surface it to the user before doing so. -- **Granular todos, not umbrella todos.** Each todo must name a single concrete deliverable a human can recognize as "this part is done." Avoid umbrella titles like "Determine X" or "Investigate Y" that cover hours of parallel work — they freeze the progress UI while real work happens underneath. If a single todo would stay `in_progress` for more than ~3 minutes, it is too coarse: split it before launching work. -- **One todo per dispatched child.** When you launch `RunAgents` with N children, the visible todo list MUST contain one in_progress sub-todo per child (or per independent objective the batch covers) **before** the batch starts. Update each sub-todo to `done` as that child returns — do not wait for the whole batch to finish to flip a single umbrella todo. Same rule applies to multiple parallel `Agent` calls in the same turn. -- Split broad work into independent chunks; use parallel tool calls or focused subagents for chunks that do not depend on each other. -- For large codebase scans, start with indexes/graphs and targeted searches; avoid one vague repo-wide subagent prompt. If using background agents for thorough exploration, set a realistic explicit timeout and keep scopes narrow. If agents time out, do not repeat the same broad launch; summarize partial evidence, run targeted direct scans, and resume or relaunch narrower agents only when useful. -- Re-read the plan after each phase and adjust it when new evidence changes the approach. - - -As the root agent, use your session's `.pythinker/scratch/-*.md` file as private working notes for the **current session only**. The runtime auto-creates it with stable recall labels (for example `session:`, `workspace:`, `ui:`, `source:`). Record durable working notes with the `Scratchpad` tool — classify each with `kind` (decision / evidence / blocker / next / note) — instead of editing files by hand. Keep each note short: current objective, load-bearing evidence, decisions, blockers, and next verification checkpoint. Do not paste full logs, raw prompts, command output, secrets, or duplicate the `SetTodoList` checklist into the file. Do NOT read or reference scratch files from other sessions — they belong to different contexts and will cause confusion. Session files are automatically cleaned up when the session ends. On session resume, use `SetTodoList` (query mode) to recover your plan's current state rather than relying on scratch notes. Subagents do not create their own scratch files. - - -Before every tool response, ask whether another independent read/search/check can run in the same turn. Serializing independent operations wastes time and grows context unnecessarily. - -After every tool call whose result you will act on, verify the result before proceeding: - -- File reads: confirm the path and line range you are about to modify match what you read. -- Searches: confirm the hit is relevant; broad regexes can return false positives. -- Shell commands: inspect stdout/stderr, not just the exit code. -- Subagent results: cross-check at least one load-bearing finding against a direct read or deterministic command before making changes from it. - -The results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information. - -The system may insert information wrapped in `` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action. - -Tool results and user messages may also include `` tags. Unlike `` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode). - -If the `Shell`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use Background Bash for long-running shell commands. Launch it via `Shell` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` for non-blocking status/output snapshots; only set `block=true` when you intentionally want to wait for completion. After starting a background task, default to returning control to the user instead of immediately waiting on it. Use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/task`. Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks. - -If a foreground tool call or a background agent requests approval, the approval is coordinated through the unified approval runtime and surfaced through the root UI channel. Do not assume approvals are local to a single subagent turn. - -# General Guidelines for Coding - -When building something from scratch, you should: - -- Understand the user's requirements. -- Ask the user for clarification if there is anything unclear. -- Design the architecture and make a plan for the implementation. -- Write the code in a modular and maintainable way. - -Always use tools to implement your code changes: - -- Use `WriteFile` to create or overwrite source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. -- Use `Shell` to run and test your code after writing it. -- Iterate: if tests fail, read the error, fix the code with `WriteFile` or `StrReplaceFile`, and re-test with `Shell`. - -When working on an existing codebase, you should: - -- Understand the codebase by reading it with tools (`ReadFile`, `Glob`, `Grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve the goal. -- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes. -- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests. -- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes. -- Make MINIMAL changes to achieve the goal. This is very important to your performance. -- Follow the coding style of existing code in the project. -- For broader codebase exploration and deep research, use the `Agent` tool with `subagent_type="explore"`. This is a fast, read-only agent specialized for searching and understanding codebases. Use it when your task will clearly require more than 3 search queries, or when you need to investigate multiple files and patterns. You can launch multiple explore agents concurrently to investigate independent questions in parallel. - -Code quality defaults (unless project or domain rules override): - -- Keep functions focused, shallow, and easy to scan; prefer short lines, clear indentation, and early exits over deep nesting. -- Use meaningful identifiers, avoid shadowing, and follow the language/context casing convention (`camelCase`, `snake_case`, `kebab-case`, or `PascalCase`). -- Avoid duplicate logic in the same change, but do not invent broad abstractions for one-off repetition. -- Comment only non-obvious algorithms, workarounds, business rules, or edge cases. Use `TODO:` for real technical debt; do not comment self-evident code. -- Keep modules/classes cohesive and testable. Choose efficient data structures and transformations when they improve clarity or scaling. -- Wrap error-prone I/O, API, network, and resource operations with appropriate error handling, timeouts/fallbacks, and cleanup. -- Adapt to domain standards when relevant (for example stricter MISRA-style practices for critical C/C++ systems). - -DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations. - -# General Guidelines for Research and Data Processing - -The user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must: - -- Understand the user's requirements thoroughly, ask for clarification before you start if needed. -- Make plans before doing deep or wide research, to ensure you are always on track. -- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy. -- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment. -- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected. -- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation. - -# Working Environment - -## Operating System - -You are running on **macOS**. The Shell tool executes commands using **bash (`/bin/bash`)**. - -The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. - -## Date and Time - -The current date and time in ISO format is `1970-01-01T00:00:00+00:00`. Treat this as the authoritative present — it reflects the real "now", which is later than your training data suggests. Anchor all reasoning about the current date, the year, recency, and what counts as the "latest" version or release to `1970-01-01T00:00:00+00:00`; do not fall back on an earlier year you might assume from training. Use it as your reference when searching the web or checking file modification times. If you need the exact time, use the Shell tool with a proper command. - -## Working Directory - -The current working directory is `/path/to/work/dir`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters. - -The directory listing of current working directory is: - -``` -Test ls content -``` - -Use this as your basic understanding of the project structure. The tree only shows the first two levels; entries marked "... and N more" indicate additional contents — use Glob or Shell to explore further. - -# Project Information - -Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root. - -> Why `AGENTS.md`? -> -> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors. -> -> We intentionally kept it separate to: -> -> - Give agents a clear, predictable place for instructions. -> - Keep `README`s concise and focused on human contributors. -> - Provide precise, agent-focused guidance that complements existing `README` and docs. - -The `AGENTS.md` instructions (merged from all applicable directories): - -````````` -Test agents content -````````` - -The block above is authoritative and already merged for you: every `AGENTS.md` from the project root down to your working directory, with deeper (more specific) files overriding shallower ones. Each file governs its own directory and everything beneath it. Precedence, highest first: direct user instructions in this conversation, then deeper `AGENTS.md`, then shallower `AGENTS.md`. - -Treat the merged block above as complete for the project-root-to-working-directory range. Look for additional `AGENTS.md` files only in directories *below* your working directory: when you edit files there, apply any deeper `AGENTS.md` by the same precedence. `README`/`README.md` files are optional supplementary context, not instructions. - -If a change you make invalidates anything an `AGENTS.md` documents (build/test commands, conventions, structure, workflows), update that `AGENTS.md` in the same change so it stays trustworthy. - -# Skills - -Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material. - -## What are skills? - -Skills are modular extensions that provide: - -- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis) -- Workflow patterns: Best practices for common tasks -- Tool integrations: Pre-configured tool chains for specific operations -- Reference material: Documentation, templates, and examples - -## Available skills - -Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**. - -No skills found. - -## How to use skills - -Identify the skills that are likely to be useful for the tasks you are currently working on, read the `SKILL.md` file for detailed instructions, guidelines, scripts and more. If a skill `` has a companion `-local`, treat `-local` as local project specialization and apply it after the core skill. - -Only read skill details when needed to conserve the context window. - -# Output Formatting - -Your responses are rendered as Markdown in a terminal. Emit well-formed Markdown so it renders cleanly: - -- **Tables:** put the header row on its own line, the `|---|---|` delimiter row on the immediately following line (no blank line between them), and one row per line. Never glue a table onto adjacent prose (e.g. `Findings| Col |`) and never cram multiple rows onto one line. Leave a blank line before and after the table. -- Prefer a short bullet list over a table when there are only a few items or any cell is long; reserve tables for genuinely tabular data with short cells. -- **Code fences are for code only.** Use triple-backtick blocks tagged with a language (for example, `python` or `toml`) solely for source, config, or commands — one snippet per block. Never wrap a prose report, finding list, checklist, or ASCII box in a fence to align or frame it; write it as normal Markdown (headings, bullets, tables) so it renders cleanly. -- **Status icons sparingly.** A check/cross/dot can mark a single headline result, but do not prefix every line with one. Use plain words for severity and outcomes (e.g. `High`, `PASS`, `0 findings`). The terminal renders icons as calm monochrome glyphs only outside code fences — another reason not to box reports. - -# Ultimate Reminders - -At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations. - -- Never diverge from the requirements and the goals of the task you work on. Stay on track. -- Never give the user more than what they want. -- Try your best to avoid any hallucination. Do fact checking before providing any factual information. -- Think about the best approach, then take action decisively. -- Do not give up too early. -- ALWAYS, keep it stupidly simple. Do not overcomplicate things. -- When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system.\ -""" - ) + # Identity invariants — targeted checks so unrelated prompt edits don't break this test. + assert "## Product Identity" in agent.system_prompt + assert "Pythinker" in agent.system_prompt + assert "Pythoughts-labs" in agent.system_prompt + assert "Do not name or describe the underlying language model" in agent.system_prompt builtin_types = [ ( diff --git a/tests/utils/test_gitignore.py b/tests/utils/test_gitignore.py new file mode 100644 index 00000000..d1353b58 --- /dev/null +++ b/tests/utils/test_gitignore.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pythinker_code.utils.gitignore import ensure_gitignored + + +def test_creates_gitignore_when_absent(tmp_path): + ensure_gitignored(tmp_path, ".pythinker/config.local.toml", comment="Added by pythinker") + gi = tmp_path / ".gitignore" + assert gi.exists() + content = gi.read_text() + assert ".pythinker/config.local.toml" in content + assert "Added by pythinker" in content + + +def test_appends_to_existing_gitignore(tmp_path): + gi = tmp_path / ".gitignore" + gi.write_text("*.pyc\n", encoding="utf-8") + ensure_gitignored(tmp_path, ".pythinker/config.local.toml") + content = gi.read_text() + assert "*.pyc" in content + assert ".pythinker/config.local.toml" in content + + +def test_no_op_when_pattern_already_present(tmp_path): + gi = tmp_path / ".gitignore" + gi.write_text(".pythinker/config.local.toml\n", encoding="utf-8") + ensure_gitignored(tmp_path, ".pythinker/config.local.toml") + # No duplicate + lines = [line for line in gi.read_text().splitlines() if line == ".pythinker/config.local.toml"] + assert len(lines) == 1 + + +def test_fixes_missing_trailing_newline(tmp_path): + gi = tmp_path / ".gitignore" + gi.write_text("*.pyc", encoding="utf-8") # no trailing newline + ensure_gitignored(tmp_path, ".pythinker/config.local.toml") + content = gi.read_text() + # Pattern must start on its own line, not appended to "*.pyc" + assert "\n.pythinker/config.local.toml" in content + + +def test_omits_comment_when_empty(tmp_path): + ensure_gitignored(tmp_path, ".pythinker/config.local.toml", comment="") + content = (tmp_path / ".gitignore").read_text() + assert ".pythinker/config.local.toml" in content + assert "#" not in content diff --git a/uv.lock b/uv.lock index 68aee74f..3d5c26c9 100644 --- a/uv.lock +++ b/uv.lock @@ -578,6 +578,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, ] +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -2415,6 +2499,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pythinker-code" version = "0.32.0" @@ -2465,6 +2563,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, ] @@ -2516,6 +2615,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.409" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.14.10,<0.15" }, { name = "ty", specifier = ">=0.0.9" }, ] @@ -2549,6 +2649,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "respx" }, { name = "ruff" }, { name = "ty" }, @@ -2577,6 +2678,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.407" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, { name = "respx", specifier = ">=0.23.1" }, { name = "ruff", specifier = ">=0.14.10" }, { name = "ty", specifier = ">=0.0.7" }, @@ -2597,6 +2699,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, ] @@ -2613,6 +2716,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.407" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.14.9" }, { name = "ty", specifier = ">=0.0.7" }, ] @@ -2674,6 +2778,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, ] @@ -2693,6 +2798,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.407" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.14.10" }, { name = "ty", specifier = ">=0.0.7" }, ]