Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 24 additions & 21 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep direct URLs out of PyPI release metadata

When a GitHub Release triggers .github/workflows/publish.yml, these optional dependencies are emitted as Requires-Dist direct URLs and PyPI rejects the upload, so the release workflow cannot publish this project while the Git URLs remain here. Setuptools documents this PyPI restriction.

Useful? React with 👍 / 👎.

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"
Expand Down
4 changes: 4 additions & 0 deletions src/devforge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"sql": {
"package": "json2sql",
"dist": "json2sql-cli",
"description": "Convert JSON datasets to SQL INSERT statements",
"icon": "↻",
"pricing": "Free / Pro",
Expand Down Expand Up @@ -54,6 +55,7 @@
},
"envault": {
"package": "envault",
"dist": "rh-envault",
"description": "Env variable syncing, diffing, and secret rotation",
"icon": "🔒",
"pricing": "Free / Pro",
Expand All @@ -62,6 +64,7 @@
},
"schema": {
"package": "schemaforge",
"dist": "schemaforge-cli",
"description": "Bidirectional ORM schema converter (11 formats)",
"icon": "🔄",
"pricing": "Free / Pro",
Expand All @@ -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",
Expand Down
13 changes: 8 additions & 5 deletions src/devforge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")

Expand All @@ -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],
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
179 changes: 179 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -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
Loading