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
20 changes: 20 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]') }}

Expand Down
31 changes: 31 additions & 0 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -197,6 +198,36 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
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;
Comment thread
vdusek marked this conversation as resolved.

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;
Comment thread
vdusek marked this conversation as resolved.
}

info({ message: 'Installing dependencies with "uv sync"...' });

await execWithLog({
cmd: uvPath,
args: ['sync'],
opts: { cwd: actFolderDir },
});
Comment thread
vdusek marked this conversation as resolved.

dependenciesInstalled = true;
return;
}

if (!project.runtime) {
switch (project.type) {
case ProjectLanguage.JavaScript: {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/hooks/runtimes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
});

Expand Down
11 changes: 11 additions & 0 deletions src/lib/hooks/useCwdProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 <package>`)
const packageDir = join(cwd, isPython.replace(/\./g, '/'));
const hasMainPy = await fileExists(join(packageDir, '__main__.py'));
Expand Down
46 changes: 46 additions & 0 deletions test/lib/hooks/runtimes/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
39 changes: 39 additions & 0 deletions test/lib/hooks/useCwdProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});