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..a809f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,29 +28,32 @@ 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"] +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