diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7aa347036..190c88918 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -173,6 +173,26 @@ jobs: TEST_USER_TOKEN: ${{ secrets.APIFY_TEST_USER_API_TOKEN }} run: pnpm run test:python + # Aggregate gate for the whole Python Support matrix. Branch protection requires only this single + # check, so the matrix versions can change without having to update the required checks in lockstep. + test_python_support_result: + if: ${{ always() && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[docs build]') }} + + name: Python Support Result + + needs: [test_python_support] + + runs-on: ubuntu-latest + + steps: + - name: Verify the Python Support matrix succeeded + run: | + result="${{ needs.test_python_support.result }}" + echo "Python Support matrix result: ${result}" + if [ "${result}" != "success" ]; then + exit 1 + fi + docs: if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[docs build]') }} diff --git a/src/commands/create.ts b/src/commands/create.ts index c15597887..d302e71f0 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import process from 'node:process'; import { gte, minVersion } from 'semver'; +import which from 'which'; import { fetchManifest, manifestUrl } from '@apify/actor-templates'; @@ -197,6 +198,36 @@ export class CreateCommand extends ApifyCommand { await cwdProjectResult.inspectAsync(async (project) => { const minimumSupportedNodeVersion = minVersion(SUPPORTED_NODEJS_VERSION); + // uv-managed Python projects (recognized by a committed `uv.lock`) manage their own virtual + // environment, dependencies, and Python version. Install them with `uv sync` instead of the + // pip + requirements.txt flow. uv provides the Python pinned in `.python-version` on its own, + // so this runs even when no system Python is detected. + const isPythonProject = project.type === ProjectLanguage.Python || project.type === ProjectLanguage.Scrapy; + + if (isPythonProject && project.packageManager === 'uv') { + const uvPath = await which('uv', { nothrow: true }); + + if (!uvPath) { + warning({ + message: + 'This Actor uses uv to manage its dependencies, but the uv executable was not found. ' + + 'Install uv (https://docs.astral.sh/uv/getting-started/installation/), then run "uv sync" in the Actor directory.', + }); + return; + } + + info({ message: 'Installing dependencies with "uv sync"...' }); + + await execWithLog({ + cmd: uvPath, + args: ['sync'], + opts: { cwd: actFolderDir }, + }); + + dependenciesInstalled = true; + return; + } + if (!project.runtime) { switch (project.type) { case ProjectLanguage.JavaScript: { diff --git a/src/lib/hooks/runtimes/utils.ts b/src/lib/hooks/runtimes/utils.ts index 3b72e4a73..7fa1fab28 100644 --- a/src/lib/hooks/runtimes/utils.ts +++ b/src/lib/hooks/runtimes/utils.ts @@ -59,7 +59,8 @@ export async function getInstallCommandSuggestion(actFolderDir: string) { installCommandSuggestion = 'npm install'; } } else if (project.type === ProjectLanguage.Python || project.type === ProjectLanguage.Scrapy) { - installCommandSuggestion = 'python -m pip install -r requirements.txt'; + installCommandSuggestion = + project.packageManager === 'uv' ? 'uv sync' : 'python -m pip install -r requirements.txt'; } }); diff --git a/src/lib/hooks/useCwdProject.ts b/src/lib/hooks/useCwdProject.ts index 6d92060b0..82a367f5a 100644 --- a/src/lib/hooks/useCwdProject.ts +++ b/src/lib/hooks/useCwdProject.ts @@ -39,6 +39,9 @@ export interface CwdProject { entrypoint?: Entrypoint; runtime?: Runtime; warnings?: string[]; + // Package manager for Python projects. Set to 'uv' when a committed `uv.lock` is present, + // meaning dependencies should be installed with `uv sync` instead of the pip + requirements.txt flow. + packageManager?: 'uv'; } export interface CwdProjectError { @@ -73,6 +76,10 @@ export async function useCwdProject({ project.runtime = runtime.unwrapOr(undefined); + if (await fileExists(join(cwd, 'uv.lock'))) { + project.packageManager = 'uv'; + } + const scrapyProject = new ScrapyProjectAnalyzer(cwd); scrapyProject.loadScrapyCfg(); @@ -129,6 +136,10 @@ export async function useCwdProject({ project.runtime = runtime.unwrapOr(undefined); + if (await fileExists(join(cwd, 'uv.lock'))) { + project.packageManager = 'uv'; + } + // Check if the detected package has __main__.py (required for `python -m `) const packageDir = join(cwd, isPython.replace(/\./g, '/')); const hasMainPy = await fileExists(join(packageDir, '__main__.py')); diff --git a/test/lib/hooks/runtimes/utils.test.ts b/test/lib/hooks/runtimes/utils.test.ts new file mode 100644 index 000000000..35844622f --- /dev/null +++ b/test/lib/hooks/runtimes/utils.test.ts @@ -0,0 +1,46 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getInstallCommandSuggestion } from '../../../../src/lib/hooks/runtimes/utils.js'; +import { cwdCache } from '../../../../src/lib/hooks/useCwdProject.js'; + +const testDir = join(fileURLToPath(import.meta.url), '..', '..', '..', '..', 'tmp', 'install-command-suggestion-test'); + +async function createFileIn(baseDir: string, relativePath: string, content = '') { + await mkdir(join(baseDir, ...relativePath.split('/').slice(0, -1)), { recursive: true }); + await writeFile(join(baseDir, relativePath), content); +} + +describe('getInstallCommandSuggestion', () => { + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }); + await mkdir(testDir, { recursive: true }); + cwdCache.clear(); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('suggests "uv sync" for a uv-managed Python project', async () => { + await createFileIn(testDir, 'my_package/__init__.py'); + await createFileIn(testDir, 'my_package/__main__.py', 'print("hello")'); + await createFileIn(testDir, 'uv.lock', ''); + + expect(await getInstallCommandSuggestion(testDir)).toBe('uv sync'); + }); + + it('suggests pip install for a Python project without uv.lock', async () => { + await createFileIn(testDir, 'my_package/__init__.py'); + await createFileIn(testDir, 'my_package/__main__.py', 'print("hello")'); + + expect(await getInstallCommandSuggestion(testDir)).toBe('python -m pip install -r requirements.txt'); + }); + + it('suggests npm install for a JavaScript project without a lockfile', async () => { + await createFileIn(testDir, 'package.json', '{"name": "test", "scripts": {"start": "node index.js"}}'); + + expect(await getInstallCommandSuggestion(testDir)).toBe('npm install'); + }); +}); diff --git a/test/lib/hooks/useCwdProject.test.ts b/test/lib/hooks/useCwdProject.test.ts index 2f4ded288..585a81303 100644 --- a/test/lib/hooks/useCwdProject.test.ts +++ b/test/lib/hooks/useCwdProject.test.ts @@ -514,4 +514,43 @@ describe('useCwdProject - Python project detection', () => { expect(project.warnings ?? []).toHaveLength(0); }); }); + + describe('uv package manager detection', () => { + it('should mark a Python project as uv-managed when uv.lock is present', async () => { + await createPythonPackageIn(testDir, 'my_package'); + await createFileIn(testDir, 'my_package/__main__.py', 'print("hello")'); + await createFileIn(testDir, 'uv.lock', ''); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + const project = result.unwrap(); + expect(project.type).toBe(ProjectLanguage.Python); + expect(project.packageManager).toBe('uv'); + }); + + it('should not set packageManager for a Python project without uv.lock', async () => { + await createPythonPackageIn(testDir, 'my_package'); + await createFileIn(testDir, 'my_package/__main__.py', 'print("hello")'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + const project = result.unwrap(); + expect(project.type).toBe(ProjectLanguage.Python); + expect(project.packageManager).toBeUndefined(); + }); + + it('should not mark a JavaScript project as uv-managed even when uv.lock is present', async () => { + await createFileIn(testDir, 'package.json', '{"name": "test", "scripts": {"start": "node index.js"}}'); + await createFileIn(testDir, 'uv.lock', ''); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + const project = result.unwrap(); + expect(project.type).toBe(ProjectLanguage.JavaScript); + expect(project.packageManager).toBeUndefined(); + }); + }); });