diff --git a/README.md b/README.md index c3d6406..48eecb2 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ npx --yes @eshen_fox_mie/agent-ready ci --write Current detectors cover common JavaScript/TypeScript, Python, Ruby, PHP, C#, Java, Kotlin, Rust, Go, and monorepo repositories: -- package manager: npm, pnpm, yarn, bun, pip, bundler, composer, dotnet, maven, gradle, cargo, go +- package manager: npm, pnpm, yarn, bun, Python/pip, Poetry, PDM, uv, bundler, composer, dotnet, maven, gradle, cargo, go - commands: install, dev, start, build, test, lint, format, backend install, local services - docs: README, architecture docs, ADR directories, existing agent docs - CI: GitHub Actions workflows diff --git a/docs/detectors.md b/docs/detectors.md index 85f9eb8..2d0869a 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -9,7 +9,7 @@ This page summarizes the current detector coverage. Claims here should stay alig | Ecosystem | Detected signals | Frameworks/tools | Inferred commands | | --- | --- | --- | --- | | JavaScript / TypeScript | `package.json`, lockfiles, package scripts, framework configs, route/component conventions | React, Vite, Next.js, Next.js App Router, Vue, Nuxt, Astro, Svelte, SvelteKit, Express, NestJS, Vitest, Jest, Playwright, Storybook | package manager install, `dev`, `start`, `build`, `test`, `lint`, `format` from scripts | -| Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `tests/` | FastAPI, Django, Flask, Pytest, Ruff | `python3 -m pip install ...`, `python3 -m pytest`, Ruff lint/format | +| Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `poetry.lock`, `pdm.lock`, `uv.lock`, `tests/` | FastAPI, Django, Flask, Pytest, Ruff | pip, Poetry, PDM, or uv install/test commands plus Ruff lint/format | | Ruby | `Gemfile`, `config/application.rb`, `bin/rails` | Rails | `bundle install`, `bin/rails server`, `bin/rails test` | | PHP | `composer.json`, `artisan`, `app/Http/Kernel.php` | Laravel | `composer install`, `php artisan serve`, `php artisan test` | | C# / .NET | `*.csproj`, `*.sln`, `global.json`, `Program.cs` | .NET | `dotnet restore`, `dotnet build`, `dotnet test` | diff --git a/src/scanner.mjs b/src/scanner.mjs index 0dab02e..142789f 100644 --- a/src/scanner.mjs +++ b/src/scanner.mjs @@ -133,12 +133,19 @@ async function detectPackageManager(root, files = []) { ["bun", "bun.lockb"], ["bun", "bun.lock"], ["npm", "package-lock.json"], + ["poetry", "poetry.lock"], + ["pdm", "pdm.lock"], + ["uv", "uv.lock"], ]; for (const [name, file] of checks) { if (await pathExists(path.join(root, file))) return name; } if (await pathExists(path.join(root, "package.json"))) return "npm"; - if (await pathExists(path.join(root, "pyproject.toml"))) return "python"; + const pyproject = await readTextIfExists(path.join(root, "pyproject.toml")); + if (hasPythonToolConfig(pyproject, "poetry")) return "poetry"; + if (hasPythonToolConfig(pyproject, "pdm")) return "pdm"; + if (hasPythonToolConfig(pyproject, "uv")) return "uv"; + if (pyproject) return "python"; if (await pathExists(path.join(root, "Cargo.toml"))) return "cargo"; if (await pathExists(path.join(root, "go.mod"))) return "go"; if (await pathExists(path.join(root, "mvnw")) || await pathExists(path.join(root, "pom.xml"))) return "maven"; @@ -164,13 +171,13 @@ async function detectCommands({ root, packageJson, packageManager, pyproject, ca } if (pyproject || files.includes("requirements.txt") || files.includes("setup.py")) { - commands.install ||= pyproject ? "python3 -m pip install -e ." : "python3 -m pip install -r requirements.txt"; + commands.install ||= pythonInstallCommand(packageManager, Boolean(pyproject)); if (hasPythonTool(pyproject, "pytest") || files.some((file) => /^tests?\//.test(file))) { - commands.test ||= "python3 -m pytest"; + commands.test ||= pythonRunCommand(packageManager, "pytest"); } if (hasPythonTool(pyproject, "ruff")) { - commands.lint ||= "python3 -m ruff check ."; - commands.format ||= "python3 -m ruff format ."; + commands.lint ||= pythonRunCommand(packageManager, "ruff check ."); + commands.format ||= pythonRunCommand(packageManager, "ruff format ."); } } @@ -252,11 +259,30 @@ function runScriptCommand(packageManager, script) { return `npm run ${script}`; } +function pythonInstallCommand(packageManager, hasPyproject) { + if (packageManager === "poetry") return "poetry install"; + if (packageManager === "pdm") return "pdm install"; + if (packageManager === "uv") return "uv sync"; + return hasPyproject ? "python3 -m pip install -e ." : "python3 -m pip install -r requirements.txt"; +} + +function pythonRunCommand(packageManager, command) { + if (packageManager === "poetry") return `poetry run ${command}`; + if (packageManager === "pdm") return `pdm run ${command}`; + if (packageManager === "uv") return `uv run ${command}`; + return `python3 -m ${command}`; +} + function hasPythonTool(pyproject, tool) { if (!pyproject) return false; return new RegExp(`(^|[^a-zA-Z0-9_-])${escapeRegExp(tool)}([^a-zA-Z0-9_-]|$)`, "i").test(pyproject); } +function hasPythonToolConfig(pyproject, tool) { + if (!pyproject) return false; + return new RegExp(`^\\s*\\[tool\\.${escapeRegExp(tool)}(?:\\.|\\])`, "im").test(pyproject); +} + function detectName({ packageJson, pyproject, cargoToml, goMod, pomXml, composerJson, files, root }) { if (packageJson?.name) return packageJson.name; const pyName = parseTomlValue(pyproject, "name"); diff --git a/test/fixtures/pdm-python-app/app/main.py b/test/fixtures/pdm-python-app/app/main.py new file mode 100644 index 0000000..14c1ce9 --- /dev/null +++ b/test/fixtures/pdm-python-app/app/main.py @@ -0,0 +1,3 @@ +def hello(): + return "pdm" + diff --git a/test/fixtures/pdm-python-app/pdm.lock b/test/fixtures/pdm-python-app/pdm.lock new file mode 100644 index 0000000..919a901 --- /dev/null +++ b/test/fixtures/pdm-python-app/pdm.lock @@ -0,0 +1,2 @@ +# fixture lockfile + diff --git a/test/fixtures/pdm-python-app/pyproject.toml b/test/fixtures/pdm-python-app/pyproject.toml new file mode 100644 index 0000000..dfec2c1 --- /dev/null +++ b/test/fixtures/pdm-python-app/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "fixture-pdm-python-app" +version = "0.1.0" +dependencies = ["flask"] + +[dependency-groups] +dev = ["pytest", "ruff"] + +[tool.pdm] +distribution = false + diff --git a/test/fixtures/pdm-python-app/tests/test_main.py b/test/fixtures/pdm-python-app/tests/test_main.py new file mode 100644 index 0000000..053dc48 --- /dev/null +++ b/test/fixtures/pdm-python-app/tests/test_main.py @@ -0,0 +1,6 @@ +from app.main import hello + + +def test_hello(): + assert hello() == "pdm" + diff --git a/test/fixtures/poetry-python-app/app/main.py b/test/fixtures/poetry-python-app/app/main.py new file mode 100644 index 0000000..798d1dc --- /dev/null +++ b/test/fixtures/poetry-python-app/app/main.py @@ -0,0 +1,3 @@ +def hello(): + return "poetry" + diff --git a/test/fixtures/poetry-python-app/poetry.lock b/test/fixtures/poetry-python-app/poetry.lock new file mode 100644 index 0000000..919a901 --- /dev/null +++ b/test/fixtures/poetry-python-app/poetry.lock @@ -0,0 +1,2 @@ +# fixture lockfile + diff --git a/test/fixtures/poetry-python-app/pyproject.toml b/test/fixtures/poetry-python-app/pyproject.toml new file mode 100644 index 0000000..5b9be1e --- /dev/null +++ b/test/fixtures/poetry-python-app/pyproject.toml @@ -0,0 +1,14 @@ +[tool.poetry] +name = "fixture-poetry-python-app" +version = "0.1.0" +description = "Fixture for Poetry package manager detection." +authors = ["Fixture "] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.115.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" +ruff = "^0.8.0" + diff --git a/test/fixtures/poetry-python-app/tests/test_main.py b/test/fixtures/poetry-python-app/tests/test_main.py new file mode 100644 index 0000000..b0d3968 --- /dev/null +++ b/test/fixtures/poetry-python-app/tests/test_main.py @@ -0,0 +1,6 @@ +from app.main import hello + + +def test_hello(): + assert hello() == "poetry" + diff --git a/test/fixtures/uv-python-app/app/main.py b/test/fixtures/uv-python-app/app/main.py new file mode 100644 index 0000000..6471d3a --- /dev/null +++ b/test/fixtures/uv-python-app/app/main.py @@ -0,0 +1,3 @@ +def hello(): + return "uv" + diff --git a/test/fixtures/uv-python-app/pyproject.toml b/test/fixtures/uv-python-app/pyproject.toml new file mode 100644 index 0000000..2ce8345 --- /dev/null +++ b/test/fixtures/uv-python-app/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "fixture-uv-python-app" +version = "0.1.0" +dependencies = ["django"] + +[dependency-groups] +dev = ["pytest", "ruff"] + +[tool.uv] +package = true + diff --git a/test/fixtures/uv-python-app/tests/test_main.py b/test/fixtures/uv-python-app/tests/test_main.py new file mode 100644 index 0000000..4ea1e72 --- /dev/null +++ b/test/fixtures/uv-python-app/tests/test_main.py @@ -0,0 +1,6 @@ +from app.main import hello + + +def test_hello(): + assert hello() == "uv" + diff --git a/test/fixtures/uv-python-app/uv.lock b/test/fixtures/uv-python-app/uv.lock new file mode 100644 index 0000000..919a901 --- /dev/null +++ b/test/fixtures/uv-python-app/uv.lock @@ -0,0 +1,2 @@ +# fixture lockfile + diff --git a/test/scanner.test.mjs b/test/scanner.test.mjs index 1b11830..e8a442a 100644 --- a/test/scanner.test.mjs +++ b/test/scanner.test.mjs @@ -51,6 +51,28 @@ test("scan detects Python, Rust, and Go repositories", async () => { assert.equal(go.commands.test, "go test ./..."); }); +test("scan detects Python package managers", async () => { + const poetry = await scanRepo(fixture("poetry-python-app")); + const pdm = await scanRepo(fixture("pdm-python-app")); + const uv = await scanRepo(fixture("uv-python-app")); + + assert.equal(poetry.packageManager, "poetry"); + assert.equal(poetry.commands.install, "poetry install"); + assert.equal(poetry.commands.test, "poetry run pytest"); + assert.equal(poetry.commands.lint, "poetry run ruff check ."); + assert.equal(poetry.commands.format, "poetry run ruff format ."); + + assert.equal(pdm.packageManager, "pdm"); + assert.equal(pdm.commands.install, "pdm install"); + assert.equal(pdm.commands.test, "pdm run pytest"); + assert.equal(pdm.commands.lint, "pdm run ruff check ."); + + assert.equal(uv.packageManager, "uv"); + assert.equal(uv.commands.install, "uv sync"); + assert.equal(uv.commands.test, "uv run pytest"); + assert.equal(uv.commands.lint, "uv run ruff check ."); +}); + test("scan handles Go and Rust web framework edge cases", async () => { const goGin = await scanRepo(fixture("go-gin-cmd-app")); const rustWorkspace = await scanRepo(fixture("rust-workspace-web"));