From e2e355bef37089966579b9846ae30e07a3f7f6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:56:45 +0000 Subject: [PATCH 1/2] fix(install): stop extras from installing other people's packages None of the DevForge tools is on public PyPI, so bare extras like "deadcode", "envault" and "json2sql" resolved to unrelated packages by other authors (deadcode 2.4.1, envault 0.4.6, json2sql 1.0.0rc1), and [all] failed outright. Each extra now points at its real distribution in its GitHub repo. Also fix the README's recommended command, which pip parsed as a git URL ending in "[all]" and failed to clone. Verified: pip install --dry-run "./devforge-cli[all]" resolves all ten tools to Coding-Dev-Tools repos. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0111byzLrKKTCJ23uTT6PyZq --- README.md | 2 +- pyproject.toml | 43 +++++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 738b485..4ef3d01 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Instead of installing ten separate tools and learning ten different CLIs, the `d ```bash # Install everything (recommended) -pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[all]" +pip install "devforge-tools[all] @ git+https://github.com/Coding-Dev-Tools/devforge-cli.git" # Or clone and install locally git clone https://github.com/Coding-Dev-Tools/devforge-cli.git diff --git a/pyproject.toml b/pyproject.toml index 6210959..fd9ec60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,27 +28,30 @@ dependencies = [ # Optional groups โ€” install with: pip install devforge-tools[all] [project.optional-dependencies] -guard = ["api-contract-guardian>=0.1.0"] -sql = ["json2sql>=0.1.0"] -deploy = ["deploydiff>=0.1.0"] -drift = ["configdrift>=0.1.0"] -ghost = ["apighost>=0.1.0"] -auth = ["apiauth>=0.2.0"] -envault = ["envault>=0.1.0"] -schema = ["schemaforge>=1.7.0"] -mcp = ["click-to-mcp>=0.4.0"] -deadcode = ["deadcode>=0.1.1"] +# These tools are not on public PyPI yet, so each extra points at its GitHub repo. +# Bare names (e.g. "deadcode", "envault", "json2sql") resolve to unrelated +# packages owned by other people. Switch to version pins once each tool is published. +guard = ["api-contract-guardian @ git+https://github.com/Coding-Dev-Tools/api-contract-guardian.git"] +sql = ["json2sql-cli @ git+https://github.com/Coding-Dev-Tools/json2sql.git"] +deploy = ["deploydiff @ git+https://github.com/Coding-Dev-Tools/deploydiff.git"] +drift = ["configdrift @ git+https://github.com/Coding-Dev-Tools/configdrift.git"] +ghost = ["apighost @ git+https://github.com/Coding-Dev-Tools/apighost.git"] +auth = ["apiauth @ git+https://github.com/Coding-Dev-Tools/apiauth.git"] +envault = ["rh-envault @ git+https://github.com/Coding-Dev-Tools/envault.git"] +schema = ["schemaforge-cli @ git+https://github.com/Coding-Dev-Tools/schemaforge.git"] +mcp = ["click-to-mcp @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git"] +deadcode = ["deadcode-cli @ git+https://github.com/Coding-Dev-Tools/deadcode.git"] all = [ - "api-contract-guardian>=0.1.0", - "json2sql>=0.1.0", - "deploydiff>=0.1.0", - "configdrift>=0.1.0", - "apighost>=0.1.0", - "apiauth>=0.2.0", - "envault>=0.1.0", - "schemaforge>=1.7.0", - "click-to-mcp>=0.4.0", - "deadcode>=0.1.1", + "api-contract-guardian @ git+https://github.com/Coding-Dev-Tools/api-contract-guardian.git", + "json2sql-cli @ git+https://github.com/Coding-Dev-Tools/json2sql.git", + "deploydiff @ git+https://github.com/Coding-Dev-Tools/deploydiff.git", + "configdrift @ git+https://github.com/Coding-Dev-Tools/configdrift.git", + "apighost @ git+https://github.com/Coding-Dev-Tools/apighost.git", + "apiauth @ git+https://github.com/Coding-Dev-Tools/apiauth.git", + "rh-envault @ git+https://github.com/Coding-Dev-Tools/envault.git", + "schemaforge-cli @ git+https://github.com/Coding-Dev-Tools/schemaforge.git", + "click-to-mcp @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git", + "deadcode-cli @ git+https://github.com/Coding-Dev-Tools/deadcode.git", ] dev = ["pytest>=7.0.0", "pyright>=1.1.300"] From e3545f1abb93af271efa77281bbe515ed0d9edd2 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:50:58 -0500 Subject: [PATCH 2/2] fix(install): map distribution names for version checks and use valid PEP 508 git extras --- pyproject.toml | 2 +- src/devforge/__init__.py | 4 + src/devforge/cli.py | 13 +-- tests/test_cli.py | 16 ++-- tests/test_packaging.py | 179 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 tests/test_packaging.py diff --git a/pyproject.toml b/pyproject.toml index fd9ec60..a809f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ all = [ "click-to-mcp @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git", "deadcode-cli @ git+https://github.com/Coding-Dev-Tools/deadcode.git", ] -dev = ["pytest>=7.0.0", "pyright>=1.1.300"] +dev = ["pytest>=7.0.0", "pyright>=1.1.300", "tomli>=2.0.0; python_version < '3.11'"] [project.urls] Homepage = "https://github.com/Coding-Dev-Tools/devforge-tools" diff --git a/src/devforge/__init__.py b/src/devforge/__init__.py index f883320..88fb508 100644 --- a/src/devforge/__init__.py +++ b/src/devforge/__init__.py @@ -14,6 +14,7 @@ }, "sql": { "package": "json2sql", + "dist": "json2sql-cli", "description": "Convert JSON datasets to SQL INSERT statements", "icon": "โ†ป", "pricing": "Free / Pro", @@ -54,6 +55,7 @@ }, "envault": { "package": "envault", + "dist": "rh-envault", "description": "Env variable syncing, diffing, and secret rotation", "icon": "๐Ÿ”’", "pricing": "Free / Pro", @@ -62,6 +64,7 @@ }, "schema": { "package": "schemaforge", + "dist": "schemaforge-cli", "description": "Bidirectional ORM schema converter (11 formats)", "icon": "๐Ÿ”„", "pricing": "Free / Pro", @@ -78,6 +81,7 @@ }, "deadcode": { "package": "deadcode", + "dist": "deadcode-cli", "description": "Detect unused exports, dead routes, orphaned CSS in TS/React", "icon": "๐Ÿงน", "pricing": "Free / Pro", diff --git a/src/devforge/cli.py b/src/devforge/cli.py index 2304dc2..2797c19 100644 --- a/src/devforge/cli.py +++ b/src/devforge/cli.py @@ -78,7 +78,9 @@ def list_tools( console.print(table) console.print( - '\n[dim]Install:[/dim] [green]pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[all]"[/green]' + '\n[dim]Install:[/dim] [green]pip install "devforge-tools\\[all] @ ' + 'git+https://github.com/Coding-Dev-Tools/devforge-cli.git"[/green]', + soft_wrap=True, ) console.print("[dim](devforge-tools is not on public PyPI โ€” use the git+ form above.)[/dim]") @@ -101,8 +103,8 @@ def install( # devforge-tools is NOT published on public PyPI โ€” install from GitHub source. repo_url = "https://github.com/Coding-Dev-Tools/devforge-cli.git" - pkg = f"git+{repo_url}[{extras}]" - console.print(f"[yellow]Installing {pkg}...[/yellow]") + pkg = f"devforge-tools[{extras}] @ git+{repo_url}" + console.print(f"[yellow]Installing {escape(pkg)}...[/yellow]", soft_wrap=True) try: result = subprocess.run( [sys.executable, "-m", "pip", "install", pkg], @@ -141,7 +143,7 @@ def show_versions( for t in targets: info = TOOLS[t] try: - ver = _pip_version(info["package"]) + ver = _pip_version(info.get("dist", info["package"])) except Exception as e: # Exception text can contain rich markup such as ``[/red]`` or ``[WinError 2]``; # escape it so an unrelated tool failure can never abort the whole listing. @@ -199,7 +201,8 @@ def dispatch(ctx: typer.Context): if not _is_tool_installed(module_name): console.print( f"[red]Tool '{tool_name}' is not installed.[/red]\n" - f'Run: [green]pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git\\[{tool_name}]"[/green]', + f'Run: [green]pip install "devforge-tools\\[{tool_name}] @ ' + f'git+https://github.com/Coding-Dev-Tools/devforge-cli.git"[/green]', soft_wrap=True, ) raise typer.Exit(code=1) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4df83fe..62cdefe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -56,9 +56,9 @@ def test_install_all_uses_all_extra(self, mock_run): assert "Successfully" in result.stdout mock_run.assert_called_once() call_args = mock_run.call_args[0][0] # positional arg: the command list - # Must contain the git+ URL with [all] extra, not a comma-joined list - pkg_arg = next((a for a in call_args if "devforge-cli.git[" in a), None) - expected = "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[all]" + # Must be a PEP 508 direct reference with the extra before the URL + pkg_arg = next((a for a in call_args if a.startswith("devforge-tools[")), None) + expected = "devforge-tools[all] @ git+https://github.com/Coding-Dev-Tools/devforge-cli.git" assert pkg_arg == expected, f"Expected {expected}, got {pkg_arg}" def test_install_unknown_tool(self): @@ -138,7 +138,10 @@ def test_dispatch_not_installed_shows_install_hint(self, _mock): result = runner.invoke(app, ["guard"]) assert result.exit_code == 1 assert "not installed" in result.stdout - assert 'pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]"' in result.stdout + assert ( + 'pip install "devforge-tools[guard] @ git+https://github.com/Coding-Dev-Tools/devforge-cli.git"' + in result.stdout + ) @mock.patch("devforge.cli._is_tool_installed", return_value=True) @mock.patch("devforge.cli.subprocess.Popen") @@ -186,7 +189,10 @@ def test_dispatch_install_hint_escapes_extra_brackets(self, _mock): """ result = runner.invoke(app, ["guard"]) assert result.exit_code == 1 - assert 'pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]"' in result.stdout + assert ( + 'pip install "devforge-tools[guard] @ git+https://github.com/Coding-Dev-Tools/devforge-cli.git"' + in result.stdout + ) class TestHelp: diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..9cb49ea --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,179 @@ +"""Packaging regression tests for extras, version lookups, and install hints. + +Guards three defects raised in review: +- Version checks queried the import name for the four tools whose distribution + names differ, so an installed tool reported "not installed". +- Tool extras must never resolve a bare PyPI name owned by someone else. +- Install hints used ``git+...git[extra]``, which pip rejects; in a PEP 508 + direct reference the extra must come before the ``@``. +""" + +from __future__ import annotations + +import pytest +import re +import sys +from devforge import TOOLS +from devforge.cli import app +from pathlib import Path +from typer.testing import CliRunner +from unittest import mock + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = REPO_ROOT / "pyproject.toml" + +# pyproject.toml declares name = "devforge-tools"; pip rejects a direct +# reference whose name disagrees with the built metadata. +DIST_NAME = "devforge-tools" +CLI_REPO_URL = "https://github.com/Coding-Dev-Tools/devforge-cli.git" +CLI_REPO = f"git+{CLI_REPO_URL}" +OUR_GITHUB_REQUIREMENT = " @ git+https://github.com/Coding-Dev-Tools/" + +# Tools whose import name differs from their installed distribution name. +DIST_NAME_OVERRIDES = { + "deadcode": "deadcode-cli", + "envault": "rh-envault", + "schema": "schemaforge-cli", + "sql": "json2sql-cli", +} + +# Dev-only tooling that legitimately comes from public PyPI. +ALLOWED_DEV_PACKAGES = {"pytest", "pyright", "tomli"} + +NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*") + +runner = CliRunner() + + +def _optional_dependencies() -> dict[str, list[str]]: + with PYPROJECT.open("rb") as handle: + return tomllib.load(handle)["project"]["optional-dependencies"] + + +def _requirement_name(requirement: str) -> str: + match = NAME_PATTERN.match(requirement.strip()) + assert match is not None, f"unparseable requirement: {requirement!r}" + return match.group(0).lower().replace("_", "-") + + +def _pep508_requirement(command: list[str]) -> str: + matches = [arg for arg in command if arg.startswith(f"{DIST_NAME}[")] + assert len(matches) == 1, f"expected one PEP 508 requirement in {command}, got {matches}" + return matches[0] + + +class TestExtrasInstallOurPackages: + """Every tool extra must pin a verified Coding-Dev-Tools git source.""" + + def test_every_tool_extra_is_a_coding_dev_tools_git_reference(self): + extras = _optional_dependencies() + tool_extras = {name: reqs for name, reqs in extras.items() if name != "dev"} + assert tool_extras, "no tool extras found in pyproject.toml" + violations = [ + f"{extra}: {requirement}" + for extra, requirements in tool_extras.items() + for requirement in requirements + if OUR_GITHUB_REQUIREMENT not in requirement + ] + assert not violations, ( + "These extras do not point at a verified Coding-Dev-Tools git source, " + "so pip would resolve a bare PyPI name owned by someone else:\n" + "\n".join(violations) + ) + + def test_tool_extras_match_the_tool_registry(self): + extras = _optional_dependencies() + assert {name for name in extras if name not in {"all", "dev"}} == set(TOOLS) + + def test_all_extra_aggregates_every_tool_extra(self): + extras = _optional_dependencies() + expected = [ + requirement + for extra, requirements in extras.items() + if extra not in {"all", "dev"} + for requirement in requirements + ] + assert sorted(extras["all"]) == sorted(expected) + + def test_dev_extra_only_uses_verified_pypi_packages(self): + extras = _optional_dependencies() + unexpected = [req for req in extras["dev"] if _requirement_name(req) not in ALLOWED_DEV_PACKAGES] + assert not unexpected, f"dev extra pulls unverified PyPI package(s): {unexpected}" + + +class TestDistributionNameMapping: + """Version lookups must query the distribution, not the import module.""" + + @pytest.mark.parametrize(("tool", "dist"), sorted(DIST_NAME_OVERRIDES.items())) + def test_registry_maps_module_name_to_distribution(self, tool, dist): + info = TOOLS[tool] + assert info["package"] != dist + assert info.get("dist", info["package"]) == dist + + @pytest.mark.parametrize(("tool", "dist"), sorted(DIST_NAME_OVERRIDES.items())) + def test_versions_queries_distribution_not_module(self, tool, dist): + with mock.patch("devforge.cli._pip_version", return_value="1.2.3") as version: + result = runner.invoke(app, ["versions", tool]) + assert result.exit_code == 0 + version.assert_called_once_with(dist) + assert "v1.2.3" in result.stdout + + def test_versions_queries_package_name_without_override(self): + with mock.patch("devforge.cli._pip_version", return_value="1.2.3") as version: + result = runner.invoke(app, ["versions", "guard"]) + assert result.exit_code == 0 + version.assert_called_once_with(TOOLS["guard"]["package"]) + + def test_every_tool_extra_installs_the_name_the_version_check_queries(self): + extras = _optional_dependencies() + mismatches = [] + for tool, info in TOOLS.items(): + expected = {info.get("dist", info["package"])} + installed = {_requirement_name(req) for req in extras[tool]} + if installed != expected: + mismatches.append(f"{tool}: installs {sorted(installed)}, version check queries {sorted(expected)}") + assert not mismatches + + +class TestInstallHintsUsePep508: + """Hints and pip invocations must use ``name[extra] @ git+...`` syntax.""" + + def test_tools_listing_hint(self): + result = runner.invoke(app, ["tools"]) + assert result.exit_code == 0 + assert f'pip install "{DIST_NAME}[all] @ {CLI_REPO}"' in result.stdout + + def test_tools_listing_hint_has_no_extras_after_url(self): + result = runner.invoke(app, ["tools"]) + assert f"{CLI_REPO}[" not in result.stdout + + @mock.patch("devforge.cli._is_tool_installed", return_value=False) + def test_dispatch_hint(self, _installed): + result = runner.invoke(app, ["guard"]) + assert result.exit_code == 1 + assert f'pip install "{DIST_NAME}[guard] @ {CLI_REPO}"' in result.stdout + assert f"{CLI_REPO}[" not in result.stdout + + @mock.patch("devforge.cli.subprocess.run") + def test_install_all_hands_pip_a_pep508_requirement(self, pip_run): + pip_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") + result = runner.invoke(app, ["install", "all"]) + assert result.exit_code == 0 + assert _pep508_requirement(pip_run.call_args[0][0]) == f"{DIST_NAME}[all] @ {CLI_REPO}" + + @pytest.mark.parametrize("tool", sorted(TOOLS)) + @mock.patch("devforge.cli.subprocess.run") + def test_install_single_tool_hands_pip_a_pep508_requirement(self, pip_run, tool): + pip_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") + result = runner.invoke(app, ["install", tool]) + assert result.exit_code == 0 + assert _pep508_requirement(pip_run.call_args[0][0]) == f"{DIST_NAME}[{tool}] @ {CLI_REPO}" + + def test_readme_documents_the_same_valid_form(self): + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert f'pip install "{DIST_NAME}[all] @ {CLI_REPO}"' in readme + assert f"{CLI_REPO}[" not in readme