diff --git a/src/commands/init.ts b/src/commands/init.ts index b2e96d72b..347d97f50 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -12,7 +12,7 @@ import { useYesNoConfirm } from '../lib/hooks/user-confirmations/useYesNoConfirm import { createPrefilledInputFileFromInputSchema } from '../lib/input_schema.js'; import { error, info, success, warning } from '../lib/outputs.js'; import { wrapScrapyProject } from '../lib/projects/scrapy/wrapScrapyProject.js'; -import { setLocalConfig, setLocalEnv, validateActorName } from '../lib/utils.js'; +import { sanitizeActorName, setLocalConfig, setLocalEnv, validateActorName } from '../lib/utils.js'; export class InitCommand extends ApifyCommand { static override name = 'init' as const; @@ -57,6 +57,20 @@ export class InitCommand extends ApifyCommand { const project = projectResult.unwrap(); + if (project.warnings?.length) { + for (const w of project.warnings) { + warning({ message: w }); + } + } + + let defaultActorName = basename(cwd); + if (project.type === ProjectLanguage.Python && project.entrypoint?.path) { + const entryPath = project.entrypoint.path; + // Extract the actual package name (last segment of dotted path) + const packageName = entryPath.includes('.') ? entryPath.split('.').pop()! : entryPath; + defaultActorName = sanitizeActorName(packageName); + } + if (project.type === ProjectLanguage.Scrapy) { info({ message: 'The current directory looks like a Scrapy project. Using automatic project wrapping.' }); this.telemetryData.actorWrapper = 'scrapy'; @@ -100,7 +114,7 @@ export class InitCommand extends ApifyCommand { try { const answer = await useUserInput({ message: 'Actor name:', - default: basename(cwd), + default: defaultActorName, }); validateActorName(answer); diff --git a/src/commands/run.ts b/src/commands/run.ts index e72436e02..cc1dbaf26 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -130,6 +130,12 @@ export class RunCommand extends ApifyCommand { const project = projectRuntimeResult.unwrap(); const { type, entrypoint: cwdEntrypoint, runtime } = project; + if (project.warnings?.length) { + for (const w of project.warnings) { + warning({ message: w }); + } + } + if (type === ProjectLanguage.Unknown) { throw new Error( 'Actor is of an unknown format.' + diff --git a/src/lib/hooks/useCwdProject.ts b/src/lib/hooks/useCwdProject.ts index af0ee4311..6d92060b0 100644 --- a/src/lib/hooks/useCwdProject.ts +++ b/src/lib/hooks/useCwdProject.ts @@ -1,8 +1,8 @@ -import { access, readFile } from 'node:fs/promises'; -import { basename, dirname, join, resolve } from 'node:path'; +import { access, readdir, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import process from 'node:process'; -import { ok, type Result } from '@sapphire/result'; +import { err, ok, type Result } from '@sapphire/result'; import { ScrapyProjectAnalyzer } from '../projects/scrapy/ScrapyProjectAnalyzer.js'; import { cliDebugPrint } from '../utils/cliDebugPrint.js'; @@ -38,6 +38,7 @@ export interface CwdProject { type: ProjectLanguage; entrypoint?: Entrypoint; runtime?: Runtime; + warnings?: string[]; } export interface CwdProjectError { @@ -81,35 +82,67 @@ export async function useCwdProject({ }; } else { // Fallback for scrapy projects that use apify, but are not "migrated" (like our templates) - const pythonFile = await checkPythonProject(cwd); - - if (pythonFile) { - project.entrypoint = { - path: pythonFile, - }; + try { + const pythonFile = await checkPythonProject(cwd); + + if (pythonFile) { + project.entrypoint = { + path: pythonFile, + }; + } + } catch { + // If we can't find the Python entrypoint, that's okay for Scrapy projects + // Just continue without setting the entrypoint } } return; } - const isPython = await checkPythonProject(cwd); + // Check for Node.js first. If a package.json exists (even without a start script), + // the project is treated as JavaScript. This is intentional: package.json is a strong + // signal of a Node.js project, and Python projects typically don't have one. + // If both package.json and a Python package exist, JavaScript wins. + const isNode = await checkNodeProject(cwd); - if (isPython) { - project.type = ProjectLanguage.Python; + if (!isNode) { + // No Node.js project found, check for Python + let isPython: string | null = null; + try { + isPython = await checkPythonProject(cwd); + } catch (error) { + // If checkPythonProject throws an error, it means it detected Python but + // couldn't determine the entrypoint. We should propagate this error. + return err({ + message: error instanceof Error ? error.message : String(error), + }); + } - const runtime = await usePythonRuntime({ cwd }); + if (isPython) { + project.type = ProjectLanguage.Python; - project.entrypoint = { - path: isPython, - }; + const runtime = await usePythonRuntime({ cwd }); - project.runtime = runtime.unwrapOr(undefined); + project.entrypoint = { + path: isPython, + }; - return; - } + project.runtime = runtime.unwrapOr(undefined); - const isNode = await checkNodeProject(cwd); + // 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')); + + if (!hasMainPy) { + project.warnings = [ + `The detected Python package "${isPython}" is missing __main__.py. ` + + 'Running with "python -m" will fail without it.', + ]; + } + + return; + } + } if (isNode) { project.type = ProjectLanguage.JavaScript; @@ -210,30 +243,242 @@ async function checkNodeProject(cwd: string) { return null; } -async function checkPythonProject(cwd: string) { - const baseName = basename(cwd); +// Helper functions for Python project detection - const filesToCheck = [ - join(cwd, 'src', '__main__.py'), - join(cwd, '__main__.py'), - join(cwd, baseName, '__main__.py'), - join(cwd, baseName.replaceAll('-', '_').replaceAll(' ', '_'), '__main__.py'), - ]; +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} - for (const path of filesToCheck) { - try { - await access(path); +async function dirExists(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch { + return false; + } +} - // By default in python, we run python3 -m - // For some unholy reason, python does NOT support absolute paths for this -.- - // Effectively, this returns `src` from `/cwd/src/__main__.py`, et al. - return basename(dirname(path)); - } catch { - // Ignore errors +function isValidPythonIdentifier(name: string): boolean { + // Must start with letter or underscore, contain only alphanumerics and underscores + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + +async function hasPythonFilesInDirOrSubdirs(cwd: string): Promise { + try { + const entries = await readdir(cwd, { withFileTypes: true }); + + // Check for .py files directly in cwd + if (entries.some((entry) => entry.isFile() && entry.name.endsWith('.py'))) { + return true; } + + // Check for .py files in immediate subdirectories (e.g. my_package/main.py) + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue; + + const subDir = join(cwd, entry.name); + + try { + const subEntries = await readdir(subDir, { withFileTypes: true }); + if (subEntries.some((sub) => sub.isFile() && sub.name.endsWith('.py'))) { + return true; + } + + // For src/ container, also check one level deeper (e.g. src/my_package/main.py) + if (entry.name === 'src') { + for (const srcEntry of subEntries) { + if (!srcEntry.isDirectory()) continue; + if (srcEntry.name.startsWith('.') || srcEntry.name.startsWith('_')) continue; + + try { + const srcSubEntries = await readdir(join(subDir, srcEntry.name), { withFileTypes: true }); + if (srcSubEntries.some((sub) => sub.isFile() && sub.name.endsWith('.py'))) { + return true; + } + } catch { + // Ignore unreadable subdirectories + } + } + } + } catch { + // Ignore unreadable subdirectories + } + } + + return false; + } catch { + return false; } +} - return null; +interface NearMissPackage { + name: string; + needsRename: boolean; + needsInit: boolean; +} + +async function dirHasPyFiles(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.some((entry) => entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py'); + } catch { + return false; + } +} + +async function findNearMissPackagesInDir(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + const nearMisses: NearMissPackage[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue; + + const validName = isValidPythonIdentifier(entry.name); + const hasInit = await fileExists(join(dir, entry.name, '__init__.py')); + const hasPy = hasInit || (await dirHasPyFiles(join(dir, entry.name))); + + // Skip if already a valid package (valid name + __init__.py) — not a near-miss + if (validName && hasInit) continue; + + // Skip if no Python indicators at all — not a near-miss + if (!hasPy) continue; + + nearMisses.push({ + name: entry.name, + needsRename: !validName, + needsInit: !hasInit, + }); + } + + return nearMisses; + } catch { + return []; + } +} + +async function findPackagesInDir(dir: string): Promise<{ name: string; path: string }[]> { + try { + const entries = await readdir(dir, { withFileTypes: true }); + const packages = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const { name } = entry; + + // Skip hidden directories (starting with .) and underscore-prefixed directories + // (private/special packages like _internal or __pycache__ shouldn't be main entrypoints) + if (name.startsWith('.') || name.startsWith('_')) continue; + if (!isValidPythonIdentifier(name)) continue; + + // Check for __init__.py + const initPath = join(dir, name, '__init__.py'); + if (await fileExists(initPath)) { + packages.push({ name, path: join(dir, name) }); + } + } + + return packages; + } catch { + return []; + } +} + +async function discoverPythonPackages(cwd: string): Promise { + const packages: string[] = []; + + // Search level 1 (CWD) + const level1Packages = await findPackagesInDir(cwd); + packages.push(...level1Packages.map((p) => p.name)); + + // Search level 2 (src/) - only if src/ is NOT itself a package + // If src/ has __init__.py, it's a package and anything inside is a subpackage, not a top-level package + const srcDir = join(cwd, 'src'); + const srcIsPackage = await fileExists(join(srcDir, '__init__.py')); + + if ((await dirExists(srcDir)) && !srcIsPackage) { + const level2Packages = await findPackagesInDir(srcDir); + packages.push(...level2Packages.map((p) => `src.${p.name}`)); + } + + return packages; +} + +async function checkPythonProject(cwd: string): Promise { + const discoveredPackages = await discoverPythonPackages(cwd); + + if (discoveredPackages.length === 1) { + return discoveredPackages[0]; + } + + if (discoveredPackages.length > 1) { + const packageList = discoveredPackages.map((pkg) => ` - ${pkg}`).join('\n'); + throw new Error( + `Multiple Python packages found:\n${packageList}\n\n` + + 'Apify CLI cannot determine which package to run.\n' + + 'Please specify the package using the --entrypoint flag, e.g.:\n' + + ' apify run --entrypoint ', + ); + } + + // No packages found — check for near-miss packages + const nearMissAtRoot = await findNearMissPackagesInDir(cwd); + const srcDir = join(cwd, 'src'); + const srcIsPackage = await fileExists(join(srcDir, '__init__.py')); + const nearMissInSrc = !srcIsPackage && (await dirExists(srcDir)) ? await findNearMissPackagesInDir(srcDir) : []; + + const allNearMisses = [ + ...nearMissAtRoot.map((nm) => ({ ...nm, prefix: '' })), + ...nearMissInSrc.map((nm) => ({ ...nm, prefix: 'src/' })), + ]; + + if (allNearMisses.length > 0) { + const suggestions = allNearMisses + .map(({ name, prefix, needsRename, needsInit }) => { + const fixes: string[] = []; + if (needsRename) fixes.push(`rename to "${prefix}${name.replace(/[^a-zA-Z0-9_]/g, '_')}/"`); + if (needsInit) fixes.push('add __init__.py'); + return ` - "${prefix}${name}/" → ${fixes.join(' and ')}`; + }) + .join('\n'); + + throw new Error( + `Found directories that appear to be Python packages but have issues:\n${suggestions}\n\n` + + 'A valid Python package requires a directory with a valid identifier name ' + + '(letters, numbers, underscores) and an __init__.py file.', + ); + } + + // Check if there are loose .py files (broken Python project) + const hasPyFiles = await hasPythonFilesInDirOrSubdirs(cwd); + if (hasPyFiles) { + throw new Error( + 'No Python package found. Found Python files, but no valid package structure detected.\n' + + 'A Python package requires:\n' + + ' - A directory with a valid Python identifier name (letters, numbers, underscores)\n' + + ' - An __init__.py file inside the directory\n' + + '\n' + + 'Common package structures:\n' + + ' my_package/\n' + + ' __init__.py\n' + + ' main.py\n' + + '\n' + + ' src/\n' + + ' my_package/\n' + + ' __init__.py\n' + + ' main.py', + ); + } + + return null; // Not a Python project } async function checkScrapyProject(cwd: string) { diff --git a/test/lib/hooks/useCwdProject.test.ts b/test/lib/hooks/useCwdProject.test.ts new file mode 100644 index 000000000..92accf865 --- /dev/null +++ b/test/lib/hooks/useCwdProject.test.ts @@ -0,0 +1,521 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cwdCache, ProjectLanguage, useCwdProject } from '../../../src/lib/hooks/useCwdProject.js'; +import { sanitizeActorName } from '../../../src/lib/utils.js'; + +const testDir = join(fileURLToPath(import.meta.url), '..', '..', '..', 'tmp', 'useCwdProject-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); +} + +async function createPythonPackageIn(baseDir: string, packagePath: string) { + await createFileIn(baseDir, `${packagePath}/__init__.py`); +} + +/** Derive Actor name from entrypoint path (mirrors logic in init.ts) */ +function deriveActorName(entrypointPath: string): string { + const packageName = entrypointPath.includes('.') ? entrypointPath.split('.').pop()! : entrypointPath; + return sanitizeActorName(packageName); +} + +describe('useCwdProject - Python project detection', () => { + 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 }); + }); + + // Full parametrized matrix: + // {CWD with dashes | underscores} × {flat | src container} × {pkg with dashes | underscores} × {init+py | py only | no py} + // + // Expected outcomes: + // - Valid pkg name (my_package) + __init__.py → Python detected, Actor name = "my-package" + // - Valid pkg name + no __init__.py but .py files → near-miss error suggesting __init__.py + // - Valid pkg name + no __init__.py and no .py files → unknown (no Python indicators) + // - Invalid pkg name (my-package) + __init__.py → near-miss error with rename suggestion + // - Invalid pkg name + no __init__.py but .py files → near-miss error suggesting rename + __init__.py + // - Invalid pkg name + no __init__.py and no .py files → unknown (no Python indicators) + + interface DetectionTestCase { + description: string; + cwdName: string; + structure: 'flat' | 'src'; + pkgName: string; + fileSetup: 'init_and_py' | 'py_only' | 'no_py'; + expectedOutcome: 'python' | 'error' | 'unknown'; + expectedEntrypoint: string | null; + expectedActorName: string | null; + expectedErrorContains: string | null; + } + + describe('detection matrix', () => { + const cases: DetectionTestCase[] = [ + // ── Valid pkg name (my_package) + __init__.py → Python detected ── + { + description: 'detects flat package with __init__.py in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'init_and_py', + expectedOutcome: 'python', + expectedEntrypoint: 'my_package', + expectedActorName: 'my-package', + expectedErrorContains: null, + }, + { + description: 'detects flat package with __init__.py in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'init_and_py', + expectedOutcome: 'python', + expectedEntrypoint: 'my_package', + expectedActorName: 'my-package', + expectedErrorContains: null, + }, + { + description: 'detects package inside src/ container in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'init_and_py', + expectedOutcome: 'python', + expectedEntrypoint: 'src.my_package', + expectedActorName: 'my-package', + expectedErrorContains: null, + }, + { + description: 'detects package inside src/ container in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'init_and_py', + expectedOutcome: 'python', + expectedEntrypoint: 'src.my_package', + expectedActorName: 'my-package', + expectedErrorContains: null, + }, + + // ── Valid pkg name + no __init__.py but .py files → near-miss suggesting __init__.py ── + { + description: + 'suggests adding __init__.py for flat valid package with .py files in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'add __init__.py', + }, + { + description: + 'suggests adding __init__.py for flat valid package with .py files in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'add __init__.py', + }, + { + description: + 'suggests adding __init__.py for valid package with .py files inside src/ in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'add __init__.py', + }, + { + description: + 'suggests adding __init__.py for valid package with .py files inside src/ in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'add __init__.py', + }, + + // ── Valid pkg name + no __init__.py and no .py files → unknown ── + { + description: + 'returns unknown for flat valid directory without any Python files in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for flat valid directory without any Python files in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my_package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for valid directory without any Python files inside src/ in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for valid directory without any Python files inside src/ in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my_package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + + // ── Invalid pkg name (my-package) + __init__.py → near-miss with rename suggestion ── + { + description: 'suggests rename for flat hyphenated package with __init__.py in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'init_and_py', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: 'suggests rename for flat hyphenated package with __init__.py in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'init_and_py', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: + 'suggests rename for hyphenated package inside src/ with __init__.py in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'init_and_py', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: + 'suggests rename for hyphenated package inside src/ with __init__.py in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'init_and_py', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + + // ── Invalid pkg name + no __init__.py but .py files → near-miss suggesting rename + __init__.py ── + { + description: + 'suggests rename and __init__.py for flat hyphenated package with .py files in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: + 'suggests rename and __init__.py for flat hyphenated package with .py files in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: + 'suggests rename and __init__.py for hyphenated package with .py files inside src/ in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + { + description: + 'suggests rename and __init__.py for hyphenated package with .py files inside src/ in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'py_only', + expectedOutcome: 'error', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: 'rename to', + }, + + // ── Invalid pkg name + no __init__.py and no .py files → unknown ── + { + description: + 'returns unknown for flat hyphenated directory without any Python files in a hyphenated directory', + cwdName: 'my-dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for flat hyphenated directory without any Python files in an underscored directory', + cwdName: 'my_dir', + structure: 'flat', + pkgName: 'my-package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for hyphenated directory without any Python files inside src/ in a hyphenated directory', + cwdName: 'my-dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + { + description: + 'returns unknown for hyphenated directory without any Python files inside src/ in an underscored directory', + cwdName: 'my_dir', + structure: 'src', + pkgName: 'my-package', + fileSetup: 'no_py', + expectedOutcome: 'unknown', + expectedEntrypoint: null, + expectedActorName: null, + expectedErrorContains: null, + }, + ]; + + it.each(cases)('$description', async ({ + cwdName, + structure, + pkgName, + fileSetup, + expectedOutcome, + expectedEntrypoint, + expectedActorName, + expectedErrorContains, + }) => { + const projectDir = join(testDir, cwdName); + await mkdir(projectDir, { recursive: true }); + + const pkgPath = structure === 'src' ? `src/${pkgName}` : pkgName; + + if (fileSetup === 'init_and_py') { + await createPythonPackageIn(projectDir, pkgPath); + await createFileIn(projectDir, `${pkgPath}/main.py`, 'print("hello")'); + } else if (fileSetup === 'py_only') { + await createFileIn(projectDir, `${pkgPath}/main.py`, 'print("hello")'); + } else { + // no_py: create directory with a non-Python file + await createFileIn(projectDir, `${pkgPath}/readme.txt`, 'not python'); + } + + const result = await useCwdProject({ cwd: projectDir }); + + if (expectedOutcome === 'python') { + expect(result.isOk()).toBe(true); + const project = result.unwrap(); + expect(project.type).toBe(ProjectLanguage.Python); + expect(project.entrypoint?.path).toBe(expectedEntrypoint); + expect(deriveActorName(project.entrypoint!.path!)).toBe(expectedActorName); + } else if (expectedOutcome === 'error') { + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toContain(expectedErrorContains!); + } else { + expect(result.isOk()).toBe(true); + expect(result.unwrap().type).toBe(ProjectLanguage.Unknown); + } + }); + }); + + // Individual cases + + describe('JavaScript and Python coexist', () => { + it('should prefer JavaScript when both package.json and Python package exist', async () => { + await createFileIn(testDir, 'package.json', '{"name": "test", "scripts": {"start": "node index.js"}}'); + await createPythonPackageIn(testDir, 'my_package'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().type).toBe(ProjectLanguage.JavaScript); + }); + }); + + describe('multiple packages', () => { + it('should error when multiple flat packages exist', async () => { + await createPythonPackageIn(testDir, 'package_a'); + await createPythonPackageIn(testDir, 'package_b'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.message).toContain('Multiple Python packages found'); + expect(error.message).toContain('package_a'); + expect(error.message).toContain('package_b'); + expect(error.message).toContain('--entrypoint'); + }); + + it('should error when multiple packages exist in src/ container', async () => { + await createPythonPackageIn(testDir, 'src/package_a'); + await createPythonPackageIn(testDir, 'src/package_b'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.message).toContain('Multiple Python packages found'); + expect(error.message).toContain('--entrypoint'); + }); + }); + + describe('Python files but no package', () => { + it('should error when loose .py files exist without package structure', async () => { + await createFileIn(testDir, 'main.py', 'print("hello")'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.message).toContain('No Python package found'); + expect(error.message).toContain('no valid package structure'); + }); + }); + + describe('no Python project at all', () => { + it('should return Unknown when no Python files or packages present', async () => { + await createFileIn(testDir, 'readme.txt', 'Hello'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().type).toBe(ProjectLanguage.Unknown); + }); + }); + + describe('src/ as a package (not a container)', () => { + it('should detect src/ itself as a Python package when it has __init__.py', async () => { + await createPythonPackageIn(testDir, 'src'); + await createFileIn(testDir, 'src/__main__.py', 'print("hello")'); + await createFileIn(testDir, 'src/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.entrypoint?.path).toBe('src'); + }); + + it('should not search inside src/ for packages when src/ is itself a package', async () => { + // src/ is a package, and has a sub-package inside — should still detect src/ as the only package + await createPythonPackageIn(testDir, 'src'); + await createFileIn(testDir, 'src/__main__.py', 'print("hello")'); + await createPythonPackageIn(testDir, 'src/sub_package'); + + const result = await useCwdProject({ cwd: testDir }); + + expect(result.isOk()).toBe(true); + const project = result.unwrap(); + expect(project.type).toBe(ProjectLanguage.Python); + expect(project.entrypoint?.path).toBe('src'); + }); + }); + + describe('missing __main__.py warning', () => { + it('should warn when detected package has __init__.py but no __main__.py', 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.warnings).toBeDefined(); + expect(project.warnings!.length).toBeGreaterThan(0); + expect(project.warnings![0]).toContain('__main__.py'); + }); + + it('should not warn when package has both __init__.py and __main__.py', 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.warnings ?? []).toHaveLength(0); + }); + }); +}); diff --git a/test/local/__fixtures__/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts b/test/local/__fixtures__/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts index 4c53b8b3d..99c0c0bcb 100644 --- a/test/local/__fixtures__/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts +++ b/test/local/__fixtures__/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts @@ -1,4 +1,4 @@ -import { rename } from 'node:fs/promises'; +import { rm } from 'node:fs/promises'; import { testRunCommand } from '../../../../../../src/lib/command-framework/apify-command.js'; import { useConsoleSpy } from '../../../../../__setup__/hooks/useConsoleSpy.js'; @@ -26,8 +26,12 @@ describe('[python] prints error message on project with no detected start', () = await testRunCommand(CreateCommand, { flags_template: 'python-start', args_actorName: actorName }); toggleCwdBetweenFullAndParentPath(); + // Remove src/ package and requirements.txt so there is no detectable Python package structure const srcFolder = joinPath('src'); - await rename(srcFolder, joinPath('entrypoint')); + await rm(srcFolder, { recursive: true, force: true }); + + const requirementsTxt = joinPath('requirements.txt'); + await rm(requirementsTxt, { force: true }); resetCwdCaches(); });