From 5a61487c4c8d250cc79c3805d8e99e4d2d59760d Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 11 Dec 2024 19:15:15 -0300 Subject: [PATCH 01/11] refactor(nx-python): create provider interface to support multi pkg manager --- .../src/dependency/update-dependency.ts | 112 - .../src/executors/add/executor.spec.ts | 1940 +++--- .../nx-python/src/executors/add/executor.ts | 80 +- .../src/executors/build/executor.spec.ts | 5328 +++++++++-------- .../nx-python/src/executors/build/executor.ts | 153 +- .../src/executors/flake8/executor.spec.ts | 385 +- .../src/executors/flake8/executor.ts | 17 +- .../src/executors/install/executor.spec.ts | 304 +- .../src/executors/install/executor.ts | 36 +- .../src/executors/publish/executor.spec.ts | 488 +- .../src/executors/publish/executor.ts | 82 +- .../src/executors/remove/executor.spec.ts | 1200 ++-- .../src/executors/remove/executor.ts | 50 +- .../src/executors/ruff-check/executor.spec.ts | 248 +- .../src/executors/ruff-check/executor.ts | 32 +- .../executors/run-commands/executor.spec.ts | 29 +- .../src/executors/run-commands/executor.ts | 5 +- .../src/executors/sls-deploy/executor.spec.ts | 258 +- .../src/executors/sls-deploy/executor.ts | 5 +- .../executors/sls-package/executor.spec.ts | 180 +- .../src/executors/sls-package/executor.ts | 5 +- .../src/executors/tox/executor.spec.ts | 379 +- .../nx-python/src/executors/tox/executor.ts | 18 +- .../src/executors/update/executor.spec.ts | 1655 ++--- .../src/executors/update/executor.ts | 54 +- .../enable-releases/generator.spec.ts | 6 +- .../migrate-to-shared-venv/generator.spec.ts | 2 +- .../migrate-to-shared-venv/generator.ts | 12 +- .../poetry-project/generator.spec.ts | 6 +- .../generators/poetry-project/generator.ts | 16 +- .../src/generators/project/generator.spec.ts | 2 +- .../src/generators/project/generator.ts | 6 +- .../release-version/release-version.spec.ts | 1964 +++--- .../release-version/release-version.ts | 149 +- ...ry-workspace-with-package-dependencies.ts} | 8 +- .../release-version/utils/package.ts | 85 +- .../resolve-local-package-dependencies.ts | 11 +- .../src/graph/dependency-graph.spec.ts | 821 +-- .../nx-python/src/graph/dependency-graph.ts | 219 +- .../replace-nx-run-commands.spec.ts | 8 +- packages/nx-python/src/provider/base.ts | 88 + packages/nx-python/src/provider/index.ts | 1 + .../poetry}/build/resolvers/index.ts | 0 .../poetry}/build/resolvers/locked.ts | 22 +- .../poetry}/build/resolvers/project.ts | 27 +- .../poetry}/build/resolvers/types.ts | 4 +- .../poetry}/build/resolvers/utils.ts | 6 +- .../nx-python/src/provider/poetry/index.ts | 2 + .../nx-python/src/provider/poetry/provider.ts | 763 +++ .../nx-python/src/provider/poetry/types.ts | 53 + .../poetry/utils.spec.ts} | 2 +- .../poetry.ts => provider/poetry/utils.ts} | 59 +- packages/nx-python/src/provider/resolver.ts | 20 + packages/nx-python/src/provider/uv/index.ts | 1 + .../nx-python/src/provider/uv/provider.ts | 120 + 55 files changed, 8939 insertions(+), 8587 deletions(-) delete mode 100644 packages/nx-python/src/dependency/update-dependency.ts rename packages/nx-python/src/generators/release-version/test-utils/{create-workspace-with-package-dependencies.ts => create-poetry-workspace-with-package-dependencies.ts} (91%) create mode 100644 packages/nx-python/src/provider/base.ts create mode 100644 packages/nx-python/src/provider/index.ts rename packages/nx-python/src/{executors => provider/poetry}/build/resolvers/index.ts (100%) rename packages/nx-python/src/{executors => provider/poetry}/build/resolvers/locked.ts (94%) rename packages/nx-python/src/{executors => provider/poetry}/build/resolvers/project.ts (89%) rename packages/nx-python/src/{executors => provider/poetry}/build/resolvers/types.ts (80%) rename packages/nx-python/src/{executors => provider/poetry}/build/resolvers/utils.ts (87%) create mode 100644 packages/nx-python/src/provider/poetry/index.ts create mode 100644 packages/nx-python/src/provider/poetry/provider.ts create mode 100644 packages/nx-python/src/provider/poetry/types.ts rename packages/nx-python/src/{executors/utils/poetry.spec.ts => provider/poetry/utils.spec.ts} (99%) rename packages/nx-python/src/{executors/utils/poetry.ts => provider/poetry/utils.ts} (74%) create mode 100644 packages/nx-python/src/provider/resolver.ts create mode 100644 packages/nx-python/src/provider/uv/index.ts create mode 100644 packages/nx-python/src/provider/uv/provider.ts diff --git a/packages/nx-python/src/dependency/update-dependency.ts b/packages/nx-python/src/dependency/update-dependency.ts deleted file mode 100644 index 25db58d..0000000 --- a/packages/nx-python/src/dependency/update-dependency.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ExecutorContext, ProjectsConfigurations } from '@nx/devkit'; -import chalk from 'chalk'; -import { - getDependents, - PyprojectToml, - PyprojectTomlDependencies, -} from '../graph/dependency-graph'; -import { - getProjectTomlPath, - parseToml, - runPoetry, - updateProject, -} from '../executors/utils/poetry'; -import { existsSync, readFileSync } from 'fs-extra'; -import { parse } from '@iarna/toml'; - -export function updateDependencyTree(context: ExecutorContext) { - const rootPyprojectToml = existsSync('pyproject.toml'); - const pkgName = getProjectPackageName(context, context.projectName); - - updateDependents( - context, - context.projectsConfigurations, - context.projectName, - rootPyprojectToml, - context.root, - ); - - if (rootPyprojectToml) { - const rootPyprojectToml = parse( - readFileSync('pyproject.toml', { encoding: 'utf-8' }), - ) as PyprojectToml; - - const allRootDependencyNames = Object.keys( - getAllDependenciesFromPyprojectToml(rootPyprojectToml), - ); - - if (allRootDependencyNames.includes(pkgName)) { - console.log( - chalk`\nUpdating root {bold pyproject.toml} dependency {bold ${pkgName}}`, - ); - - runPoetry(['lock', '--no-update']); - runPoetry(['install', '--no-root']); - } - } -} - -export function updateDependents( - context: ExecutorContext, - workspace: ProjectsConfigurations, - projectName: string, - updateLockOnly: boolean, - workspaceRoot: string, - updatedProjects: string[] = [], -) { - updatedProjects.push(projectName); - const deps = getDependents(projectName, workspace.projects, workspaceRoot); - - for (const dep of deps) { - if (updatedProjects.includes(dep)) { - continue; - } - - console.log(chalk`\nUpdating project {bold ${dep}}`); - const depConfig = workspace.projects[dep]; - - updateProject(depConfig.root, updateLockOnly); - - updateDependents( - context, - workspace, - dep, - updateLockOnly, - workspaceRoot, - updatedProjects, - ); - } -} - -function getProjectPackageName(context: ExecutorContext, projectName: string) { - const projectConfig = context.projectsConfigurations.projects[projectName]; - const projectToml = getProjectTomlPath(projectConfig); - const { - tool: { - poetry: { name }, - }, - } = parseToml(projectToml); - - return name; -} - -/** - * Parses all dependency names from a Pyproject.toml file - * and returns a flattened collection of dependencies - * - * Optionally you may supply a list of groups to ignore - */ -export const getAllDependenciesFromPyprojectToml = ( - tomlData: PyprojectToml, - /** optional dependency groups to omit from collection */ - omitGroups: string[] = [], -): PyprojectTomlDependencies => { - return { - ...(tomlData.tool?.poetry?.dependencies ?? {}), - ...Object.fromEntries( - Object.entries(tomlData.tool?.poetry?.group ?? {}) - .filter(([name]) => !omitGroups.includes(name)) - .flatMap(([, group]) => Object.entries(group.dependencies ?? {})), - ), - }; -}; diff --git a/packages/nx-python/src/executors/add/executor.spec.ts b/packages/nx-python/src/executors/add/executor.spec.ts index 82af995..f12ef5f 100644 --- a/packages/nx-python/src/executors/add/executor.spec.ts +++ b/packages/nx-python/src/executors/add/executor.spec.ts @@ -2,84 +2,87 @@ import { vi, MockInstance } from 'vitest'; import { vol } from 'memfs'; import '../../utils/mocks/cross-spawn.mock'; import '../../utils/mocks/fs.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import chalk from 'chalk'; -import { parseToml } from '../utils/poetry'; +import { parseToml } from '../../provider/poetry/utils'; import dedent from 'string-dedent'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; describe('Add Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - beforeAll(() => { console.log(chalk`init chalk`); }); - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - afterEach(() => { vol.reset(); vi.resetAllMocks(); }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; + + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); - it('run add target and should add the dependency to the project', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target and should add the dependency to the project', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -93,48 +96,48 @@ describe('Add Executor', () => { [tool.poetry.group.dev.dependencies] pytest = "6.2.4" `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['add', 'numpy'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['add', 'numpy'], { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run add target and should add the dependency to the project group dev', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target and should add the dependency to the project group dev', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -148,53 +151,53 @@ describe('Add Executor', () => { [tool.poetry.group.dev.dependencies] pytest = "6.2.4" `, - }); - - const options = { - name: 'numpy', - local: false, - group: 'dev', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + group: 'dev', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'poetry', - ['add', 'numpy', '--group', 'dev'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['add', 'numpy', '--group', 'dev'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); - it('run add target and should add the dependency to the project extras', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target and should add the dependency to the project extras', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -208,155 +211,157 @@ describe('Add Executor', () => { [tool.poetry.group.dev.dependencies] pytest = "6.2.4" `, - }); - - const options = { - name: 'numpy', - local: false, - extras: ['dev'], - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + extras: ['dev'], + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'poetry', - ['add', 'numpy', '--extras=dev'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); - - it('run add target and should not add the dependency to the project because the project does not exist', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['add', 'numpy', '--extras=dev'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - const options = { - local: true, - name: 'lib1', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run add target and should not add the dependency to the project because the project does not exist', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + local: true, + name: 'lib1', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); - - it('run add target and should throw an exception', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - vi.mocked(spawn.sync).mockImplementation(() => { - throw new Error('fake error'); + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run add target and should throw an exception', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake error'); + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['add', 'numpy'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['add', 'numpy'], { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(false); }); - expect(output.success).toBe(false); - }); - it('run add target and should update all the dependency tree', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target and should update all the dependency tree', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -369,7 +374,7 @@ version = "1.0.0" lib1 = { path = "../../libs/lib1" } `, - 'apps/app1/pyproject.toml': dedent` + 'apps/app1/pyproject.toml': dedent` [tool.poetry] name = "app1" version = "1.0.0" @@ -382,7 +387,7 @@ version = "1.0.0" lib1 = { path = "../../libs/lib1" } `, - 'libs/lib1/pyproject.toml': dedent` + 'libs/lib1/pyproject.toml': dedent` [tool.poetry] name = "lib1" version = "1.0.0" @@ -394,7 +399,7 @@ version = "1.0.0" shared1 = { path = "../shared1" } `, - 'libs/shared1/pyproject.toml': dedent` + 'libs/shared1/pyproject.toml': dedent` [tool.poetry] name = "shared1" version = "1.0.0" @@ -404,110 +409,115 @@ version = "1.0.0" [tool.poetry.dependencies] python = "^3.8" `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'shared1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - app3: { - root: 'apps/app3', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'shared1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + app3: { + root: 'apps/app3', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(7); - expect(spawn.sync).toHaveBeenNthCalledWith(1, 'poetry', ['add', 'numpy'], { - cwd: 'libs/shared1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(7); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['add', 'numpy'], + { + cwd: 'libs/shared1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { cwd: 'libs/lib1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 6, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 6, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { cwd: 'apps/app1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run add target and should update all the dependency tree for dev dependencies', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target and should update all the dependency tree for dev dependencies', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -520,7 +530,7 @@ version = "1.0.0" lib1 = { path = "../../libs/lib1" } `, - 'apps/app1/pyproject.toml': dedent` + 'apps/app1/pyproject.toml': dedent` [tool.poetry] name = "app1" version = "1.0.0" @@ -533,7 +543,7 @@ version = "1.0.0" lib1 = { path = "../../libs/lib1" } `, - 'libs/lib1/pyproject.toml': dedent` + 'libs/lib1/pyproject.toml': dedent` [tool.poetry] name = "lib1" version = "1.0.0" @@ -545,7 +555,7 @@ version = "1.0.0" shared1 = { path = "../shared1" } `, - 'libs/shared1/pyproject.toml': dedent` + 'libs/shared1/pyproject.toml': dedent` [tool.poetry] name = "shared1" version = "1.0.0" @@ -555,259 +565,271 @@ version = "1.0.0" [tool.poetry.dependencies] python = "^3.8" `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'shared1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - app3: { - root: 'apps/app3', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'shared1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + app3: { + root: 'apps/app3', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(7); - expect(spawn.sync).toHaveBeenNthCalledWith(1, 'poetry', ['add', 'numpy'], { - cwd: 'libs/shared1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(7); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['add', 'numpy'], + { + cwd: 'libs/shared1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { cwd: 'libs/lib1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 6, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 6, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { cwd: 'apps/app1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); - - it('run add target with local dependency', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click"`, - - 'libs/lib1/pyproject.toml': `[tool.poetry] -name = "lib1" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8"`, + }); + expect(output.success).toBe(true); }); - const options = { - name: 'lib1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + it('run add target with local dependency', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'lib1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run add target with local dependency with group dev', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click"`, - - 'libs/lib1/pyproject.toml': `[tool.poetry] -name = "lib1" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8"`, - }); - - const options = { - name: 'lib1', - local: true, - group: 'dev', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + it('run add target with local dependency with group dev', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'lib1', + local: true, + group: 'dev', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run add target with local dependency with extras', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target with local dependency with extras', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -819,7 +841,7 @@ version = "1.0.0" click = "click" `, - 'libs/lib1/pyproject.toml': dedent` + 'libs/lib1/pyproject.toml': dedent` [tool.poetry] name = "lib1" version = "1.0.0" @@ -829,64 +851,64 @@ version = "1.0.0" [tool.poetry.dependencies] python = "^3.8" `, - }); - - const options = { - name: 'lib1', - local: true, - extras: ['dev'], - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + }); + + const options = { + name: 'lib1', + local: true, + extras: ['dev'], + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run add target with local dependency with extras group dev', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` + it('run add target with local dependency with extras group dev', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -898,7 +920,7 @@ version = "1.0.0" click = "click" `, - 'libs/lib1/pyproject.toml': dedent` + 'libs/lib1/pyproject.toml': dedent` [tool.poetry] name = "lib1" version = "1.0.0" @@ -908,206 +930,211 @@ version = "1.0.0" [tool.poetry.dependencies] python = "^3.8" `, - }); - - const options = { - name: 'lib1', - local: true, - group: 'dev', - extras: ['dev'], - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + }); + + const options = { + name: 'lib1', + local: true, + group: 'dev', + extras: ['dev'], + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); - - it('run add target with local dependency with project name and package name different', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "dgx-devops-app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click"`, - - 'libs/lib1/pyproject.toml': `[tool.poetry] -name = "dgx-devops-lib1" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8"`, + }); + expect(output.success).toBe(true); }); - const options = { - name: 'lib1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + it('run add target with local dependency with project name and package name different', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "dgx-devops-app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "dgx-devops-lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'lib1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(output.success).toBe(true); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(output.success).toBe(true); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); + }); - const { - tool: { - poetry: { dependencies }, - }, - } = parseToml('apps/app/pyproject.toml'); - - expect(dependencies['dgx-devops-lib1']).toStrictEqual({ - path: '../../libs/lib1', - develop: true, - }); - }); + const { + tool: { + poetry: { dependencies }, + }, + } = parseToml('apps/app/pyproject.toml'); - it('run add target and should add the dependency using custom args', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + expect(dependencies['dgx-devops-lib1']).toStrictEqual({ + path: '../../libs/lib1', + develop: true, + }); }); - const options = { - name: 'numpy', - local: false, - args: '--group dev', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run add target and should add the dependency using custom args', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + name: 'numpy', + local: false, + args: '--group dev', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'poetry', - ['add', 'numpy', '--group', 'dev'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['add', 'numpy', '--group', 'dev'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); - it('run add target and should add the dependency to the project using --lock when the root pyproject.toml is present', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` + it('run add target and should add the dependency to the project using --lock when the root pyproject.toml is present', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -1116,7 +1143,7 @@ version = "1.0.0" python = "^3.8" app = { path = "apps/app", develop = true} `, - 'apps/app/pyproject.toml': dedent` + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -1127,71 +1154,71 @@ version = "1.0.0" python = "^3.8" click = "click" `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['add', 'numpy', '--lock'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['add', 'numpy', '--lock'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); - it('run add target and should add the dependency to the project using --lock when the root pyproject.toml is present when project is grouped in root', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` + it('run add target and should add the dependency to the project using --lock when the root pyproject.toml is present when project is grouped in root', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -1203,7 +1230,7 @@ version = "1.0.0" path = "apps/app" develop = true `, - 'apps/app/pyproject.toml': dedent` + 'apps/app/pyproject.toml': dedent` [tool.poetry] name = "app" version = "1.0.0" @@ -1214,65 +1241,66 @@ version = "1.0.0" python = "^3.8" click = "click" `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['add', 'numpy', '--lock'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['add', 'numpy', '--lock'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); }); }); diff --git a/packages/nx-python/src/executors/add/executor.ts b/packages/nx-python/src/executors/add/executor.ts index b98a570..48ddd1b 100644 --- a/packages/nx-python/src/executors/add/executor.ts +++ b/packages/nx-python/src/executors/add/executor.ts @@ -1,17 +1,7 @@ -import { ExecutorContext, ProjectConfiguration } from '@nx/devkit'; +import { ExecutorContext } from '@nx/devkit'; import { AddExecutorSchema } from './schema'; import chalk from 'chalk'; -import { updateDependencyTree } from '../../dependency/update-dependency'; -import { existsSync } from 'fs-extra'; -import path from 'path'; -import { - activateVenv, - addLocalProjectToPoetryProject, - checkPoetryExecutable, - getLocalDependencyConfig, - runPoetry, - updateProject, -} from '../utils/poetry'; +import { getProvider } from '../../provider'; export default async function executor( options: AddExecutorSchema, @@ -20,45 +10,8 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); - const projectConfig = - context.projectsConfigurations.projects[context.projectName]; - const rootPyprojectToml = existsSync('pyproject.toml'); - - if (options.local) { - console.log( - chalk`\n {bold Adding {bgBlue ${options.name} } workspace dependency...}\n`, - ); - updateLocalProject( - context, - options.name, - projectConfig, - rootPyprojectToml, - options.group, - options.extras, - ); - } else { - console.log( - chalk`\n {bold Adding {bgBlue ${options.name} } dependency...}\n`, - ); - const installArgs = ['add', options.name] - .concat(options.group ? ['--group', options.group] : []) - .concat(options.args ? options.args.split(' ') : []) - .concat( - options.extras ? options.extras.map((ex) => `--extras=${ex}`) : [], - ) - .concat(rootPyprojectToml ? ['--lock'] : []); - - runPoetry(installArgs, { cwd: projectConfig.root }); - } - - updateDependencyTree(context); - - console.log( - chalk`\n {green.bold '${options.name}'} {green dependency has been successfully added to the project}\n`, - ); - + const provider = await getProvider(workspaceRoot); + await provider.add(options, context); return { success: true, }; @@ -69,28 +22,3 @@ export default async function executor( }; } } - -function updateLocalProject( - context: ExecutorContext, - dependencyName: string, - projectConfig: ProjectConfiguration, - updateLockOnly: boolean, - group?: string, - extras?: string[], -) { - const dependencyConfig = getLocalDependencyConfig(context, dependencyName); - - const dependencyPath = path.relative( - projectConfig.root, - dependencyConfig.root, - ); - - addLocalProjectToPoetryProject( - projectConfig, - dependencyConfig, - dependencyPath, - group, - extras, - ); - updateProject(projectConfig.root, updateLockOnly); -} diff --git a/packages/nx-python/src/executors/build/executor.spec.ts b/packages/nx-python/src/executors/build/executor.spec.ts index 97b46fc..c05c33b 100644 --- a/packages/nx-python/src/executors/build/executor.spec.ts +++ b/packages/nx-python/src/executors/build/executor.spec.ts @@ -4,7 +4,7 @@ import { BuildExecutorSchema } from './schema'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; import { uuidMock } from '../../utils/mocks/uuid.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import * as osUtils from '../utils/os'; import executor from './executor'; import { existsSync, readFileSync, mkdirsSync, writeFileSync } from 'fs-extra'; @@ -12,243 +12,56 @@ import { parse } from '@iarna/toml'; import { join } from 'path'; import { tmpdir } from 'os'; import chalk from 'chalk'; -import { PyprojectToml } from '../../graph/dependency-graph'; import dedent from 'string-dedent'; import spawn from 'cross-spawn'; import { SpawnSyncOptions } from 'child_process'; import { ExecutorContext } from '@nx/devkit'; +import { PoetryPyprojectToml } from '../../provider/poetry'; describe('Build Executor', () => { - let buildPath = null; - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - beforeAll(() => { console.log(chalk`init chalk`); }); - beforeEach(() => { - uuidMock.mockReturnValue('abc'); - buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); - - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - afterEach(() => { vol.reset(); vi.resetAllMocks(); }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); - - const options = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); - - it('should throw an error when the lockedVersion is set to true and bundleLocalDependencies to false', async () => { - checkPoetryExecutableMock.mockResolvedValue(undefined); - - const options = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); - - describe('locked resolver', () => { - it('should build python project with local dependencies and keep the build folder', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = false - python-versions = ">=3.7" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" + describe('poetry', () => { + let buildPath = null; + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + beforeEach(() => { + uuidMock.mockReturnValue('abc'); + buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); - 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); - [tool.poetry.dependencies] - python = "^3.8" + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - click==7.1.2 - dep1 @ file://${process.cwd()}/libs/dep1 - numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" - - `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); - const options: BuildExecutorSchema = { + const options = { ignorePaths: ['.venv', '.tox', 'tests/'], silent: false, outputPath: 'dist/apps/app', @@ -258,7 +71,7 @@ describe('Build Executor', () => { bundleLocalDependencies: true, }; - const output = await executor(options, { + const context: ExecutorContext = { cwd: '', root: '.', isVerbose: false, @@ -270,14 +83,6 @@ describe('Build Executor', () => { root: 'apps/app', targets: {}, }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, - }, }, }, nxJsonConfiguration: {}, @@ -285,170 +90,29 @@ describe('Build Executor', () => { dependencies: {}, nodes: {}, }, - }); + }; + const output = await executor(options, context); expect(checkPoetryExecutableMock).toHaveBeenCalled(); expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); - - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; - - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '7.1.2', - numpy: { - version: '1.21.0', - optional: false, - markers: 'python_version >= "3.8" and python_version < "4.0"', - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - - expect(output.success).toBe(true); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - it('should build python project with local dependencies poetry-plugin-export@1.8.0', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = false - python-versions = ">=3.7" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" - - [tool.poetry.dependencies] - python = "^3.8" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); - - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - click==7.1.2 - -e file://${process.cwd()}/libs/dep1 - numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" - - `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + it('should throw an error when the lockedVersion is set to true and bundleLocalDependencies to false', async () => { + checkPoetryExecutableMock.mockResolvedValue(undefined); - const options: BuildExecutorSchema = { + const options = { ignorePaths: ['.venv', '.tox', 'tests/'], silent: false, outputPath: 'dist/apps/app', keepBuildFolder: true, devDependencies: false, lockedVersions: true, - bundleLocalDependencies: true, + bundleLocalDependencies: false, }; - const output = await executor(options, { + const context: ExecutorContext = { cwd: '', root: '.', isVerbose: false, @@ -460,14 +124,6 @@ describe('Build Executor', () => { root: 'apps/app', targets: {}, }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, - }, }, }, nxJsonConfiguration: {}, @@ -475,2494 +131,2848 @@ describe('Build Executor', () => { dependencies: {}, nodes: {}, }, - }); + }; + const output = await executor(options, context); expect(checkPoetryExecutableMock).toHaveBeenCalled(); expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + describe('locked resolver', () => { + it('should build python project with local dependencies and keep the build folder', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '7.1.2', - numpy: { - version: '1.21.0', - optional: false, - markers: 'python_version >= "3.8" and python_version < "4.0"', - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = false + python-versions = ">=3.7" + `, - expect(output.success).toBe(true); - }); + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - it('should build python project with local dependencies Windows', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = false - python-versions = ">=3.7" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" - [tool.poetry.dependencies] - python = "^3.8" + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - vi.spyOn(osUtils, 'isWindows').mockReturnValue(true); + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` click==7.1.2 - -e file:///${process.cwd()}/libs/dep1 + dep1 @ file://${process.cwd()}/libs/dep1 numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" - `, // file:///C:/Users/ (Windows) to C:/Users/ - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; + + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '7.1.2', + numpy: { + version: '1.21.0', + optional: false, + markers: 'python_version >= "3.8" and python_version < "4.0"', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies poetry-plugin-export@1.8.0', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = false + python-versions = ">=3.7" + `, - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '7.1.2', - numpy: { - version: '1.21.0', - optional: false, - markers: 'python_version >= "3.8" and python_version < "4.0"', - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - expect(output.success).toBe(true); - }); + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } - it('should throw an exception when poetry-plugin-export@1.8.0 local project is not a valid poetry project', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = false - python-versions = ">=3.7" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" - 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" - [tool.poetry.dependencies] - python = "^3.8" + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` click==7.1.2 - -e file://${process.cwd()}/libs/dep10 + -e file://${process.cwd()}/libs/dep1 numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(output.success).toBe(false); - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - it('should build python project with git dependency with revision and markers', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "Django" - version = "5.0.dev20230117182751" - description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." - category = "main" - optional = false - python-versions = ">=3.8" - files = [] - develop = false - - [package.source] - type = "git" - url = "https://github.com/django/django.git" - reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" - resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} - `, - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '7.1.2', + numpy: { + version: '1.21.0', + optional: false, + markers: 'python_version >= "3.8" and python_version < "4.0"', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies Windows', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - django @ git+https://github.com/django/django.git@d54717118360e8679aa2bd0c5a1625f3e84712ba ; python_version >= "3.8" and python_version < "3.10" + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = false + python-versions = ">=3.7" `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - django: { - git: 'https://github.com/django/django.git', - optional: false, - rev: 'd54717118360e8679aa2bd0c5a1625f3e84712ba', - markers: 'python_version >= "3.8" and python_version < "3.10"', - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" - expect(output.success).toBe(true); - }); + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - it('should build python project with git dependency without revision and markers', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "Django" - version = "5.0.dev20230117182751" - description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." - category = "main" - optional = false - python-versions = ">=3.8" - files = [] - develop = false - - [package.source] - type = "git" - url = "https://github.com/django/django.git" - reference = "HEAD" - resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} - `, - }); + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - django @ git+https://github.com/django/django.git" + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + vi.spyOn(osUtils, 'isWindows').mockReturnValue(true); - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + click==7.1.2 + -e file:///${process.cwd()}/libs/dep1 + numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" + + `, // file:///C:/Users/ (Windows) to C:/Users/ + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '7.1.2', + numpy: { + version: '1.21.0', + optional: false, + markers: 'python_version >= "3.8" and python_version < "4.0"', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should throw an exception when poetry-plugin-export@1.8.0 local project is not a valid poetry project', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - django: { - git: 'https://github.com/django/django.git', - optional: false, - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = false + python-versions = ">=3.7" + `, - expect(output.success).toBe(true); - }); + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - it('should build python project with git dependency with extras', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "Django" - version = "5.0.dev20230117182751" - description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." - category = "main" - optional = false - python-versions = ">=3.8" - files = [] - develop = false - - [package.source] - type = "git" - url = "https://github.com/django/django.git" - reference = "HEAD" - resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba", extras = ["argon2"]} - `, - }); + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - django[argon2] @ git+https://github.com/django/django.git" + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" - expect(output.success).toBe(true); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - django: { - git: 'https://github.com/django/django.git', - optional: false, - extras: ['argon2'], - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - }); + [tool.poetry.dependencies] + python = "^3.8" - it('should throw an exception when the source type is not supported', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "Django" - version = "5.0.dev20230117182751" - - [package.source] - type = "invalid" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} - `, - }); + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + click==7.1.2 + -e file://${process.cwd()}/libs/dep10 + numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - django @ git+https://github.com/django/django.git" `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(output.success).toBe(false); + }); + + it('should build python project with git dependency with revision and markers', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "Django" + version = "5.0.dev20230117182751" + description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." + category = "main" + optional = false + python-versions = ">=3.8" + files = [] + develop = false + + [package.source] + type = "git" + url = "https://github.com/django/django.git" + reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" + resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" + `, - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + django @ git+https://github.com/django/django.git@d54717118360e8679aa2bd0c5a1625f3e84712ba ; python_version >= "3.8" and python_version < "3.10" + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(false); - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - it('should build python project with local dependencies with poetry plugins', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = false - python-versions = ">=3.7" - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - - [tool.poetry.plugins."test.a"] - "test_a" = "dep1.index:TestA" - "test_aa" = "dep1.index:TestAA" - - [tool.poetry.plugins."test.b"] - "test_b" = "dep1.index:TestB" - "test_bb" = "dep1.index:TestBB" - - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" - `, - - 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" - - [tool.poetry.dependencies] - python = "^3.8" - `, - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + django: { + git: 'https://github.com/django/django.git', + optional: false, + rev: 'd54717118360e8679aa2bd0c5a1625f3e84712ba', + markers: 'python_version >= "3.8" and python_version < "3.10"', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with git dependency without revision and markers', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "Django" + version = "5.0.dev20230117182751" + description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." + category = "main" + optional = false + python-versions = ">=3.8" + files = [] + develop = false + + [package.source] + type = "git" + url = "https://github.com/django/django.git" + reference = "HEAD" + resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" + `, - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - click==7.1.2 - dep1 @ file://${process.cwd()}/libs/dep1 - numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + [tool.poetry.dependencies] + python = "^3.8" + django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + django @ git+https://github.com/django/django.git" + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + django: { + git: 'https://github.com/django/django.git', + optional: false, + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with git dependency with extras', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "Django" + version = "5.0.dev20230117182751" + description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." + category = "main" + optional = false + python-versions = ">=3.8" + files = [] + develop = false + + [package.source] + type = "git" + url = "https://github.com/django/django.git" + reference = "HEAD" + resolved_reference = "d54717118360e8679aa2bd0c5a1625f3e84712ba" + `, - expect(projectTomlData.tool.poetry.plugins).toStrictEqual({ - 'test.a': { test_a: 'dep1.index:TestA', test_aa: 'dep1.index:TestAA' }, - 'test.b': { test_b: 'dep1.index:TestB', test_bb: 'dep1.index:TestBB' }, - }); + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '7.1.2', - numpy: { - version: '1.21.0', - optional: false, - markers: 'python_version >= "3.8" and python_version < "4.0"', - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + [tool.poetry.dependencies] + python = "^3.8" + django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba", extras = ["argon2"]} + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + django[argon2] @ git+https://github.com/django/django.git" + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; - expect(output.success).toBe(true); - }); + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(output.success).toBe(true); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - it('should build python project with local dependencies that specify a "from" directory', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = false - python-versions = "^3.8" - develop = false - - [package.source] - type = "directory" - url = "../../libs/dep1" - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - - 'libs/dep1/src/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - - [[tool.poetry.packages]] - include = "dep1" - from = "src" - - [tool.poetry.dependencies] - python = "^3.8" - `, - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - dep1 @ file://${process.cwd()}/libs/dep1 - `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + django: { + git: 'https://github.com/django/django.git', + optional: false, + extras: ['argon2'], + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + }); + + it('should throw an exception when the source type is not supported', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "Django" + version = "5.0.dev20230117182751" + + [package.source] + type = "invalid" + `, - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, + [tool.poetry.dependencies] + python = "^3.8" + django = {git = "https://github.com/django/django.git", rev = "d54717118360e8679aa2bd0c5a1625f3e84712ba"} + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + django @ git+https://github.com/django/django.git" + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(false); + }); + + it('should build python project with local dependencies with poetry plugins', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = false + python-versions = ">=3.7" + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } + `, - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); + [tool.poetry.plugins."test.a"] + "test_a" = "dep1.index:TestA" + "test_aa" = "dep1.index:TestAA" - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - }); + [tool.poetry.plugins."test.b"] + "test_b" = "dep1.index:TestB" + "test_bb" = "dep1.index:TestBB" - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); + [[tool.poetry.packages]] + include = "dep1" - expect(output.success).toBe(true); - }); + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" + `, - it('should build python project with local dependencies and extras', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "pendulum" - version = "2.1.2" - description = "Python datetimes made easy" - category = "main" - optional = true - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - - [[package]] - name = "dep1" - version = "1.0.0" - description = "Dep1" - category = "main" - optional = true - python-versions = "^3.8" - develop = false - - [package.dependencies] - numpy = "1.21.0" - - [package.source] - type = "directory" - url = "../../libs/dep1" - - [[package]] - name = "numpy" - version = "1.21.0" - description = "NumPy is the fundamental package for array computing with Python." - category = "main" - optional = true - python-versions = ">=3.7" - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - pendulum = { version = "2.1.2", optional = true } - dep1 = { path = "../../libs/dep1", optional = true } - - [tool.poetry.extras] - extra1 = ["pendulum", "dep1"] - `, - - 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "1.21.0" - `, - }); + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` click==7.1.2 - pendulum==2.1.2 dep1 @ file://${process.cwd()}/libs/dep1 numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" + `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(true); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - [ - 'export', - '--format', - 'requirements.txt', - '--without-hashes', - '--without-urls', - '--output', - `${buildPath}/requirements.txt`, - '--extras', - 'extra1', - ], - { - cwd: 'apps/app', + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '7.1.2', - numpy: { - version: '1.21.0', - optional: true, - markers: 'python_version >= "3.8" and python_version < "4.0"', - }, - pendulum: { - optional: true, - version: '2.1.2', - }, - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); - expect(projectTomlData.tool.poetry.extras).toStrictEqual({ - extra1: ['pendulum', 'numpy'], - }); + expect(projectTomlData.tool.poetry.plugins).toStrictEqual({ + 'test.a': { + test_a: 'dep1.index:TestA', + test_aa: 'dep1.index:TestAA', + }, + 'test.b': { + test_b: 'dep1.index:TestB', + test_bb: 'dep1.index:TestBB', + }, + }); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '7.1.2', + numpy: { + version: '1.21.0', + optional: false, + markers: 'python_version >= "3.8" and python_version < "4.0"', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies that specify a "from" directory', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = false + python-versions = "^3.8" + develop = false - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - }); + [package.source] + type = "directory" + url = "../../libs/dep1" + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, - it('should build python project with dependencies with extras', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "moto" - version = "2.3.2" - description = "A library that allows your python tests to easily mock out the boto library" - category = "dev" - optional = false - python-versions = "*" - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - moto = {extras = ["s3", "sqs"], version = "2.3.2"} - `, - }); + 'libs/dep1/src/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync( - join(buildPath, 'requirements.txt'), - dedent` - moto[s3,sqs]==2.3.2 + [[tool.poetry.packages]] + include = "dep1" + from = "src" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + dep1 @ file://${process.cwd()}/libs/dep1 `, - ); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - moto: { - version: '2.3.2', - optional: false, - extras: ['s3', 'sqs'], - }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - expect(output.success).toBe(true); - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + }); + + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies and extras', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "pendulum" + version = "2.1.2" + description = "Python datetimes made easy" + category = "main" + optional = true + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + + [[package]] + name = "dep1" + version = "1.0.0" + description = "Dep1" + category = "main" + optional = true + python-versions = "^3.8" + develop = false + + [package.dependencies] + numpy = "1.21.0" - it('should throw an exception when the package is not found in the poetry.lock', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "moto" - version = "2.3.2" - description = "A library that allows your python tests to easily mock out the boto library" - category = "dev" - optional = false - python-versions = "*" - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - `, - }); + [package.source] + type = "directory" + url = "../../libs/dep1" + + [[package]] + name = "numpy" + version = "1.21.0" + description = "NumPy is the fundamental package for array computing with Python." + category = "main" + optional = true + python-versions = ">=3.7" + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + pendulum = { version = "2.1.2", optional = true } + dep1 = { path = "../../libs/dep1", optional = true } + + [tool.poetry.extras] + extra1 = ["pendulum", "dep1"] + `, - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync(join(buildPath, 'requirements.txt'), 'click==7.1.2'); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: false, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; + [[tool.poetry.packages]] + include = "dep1" - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + [tool.poetry.dependencies] + python = "^3.8" + numpy = "1.21.0" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + click==7.1.2 + pendulum==2.1.2 + dep1 @ file://${process.cwd()}/libs/dep1 + numpy==1.21.0; python_version >= "3.8" and python_version < "4.0" + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + [ + 'export', + '--format', + 'requirements.txt', + '--without-hashes', + '--without-urls', + '--output', + `${buildPath}/requirements.txt`, + '--extras', + 'extra1', + ], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - it('should build python project with dependencies and delete the build folder', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/poetry.lock': dedent` - [[package]] - name = "click" - version = "7.1.2" - description = "Composable command line interface toolkit" - category = "main" - optional = false - python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - `, - - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - - [tool.poetry.group.dev.dependencies] - click = "7.1.2" - `, - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - writeFileSync(join(buildPath, 'requirements.txt'), 'click==7.1.2'); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '7.1.2', + numpy: { + version: '1.21.0', + optional: true, + markers: 'python_version >= "3.8" and python_version < "4.0"', + }, + pendulum: { + optional: true, + version: '2.1.2', + }, + }); + + expect(projectTomlData.tool.poetry.extras).toStrictEqual({ + extra1: ['pendulum', 'numpy'], + }); + + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + }); + + it('should build python project with dependencies with extras', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "moto" + version = "2.3.2" + description = "A library that allows your python tests to easily mock out the boto library" + category = "dev" + optional = false + python-versions = "*" + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + moto = {extras = ["s3", "sqs"], version = "2.3.2"} + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync( + join(buildPath, 'requirements.txt'), + dedent` + moto[s3,sqs]==2.3.2 + `, + ); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: false, - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(existsSync(buildPath)).not.toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); - - it('should throw an exception when runs build', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "7.1.2" - dep1 = { path = "../../libs/dep1" } - `, - }); - - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - if (args[0] == 'build') { - spawnBuildMockImpl(opts); - } else if (args[0] == 'export' && opts.cwd === 'apps/app') { - throw Error('Poetry export error'); - } - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + moto: { + version: '2.3.2', + optional: false, + extras: ['s3', 'sqs'], + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + expect(output.success).toBe(true); + }); + + it('should throw an exception when the package is not found in the poetry.lock', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "moto" + version = "2.3.2" + description = "A library that allows your python tests to easily mock out the boto library" + category = "dev" + optional = false + python-versions = "*" + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync(join(buildPath, 'requirements.txt'), 'click==7.1.2'); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: false, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: true, - bundleLocalDependencies: true, - }; - - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(false); + }); + + it('should build python project with dependencies and delete the build folder', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/poetry.lock': dedent` + [[package]] + name = "click" + version = "7.1.2" + description = "Composable command line interface toolkit" + category = "main" + optional = false + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + `, - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(false); - }); - }); + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" - describe('project resolver', () => { - it('should throw an exception when the local dependency cannot be found on the workspace config', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'apps/dep1/.venv/pyvenv.cfg': 'fake', - 'apps/dep1/dep1/index.py': 'print("Hello from app")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + click = "7.1.2" + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + writeFileSync(join(buildPath, 'requirements.txt'), 'click==7.1.2'); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: false, + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - spawnBuildMockImpl(opts); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(existsSync(buildPath)).not.toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); + }); + + it('should throw an exception when runs build', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "7.1.2" + dep1 = { path = "../../libs/dep1" } + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + if (args[0] == 'build') { + spawnBuildMockImpl(opts); + } else if (args[0] == 'export' && opts.cwd === 'apps/app') { + throw Error('Poetry export error'); + } + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: false, - bundleLocalDependencies: false, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(false); - expect(existsSync(buildPath)).toBeTruthy(); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(false); + }); }); - it('should build the project without locked versions and without bundle the local dependencies', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'apps/dep1/.venv/pyvenv.cfg': 'fake', - 'apps/dep1/dep1/index.py': 'print("Hello from app")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + describe('project resolver', () => { + it('should throw an exception when the local dependency cannot be found on the workspace config', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^7.1.2" + dep1 = { path = "../../libs/dep1" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - spawnBuildMockImpl(opts); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + 'apps/dep1/.venv/pyvenv.cfg': 'fake', + 'apps/dep1/dep1/index.py': 'print("Hello from app")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: false, - bundleLocalDependencies: false, - }; + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(false); + expect(existsSync(buildPath)).toBeTruthy(); + }); + + it('should build the project without locked versions and without bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^7.1.2" + dep1 = { path = "../../libs/dep1" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(true); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + 'apps/dep1/.venv/pyvenv.cfg': 'fake', + 'apps/dep1/dep1/index.py': 'print("Hello from app")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '^7.1.2', - dep1: '1.0.0', - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - }); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - it('should build the project without locked versions and bundle the local dependencies', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/.venv/pyvenv.cfg': 'fake', - 'libs/dep1/dep1/index.py': 'print("Hello from app")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '^7.1.2', + dep1: '1.0.0', + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^7.1.2" + dep1 = { path = "../../libs/dep1" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - spawnBuildMockImpl(opts); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; - }); + 'libs/dep1/.venv/pyvenv.cfg': 'fake', + 'libs/dep1/dep1/index.py': 'print("Hello from app")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: false, - bundleLocalDependencies: false, - }; + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: { - build: { - options: { - publish: false, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, }, }, }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(true); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); - - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '^7.1.2', - numpy: '^1.21.0', - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '^7.1.2', + numpy: '^1.21.0', + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle only local dependency and not the second level', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^7.1.2" + dep1 = { path = "../../libs/dep1" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - it('should build the project without locked versions and bundle only local dependency and not the second level', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^7.1.2" - dep1 = { path = "../../libs/dep1" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/.venv/pyvenv.cfg': 'fake', - 'libs/dep1/dep1/index.py': 'print("Hello from app")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - django = { version = "^4.1.5", extras = ["argon2"] } - dep2 = { path = "../dep2" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - 'libs/dep2/.venv/pyvenv.cfg': 'fake', - 'libs/dep2/dep2/index.py': 'print("Hello from app")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + 'libs/dep1/.venv/pyvenv.cfg': 'fake', + 'libs/dep1/dep1/index.py': 'print("Hello from app")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" + + [tool.poetry.dependencies] + python = "^3.8" + django = { version = "^4.1.5", extras = ["argon2"] } + dep2 = { path = "../dep2" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + 'libs/dep2/.venv/pyvenv.cfg': 'fake', + 'libs/dep2/dep2/index.py': 'print("Hello from app")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - spawnBuildMockImpl(opts); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: false, - bundleLocalDependencies: false, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: { - build: { - options: { - publish: false, + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, }, }, }, - }, - dep2: { - root: 'libs/dep2', - targets: { - build: { - options: { - publish: true, - customSourceName: 'foo', - customSourceUrl: 'http://example.com/bar', + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, }, }, }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(true); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - expect(projectTomlData.tool.poetry.source).toStrictEqual([ - { - name: 'foo', - url: 'http://example.com/bar', - }, - ]); + expect(projectTomlData.tool.poetry.source).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/bar', + }, + ]); - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - { - include: 'dep1', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '^7.1.2', - django: { - version: '^4.1.5', - extras: ['argon2'], - }, - dep2: { version: '1.0.0', source: 'foo' }, - }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); - }); + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + { + include: 'dep1', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '^7.1.2', + django: { + version: '^4.1.5', + extras: ['argon2'], + }, + dep2: { version: '1.0.0', source: 'foo' }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); + }); + + it('should build the project without locked versions and handle duplicate sources', async () => { + vol.fromJSON({ + 'apps/app/.venv/pyvenv.cfg': 'fake', + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^7.1.2" + dep1 = { path = "../../libs/dep1" } + dep2 = { path = "../../libs/dep2" } + dep3 = { path = "../../libs/dep3" } + dep4 = { path = "../../libs/dep4" } + dep5 = { path = "../../libs/dep5" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, - it('should build the project without locked versions and handle duplicate sources', async () => { - vol.fromJSON({ - 'apps/app/.venv/pyvenv.cfg': 'fake', - 'apps/app/app/index.py': 'print("Hello from app")', - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^7.1.2" - dep1 = { path = "../../libs/dep1" } - dep2 = { path = "../../libs/dep2" } - dep3 = { path = "../../libs/dep3" } - dep4 = { path = "../../libs/dep4" } - dep5 = { path = "../../libs/dep5" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - - 'libs/dep1/.venv/pyvenv.cfg': 'fake', - 'libs/dep1/dep1/index.py': 'print("Hello from app")', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep1" - - [tool.poetry.dependencies] - python = "^3.8" - django = { version = "^4.1.5", extras = ["argon2"] } - dep2 = { path = "../dep2" } - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - 'libs/dep2/.venv/pyvenv.cfg': 'fake', - 'libs/dep2/dep2/index.py': 'print("Hello from app")', - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep2" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - 'libs/dep3/.venv/pyvenv.cfg': 'fake', - 'libs/dep3/dep3/index.py': 'print("Hello from app")', - 'libs/dep3/pyproject.toml': dedent` - [tool.poetry] - name = "dep3" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep3" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - 'libs/dep4/.venv/pyvenv.cfg': 'fake', - 'libs/dep4/dep4/index.py': 'print("Hello from app")', - 'libs/dep4/pyproject.toml': dedent` - [tool.poetry] - name = "dep4" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep3" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - 'libs/dep5/.venv/pyvenv.cfg': 'fake', - 'libs/dep5/dep5/index.py': 'print("Hello from app")', - 'libs/dep5/pyproject.toml': dedent` - [tool.poetry] - name = "dep5" - version = "1.0.0" - [[tool.poetry.packages]] - include = "dep3" - - [tool.poetry.dependencies] - python = "^3.8" - numpy = "^1.21.0" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); + 'libs/dep1/.venv/pyvenv.cfg': 'fake', + 'libs/dep1/dep1/index.py': 'print("Hello from app")', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep1" + + [tool.poetry.dependencies] + python = "^3.8" + django = { version = "^4.1.5", extras = ["argon2"] } + dep2 = { path = "../dep2" } + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + 'libs/dep2/.venv/pyvenv.cfg': 'fake', + 'libs/dep2/dep2/index.py': 'print("Hello from app")', + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep2" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + 'libs/dep3/.venv/pyvenv.cfg': 'fake', + 'libs/dep3/dep3/index.py': 'print("Hello from app")', + 'libs/dep3/pyproject.toml': dedent` + [tool.poetry] + name = "dep3" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep3" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + 'libs/dep4/.venv/pyvenv.cfg': 'fake', + 'libs/dep4/dep4/index.py': 'print("Hello from app")', + 'libs/dep4/pyproject.toml': dedent` + [tool.poetry] + name = "dep4" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep3" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + 'libs/dep5/.venv/pyvenv.cfg': 'fake', + 'libs/dep5/dep5/index.py': 'print("Hello from app")', + 'libs/dep5/pyproject.toml': dedent` + [tool.poetry] + name = "dep5" + version = "1.0.0" + [[tool.poetry.packages]] + include = "dep3" + + [tool.poetry.dependencies] + python = "^3.8" + numpy = "^1.21.0" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); - vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { - spawnBuildMockImpl(opts); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, }; - }); - - const options: BuildExecutorSchema = { - ignorePaths: ['.venv', '.tox', 'tests/'], - silent: false, - outputPath: 'dist/apps/app', - keepBuildFolder: true, - devDependencies: false, - lockedVersions: false, - bundleLocalDependencies: false, - }; - const output = await executor(options, { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: { - build: { - options: { - publish: true, - customSourceName: 'foo', - customSourceUrl: 'http://example.com/foo', + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/foo', + }, }, }, }, - }, - dep2: { - root: 'libs/dep2', - targets: { - build: { - options: { - publish: true, - customSourceName: 'foo', - customSourceUrl: 'http://example.com/bar', + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, }, }, }, - }, - dep3: { - root: 'libs/dep3', - targets: { - build: { - options: { - publish: true, - customSourceName: 'foo', - customSourceUrl: 'http://example.com/bar', + dep3: { + root: 'libs/dep3', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, }, }, }, - }, - dep4: { - root: 'libs/dep4', - targets: { - build: { - options: { - publish: true, - customSourceName: 'another', - customSourceUrl: 'http://example.com/another', + dep4: { + root: 'libs/dep4', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, }, }, }, - }, - dep5: { - root: 'libs/dep5', - targets: { - build: { - options: { - publish: true, - customSourceName: 'another', - customSourceUrl: 'http://example.com/another', + dep5: { + root: 'libs/dep5', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, }, }, }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }); - - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(output.success).toBe(true); - expect(existsSync(buildPath)).toBeTruthy(); - expect(existsSync(`${buildPath}/app`)).toBeTruthy(); - expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { - cwd: buildPath, - shell: false, - stdio: 'inherit', - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); - const projectTomlData = parse( - readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), - ) as PyprojectToml; + const projectTomlData = parse( + readFileSync(`${buildPath}/pyproject.toml`).toString('utf-8'), + ) as PoetryPyprojectToml; - expect(projectTomlData.tool.poetry.source).toStrictEqual([ - { - name: 'foo', - url: 'http://example.com/foo', - }, - { - name: 'foo-198fb9d8236b3d9116a180365e447b05', - url: 'http://example.com/bar', - }, - { - name: 'another', - url: 'http://example.com/another', - }, - ]); + expect(projectTomlData.tool.poetry.source).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/foo', + }, + { + name: 'foo-198fb9d8236b3d9116a180365e447b05', + url: 'http://example.com/bar', + }, + { + name: 'another', + url: 'http://example.com/another', + }, + ]); - expect(projectTomlData.tool.poetry.packages).toStrictEqual([ - { - include: 'app', - }, - ]); - - expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ - python: '^3.8', - click: '^7.1.2', - dep1: { version: '1.0.0', source: 'foo' }, - dep2: { - version: '1.0.0', - source: 'foo-198fb9d8236b3d9116a180365e447b05', - }, - dep3: { - version: '1.0.0', - source: 'foo-198fb9d8236b3d9116a180365e447b05', - }, - dep4: { - version: '1.0.0', - source: 'another', - }, - dep5: { - version: '1.0.0', - source: 'another', - }, + expect(projectTomlData.tool.poetry.packages).toStrictEqual([ + { + include: 'app', + }, + ]); + + expect(projectTomlData.tool.poetry.dependencies).toStrictEqual({ + python: '^3.8', + click: '^7.1.2', + dep1: { version: '1.0.0', source: 'foo' }, + dep2: { + version: '1.0.0', + source: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep3: { + version: '1.0.0', + source: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep4: { + version: '1.0.0', + source: 'another', + }, + dep5: { + version: '1.0.0', + source: 'another', + }, + }); + expect( + projectTomlData.tool.poetry.group.dev.dependencies, + ).toStrictEqual({}); }); - expect(projectTomlData.tool.poetry.group.dev.dependencies).toStrictEqual( - {}, - ); }); }); }); diff --git a/packages/nx-python/src/executors/build/executor.ts b/packages/nx-python/src/executors/build/executor.ts index 18836b8..3b3774c 100644 --- a/packages/nx-python/src/executors/build/executor.ts +++ b/packages/nx-python/src/executors/build/executor.ts @@ -1,33 +1,8 @@ import { ExecutorContext } from '@nx/devkit'; import { BuildExecutorOutput, BuildExecutorSchema } from './schema'; -import { - readdirSync, - copySync, - readFileSync, - writeFileSync, - mkdirSync, - removeSync, -} from 'fs-extra'; -import { join } from 'path'; -import { - PyprojectToml, - PyprojectTomlDependency, -} from '../../graph/dependency-graph'; -import { parse, stringify } from '@iarna/toml'; -import { tmpdir } from 'os'; -import { v4 as uuid } from 'uuid'; import chalk from 'chalk'; import { Logger } from '../utils/logger'; -import { - activateVenv, - checkPoetryExecutable, - runPoetry, -} from '../utils/poetry'; -import { - LockedDependencyResolver, - ProjectDependencyResolver, -} from './resolvers'; -import { Dependency } from './resolvers/types'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -39,89 +14,8 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); - if ( - options.lockedVersions === true && - options.bundleLocalDependencies === false - ) { - throw new Error( - 'Not supported operations, you cannot use lockedVersions without bundleLocalDependencies', - ); - } - - logger.info( - chalk`\n {bold Building project {bgBlue ${context.projectName} }...}\n`, - ); - - const { root } = - context.projectsConfigurations.projects[context.projectName]; - - const buildFolderPath = join(tmpdir(), 'nx-python', 'build', uuid()); - - mkdirSync(buildFolderPath, { recursive: true }); - - logger.info(chalk` Copying project files to a temporary folder`); - readdirSync(root).forEach((file) => { - if (!options.ignorePaths.includes(file)) { - const source = join(root, file); - const target = join(buildFolderPath, file); - copySync(source, target); - } - }); - - const buildPyProjectToml = join(buildFolderPath, 'pyproject.toml'); - - const buildTomlData = parse( - readFileSync(buildPyProjectToml).toString('utf-8'), - ) as PyprojectToml; - - const deps = resolveDependencies( - options, - root, - buildFolderPath, - buildTomlData, - workspaceRoot, - context, - ); - - const pythonDependency = buildTomlData.tool.poetry.dependencies.python; - - buildTomlData.tool.poetry.dependencies = {}; - buildTomlData.tool.poetry.group = { - dev: { - dependencies: {}, - }, - }; - - if (pythonDependency) { - buildTomlData.tool.poetry.dependencies['python'] = pythonDependency; - } - - for (const dep of deps) { - const pyprojectDep = parseToPyprojectDependency(dep); - buildTomlData.tool.poetry.dependencies[dep.name] = pyprojectDep; - } - - writeFileSync(buildPyProjectToml, stringify(buildTomlData)); - const distFolder = join(buildFolderPath, 'dist'); - - removeSync(distFolder); - - logger.info(chalk` Generating sdist and wheel artifacts`); - const buildArgs = ['build']; - runPoetry(buildArgs, { cwd: buildFolderPath }); - - removeSync(options.outputPath); - mkdirSync(options.outputPath, { recursive: true }); - logger.info( - chalk` Artifacts generated at {bold ${options.outputPath}} folder`, - ); - copySync(distFolder, options.outputPath); - - if (!options.keepBuildFolder) { - removeSync(buildFolderPath); - } + const provider = await getProvider(context.root, logger); + const buildFolderPath = await provider.build(options, context); return { buildFolderPath, @@ -135,44 +29,3 @@ export default async function executor( }; } } - -function parseToPyprojectDependency(dep: Dependency): PyprojectTomlDependency { - if (dep.markers || dep.optional || dep.extras || dep.git || dep.source) { - return { - version: dep.version, - markers: dep.markers, - optional: dep.optional, - extras: dep.extras, - git: dep.git, - rev: dep.rev, - source: dep.source, - }; - } else { - return dep.version; - } -} - -function resolveDependencies( - options: BuildExecutorSchema, - root: string, - buildFolderPath: string, - buildTomlData: PyprojectToml, - workspaceRoot: string, - context: ExecutorContext, -) { - if (options.lockedVersions) { - return new LockedDependencyResolver(logger).resolve( - root, - buildFolderPath, - buildTomlData, - options.devDependencies, - workspaceRoot, - ); - } else { - return new ProjectDependencyResolver(logger, options, context).resolve( - root, - buildFolderPath, - buildTomlData, - ); - } -} diff --git a/packages/nx-python/src/executors/flake8/executor.spec.ts b/packages/nx-python/src/executors/flake8/executor.spec.ts index f6d25a4..ca4d59f 100644 --- a/packages/nx-python/src/executors/flake8/executor.spec.ts +++ b/packages/nx-python/src/executors/flake8/executor.spec.ts @@ -3,7 +3,7 @@ import { vol } from 'memfs'; import chalk from 'chalk'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -14,30 +14,6 @@ import { ExecutorContext } from '@nx/devkit'; describe('Flake8 Executor', () => { let tmppath = null; - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - - beforeEach(() => { - tmppath = join(tmpdir(), 'nx-python', 'flake8', uuid()); - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); beforeAll(() => { console.log(chalk`init chalk`); @@ -48,62 +24,43 @@ describe('Flake8 Executor', () => { vi.resetAllMocks(); }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; - const options = { - outputFile: '', - silent: false, - }; + beforeEach(() => { + tmppath = join(tmpdir(), 'nx-python', 'flake8', uuid()); + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); - it('should execute flake8 linting', async () => { - const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); - vi.mocked(spawn.sync).mockImplementation(() => { - writeFileSync(outputFile, '', { encoding: 'utf8' }); - return { + vi.mocked(spawn.sync).mockReturnValue({ status: 0, output: [''], pid: 0, signal: null, stderr: null, stdout: null, - }; + }); + + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - const output = await executor( - { - outputFile, + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + outputFile: '', silent: false, - }, - { + }; + + const context: ExecutorContext = { cwd: '', root: '.', isVerbose: false, @@ -122,143 +79,191 @@ describe('Flake8 Executor', () => { dependencies: {}, nodes: {}, }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(output.success).toBe(true); - }); - - it('should execute flake8 linting when the reports folder already exists', async () => { - mkdirsSync(join(tmppath, 'reports/apps/app')); - const outputFile = join(tmppath, 'reports/apps/app/pylint.vi.mocked(txt'); - vi.mocked(spawn.sync).mockImplementation(() => { - writeFileSync(outputFile, '', { encoding: 'utf8' }); - - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor( - { - outputFile, - silent: false, - }, - { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should execute flake8 linting', async () => { + const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, '', { encoding: 'utf8' }); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(output.success).toBe(true); - }); - - it('should returns a error when run the flake8 CLI', async () => { - vi.mocked(spawn.sync).mockImplementation(() => { - throw new Error('Some error'); + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); }); - const output = await executor( - { - outputFile: join(tmppath, 'reports/apps/app/pylint.txt'), - silent: false, - }, - { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should execute flake8 linting when the reports folder already exists', async () => { + mkdirsSync(join(tmppath, 'reports/apps/app')); + const outputFile = join(tmppath, 'reports/apps/app/pylint.vi.mocked(txt'); + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, '', { encoding: 'utf8' }); + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(output.success).toBe(false); - }); - - it('should execute flake8 linting with pylint content more than 1 line', async () => { - mkdirsSync(join(tmppath, 'reports/apps/app')); - const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); - vi.mocked(spawn.sync).mockImplementation(() => { - writeFileSync(outputFile, 'test\n', { encoding: 'utf8' }); - return { - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }; + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); }); - const output = await executor( - { - outputFile, - silent: false, - }, - { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should returns a error when run the flake8 CLI', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('Some error'); + }); + + const output = await executor( + { + outputFile: join(tmppath, 'reports/apps/app/pylint.txt'), + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(output.success).toBe(false); + }); + + it('should execute flake8 linting with pylint content more than 1 line', async () => { + mkdirsSync(join(tmppath, 'reports/apps/app')); + const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, 'test\n', { encoding: 'utf8' }); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(output.success).toBe(false); + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(output.success).toBe(false); + }); }); }); diff --git a/packages/nx-python/src/executors/flake8/executor.ts b/packages/nx-python/src/executors/flake8/executor.ts index d3d19b2..ebf668c 100644 --- a/packages/nx-python/src/executors/flake8/executor.ts +++ b/packages/nx-python/src/executors/flake8/executor.ts @@ -4,11 +4,7 @@ import { Logger } from '../utils/logger'; import { Flake8ExecutorSchema } from './schema'; import path from 'path'; import { mkdirsSync, existsSync, readFileSync, rmSync } from 'fs-extra'; -import { - activateVenv, - checkPoetryExecutable, - runPoetry, -} from '../utils/poetry'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -20,8 +16,6 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); logger.info( chalk`\n {bold Running flake8 linting on project {bgBlue ${context.projectName} }...}\n`, ); @@ -40,8 +34,13 @@ export default async function executor( rmSync(absPath, { force: true }); } - const lintingArgs = ['run', 'flake8', '--output-file', absPath]; - runPoetry(lintingArgs, { cwd, log: false, error: false }); + const lintingArgs = ['flake8', '--output-file', absPath]; + const provider = await getProvider(workspaceRoot); + await provider.run(lintingArgs, workspaceRoot, { + cwd, + log: false, + error: false, + }); const output = readFileSync(absPath, 'utf8'); const lines = output.split('\n').length; diff --git a/packages/nx-python/src/executors/install/executor.spec.ts b/packages/nx-python/src/executors/install/executor.spec.ts index 61e27fc..77e57c4 100644 --- a/packages/nx-python/src/executors/install/executor.spec.ts +++ b/packages/nx-python/src/executors/install/executor.spec.ts @@ -1,14 +1,12 @@ import { vi, MockInstance } from 'vitest'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import path from 'path'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; describe('Install Executor', () => { - let checkPoetryExecutableMock: MockInstance; - const context: ExecutorContext = { cwd: '', root: '.', @@ -30,172 +28,178 @@ describe('Install Executor', () => { }, }; - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); - - const options = { - silent: false, - debug: false, - verbose: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + silent: false, + debug: false, + verbose: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('should install the poetry dependencies using default values', async () => { - const options = { - silent: false, - debug: false, - verbose: false, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { - stdio: 'inherit', - shell: false, - cwd: 'apps/app', + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - expect(output.success).toBe(true); - }); - it('should install the poetry dependencies with args', async () => { - const options = { - silent: false, - debug: false, - verbose: false, - args: '--no-dev', - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith( - 'poetry', - ['install', '-v', '--no-dev'], - { + it('should install the poetry dependencies using default values', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { stdio: 'inherit', shell: false, cwd: 'apps/app', - }, - ); - expect(output.success).toBe(true); - }); + }); + expect(output.success).toBe(true); + }); - it('should install the poetry dependencies with verbose flag', async () => { - const options = { - silent: false, - debug: false, - verbose: true, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-vv'], { - stdio: 'inherit', - shell: false, - cwd: 'apps/app', + it('should install the poetry dependencies with args', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + args: '--no-dev', + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['install', '-v', '--no-dev'], + { + stdio: 'inherit', + shell: false, + cwd: 'apps/app', + }, + ); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('should install the poetry dependencies with debug flag', async () => { - const options = { - silent: false, - debug: true, - verbose: false, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-vv'], { - stdio: 'inherit', - shell: false, - cwd: 'apps/app', + it('should install the poetry dependencies with verbose flag', async () => { + const options = { + silent: false, + debug: false, + verbose: true, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-vv'], { + stdio: 'inherit', + shell: false, + cwd: 'apps/app', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('should install the poetry dependencies with custom cache dir', async () => { - const options = { - silent: false, - debug: false, - verbose: false, - cacheDir: 'apps/app/.cache/pypoetry', - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { - stdio: 'inherit', - cwd: 'apps/app', - shell: false, - env: { - ...process.env, - POETRY_CACHE_DIR: path.resolve('apps/app/.cache/pypoetry'), - }, + it('should install the poetry dependencies with debug flag', async () => { + const options = { + silent: false, + debug: true, + verbose: false, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-vv'], { + stdio: 'inherit', + shell: false, + cwd: 'apps/app', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('should not install when the command fail', async () => { - vi.mocked(spawn.sync).mockImplementation(() => { - throw new Error('fake'); + it('should install the poetry dependencies with custom cache dir', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + cacheDir: 'apps/app/.cache/pypoetry', + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { + stdio: 'inherit', + cwd: 'apps/app', + shell: false, + env: { + ...process.env, + POETRY_CACHE_DIR: path.resolve('apps/app/.cache/pypoetry'), + }, + }); + expect(output.success).toBe(true); }); - const options = { - silent: false, - debug: false, - verbose: false, - cacheDir: 'apps/app/.cache/pypoetry', - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { - stdio: 'inherit', - shell: false, - cwd: 'apps/app', + it('should not install when the command fail', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake'); + }); + + const options = { + silent: false, + debug: false, + verbose: false, + cacheDir: 'apps/app/.cache/pypoetry', + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['install', '-v'], { + stdio: 'inherit', + shell: false, + cwd: 'apps/app', + }); + expect(output.success).toBe(false); }); - expect(output.success).toBe(false); }); }); diff --git a/packages/nx-python/src/executors/install/executor.ts b/packages/nx-python/src/executors/install/executor.ts index 1fda284..90cb84d 100644 --- a/packages/nx-python/src/executors/install/executor.ts +++ b/packages/nx-python/src/executors/install/executor.ts @@ -2,12 +2,7 @@ import { InstallExecutorSchema } from './schema'; import { Logger } from '../utils/logger'; import { ExecutorContext } from '@nx/devkit'; import chalk from 'chalk'; -import path from 'path'; -import { - checkPoetryExecutable, - runPoetry, - RunPoetryOptions, -} from '../utils/poetry'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -19,33 +14,8 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - await checkPoetryExecutable(); - const projectConfig = - context.projectsConfigurations.projects[context.projectName]; - let verboseArg = '-v'; - - if (options.debug) { - verboseArg = '-vvv'; - } else if (options.verbose) { - verboseArg = '-vv'; - } - - const installArgs = ['install', verboseArg].concat( - options.args ? options.args.split(' ') : [], - ); - - const execOpts: RunPoetryOptions = { - cwd: projectConfig.root, - }; - - if (options.cacheDir) { - execOpts.env = { - ...process.env, - POETRY_CACHE_DIR: path.resolve(options.cacheDir), - }; - } - - runPoetry(installArgs, execOpts); + const provider = await getProvider(workspaceRoot, logger); + await provider.install(options, context); return { success: true, diff --git a/packages/nx-python/src/executors/publish/executor.spec.ts b/packages/nx-python/src/executors/publish/executor.spec.ts index bef41a6..ba5265a 100644 --- a/packages/nx-python/src/executors/publish/executor.spec.ts +++ b/packages/nx-python/src/executors/publish/executor.spec.ts @@ -43,14 +43,19 @@ vi.mock('child_process', async (importOriginal) => { }); import chalk from 'chalk'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import { EventEmitter } from 'events'; import { ExecutorContext } from '@nx/devkit'; describe('Publish Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; + beforeAll(() => { + console.log(chalk`init chalk`); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); const context: ExecutorContext = { cwd: '', @@ -73,271 +78,270 @@ describe('Publish Executor', () => { }, }; - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - - beforeAll(() => { - console.log(chalk`init chalk`); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); - const options = { - buildTarget: 'build', - silent: false, - dryRun: false, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); - - it('should return success false when the build target fails', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: false }]); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); - const options = { - buildTarget: 'build', - silent: false, - dryRun: false, - }; + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + it('should return success false when the build target fails', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: false }]); - it('should return success false when the build target does not return the temp folder', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: true }]); - - const options = { - buildTarget: 'build', - silent: false, - dryRun: false, - __unparsed__: [], - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; - it('should run poetry publish command without agrs', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([ - { success: true, buildFolderPath: 'tmp' }, - ]); - fsExtraMocks.removeSync.mockReturnValue(undefined); - - const options = { - buildTarget: 'build', - silent: false, - dryRun: false, - }; - - const spawnEvent = new EventEmitter(); - childProcessMocks.spawn.mockReturnValue({ - stdout: new EventEmitter(), - stderr: new EventEmitter(), - on: vi.fn().mockImplementation((event, callback) => { - spawnEvent.on(event, callback); - spawnEvent.emit('close', 0); - }), + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { - cwd: 'tmp', - env: { ...process.env, FORCE_COLOR: 'true' }, - shell: true, - stdio: ['inherit', 'pipe', 'pipe'], + it('should return success false when the build target does not return the temp folder', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: true }]); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + __unparsed__: [], + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - expect(output.success).toBe(true); - expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( - { - configuration: undefined, - project: 'app', - target: 'build', - }, - { - keepBuildFolder: true, - }, - context, - ); - expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); - }); - it('should run poetry publish command with agrs', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([ - { success: true, buildFolderPath: 'tmp' }, - ]); - fsExtraMocks.removeSync.mockReturnValue(undefined); - - const options = { - buildTarget: 'build', - dryRun: false, - silent: false, - __unparsed__: ['-vvv', '--dry-run'], - }; - - const spawnEvent = new EventEmitter(); - childProcessMocks.spawn.mockReturnValue({ - stdout: new EventEmitter(), - stderr: new EventEmitter(), - on: vi.fn().mockImplementation((event, callback) => { - spawnEvent.on(event, callback); - spawnEvent.emit('close', 0); - }), - }); + it('should run poetry publish command without agrs', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; + + const spawnEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: vi.fn().mockImplementation((event, callback) => { + spawnEvent.on(event, callback); + spawnEvent.emit('close', 0); + }), + }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).toHaveBeenCalledWith( - 'poetry publish -vvv --dry-run', - { + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { cwd: 'tmp', env: { ...process.env, FORCE_COLOR: 'true' }, shell: true, stdio: ['inherit', 'pipe', 'pipe'], - }, - ); - expect(output.success).toBe(true); - expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( - { - configuration: undefined, - project: 'app', - target: 'build', - }, - { - keepBuildFolder: true, - }, - context, - ); - expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); - }); + }); + expect(output.success).toBe(true); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); + expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); + }); - it('should run poetry publish and not throw an exception when the message contains "File already exists"', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([ - { success: true, buildFolderPath: 'tmp' }, - ]); - fsExtraMocks.removeSync.mockReturnValue(undefined); - - const options = { - buildTarget: 'build', - dryRun: false, - silent: false, - }; - - const spawnEvent = new EventEmitter(); - const stdoutEvent = new EventEmitter(); - childProcessMocks.spawn.mockReturnValue({ - stdout: { + it('should run poetry publish command with agrs', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + dryRun: false, + silent: false, + __unparsed__: ['-vvv', '--dry-run'], + }; + + const spawnEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: new EventEmitter(), + stderr: new EventEmitter(), on: vi.fn().mockImplementation((event, callback) => { - stdoutEvent.on(event, callback); - stdoutEvent.emit(event, 'HTTP Error 400: File already exists'); + spawnEvent.on(event, callback); + spawnEvent.emit('close', 0); }), - }, - stderr: new EventEmitter(), - on: vi.fn().mockImplementation((event, callback) => { - spawnEvent.on(event, callback); - spawnEvent.emit('close', 1); - }), + }); + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).toHaveBeenCalledWith( + 'poetry publish -vvv --dry-run', + { + cwd: 'tmp', + env: { ...process.env, FORCE_COLOR: 'true' }, + shell: true, + stdio: ['inherit', 'pipe', 'pipe'], + }, + ); + expect(output.success).toBe(true); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); + expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { - cwd: 'tmp', - env: { ...process.env, FORCE_COLOR: 'true' }, - shell: true, - stdio: ['inherit', 'pipe', 'pipe'], + it('should run poetry publish and not throw an exception when the message contains "File already exists"', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + dryRun: false, + silent: false, + }; + + const spawnEvent = new EventEmitter(); + const stdoutEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: { + on: vi.fn().mockImplementation((event, callback) => { + stdoutEvent.on(event, callback); + stdoutEvent.emit(event, 'HTTP Error 400: File already exists'); + }), + }, + stderr: new EventEmitter(), + on: vi.fn().mockImplementation((event, callback) => { + spawnEvent.on(event, callback); + spawnEvent.emit('close', 1); + }), + }); + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { + cwd: 'tmp', + env: { ...process.env, FORCE_COLOR: 'true' }, + shell: true, + stdio: ['inherit', 'pipe', 'pipe'], + }); + expect(output.success).toBe(true); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); + expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); }); - expect(output.success).toBe(true); - expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( - { - configuration: undefined, - project: 'app', - target: 'build', - }, - { - keepBuildFolder: true, - }, - context, - ); - expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); - }); - it('should throw an exception when status code is not 0 and the message does not contains "File already exists"', async () => { - nxDevkitMocks.runExecutor.mockResolvedValueOnce([ - { success: true, buildFolderPath: 'tmp' }, - ]); - fsExtraMocks.removeSync.mockReturnValue(undefined); - - const options = { - buildTarget: 'build', - dryRun: false, - silent: false, - }; - - const spawnEvent = new EventEmitter(); - const stdoutEvent = new EventEmitter(); - childProcessMocks.spawn.mockReturnValue({ - stdout: { + it('should throw an exception when status code is not 0 and the message does not contains "File already exists"', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + dryRun: false, + silent: false, + }; + + const spawnEvent = new EventEmitter(); + const stdoutEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: { + on: vi.fn().mockImplementation((event, callback) => { + stdoutEvent.on(event, callback); + stdoutEvent.emit('data', 'Some other error message'); + }), + }, + stderr: new EventEmitter(), on: vi.fn().mockImplementation((event, callback) => { - stdoutEvent.on(event, callback); - stdoutEvent.emit('data', 'Some other error message'); + spawnEvent.on(event, callback); + spawnEvent.emit('close', 1); }), - }, - stderr: new EventEmitter(), - on: vi.fn().mockImplementation((event, callback) => { - spawnEvent.on(event, callback); - spawnEvent.emit('close', 1); - }), - }); + }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { - cwd: 'tmp', - env: { ...process.env, FORCE_COLOR: 'true' }, - shell: true, - stdio: ['inherit', 'pipe', 'pipe'], + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(childProcessMocks.spawn).toHaveBeenCalledWith('poetry publish', { + cwd: 'tmp', + env: { ...process.env, FORCE_COLOR: 'true' }, + shell: true, + stdio: ['inherit', 'pipe', 'pipe'], + }); + expect(output.success).toBe(false); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); }); - expect(output.success).toBe(false); - expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( - { - configuration: undefined, - project: 'app', - target: 'build', - }, - { - keepBuildFolder: true, - }, - context, - ); }); }); diff --git a/packages/nx-python/src/executors/publish/executor.ts b/packages/nx-python/src/executors/publish/executor.ts index 539e4e0..dabcc9d 100644 --- a/packages/nx-python/src/executors/publish/executor.ts +++ b/packages/nx-python/src/executors/publish/executor.ts @@ -1,15 +1,8 @@ -import { ExecutorContext, runExecutor } from '@nx/devkit'; +import { ExecutorContext } from '@nx/devkit'; import { PublishExecutorSchema } from './schema'; import chalk from 'chalk'; import { Logger } from '../utils/logger'; -import { - activateVenv, - checkPoetryExecutable, - POETRY_EXECUTABLE, -} from '../utils/poetry'; -import { BuildExecutorOutput } from '../build/schema'; -import { removeSync } from 'fs-extra'; -import { spawnPromise } from '../utils/cmd'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -20,79 +13,18 @@ export default async function executor( logger.setOptions(options); const workspaceRoot = context.root; process.chdir(workspaceRoot); - let buildFolderPath = ''; try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); - - for await (const output of await runExecutor( - { - project: context.projectName, - target: options.buildTarget, - configuration: context.configurationName, - }, - { - keepBuildFolder: true, - }, - context, - )) { - if (!output.success) { - throw new Error('Build failed'); - } - - buildFolderPath = output.buildFolderPath; - } - - if (!buildFolderPath) { - throw new Error('Cannot find the temporary build folder'); - } - - logger.info( - chalk`\n {bold Publishing project {bgBlue ${context.projectName} }...}\n`, - ); - - const commandArgs = [ - 'publish', - ...(options.dryRun ? ['--dry-run'] : []), - ...(options.__unparsed__ ?? []), - ]; - const commandStr = `${POETRY_EXECUTABLE} ${commandArgs.join(' ')}`; - - console.log( - chalk`{bold Running command}: ${commandStr} ${ - buildFolderPath && buildFolderPath !== '.' - ? chalk`at {bold ${buildFolderPath}} folder` - : '' - }\n`, - ); - - await spawnPromise(commandStr, buildFolderPath); - removeSync(buildFolderPath); + const provider = await getProvider(workspaceRoot, logger); + await provider.publish(options, context); return { success: true, }; } catch (error) { - if (buildFolderPath) { - removeSync(buildFolderPath); - } - - if (typeof error === 'object' && 'code' in error && 'output' in error) { - if (error.code !== 0 && error.output.includes('File already exists')) { - logger.info( - chalk`\n {bgYellow.bold WARNING } {bold The package is already published}\n`, - ); - - return { - success: true, - }; - } else if (error.code !== 0) { - logger.info( - chalk`\n {bgRed.bold ERROR } {bold The publish command failed}\n`, - ); - } - } + logger.info( + chalk`\n {bgRed.bold ERROR } {bold The publish command failed}\n`, + ); return { success: false, diff --git a/packages/nx-python/src/executors/remove/executor.spec.ts b/packages/nx-python/src/executors/remove/executor.spec.ts index ad58cce..159a38d 100644 --- a/packages/nx-python/src/executors/remove/executor.spec.ts +++ b/packages/nx-python/src/executors/remove/executor.spec.ts @@ -2,7 +2,7 @@ import { vi, MockInstance } from 'vitest'; import { vol } from 'memfs'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import chalk from 'chalk'; import executor from './executor'; import dedent from 'string-dedent'; @@ -10,647 +10,659 @@ import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; describe('Delete Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - let getPoetryVersionMock: MockInstance; + afterEach(() => { + vol.reset(); + vi.resetAllMocks(); + }); beforeAll(() => { console.log(chalk`init chalk`); }); - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - getPoetryVersionMock = vi - .spyOn(poetryUtils, 'getPoetryVersion') - .mockResolvedValue('1.5.0'); - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; + let getPoetryVersionMock: MockInstance; + + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); + getPoetryVersionMock = vi + .spyOn(poetryUtils, 'getPoetryVersion') + .mockResolvedValue('1.5.0'); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - afterEach(() => { - vol.reset(); - vi.resetAllMocks(); - }); - - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); - - const options = { - name: 'shared1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + name: 'shared1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('should remove local dependency and update all the dependency tree', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } -`, - - 'apps/app1/pyproject.toml': `[tool.poetry] -name = "app1" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } -`, - - 'libs/lib1/pyproject.toml': `[tool.poetry] - name = "lib1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - shared1 = { path = "../../libs/shared1" }`, - - 'libs/shared1/pyproject.toml': `[tool.poetry] - name = "shared1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8"`, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const options = { - name: 'shared1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'lib1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - app3: { - root: 'apps/app3', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + it('should remove local dependency and update all the dependency tree', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + shared1 = { path = "../../libs/shared1" } + `, + + 'libs/shared1/pyproject.toml': dedent` + [tool.poetry] + name = "shared1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'shared1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'lib1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + app3: { + root: 'apps/app3', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(5); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'shared1'], - { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(5); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'shared1'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { cwd: 'apps/app1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); - - it('should remove the external dependency and update all the dependency tree', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - `, - - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - `, - - 'libs/lib1/pyproject.toml': dedent` - [tool.poetry] - name = "lib1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - shared1 = { path = "../shared1" } - `, - - 'libs/shared1/pyproject.toml': dedent` - [tool.poetry] - name = "shared1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - `, + }); + expect(output.success).toBe(true); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'shared1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + it('should remove the external dependency and update all the dependency tree', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + shared1 = { path = "../shared1" } + `, + + 'libs/shared1/pyproject.toml': dedent` + [tool.poetry] + name = "shared1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'shared1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(7); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'numpy'], - { - cwd: 'libs/shared1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(7); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'numpy'], + { + cwd: 'libs/shared1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { cwd: 'libs/lib1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 6, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 6, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { cwd: 'apps/app1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('should remove external dependency with args', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "1.8" -`, - }); - const options = { - name: 'click', - local: false, - args: '-vvv', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should remove external dependency with args', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "1.8" + `, + }); + const options = { + name: 'click', + local: false, + args: '-vvv', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'click', '-vvv'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); - - it('should remove external dependency with error', async () => { - vi.mocked(spawn.sync).mockImplementation(() => { - throw new Error('fake error'); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'click', '-vvv'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "1.8" -`, - }); - const options = { - name: 'click', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should remove external dependency with error', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake error'); + }); + + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "1.8" + `, + }); + const options = { + name: 'click', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'click'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(false); - }); - - it('run remove target and should remove the dependency to the project using --lock when the root pyproject.toml is present (poetry version 1.5.0)', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - - [tool.poetry.dependencies] - python = "^3.8" - app = { path = "apps/app", develop = true} - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^8.0" - `, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'click'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(false); }); - const options = { - name: 'click', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run remove target and should remove the dependency to the project using --lock when the root pyproject.toml is present (poetry version 1.5.0)', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + + [tool.poetry.dependencies] + python = "^3.8" + app = { path = "apps/app", develop = true} + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^8.0" + `, + }); + + const options = { + name: 'click', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'click', '--lock'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); - - it('run remove target and should remove the dependency to the project without using --lock when the root pyproject.toml is present (poetry version 1.4.0)', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - - [tool.poetry.dependencies] - python = "^3.8" - app = { path = "apps/app", develop = true} - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "^8.0" - `, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'click', '--lock'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - getPoetryVersionMock.mockResolvedValueOnce('1.4.0'); - - const options = { - name: 'click', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run remove target and should remove the dependency to the project without using --lock when the root pyproject.toml is present (poetry version 1.4.0)', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + + [tool.poetry.dependencies] + python = "^3.8" + app = { path = "apps/app", develop = true} + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "^8.0" + `, + }); + + getPoetryVersionMock.mockResolvedValueOnce('1.4.0'); + + const options = { + name: 'click', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['remove', 'click'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['remove', 'click'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); }); }); diff --git a/packages/nx-python/src/executors/remove/executor.ts b/packages/nx-python/src/executors/remove/executor.ts index af39748..468ed5e 100644 --- a/packages/nx-python/src/executors/remove/executor.ts +++ b/packages/nx-python/src/executors/remove/executor.ts @@ -1,17 +1,7 @@ import { ExecutorContext } from '@nx/devkit'; import chalk from 'chalk'; -import { existsSync } from 'fs-extra'; -import { updateDependencyTree } from '../../dependency/update-dependency'; -import { - activateVenv, - checkPoetryExecutable, - getLocalDependencyConfig, - getPoetryVersion, - getProjectTomlPath, - parseToml, - runPoetry, -} from '../utils/poetry'; import { RemoveExecutorSchema } from './schema'; +import { getProvider } from '../../provider'; export default async function executor( options: RemoveExecutorSchema, @@ -20,42 +10,8 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); - const rootPyprojectToml = existsSync('pyproject.toml'); - const projectConfig = - context.projectsConfigurations.projects[context.projectName]; - console.log( - chalk`\n {bold Removing {bgBlue ${options.name} } dependency...}\n`, - ); - - let dependencyName = options.name; - if (options.local) { - const dependencyConfig = getLocalDependencyConfig(context, options.name); - - const pyprojectTomlPath = getProjectTomlPath(dependencyConfig); - const { - tool: { - poetry: { name }, - }, - } = parseToml(pyprojectTomlPath); - - dependencyName = name; - } - - const poetryVersion = await getPoetryVersion(); - const hasLockOption = poetryVersion >= '1.5.0'; - - const removeArgs = ['remove', dependencyName] - .concat(options.args ? options.args.split(' ') : []) - .concat(rootPyprojectToml && hasLockOption ? ['--lock'] : []); - runPoetry(removeArgs, { cwd: projectConfig.root }); - - updateDependencyTree(context); - - console.log( - chalk`\n {green.bold '${options.name}'} {green dependency has been successfully removed}\n`, - ); + const provider = await getProvider(workspaceRoot); + await provider.remove(options, context); return { success: true, diff --git a/packages/nx-python/src/executors/ruff-check/executor.spec.ts b/packages/nx-python/src/executors/ruff-check/executor.spec.ts index ef3a368..1a57548 100644 --- a/packages/nx-python/src/executors/ruff-check/executor.spec.ts +++ b/packages/nx-python/src/executors/ruff-check/executor.spec.ts @@ -3,35 +3,12 @@ import { vol } from 'memfs'; import chalk from 'chalk'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; describe('Ruff Check Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - beforeAll(() => { console.log(chalk`init chalk`); }); @@ -41,58 +18,41 @@ describe('Ruff Check Executor', () => { vi.resetAllMocks(); }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; - const options = { - lintFilePatterns: ['app'], - __unparsed__: [], - }; + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); - it('should execute ruff check linting', async () => { - vi.mocked(spawn.sync).mockReturnValueOnce({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - const output = await executor( - { + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { lintFilePatterns: ['app'], __unparsed__: [], - }, - { + }; + + const context: ExecutorContext = { cwd: '', root: '.', isVerbose: false, @@ -111,63 +71,115 @@ describe('Ruff Check Executor', () => { dependencies: {}, nodes: {}, }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(spawn.sync).toHaveBeenCalledWith('poetry run ruff check app', { - cwd: 'apps/app', - shell: true, - stdio: 'inherit', + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - expect(output.success).toBe(true); - }); - it('should fail to execute ruff check linting ', async () => { - vi.mocked(spawn.sync).mockReturnValueOnce({ - status: 1, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + it('should execute ruff check linting', async () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + lintFilePatterns: ['app'], + __unparsed__: [], + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['run', 'ruff', 'check', 'app'], + { + cwd: 'apps/app', + shell: true, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - const output = await executor( - { - lintFilePatterns: ['app'], - __unparsed__: [], - }, - { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should fail to execute ruff check linting ', async () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + status: 1, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + lintFilePatterns: ['app'], + __unparsed__: [], + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, + ); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['run', 'ruff', 'check', 'app'], + { + cwd: 'apps/app', + shell: true, + stdio: 'inherit', }, - }, - ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(1); - expect(spawn.sync).toHaveBeenCalledWith('poetry run ruff check app', { - cwd: 'apps/app', - shell: true, - stdio: 'inherit', + ); + expect(output.success).toBe(false); }); - expect(output.success).toBe(false); }); }); diff --git a/packages/nx-python/src/executors/ruff-check/executor.ts b/packages/nx-python/src/executors/ruff-check/executor.ts index bcfcae9..32643e6 100644 --- a/packages/nx-python/src/executors/ruff-check/executor.ts +++ b/packages/nx-python/src/executors/ruff-check/executor.ts @@ -2,12 +2,7 @@ import { ExecutorContext } from '@nx/devkit'; import chalk from 'chalk'; import { Logger } from '../utils/logger'; import { RuffCheckExecutorSchema } from './schema'; -import { - POETRY_EXECUTABLE, - activateVenv, - checkPoetryExecutable, -} from '../utils/poetry'; -import spawn from 'cross-spawn'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -18,8 +13,6 @@ export default async function executor( const workspaceRoot = context.root; process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); logger.info( chalk`\n{bold Running ruff check on project {bgBlue ${context.projectName} }...}\n`, ); @@ -27,11 +20,17 @@ export default async function executor( const projectConfig = context.projectsConfigurations.projects[context.projectName]; - const commandArgs = ['run', 'ruff', 'check'] + const commandArgs = ['ruff', 'check'] .concat(options.lintFilePatterns) .concat(options.__unparsed__); - runCheck(commandArgs, projectConfig.root); + const provider = await getProvider(workspaceRoot); + await provider.run(commandArgs, workspaceRoot, { + cwd: projectConfig.root, + log: false, + error: true, + shell: true, + }); return { success: true, @@ -43,16 +42,3 @@ export default async function executor( }; } } - -function runCheck(commandArgs: string[], cwd: string) { - const command = `${POETRY_EXECUTABLE} ${commandArgs.join(' ')}`; - const result = spawn.sync(command, { - stdio: 'inherit', - shell: true, - cwd, - }); - - if (result.status !== 0) { - throw new Error('Ruff check failed.'); - } -} diff --git a/packages/nx-python/src/executors/run-commands/executor.spec.ts b/packages/nx-python/src/executors/run-commands/executor.spec.ts index 83de87f..435c130 100644 --- a/packages/nx-python/src/executors/run-commands/executor.spec.ts +++ b/packages/nx-python/src/executors/run-commands/executor.spec.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; vi.mock('nx/src/executors/run-commands/run-commands.impl'); -vi.mock('../utils/poetry'); +vi.mock('../../provider/poetry/utils'); import executor from './executor'; import { ExecutorContext } from '@nx/devkit'; @@ -28,18 +28,21 @@ describe('run commands executor', () => { }, }; - it('should activate the venv and call the base executor', async () => { - const options = { - command: 'test', - __unparsed__: [], - }; - await executor(options, context); + describe('poetry', () => { + it('should activate the venv and call the base executor', async () => { + const options = { + command: 'test', + __unparsed__: [], + }; + await executor(options, context); - expect((await import('../utils/poetry')).activateVenv).toHaveBeenCalledWith( - context.root, - ); - expect( - (await import('nx/src/executors/run-commands/run-commands.impl')).default, - ).toHaveBeenCalledWith(options, context); + expect( + (await import('../../provider/poetry/utils')).activateVenv, + ).toHaveBeenCalledWith(context.root); + expect( + (await import('nx/src/executors/run-commands/run-commands.impl')) + .default, + ).toHaveBeenCalledWith(options, context); + }); }); }); diff --git a/packages/nx-python/src/executors/run-commands/executor.ts b/packages/nx-python/src/executors/run-commands/executor.ts index e8934b9..d81531f 100644 --- a/packages/nx-python/src/executors/run-commands/executor.ts +++ b/packages/nx-python/src/executors/run-commands/executor.ts @@ -2,12 +2,13 @@ import { ExecutorContext } from '@nx/devkit'; import baseExecutor, { RunCommandsOptions, } from 'nx/src/executors/run-commands/run-commands.impl'; -import { activateVenv } from '../utils/poetry'; +import { getProvider } from '../../provider'; export default async function executor( options: RunCommandsOptions, context: ExecutorContext, ) { - activateVenv(context.root); + const provider = await getProvider(context.root); + provider.activateVenv(context.root); return baseExecutor(options, context); } diff --git a/packages/nx-python/src/executors/sls-deploy/executor.spec.ts b/packages/nx-python/src/executors/sls-deploy/executor.spec.ts index 555de1a..b85803b 100644 --- a/packages/nx-python/src/executors/sls-deploy/executor.spec.ts +++ b/packages/nx-python/src/executors/sls-deploy/executor.spec.ts @@ -3,7 +3,7 @@ import { vol } from 'memfs'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; import chalk from 'chalk'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; @@ -36,149 +36,151 @@ describe('Serverless Framework Deploy Executor', () => { console.log(chalk`init chalk`); }); - beforeEach(() => { - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - afterEach(() => { vol.reset(); vi.resetAllMocks(); }); - it('should throw an exception when the dist folder is empty', async () => { - const output = await executor( - { - stage: 'dev', - verbose: true, - force: false, - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + describe('poetry', () => { + beforeEach(() => { + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); - it('should throw an exception when the whl file does not exist', async () => { - vol.fromJSON({ - 'apps/app/dist/test.tar.gz': 'abc123', + it('should throw an exception when the dist folder is empty', async () => { + const output = await executor( + { + stage: 'dev', + verbose: true, + force: false, + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor( - { - stage: 'dev', - verbose: true, - force: false, - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + it('should throw an exception when the whl file does not exist', async () => { + vol.fromJSON({ + 'apps/app/dist/test.tar.gz': 'abc123', + }); - it('should run serverless framework command using npx', async () => { - vol.fromJSON({ - 'apps/app/dist/test.whl': 'abc123', - }); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + const output = await executor( + { + stage: 'dev', + verbose: true, + force: false, + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor( - { - stage: 'dev', - verbose: false, - force: false, - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'npx', - ['sls', 'deploy', '--stage', 'dev'], - { - cwd: 'apps/app', - stdio: 'inherit', - shell: false, - }, - ); - expect(output.success).toBe(true); - }); + it('should run serverless framework command using npx', async () => { + vol.fromJSON({ + 'apps/app/dist/test.whl': 'abc123', + }); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); - it('should run serverless framework command with error status code', async () => { - vol.fromJSON({ - 'apps/app/dist/test.whl': 'abc123', - }); - vi.mocked(spawn.sync).mockReturnValue({ - status: 1, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + const output = await executor( + { + stage: 'dev', + verbose: false, + force: false, + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'npx', + ['sls', 'deploy', '--stage', 'dev'], + { + cwd: 'apps/app', + stdio: 'inherit', + shell: false, + }, + ); + expect(output.success).toBe(true); }); - const output = await executor( - { - stage: 'dev', - verbose: false, - force: false, - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'npx', - ['sls', 'deploy', '--stage', 'dev'], - { - cwd: 'apps/app', - stdio: 'inherit', - shell: false, - }, - ); - expect(output.success).toBe(false); - }); + it('should run serverless framework command with error status code', async () => { + vol.fromJSON({ + 'apps/app/dist/test.whl': 'abc123', + }); + vi.mocked(spawn.sync).mockReturnValue({ + status: 1, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); - it('should run serverless framework command using npx with verbose and force', async () => { - vol.fromJSON({ - 'apps/app/dist/test.whl': 'abc123', - }); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + const output = await executor( + { + stage: 'dev', + verbose: false, + force: false, + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'npx', + ['sls', 'deploy', '--stage', 'dev'], + { + cwd: 'apps/app', + stdio: 'inherit', + shell: false, + }, + ); + expect(output.success).toBe(false); }); - const output = await executor( - { - stage: 'dev', - verbose: true, - force: true, - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'npx', - ['sls', 'deploy', '--stage', 'dev', '--verbose', '--force'], - { - cwd: 'apps/app', - stdio: 'inherit', - shell: false, - }, - ); - expect(output.success).toBe(true); + it('should run serverless framework command using npx with verbose and force', async () => { + vol.fromJSON({ + 'apps/app/dist/test.whl': 'abc123', + }); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + stage: 'dev', + verbose: true, + force: true, + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'npx', + ['sls', 'deploy', '--stage', 'dev', '--verbose', '--force'], + { + cwd: 'apps/app', + stdio: 'inherit', + shell: false, + }, + ); + expect(output.success).toBe(true); + }); }); }); diff --git a/packages/nx-python/src/executors/sls-deploy/executor.ts b/packages/nx-python/src/executors/sls-deploy/executor.ts index 87b2477..3496be1 100644 --- a/packages/nx-python/src/executors/sls-deploy/executor.ts +++ b/packages/nx-python/src/executors/sls-deploy/executor.ts @@ -5,7 +5,7 @@ import { Logger } from '../utils/logger'; import { ExecutorSchema } from './schema'; import path from 'path'; import { existsSync, readdirSync, writeFileSync, removeSync } from 'fs-extra'; -import { activateVenv } from '../utils/poetry'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -15,7 +15,8 @@ export default async function executor( ) { const workspaceRoot = context.root; process.chdir(workspaceRoot); - activateVenv(workspaceRoot); + const provider = await getProvider(workspaceRoot); + provider.activateVenv(workspaceRoot); const projectConfig = context.projectsConfigurations.projects[context.projectName]; diff --git a/packages/nx-python/src/executors/sls-package/executor.spec.ts b/packages/nx-python/src/executors/sls-package/executor.spec.ts index 3a966a4..6a967fe 100644 --- a/packages/nx-python/src/executors/sls-package/executor.spec.ts +++ b/packages/nx-python/src/executors/sls-package/executor.spec.ts @@ -3,7 +3,7 @@ import { vol } from 'memfs'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; import chalk from 'chalk'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; @@ -32,13 +32,6 @@ describe('Serverless Framework Package Executor', () => { }, }; - beforeEach(() => { - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - beforeAll(() => { console.log(chalk`init chalk`); }); @@ -48,95 +41,104 @@ describe('Serverless Framework Package Executor', () => { vi.resetAllMocks(); }); - it('should throw an exception when the dist folder is empty', async () => { - const output = await executor( - { - stage: 'dev', - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + describe('poetry', () => { + beforeEach(() => { + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); - it('should throw an exception when the whl file does not exist', async () => { - vol.fromJSON({ - 'apps/app/dist/test.tar.gz': 'abc123', + it('should throw an exception when the dist folder is empty', async () => { + const output = await executor( + { + stage: 'dev', + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor( - { - stage: 'dev', - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + it('should throw an exception when the whl file does not exist', async () => { + vol.fromJSON({ + 'apps/app/dist/test.tar.gz': 'abc123', + }); - it('should run serverless framework command using npx', async () => { - vol.fromJSON({ - 'apps/app/dist/test.whl': 'abc123', - }); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + const output = await executor( + { + stage: 'dev', + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const output = await executor( - { - stage: 'dev', - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'npx', - ['sls', 'package', '--stage', 'dev'], - { - cwd: 'apps/app', - stdio: 'inherit', - shell: false, - }, - ); - expect(output.success).toBe(true); - }); + it('should run serverless framework command using npx', async () => { + vol.fromJSON({ + 'apps/app/dist/test.whl': 'abc123', + }); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); - it('should run serverless framework command with error', async () => { - vol.fromJSON({ - 'apps/app/dist/test.whl': 'abc123', - }); - vi.mocked(spawn.sync).mockReturnValue({ - status: 1, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + const output = await executor( + { + stage: 'dev', + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'npx', + ['sls', 'package', '--stage', 'dev'], + { + cwd: 'apps/app', + stdio: 'inherit', + shell: false, + }, + ); + expect(output.success).toBe(true); }); - const output = await executor( - { - stage: 'dev', - }, - context, - ); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'npx', - ['sls', 'package', '--stage', 'dev'], - { - cwd: 'apps/app', - stdio: 'inherit', - shell: false, - }, - ); - expect(output.success).toBe(false); + it('should run serverless framework command with error', async () => { + vol.fromJSON({ + 'apps/app/dist/test.whl': 'abc123', + }); + vi.mocked(spawn.sync).mockReturnValue({ + status: 1, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + stage: 'dev', + }, + context, + ); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'npx', + ['sls', 'package', '--stage', 'dev'], + { + cwd: 'apps/app', + stdio: 'inherit', + shell: false, + }, + ); + expect(output.success).toBe(false); + }); }); }); diff --git a/packages/nx-python/src/executors/sls-package/executor.ts b/packages/nx-python/src/executors/sls-package/executor.ts index 89d51fb..abd8847 100644 --- a/packages/nx-python/src/executors/sls-package/executor.ts +++ b/packages/nx-python/src/executors/sls-package/executor.ts @@ -5,7 +5,7 @@ import { Logger } from '../utils/logger'; import { ExecutorSchema } from './schema'; import path from 'path'; import { existsSync, readdirSync, writeFileSync, removeSync } from 'fs-extra'; -import { activateVenv } from '../utils/poetry'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -15,7 +15,8 @@ export default async function executor( ) { const workspaceRoot = context.root; process.chdir(workspaceRoot); - activateVenv(workspaceRoot); + const provider = await getProvider(workspaceRoot); + provider.activateVenv(workspaceRoot); const projectConfig = context.projectsConfigurations.projects[context.projectName]; diff --git a/packages/nx-python/src/executors/tox/executor.spec.ts b/packages/nx-python/src/executors/tox/executor.spec.ts index adc911b..2c03aba 100644 --- a/packages/nx-python/src/executors/tox/executor.spec.ts +++ b/packages/nx-python/src/executors/tox/executor.spec.ts @@ -2,7 +2,7 @@ import { vi, MockInstance } from 'vitest'; import { vol } from 'memfs'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import * as buildExecutor from '../build/executor'; import { ToxExecutorSchema } from './schema'; import executor from './executor'; @@ -15,8 +15,6 @@ const options: ToxExecutorSchema = { }; describe('Tox Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; let buildExecutorMock: MockInstance; const context: ExecutorContext = { @@ -44,216 +42,225 @@ describe('Tox Executor', () => { console.log(chalk`init chalk`); }); - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; + beforeEach(() => { buildExecutorMock = vi.spyOn(buildExecutor, 'default'); + }); + + describe('poetry', () => { + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - afterEach(() => { - vol.reset(); - vi.resetAllMocks(); - }); + afterEach(() => { + vol.reset(); + vi.resetAllMocks(); + }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).not.toHaveBeenCalled(); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('should build and run tox successfully', async () => { - buildExecutorMock.mockResolvedValue({ - success: true, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).not.toHaveBeenCalled(); + expect(buildExecutorMock).not.toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - vol.fromJSON({ - 'apps/app/dist/package.tar.gz': 'fake', - }); + it('should build and run tox successfully', async () => { + buildExecutorMock.mockResolvedValue({ + success: true, + }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).toBeCalledWith( - { - silent: options.silent, - keepBuildFolder: false, - ignorePaths: ['.venv', '.tox', 'tests'], - outputPath: 'apps/app/dist', - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }, - context, - ); - expect(spawn.sync).toBeCalledWith( - 'poetry', - ['run', 'tox', '--installpkg', 'dist/package.tar.gz'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); + vol.fromJSON({ + 'apps/app/dist/package.tar.gz': 'fake', + }); - it('should build and run tox successfully with args', async () => { - buildExecutorMock.mockResolvedValue({ - success: true, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).toBeCalledWith( + 'poetry', + ['run', 'tox', '--installpkg', 'dist/package.tar.gz'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - vol.fromJSON({ - 'apps/app/dist/package.tar.gz': 'fake', - }); + it('should build and run tox successfully with args', async () => { + buildExecutorMock.mockResolvedValue({ + success: true, + }); - const output = await executor( - { - silent: false, - args: '-e linters', - }, - context, - ); + vol.fromJSON({ + 'apps/app/dist/package.tar.gz': 'fake', + }); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).toBeCalledWith( - { - silent: options.silent, - keepBuildFolder: false, - ignorePaths: ['.venv', '.tox', 'tests'], - outputPath: 'apps/app/dist', - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }, - context, - ); - expect(spawn.sync).toBeCalledWith( - 'poetry', - ['run', 'tox', '--installpkg', 'dist/package.tar.gz', '-e', 'linters'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); + const output = await executor( + { + silent: false, + args: '-e linters', + }, + context, + ); - it('should failure the build and not run tox command', async () => { - buildExecutorMock.mockResolvedValue({ - success: false, + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).toBeCalledWith( + 'poetry', + ['run', 'tox', '--installpkg', 'dist/package.tar.gz', '-e', 'linters'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).toBeCalledWith( - { - silent: options.silent, - keepBuildFolder: false, - ignorePaths: ['.venv', '.tox', 'tests'], - outputPath: 'apps/app/dist', - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }, - context, - ); - expect(spawn.sync).not.toBeCalled(); - expect(output.success).toBe(false); - }); + it('should failure the build and not run tox command', async () => { + buildExecutorMock.mockResolvedValue({ + success: false, + }); - it('should dist folder not exists and not run tox command', async () => { - buildExecutorMock.mockResolvedValue({ - success: true, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).not.toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).not.toBeCalled(); + expect(output.success).toBe(false); }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).toBeCalledWith( - { - silent: options.silent, - keepBuildFolder: false, - ignorePaths: ['.venv', '.tox', 'tests'], - outputPath: 'apps/app/dist', - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }, - context, - ); - expect(spawn.sync).not.toBeCalled(); - expect(output.success).toBe(false); - }); + it('should dist folder not exists and not run tox command', async () => { + buildExecutorMock.mockResolvedValue({ + success: true, + }); - it('should not generate the tar.gz and not run tox command', async () => { - vol.fromJSON({ - 'apps/app/dist/something.txt': 'fake', + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).not.toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).not.toBeCalled(); + expect(output.success).toBe(false); }); - buildExecutorMock.mockResolvedValue({ - success: true, - }); + it('should not generate the tar.gz and not run tox command', async () => { + vol.fromJSON({ + 'apps/app/dist/something.txt': 'fake', + }); - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(buildExecutorMock).toBeCalledWith( - { - silent: options.silent, - keepBuildFolder: false, - ignorePaths: ['.venv', '.tox', 'tests'], - outputPath: 'apps/app/dist', - devDependencies: true, - lockedVersions: true, - bundleLocalDependencies: true, - }, - context, - ); - expect(spawn.sync).not.toBeCalled(); - expect(output.success).toBe(false); + buildExecutorMock.mockResolvedValue({ + success: true, + }); + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).not.toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).not.toBeCalled(); + expect(output.success).toBe(false); + }); }); }); diff --git a/packages/nx-python/src/executors/tox/executor.ts b/packages/nx-python/src/executors/tox/executor.ts index 61844b4..737bb70 100644 --- a/packages/nx-python/src/executors/tox/executor.ts +++ b/packages/nx-python/src/executors/tox/executor.ts @@ -5,11 +5,7 @@ import path from 'path'; import chalk from 'chalk'; import { Logger } from '../utils/logger'; import { readdirSync, existsSync } from 'fs-extra'; -import { - activateVenv, - checkPoetryExecutable, - runPoetry, -} from '../utils/poetry'; +import { getProvider } from '../../provider'; const logger = new Logger(); @@ -21,8 +17,9 @@ export default async function executor( process.chdir(workspaceRoot); logger.setOptions(options); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); + const provider = await getProvider(workspaceRoot); + await provider.checkPrerequisites(); + const projectConfig = context.projectsConfigurations.projects[context.projectName]; const distFolder = path.join(projectConfig.root, 'dist'); @@ -63,10 +60,13 @@ export default async function executor( path.join(distFolder, packageFile), ); - const toxArgs = ['run', 'tox', '--installpkg', packagePath].concat( + const toxArgs = ['tox', '--installpkg', packagePath].concat( options.args ? options.args.split(' ') : [], ); - runPoetry(toxArgs, { cwd: projectConfig.root }); + + await provider.run(toxArgs, workspaceRoot, { + cwd: projectConfig.root, + }); return { success: true, diff --git a/packages/nx-python/src/executors/update/executor.spec.ts b/packages/nx-python/src/executors/update/executor.spec.ts index d7cb19b..9e96bc8 100644 --- a/packages/nx-python/src/executors/update/executor.spec.ts +++ b/packages/nx-python/src/executors/update/executor.spec.ts @@ -2,905 +2,918 @@ import { vi, MockInstance } from 'vitest'; import { vol } from 'memfs'; import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import chalk from 'chalk'; -import { parseToml } from '../utils/poetry'; +import { parseToml } from '../../provider/poetry/utils'; import dedent from 'string-dedent'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; describe('Update Executor', () => { - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; + afterEach(() => { + vol.reset(); + vi.resetAllMocks(); + }); beforeAll(() => { console.log(chalk`init chalk`); }); - beforeEach(() => { - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); - activateVenvMock = vi - .spyOn(poetryUtils, 'activateVenv') - .mockReturnValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; + + beforeEach(() => { + checkPoetryExecutableMock = vi + .spyOn(poetryUtils, 'checkPoetryExecutable') + .mockResolvedValue(undefined); + activateVenvMock = vi + .spyOn(poetryUtils, 'activateVenv') + .mockReturnValue(undefined); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); - }); - - afterEach(() => { - vol.reset(); - vi.resetAllMocks(); - }); - it('should return success false when the poetry is not installed', async () => { - checkPoetryExecutableMock.mockRejectedValue(new Error('poetry not found')); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('should return success false when the poetry is not installed', async () => { + checkPoetryExecutableMock.mockRejectedValue( + new Error('poetry not found'), + ); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('run update target and should update the dependency to the project', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run update target and should update the dependency to the project', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update', 'numpy'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('run update target and should not update the dependency to the project because the project does not exist', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update', 'numpy'], { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - const options = { - local: true, - name: 'lib1', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run update target and should not update the dependency to the project because the project does not exist', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + local: true, + name: 'lib1', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).not.toHaveBeenCalled(); - expect(output.success).toBe(false); - }); - - it('run update target and should throw an exception', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - vi.mocked(spawn.sync).mockImplementation(() => { - throw new Error('fake error'); + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run update target and should throw an exception', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake error'); + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update', 'numpy'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(false); - }); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; - it('run update target and should update all the dependency tree ---', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - shared1 = { path = "../../libs/shared1" } - `, - - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - `, - - 'libs/lib1/pyproject.toml': dedent` - [tool.poetry] - name = "lib1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - shared1 = { path = "../shared1" } - `, - - 'libs/shared1/pyproject.toml': dedent` - [tool.poetry] - name = "shared1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - `, + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update', 'numpy'], { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(false); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'shared1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + it('run update target and should update all the dependency tree ---', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + shared1 = { path = "../../libs/shared1" } + `, + + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + shared1 = { path = "../shared1" } + `, + + 'libs/shared1/pyproject.toml': dedent` + [tool.poetry] + name = "shared1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'shared1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(7); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['update', 'numpy'], - { - cwd: 'libs/shared1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(7); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['update', 'numpy'], + { + cwd: 'libs/shared1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { cwd: 'libs/lib1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(5, 'poetry', ['install'], { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }); - expect(spawn.sync).toHaveBeenNthCalledWith( - 6, - 'poetry', - ['lock', '--no-update'], - { + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 6, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { cwd: 'apps/app1', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(7, 'poetry', ['install'], { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run update target with local dependency', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click"`, - - 'libs/lib1/pyproject.toml': `[tool.poetry] -name = "lib1" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8"`, - }); - - const options = { - name: 'lib1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + it('run update target with local dependency', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'lib1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', + }); + expect(output.success).toBe(true); }); - expect(output.success).toBe(true); - }); - it('run update target with local dependency with project name and package name different', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "dgx-devops-app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - dgx-devops-lib1 = { path = "../../libs/lib1", develop = true } - `, - 'libs/lib1/pyproject.toml': dedent` - [tool.poetry] - name = "dgx-devops-lib1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - `, - }); - - const options = { - name: 'lib1', - local: true, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, + it('run update target with local dependency with project name and package name different', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "dgx-devops-app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + dgx-devops-lib1 = { path = "../../libs/lib1", develop = true } + `, + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "dgx-devops-lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'lib1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(2); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['lock', '--no-update'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); + }); + expect(output.success).toBe(true); - const { - tool: { - poetry: { dependencies }, - }, - } = parseToml('apps/app/pyproject.toml'); + const { + tool: { + poetry: { dependencies }, + }, + } = parseToml('apps/app/pyproject.toml'); - expect(dependencies['dgx-devops-lib1']).toStrictEqual({ - path: '../../libs/lib1', - develop: true, + expect(dependencies['dgx-devops-lib1']).toStrictEqual({ + path: '../../libs/lib1', + develop: true, + }); }); - }); - it('run update target and should update the dependency using custom args', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + it('run update target and should update the dependency using custom args', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + name: 'numpy', + local: false, + args: '--group dev', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith( + 'poetry', + ['update', 'numpy', '--group', 'dev'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - const options = { - name: 'numpy', - local: false, - args: '--group dev', - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run update target and should update all dependencies', async () => { + vol.fromJSON({ + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith( - 'poetry', - ['update', 'numpy', '--group', 'dev'], - { + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update'], { cwd: 'apps/app', shell: false, stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); - - it('run update target and should update all dependencies', async () => { - vol.fromJSON({ - 'apps/app/pyproject.toml': `[tool.poetry] -name = "app" -version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" -`, + }); + expect(output.success).toBe(true); }); - const options = { - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, + it('run update target and should the root pyproject.toml', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` + [tool.poetry] + name = "root" + version = "1.0.0" + + [tool.poetry.dependencies] + python = "^3.8" + app = { path = "apps/app", develop = true } + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + `, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledWith('poetry', ['update'], { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }); - expect(output.success).toBe(true); - }); - - it('run update target and should the root pyproject.toml', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` - [tool.poetry] - name = "root" - version = "1.0.0" - - [tool.poetry.dependencies] - python = "^3.8" - app = { path = "apps/app", develop = true } - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - `, - }); - - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'app', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['update', 'numpy', '--lock'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); - }); - - it('run update target and should update all the dependency tree using --lock when pyproject.toml is present', async () => { - vol.fromJSON({ - 'pyproject.toml': dedent` - [tool.poetry] - name = "root" - version = "1.0.0" - - [tool.poetry.dependencies] - python = "^3.8" - app = { path = "apps/app", develop = true } - app1 = { path = "apps/app1", develop = true } - lib1 = { path = "libs/lib1", develop = true } - shared1 = { path = "libs/shared1", develop = true } - `, - 'apps/app/pyproject.toml': dedent` - [tool.poetry] - name = "app" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - `, - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - click = "click" - lib1 = { path = "../../libs/lib1" } - `, - 'libs/lib1/pyproject.toml': dedent` - [tool.poetry] - name = "lib1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - shared1 = { path = "../../libs/shared1" } - `, - 'libs/shared1/pyproject.toml': dedent` - [tool.poetry] - name = "shared1" - version = "1.0.0" - [[tool.poetry.packages]] - include = "app" - - [tool.poetry.dependencies] - python = "^3.8" - `, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['update', 'numpy', '--lock'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); }); - const options = { - name: 'numpy', - local: false, - }; - - const context: ExecutorContext = { - cwd: '', - root: '.', - isVerbose: false, - projectName: 'shared1', - projectsConfigurations: { - version: 2, - projects: { - app: { - root: 'apps/app', - targets: {}, - }, - app1: { - root: 'apps/app1', - targets: {}, - }, - app3: { - root: 'apps/app3', - targets: {}, - }, - lib1: { - root: 'libs/lib1', - targets: {}, - }, - shared1: { - root: 'libs/shared1', - targets: {}, + it('run update target and should update all the dependency tree using --lock when pyproject.toml is present', async () => { + vol.fromJSON({ + 'pyproject.toml': dedent` + [tool.poetry] + name = "root" + version = "1.0.0" + + [tool.poetry.dependencies] + python = "^3.8" + app = { path = "apps/app", develop = true } + app1 = { path = "apps/app1", develop = true } + lib1 = { path = "libs/lib1", develop = true } + shared1 = { path = "libs/shared1", develop = true } + `, + 'apps/app/pyproject.toml': dedent` + [tool.poetry] + name = "app" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + click = "click" + lib1 = { path = "../../libs/lib1" } + `, + 'libs/lib1/pyproject.toml': dedent` + [tool.poetry] + name = "lib1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + shared1 = { path = "../../libs/shared1" } + `, + 'libs/shared1/pyproject.toml': dedent` + [tool.poetry] + name = "shared1" + version = "1.0.0" + [[tool.poetry.packages]] + include = "app" + + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'shared1', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + app1: { + root: 'apps/app1', + targets: {}, + }, + app3: { + root: 'apps/app3', + targets: {}, + }, + lib1: { + root: 'libs/lib1', + targets: {}, + }, + shared1: { + root: 'libs/shared1', + targets: {}, + }, }, }, - }, - nxJsonConfiguration: {}, - projectGraph: { - dependencies: {}, - nodes: {}, - }, - }; - - const output = await executor(options, context); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); - expect(activateVenvMock).toHaveBeenCalledWith('.'); - expect(spawn.sync).toHaveBeenCalledTimes(6); - expect(spawn.sync).toHaveBeenNthCalledWith( - 1, - 'poetry', - ['update', 'numpy', '--lock'], - { - cwd: 'libs/shared1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 2, - 'poetry', - ['lock', '--no-update'], - { - cwd: 'libs/lib1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 3, - 'poetry', - ['lock', '--no-update'], - { - cwd: 'apps/app', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 4, - 'poetry', - ['lock', '--no-update'], - { - cwd: 'apps/app1', - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 5, - 'poetry', - ['lock', '--no-update'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(spawn.sync).toHaveBeenNthCalledWith( - 6, - 'poetry', - ['install', '--no-root'], - { - shell: false, - stdio: 'inherit', - }, - ); - expect(output.success).toBe(true); + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPoetryExecutableMock).toHaveBeenCalled(); + expect(activateVenvMock).toHaveBeenCalledWith('.'); + expect(spawn.sync).toHaveBeenCalledTimes(6); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'poetry', + ['update', 'numpy', '--lock'], + { + cwd: 'libs/shared1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'libs/lib1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 3, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 4, + 'poetry', + ['lock', '--no-update'], + { + cwd: 'apps/app1', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 5, + 'poetry', + ['lock', '--no-update'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith( + 6, + 'poetry', + ['install', '--no-root'], + { + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); }); }); diff --git a/packages/nx-python/src/executors/update/executor.ts b/packages/nx-python/src/executors/update/executor.ts index 75f514c..99f5bab 100644 --- a/packages/nx-python/src/executors/update/executor.ts +++ b/packages/nx-python/src/executors/update/executor.ts @@ -1,14 +1,7 @@ import { ExecutorContext } from '@nx/devkit'; import chalk from 'chalk'; import { UpdateExecutorSchema } from './schema'; -import { - activateVenv, - checkPoetryExecutable, - runPoetry, - updateProject, -} from '../utils/poetry'; -import { updateDependencyTree } from '../../dependency/update-dependency'; -import { existsSync } from 'fs-extra'; +import { getProvider } from '../../provider'; export default async function executor( options: UpdateExecutorSchema, @@ -18,49 +11,8 @@ export default async function executor( process.chdir(workspaceRoot); try { - activateVenv(workspaceRoot); - await checkPoetryExecutable(); - const projectConfig = - context.projectsConfigurations.projects[context.projectName]; - const rootPyprojectToml = existsSync('pyproject.toml'); - - if (options.local && options.name) { - console.log( - chalk`\n {bold Updating {bgBlue ${options.name} } workspace dependency...}\n`, - ); - - if ( - !Object.keys(context.projectsConfigurations.projects).some( - (projectName) => options.name === projectName, - ) - ) { - throw new Error( - chalk`\n {red.bold ${options.name}} workspace project does not exist\n`, - ); - } - - updateProject(projectConfig.root, rootPyprojectToml); - } else { - if (options.name) { - console.log( - chalk`\n {bold Updating {bgBlue ${options.name} } dependency...}\n`, - ); - } else { - console.log(chalk`\n {bold Updating project dependencies...}\n`); - } - - const updateArgs = ['update'] - .concat(options.name ? [options.name] : []) - .concat(options.args ? options.args.split(' ') : []) - .concat(rootPyprojectToml ? ['--lock'] : []); - runPoetry(updateArgs, { cwd: projectConfig.root }); - } - - updateDependencyTree(context); - - console.log( - chalk`\n {green.bold '${options.name}'} {green dependency has been successfully added to the project}\n`, - ); + const provider = await getProvider(workspaceRoot); + await provider.update(options, context); return { success: true, diff --git a/packages/nx-python/src/generators/enable-releases/generator.spec.ts b/packages/nx-python/src/generators/enable-releases/generator.spec.ts index 6deb78d..71ba835 100644 --- a/packages/nx-python/src/generators/enable-releases/generator.spec.ts +++ b/packages/nx-python/src/generators/enable-releases/generator.spec.ts @@ -1,6 +1,5 @@ -import { vi, MockInstance } from 'vitest'; +import { vi } from 'vitest'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../../executors/utils/poetry'; import { readJson, Tree } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import generator from './generator'; @@ -8,13 +7,10 @@ import projectGenerator from '../poetry-project/generator'; import spawn from 'cross-spawn'; describe('nx-python enable-releases', () => { - let checkPoetryExecutableMock: MockInstance; let appTree: Tree; beforeEach(() => { appTree = createTreeWithEmptyWorkspace({}); - checkPoetryExecutableMock = vi.spyOn(poetryUtils, 'checkPoetryExecutable'); - checkPoetryExecutableMock.mockResolvedValue(undefined); vi.mocked(spawn.sync).mockReturnValue({ status: 0, output: [''], diff --git a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.spec.ts b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.spec.ts index 74044f1..677d679 100644 --- a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.spec.ts +++ b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.spec.ts @@ -1,6 +1,6 @@ import { vi, MockInstance } from 'vitest'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../../executors/utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import { Tree } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import generator from './generator'; diff --git a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts index 8f173d8..b5d2f5b 100644 --- a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts +++ b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts @@ -9,9 +9,9 @@ import { import path from 'path'; import { Schema } from './schema'; import { parse, stringify } from '@iarna/toml'; -import { PyprojectToml } from '../../graph/dependency-graph'; import chalk from 'chalk'; -import { checkPoetryExecutable, runPoetry } from '../../executors/utils/poetry'; +import { PoetryPyprojectToml } from '../../provider/poetry'; +import { checkPoetryExecutable, runPoetry } from '../../provider/poetry/utils'; async function addFiles(host: Tree, options: Schema) { const packageJson = await readJsonFile('package.json'); @@ -34,7 +34,7 @@ function updatePyprojectRoot(host: Tree, options: Schema): LockUpdateTask[] { const rootPyprojectToml = parse( host.read('pyproject.toml').toString(), - ) as PyprojectToml; + ) as PoetryPyprojectToml; for (const project of getProjects(host)) { const [, projectConfig] = project; @@ -42,7 +42,7 @@ function updatePyprojectRoot(host: Tree, options: Schema): LockUpdateTask[] { if (host.exists(pyprojectTomlPath)) { const pyprojectToml = parse( host.read(pyprojectTomlPath).toString(), - ) as PyprojectToml; + ) as PoetryPyprojectToml; rootPyprojectToml.tool.poetry.dependencies[ pyprojectToml.tool.poetry.name @@ -70,8 +70,8 @@ function updatePyprojectRoot(host: Tree, options: Schema): LockUpdateTask[] { } function moveDevDependencies( - pyprojectToml: PyprojectToml, - rootPyprojectToml: PyprojectToml, + pyprojectToml: PoetryPyprojectToml, + rootPyprojectToml: PoetryPyprojectToml, host: Tree, pyprojectTomlPath: string, projectConfig: ProjectConfiguration, diff --git a/packages/nx-python/src/generators/poetry-project/generator.spec.ts b/packages/nx-python/src/generators/poetry-project/generator.spec.ts index cb599a0..a75f17e 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.spec.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.spec.ts @@ -1,6 +1,6 @@ import { vi, MockInstance } from 'vitest'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../../executors/utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, readProjectConfiguration } from '@nx/devkit'; @@ -8,9 +8,9 @@ import generator from './generator'; import { PoetryProjectGeneratorSchema } from './schema'; import dedent from 'string-dedent'; import { parse, stringify } from '@iarna/toml'; -import { PyprojectToml } from '../../graph/dependency-graph'; import path from 'path'; import spawn from 'cross-spawn'; +import { PoetryPyprojectToml } from '../../provider/poetry'; describe('application generator', () => { let checkPoetryExecutableMock: MockInstance; @@ -410,7 +410,7 @@ describe('application generator', () => { const pyprojectToml = parse( appTree.read('libs/shared/dev-lib/pyproject.toml', 'utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; pyprojectToml.tool.poetry.dependencies = { python: '>=3.9,<3.11', diff --git a/packages/nx-python/src/generators/poetry-project/generator.ts b/packages/nx-python/src/generators/poetry-project/generator.ts index fce3c3c..f661db2 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.ts @@ -11,14 +11,14 @@ import { } from '@nx/devkit'; import * as path from 'path'; import { PoetryProjectGeneratorSchema } from './schema'; -import { checkPoetryExecutable, runPoetry } from '../../executors/utils/poetry'; -import { - PyprojectToml, - PyprojectTomlDependencies, -} from '../../graph/dependency-graph'; import { parse, stringify } from '@iarna/toml'; import chalk from 'chalk'; import _ from 'lodash'; +import { + PoetryPyprojectToml, + PoetryPyprojectTomlDependencies, +} from '../../provider/poetry'; +import { checkPoetryExecutable, runPoetry } from '../../provider/poetry/utils'; interface NormalizedSchema extends PoetryProjectGeneratorSchema { projectName: string; @@ -221,7 +221,7 @@ function updateRootPyprojectToml( if (!normalizedOptions.individualPackage) { const rootPyprojectToml = parse( host.read('./pyproject.toml', 'utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; const group = normalizedOptions.rootPyprojectDependencyGroup ?? 'main'; @@ -301,13 +301,13 @@ function getPyprojectTomlByProjectName(host: Tree, projectName: string) { const pyprojectToml = parse( host.read(pyprojectTomlPath, 'utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; return { pyprojectToml, pyprojectTomlPath }; } function addTestDependencies( - dependencies: PyprojectTomlDependencies, + dependencies: PoetryPyprojectTomlDependencies, normalizedOptions: NormalizedSchema, ) { const originalDependencies = _.clone(dependencies); diff --git a/packages/nx-python/src/generators/project/generator.spec.ts b/packages/nx-python/src/generators/project/generator.spec.ts index fee0941..78822ab 100644 --- a/packages/nx-python/src/generators/project/generator.spec.ts +++ b/packages/nx-python/src/generators/project/generator.spec.ts @@ -1,6 +1,6 @@ import { vi, MockInstance } from 'vitest'; import '../../utils/mocks/cross-spawn.mock'; -import * as poetryUtils from '../../executors/utils/poetry'; +import * as poetryUtils from '../../provider/poetry/utils'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, readProjectConfiguration } from '@nx/devkit'; diff --git a/packages/nx-python/src/generators/project/generator.ts b/packages/nx-python/src/generators/project/generator.ts index a6537c6..d8bb1d2 100644 --- a/packages/nx-python/src/generators/project/generator.ts +++ b/packages/nx-python/src/generators/project/generator.ts @@ -10,9 +10,9 @@ import { import path from 'path'; import { Schema } from './schema'; import { parse, stringify } from '@iarna/toml'; -import { PyprojectToml } from '../../graph/dependency-graph'; import chalk from 'chalk'; -import { checkPoetryExecutable, runPoetry } from '../../executors/utils/poetry'; +import { PoetryPyprojectToml } from '../../provider/poetry'; +import { checkPoetryExecutable, runPoetry } from '../../provider/poetry/utils'; export interface NormalizedSchema extends Schema { projectName: string; @@ -111,7 +111,7 @@ function updateRootPyprojectToml( if (host.exists('./pyproject.toml')) { const rootPyprojectToml = parse( host.read('pyproject.toml', 'utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; rootPyprojectToml.tool.poetry.dependencies[normalizedOptions.packageName] = { path: normalizedOptions.projectRoot, diff --git a/packages/nx-python/src/generators/release-version/release-version.spec.ts b/packages/nx-python/src/generators/release-version/release-version.spec.ts index 978b613..05c4fae 100644 --- a/packages/nx-python/src/generators/release-version/release-version.spec.ts +++ b/packages/nx-python/src/generators/release-version/release-version.spec.ts @@ -21,10 +21,10 @@ const enquirerMocks = vi.hoisted(() => { import { output, ProjectGraph, Tree } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { createWorkspaceWithPackageDependencies } from './test-utils/create-workspace-with-package-dependencies'; +import { createPoetryWorkspaceWithPackageDependencies } from './test-utils/create-poetry-workspace-with-package-dependencies'; import { releaseVersionGenerator } from './release-version'; import { ReleaseGroupWithName } from 'nx/src/command-line/release/config/filter-release-groups'; -import { readPyprojectToml } from '../../executors/utils/poetry'; +import { readPyprojectToml } from '../../provider/poetry/utils'; process.env.NX_DAEMON = 'false'; @@ -32,60 +32,61 @@ describe('release-version', () => { let tree: Tree; let projectGraph: ProjectGraph; - beforeEach(() => { - tree = createTreeWithEmptyWorkspace(); - - projectGraph = createWorkspaceWithPackageDependencies(tree, { - 'my-lib': { - projectRoot: 'libs/my-lib', - packageName: 'my-lib', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-lib/pyproject.toml', - localDependencies: [], - }, - 'project-with-dependency-on-my-pkg': { - projectRoot: 'libs/project-with-dependency-on-my-pkg', - packageName: 'project-with-dependency-on-my-pkg', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dependencies', - }, - ], - }, - 'project-with-devDependency-on-my-pkg': { - projectRoot: 'libs/project-with-devDependency-on-my-pkg', - packageName: 'project-with-devDependency-on-my-pkg', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dev', - }, - ], - }, + describe('poetry', () => { + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + + projectGraph = createPoetryWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-pkg': { + projectRoot: 'libs/project-with-dependency-on-my-pkg', + packageName: 'project-with-dependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/project-with-devDependency-on-my-pkg', + packageName: 'project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); }); - }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => { + vi.clearAllMocks(); + }); - it('should return a versionData object', async () => { - expect( - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }), - ).toMatchInlineSnapshot(` + it('should return a versionData object', async () => { + expect( + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -122,88 +123,90 @@ describe('release-version', () => { }, } `); - }); - - describe('not all given projects have pyproject.toml files', () => { - beforeEach(() => { - tree.delete('libs/my-lib/pyproject.toml'); }); - it(`should exit with code one and print guidance when not all of the given projects are appropriate for Python versioning`, async () => { - stubProcessExit = true; - - const outputSpy = vi.spyOn(output, 'error').mockImplementationOnce(() => { - return undefined as never; + describe('not all given projects have pyproject.toml files', () => { + beforeEach(() => { + tree.delete('libs/my-lib/pyproject.toml'); }); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }); + it(`should exit with code one and print guidance when not all of the given projects are appropriate for Python versioning`, async () => { + stubProcessExit = true; - expect(outputSpy).toHaveBeenCalledWith({ - title: `The project "my-lib" does not have a pyproject.toml available at libs/my-lib/pyproject.toml. + const outputSpy = vi + .spyOn(output, 'error') + .mockImplementationOnce(() => { + return undefined as never; + }); -To fix this you will either need to add a pyproject.toml file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the pyproject.toml should be.`, - }); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); - outputSpy.mockRestore(); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(outputSpy).toHaveBeenCalledWith({ + title: `The project "my-lib" does not have a pyproject.toml available at libs/my-lib/pyproject.toml. - stubProcessExit = false; - }); - }); +To fix this you will either need to add a pyproject.toml file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the pyproject.toml should be.`, + }); - describe('package with mixed "prod" and "dev" dependencies', () => { - beforeEach(() => { - projectGraph = createWorkspaceWithPackageDependencies(tree, { - 'my-app': { - projectRoot: 'libs/my-app', - packageName: 'my-app', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-app/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib-1', - dependencyCollection: 'dependencies', - }, - { - projectName: 'my-lib-2', - dependencyCollection: 'dev', - }, - ], - }, - 'my-lib-1': { - projectRoot: 'libs/my-lib-1', - packageName: 'my-lib-1', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-lib-1/pyproject.toml', - localDependencies: [], - }, - 'my-lib-2': { - projectRoot: 'libs/my-lib-2', - packageName: 'my-lib-2', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-lib-2/pyproject.toml', - localDependencies: [], - }, + outputSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + + stubProcessExit = false; }); }); - it('should update local dependencies only where it needs to', async () => { - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), + describe('package with mixed "prod" and "dev" dependencies', () => { + beforeEach(() => { + projectGraph = createPoetryWorkspaceWithPackageDependencies(tree, { + 'my-app': { + projectRoot: 'libs/my-app', + packageName: 'my-app', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-app/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib-1', + dependencyCollection: 'dependencies', + }, + { + projectName: 'my-lib-2', + dependencyCollection: 'dev', + }, + ], + }, + 'my-lib-1': { + projectRoot: 'libs/my-lib-1', + packageName: 'my-lib-1', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib-1/pyproject.toml', + localDependencies: [], + }, + 'my-lib-2': { + projectRoot: 'libs/my-lib-2', + packageName: 'my-lib-2', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib-2/pyproject.toml', + localDependencies: [], + }, + }); }); - expect(readPyprojectToml(tree, 'libs/my-app/pyproject.toml')) - .toMatchInlineSnapshot(` + it('should update local dependencies only where it needs to', async () => { + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + + expect(readPyprojectToml(tree, 'libs/my-app/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -229,75 +232,75 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - }); - - describe('fixed release group', () => { - it(`should work with semver keywords and exact semver versions`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), }); - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('1.0.0'); - - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'minor', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }); - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('1.1.0'); - - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'patch', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }); - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('1.1.1'); - - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '1.2.3', // exact version - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }); - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('1.2.3'); }); - it(`should apply the updated version to the projects, including updating dependents`, async () => { - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), + describe('fixed release group', () => { + it(`should work with semver keywords and exact semver versions`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('1.0.0'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'minor', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('1.1.0'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'patch', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('1.1.1'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '1.2.3', // exact version + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('1.2.3'); }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + it(`should apply the updated version to the projects, including updating dependents`, async () => { + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -308,12 +311,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -329,12 +332,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -354,49 +357,49 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); + }); }); - }); - describe('independent release group', () => { - describe('specifierSource: prompt', () => { - it(`should appropriately prompt for each project independently and apply the version updates across all pyproject.toml files`, async () => { - enquirerMocks.prompt - // First project will be minor - .mockResolvedValueOnce({ specifier: 'minor' }) - // Next project will be patch - .mockResolvedValueOnce({ specifier: 'patch' }) - // Final project will be custom explicit version - .mockResolvedValueOnce({ specifier: 'custom' }) - .mockResolvedValueOnce({ specifier: '1.2.3' }); + describe('independent release group', () => { + describe('specifierSource: prompt', () => { + it(`should appropriately prompt for each project independently and apply the version updates across all pyproject.toml files`, async () => { + enquirerMocks.prompt + // First project will be minor + .mockResolvedValueOnce({ specifier: 'minor' }) + // Next project will be patch + .mockResolvedValueOnce({ specifier: 'patch' }) + // Final project will be custom explicit version + .mockResolvedValueOnce({ specifier: 'custom' }) + .mockResolvedValueOnce({ specifier: '1.2.3' }); - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ).tool.poetry.version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ).tool.poetry.version, - ).toEqual('0.0.1'); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ).tool.poetry.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ).tool.poetry.version, + ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '', // no specifier override set, each individual project will be prompted - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - }); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '', // no specifier override set, each individual project will be prompted + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -407,12 +410,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -428,12 +431,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -453,37 +456,37 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - - it(`should respect an explicit user CLI specifier for all, even when projects are independent, and apply the version updates across all pyproject.toml files`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ).tool.poetry.version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ).tool.poetry.version, - ).toEqual('0.0.1'); - - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '4.5.6', // user CLI specifier override set, no prompting should occur - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + it(`should respect an explicit user CLI specifier for all, even when projects are independent, and apply the version updates across all pyproject.toml files`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ).tool.poetry.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ).tool.poetry.version, + ).toEqual('0.0.1'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '4.5.6', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -494,12 +497,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -515,12 +518,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -540,20 +543,20 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); + }); - describe('updateDependentsOptions', () => { - it(`should not update dependents when filtering to a subset of projects by default`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + describe('updateDependentsOptions', () => { + it(`should not update dependents when filtering to a subset of projects by default`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -569,12 +572,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -595,17 +598,17 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', // user CLI specifier override set, no prompting should occur - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - }); - - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -616,12 +619,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -637,12 +640,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -662,19 +665,19 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); + }); - it(`should not update dependents when filtering to a subset of projects by default, if "updateDependents" is set to "never"`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + it(`should not update dependents when filtering to a subset of projects by default, if "updateDependents" is set to "never"`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -690,12 +693,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -716,18 +719,18 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', // user CLI specifier override set, no prompting should occur - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'never', - }); - - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -738,12 +741,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -759,12 +762,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -784,19 +787,19 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); + }); - it(`should update dependents even when filtering to a subset of projects which do not include those dependents, if "updateDependents" is "auto"`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + it(`should update dependents even when filtering to a subset of projects which do not include those dependents, if "updateDependents" is "auto"`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -812,12 +815,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -838,18 +841,18 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', // user CLI specifier override set, no prompting should occur - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'auto', - }); - - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -860,12 +863,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -881,12 +884,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -906,26 +909,26 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); + }); }); }); }); - }); - describe('leading v in version', () => { - it(`should strip a leading v from the provided specifier`, async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'v8.8.8', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + describe('leading v in version', () => { + it(`should strip a leading v from the provided specifier`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'v8.8.8', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -936,12 +939,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -957,12 +960,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -982,76 +985,76 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - }); - - describe('dependent version prefix', () => { - beforeEach(() => { - projectGraph = createWorkspaceWithPackageDependencies(tree, { - 'my-lib': { - projectRoot: 'libs/my-lib', - packageName: 'my-lib', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-lib/pyproject.toml', - localDependencies: [], - }, - 'project-with-dependency-on-my-pkg': { - projectRoot: 'libs/project-with-dependency-on-my-pkg', - packageName: 'project-with-dependency-on-my-pkg', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dependencies', - }, - ], - }, - 'project-with-devDependency-on-my-pkg': { - projectRoot: 'libs/project-with-devDependency-on-my-pkg', - packageName: 'project-with-devDependency-on-my-pkg', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dev', - }, - ], - }, - 'another-project-with-devDependency-on-my-pkg': { - projectRoot: 'libs/another-project-with-devDependency-on-my-pkg', - packageName: 'another-project-with-devDependency-on-my-pkg', - version: '0.0.1', - pyprojectTomlPath: - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dev', - }, - ], - }, }); }); - it('should work with an empty prefix', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: '', + describe('dependent version prefix', () => { + beforeEach(() => { + projectGraph = createPoetryWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-pkg': { + projectRoot: 'libs/project-with-dependency-on-my-pkg', + packageName: 'project-with-dependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/project-with-devDependency-on-my-pkg', + packageName: 'project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + 'another-project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/another-project-with-devDependency-on-my-pkg', + packageName: 'another-project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + + it('should work with an empty prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1062,12 +1065,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1083,12 +1086,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1108,12 +1111,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1133,23 +1136,23 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - - it('should work with a ^ prefix', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: '^', }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + + it('should work with a ^ prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '^', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1160,12 +1163,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1181,12 +1184,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1206,12 +1209,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1231,23 +1234,23 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - - it('should work with a ~ prefix', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: '~', }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + + it('should work with a ~ prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '~', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1258,12 +1261,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1279,12 +1282,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1304,12 +1307,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1329,23 +1332,23 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - - it('should respect any existing prefix when set to "auto"', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: 'auto', }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + + it('should respect any existing prefix when set to "auto"', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: 'auto', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1356,12 +1359,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1377,12 +1380,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1402,12 +1405,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1427,23 +1430,23 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); - - it('should use the behavior of "auto" by default', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: undefined, }); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + + it('should use the behavior of "auto" by default', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: undefined, + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1454,12 +1457,12 @@ To fix this you will either need to add a pyproject.toml file at that location, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1475,12 +1478,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1500,12 +1503,12 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1525,88 +1528,90 @@ To fix this you will either need to add a pyproject.toml file at that location, }, } `); - }); + }); - it(`should exit with code one and print guidance for invalid prefix values`, async () => { - stubProcessExit = true; + it(`should exit with code one and print guidance for invalid prefix values`, async () => { + stubProcessExit = true; - const outputSpy = vi.spyOn(output, 'error').mockImplementationOnce(() => { - return undefined as never; - }); + const outputSpy = vi + .spyOn(output, 'error') + .mockImplementationOnce(() => { + return undefined as never; + }); - await releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - releaseGroup: createReleaseGroup('fixed'), - versionPrefix: '$' as never, - }); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '$' as never, + }); - expect(outputSpy).toHaveBeenCalledWith({ - title: `Invalid value for version.generatorOptions.versionPrefix: "$" + expect(outputSpy).toHaveBeenCalledWith({ + title: `Invalid value for version.generatorOptions.versionPrefix: "$" Valid values are: "auto", "", "~", "^", "="`, - }); + }); - outputSpy.mockRestore(); - expect(processExitSpy).toHaveBeenCalledWith(1); + outputSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); - stubProcessExit = false; + stubProcessExit = false; + }); }); - }); - describe('transitive updateDependents', () => { - beforeEach(() => { - projectGraph = createWorkspaceWithPackageDependencies(tree, { - 'my-lib': { - projectRoot: 'libs/my-lib', - packageName: 'my-lib', - version: '0.0.1', - pyprojectTomlPath: 'libs/my-lib/pyproject.toml', - localDependencies: [], - }, - 'project-with-dependency-on-my-lib': { - projectRoot: 'libs/project-with-dependency-on-my-lib', - packageName: 'project-with-dependency-on-my-lib', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-dependency-on-my-lib/pyproject.toml', - localDependencies: [ - { - projectName: 'my-lib', - dependencyCollection: 'dependencies', - }, - ], - }, - 'project-with-transitive-dependency-on-my-lib': { - projectRoot: 'libs/project-with-transitive-dependency-on-my-lib', - packageName: 'project-with-transitive-dependency-on-my-lib', - version: '0.0.1', - pyprojectTomlPath: - 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', - localDependencies: [ - { - // Depends on my-lib via the project-with-dependency-on-my-lib - projectName: 'project-with-dependency-on-my-lib', - dependencyCollection: 'dev', - }, - ], - }, + describe('transitive updateDependents', () => { + beforeEach(() => { + projectGraph = createPoetryWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-lib': { + projectRoot: 'libs/project-with-dependency-on-my-lib', + packageName: 'project-with-dependency-on-my-lib', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-transitive-dependency-on-my-lib': { + projectRoot: 'libs/project-with-transitive-dependency-on-my-lib', + packageName: 'project-with-transitive-dependency-on-my-lib', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + localDependencies: [ + { + // Depends on my-lib via the project-with-dependency-on-my-lib + projectName: 'project-with-dependency-on-my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); }); - }); - it('should not update transitive dependents when updateDependents is set to "never" and the transitive dependents are not in the same batch', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + it('should not update transitive dependents when updateDependents is set to "never" and the transitive dependents are not in the same batch', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1622,12 +1627,12 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1648,18 +1653,18 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // It should not include transitive dependents in the versionData because we are filtering to only my-lib and updateDependents is set to "never" - expect( - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'never', - }), - ).toMatchInlineSnapshot(` + // It should not include transitive dependents in the versionData because we are filtering to only my-lib and updateDependents is set to "never" + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -1672,8 +1677,8 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1684,13 +1689,13 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // The version of project-with-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + // The version of project-with-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1707,13 +1712,13 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // The version of project-with-transitive-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" - expect( - readPyprojectToml( - tree, - 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + // The version of project-with-transitive-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1733,19 +1738,19 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - }); + }); - it('should always update transitive dependents when updateDependents is set to "auto"', async () => { - expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, - ).toEqual('0.0.1'); - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + it('should always update transitive dependents when updateDependents is set to "auto"', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry + .version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1761,12 +1766,12 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect( - readPyprojectToml( - tree, - 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1787,59 +1792,59 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // It should include the appropriate versionData for transitive dependents - expect( - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'auto', - }), - ).toMatchInlineSnapshot(` - { - "callback": [Function], - "data": { - "my-lib": { - "currentVersion": "0.0.1", - "dependentProjects": [ - { - "dependencyCollection": "dependencies", - "rawVersionSpec": "0.0.1", - "source": "project-with-dependency-on-my-lib", - "target": "my-lib", - "type": "static", - }, - ], - "newVersion": "9.9.9", - }, - "project-with-dependency-on-my-lib": { - "currentVersion": "0.0.1", - "dependentProjects": [ - { - "dependencyCollection": "devDependencies", - "groupKey": "dev", - "rawVersionSpec": "0.0.1", - "source": "project-with-transitive-dependency-on-my-lib", - "target": "project-with-dependency-on-my-lib", - "type": "static", - }, - ], - "newVersion": "0.0.2", - }, - "project-with-transitive-dependency-on-my-lib": { - "currentVersion": "0.0.1", - "dependentProjects": [], - "newVersion": "0.0.2", + // It should include the appropriate versionData for transitive dependents + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "0.0.1", + "source": "project-with-dependency-on-my-lib", + "target": "my-lib", + "type": "static", + }, + ], + "newVersion": "9.9.9", + }, + "project-with-dependency-on-my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [ + { + "dependencyCollection": "devDependencies", + "groupKey": undefined, + "rawVersionSpec": "0.0.1", + "source": "project-with-transitive-dependency-on-my-lib", + "target": "project-with-dependency-on-my-lib", + "type": "static", + }, + ], + "newVersion": "0.0.2", + }, + "project-with-transitive-dependency-on-my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [], + "newVersion": "0.0.2", + }, }, - }, - } - `); + } + `); - expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1850,13 +1855,13 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // The version of project-with-dependency-on-my-lib gets bumped by a patch number and the dependencies reference is updated to the new version of my-lib - expect( - readPyprojectToml( - tree, - 'libs/project-with-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + // The version of project-with-dependency-on-my-lib gets bumped by a patch number and the dependencies reference is updated to the new version of my-lib + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1873,13 +1878,13 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // The version of project-with-transitive-dependency-on-my-lib gets bumped by a patch number and the devDependencies reference is updated to the new version of project-with-dependency-on-my-lib because of the transitive dependency on my-lib - expect( - readPyprojectToml( - tree, - 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', - ), - ).toMatchInlineSnapshot(` + // The version of project-with-transitive-dependency-on-my-lib gets bumped by a patch number and the devDependencies reference is updated to the new version of project-with-dependency-on-my-lib because of the transitive dependency on my-lib + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1899,44 +1904,44 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); + }); }); - }); - describe('circular dependencies', () => { - beforeEach(() => { - // package-a <-> package-b - projectGraph = createWorkspaceWithPackageDependencies(tree, { - 'package-a': { - projectRoot: 'packages/package-a', - packageName: 'package-a', - version: '1.0.0', - pyprojectTomlPath: 'packages/package-a/pyproject.toml', - localDependencies: [ - { - projectName: 'package-b', - dependencyCollection: 'dependencies', - }, - ], - }, - 'package-b': { - projectRoot: 'packages/package-b', - packageName: 'package-b', - version: '1.0.0', - pyprojectTomlPath: 'packages/package-b/pyproject.toml', - localDependencies: [ - { - projectName: 'package-a', - dependencyCollection: 'dependencies', - }, - ], - }, + describe('circular dependencies', () => { + beforeEach(() => { + // package-a <-> package-b + projectGraph = createPoetryWorkspaceWithPackageDependencies(tree, { + 'package-a': { + projectRoot: 'packages/package-a', + packageName: 'package-a', + version: '1.0.0', + pyprojectTomlPath: 'packages/package-a/pyproject.toml', + localDependencies: [ + { + projectName: 'package-b', + dependencyCollection: 'dependencies', + }, + ], + }, + 'package-b': { + projectRoot: 'packages/package-b', + packageName: 'package-b', + version: '1.0.0', + pyprojectTomlPath: 'packages/package-b/pyproject.toml', + localDependencies: [ + { + projectName: 'package-a', + dependencyCollection: 'dependencies', + }, + ], + }, + }); }); - }); - describe("updateDependents: 'never'", () => { - it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + describe("updateDependents: 'never'", () => { + it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1952,8 +1957,8 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -1970,17 +1975,17 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect( - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['package-a']], // version only package-a - projectGraph, - specifier: '2.0.0', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'never', - }), - ).toMatchInlineSnapshot(` + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['package-a']], // version only package-a + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -1993,8 +1998,8 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2010,9 +2015,9 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - // package-b is unchanged - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + // package-b is unchanged + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2028,11 +2033,11 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - }); + }); - it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2048,8 +2053,8 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2066,22 +2071,22 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect( - await releaseVersionGenerator(tree, { - // version both packages - projects: [ - projectGraph.nodes['package-a'], - projectGraph.nodes['package-b'], - ], + expect( + await releaseVersionGenerator(tree, { + // version both packages + projects: [ + projectGraph.nodes['package-a'], + projectGraph.nodes['package-b'], + ], - projectGraph, - specifier: '2.0.0', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'never', - }), - ).toMatchInlineSnapshot(` + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -2115,9 +2120,9 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2133,9 +2138,9 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2151,13 +2156,13 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); + }); }); - }); - describe("updateDependents: 'auto'", () => { - it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + describe("updateDependents: 'auto'", () => { + it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2173,8 +2178,8 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2191,17 +2196,17 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect( - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['package-a']], // version only package-a - projectGraph, - specifier: '2.0.0', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'auto', - }), - ).toMatchInlineSnapshot(` + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['package-a']], // version only package-a + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -2236,9 +2241,9 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // The version of package-a has been updated to 2.0.0, and the dependency on package-b has been updated to 1.0.1 - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + // The version of package-a has been updated to 2.0.0, and the dependency on package-b has been updated to 1.0.1 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2254,9 +2259,9 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - // The version of package-b has been patched to 1.0.1, and the dependency on package-a has been updated to 2.0.0 - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + // The version of package-b has been patched to 1.0.1, and the dependency on package-a has been updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2272,11 +2277,11 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - }); + }); - it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2292,8 +2297,8 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2310,21 +2315,21 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - expect( - await releaseVersionGenerator(tree, { - // version both packages - projects: [ - projectGraph.nodes['package-a'], - projectGraph.nodes['package-b'], - ], - projectGraph, - specifier: '2.0.0', - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), - updateDependents: 'auto', - }), - ).toMatchInlineSnapshot(` + expect( + await releaseVersionGenerator(tree, { + // version both packages + projects: [ + projectGraph.nodes['package-a'], + projectGraph.nodes['package-b'], + ], + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` { "callback": [Function], "data": { @@ -2358,9 +2363,9 @@ Valid values are: "auto", "", "~", "^", "="`, } `); - // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 - expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) - .toMatchInlineSnapshot(` + // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2376,9 +2381,9 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); - // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 - expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) - .toMatchInlineSnapshot(` + // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` { "tool": { "poetry": { @@ -2394,6 +2399,7 @@ Valid values are: "auto", "", "~", "^", "="`, }, } `); + }); }); }); }); diff --git a/packages/nx-python/src/generators/release-version/release-version.ts b/packages/nx-python/src/generators/release-version/release-version.ts index db2e643..85afa09 100644 --- a/packages/nx-python/src/generators/release-version/release-version.ts +++ b/packages/nx-python/src/generators/release-version/release-version.ts @@ -37,21 +37,16 @@ import { resolveLocalPackageDependencies, } from './utils/resolve-local-package-dependencies'; import { sortProjectsTopologically } from './utils/sort-projects-topologically'; -import { - readPyprojectToml, - runPoetry, - writePyprojectToml, -} from '../../executors/utils/poetry'; -import { extractDependencyVersion } from './utils/package'; import path, { dirname } from 'node:path'; +import { getProvider } from '../../provider'; export async function releaseVersionGenerator( tree: Tree, options: ReleaseVersionGeneratorSchema, ): Promise { let logger: ProjectLogger | undefined; - - const poetryLocks: string[] = []; + const provider = await getProvider(tree.root); + const updatedProjects: string[] = []; try { const versionData: VersionData = {}; @@ -157,14 +152,12 @@ To fix this you will either need to add a pyproject.toml file at that location, const color = getColor(projectName); logger = new ProjectLogger(projectName, color); - const pyprojectToml = readPyprojectToml(tree, pyprojectTomlPath); + const { name: packageName, version: currentVersionFromDisk } = + provider.getMetadata(packageRoot, tree); logger.buffer( - `🔍 Reading data for package "${pyprojectToml.tool.poetry.name}" from ${pyprojectTomlPath}`, + `🔍 Reading data for package "${packageName}" from ${pyprojectTomlPath}`, ); - const { name: packageName, version: currentVersionFromDisk } = - pyprojectToml.tool.poetry; - switch (options.currentVersionResolver) { case 'registry': { /** @@ -539,6 +532,7 @@ To fix this you will either need to add a pyproject.toml file at that location, options.projectGraph, projects, projectNameToPackageRootMap, + provider, resolvePackageRoot, // includeAll when the release group is independent, as we may be filtering to a specific subset of projects, but we still want to update their dependents options.releaseGroup.projectsRelationship === 'independent', @@ -647,7 +641,7 @@ To fix this you will either need to add a pyproject.toml file at that location, if (!specifier) { logger.buffer( - `🚫 Skipping versioning "${pyprojectToml.tool.poetry.name}" as no changes were detected.`, + `🚫 Skipping versioning "${packageName}" as no changes were detected.`, ); // Print the buffered logs for this unchanged project, as long as the user has not explicitly disabled this behavior if (logUnchangedProjects) { @@ -663,18 +657,9 @@ To fix this you will either need to add a pyproject.toml file at that location, ); versionData[projectName].newVersion = newVersion; - poetryLocks.push(dirname(pyprojectTomlPath)); - - writePyprojectToml(tree, pyprojectTomlPath, { - ...pyprojectToml, - tool: { - ...pyprojectToml.tool, - poetry: { - ...pyprojectToml.tool.poetry, - version: newVersion, - }, - }, - }); + updatedProjects.push(dirname(pyprojectTomlPath)); + + provider.updateVersion(packageRoot, newVersion, tree); logger.buffer( `✍️ New version ${newVersion} written to ${pyprojectTomlPath}`, @@ -702,50 +687,30 @@ To fix this you will either need to add a pyproject.toml file at that location, const updateDependentProjectAndAddToVersionData = ({ dependentProject, dependencyPackageName, - newDependencyVersion, forceVersionBump, }: { dependentProject: LocalPackageDependency; dependencyPackageName: string; - newDependencyVersion: string; forceVersionBump: 'major' | 'minor' | 'patch' | false; }) => { - const updatedPyprojectFilePath = joinPathFragments( + updatedProjects.push( projectNameToPackageRootMap.get(dependentProject.source), - 'pyproject.toml', ); - poetryLocks.push(dirname(updatedPyprojectFilePath)); - - const updatedPyproject = readPyprojectToml( + const projectMetadata = provider.getMetadata( + projectNameToPackageRootMap.get(dependentProject.source), tree, - updatedPyprojectFilePath, ); - if (!updatedPyproject?.tool?.poetry) { - return; - } - // Auto (i.e.infer existing) by default let versionPrefix = options.versionPrefix ?? 'auto'; - const currentDependencyVersion = - dependentProject.dependencyCollection === 'dependencies' - ? extractDependencyVersion( - tree, - projectNameToPackageRootMap.get(dependentProject.source), - updatedPyproject.tool.poetry.dependencies, - dependencyPackageName, - ) - : extractDependencyVersion( - tree, - projectNameToPackageRootMap.get(dependentProject.source), - updatedPyproject.tool.poetry.group[dependentProject.groupKey] - ?.dependencies, - dependencyPackageName, - ); + const currentDependencyVersion = provider.getDependencyMetadata( + projectNameToPackageRootMap.get(dependentProject.source), + dependencyPackageName, + tree, + ).version; - const currentPackageVersion = - updatedPyproject.tool.poetry.version ?? null; + const currentPackageVersion = projectMetadata.version; if (!currentPackageVersion) { if (forceVersionBump) { // Look up any dependent projects from the transitiveLocalPackageDependents list @@ -760,7 +725,6 @@ To fix this you will either need to add a pyproject.toml file at that location, dependentProjects: transitiveDependentProjects, }; } - writePyprojectToml(tree, updatedPyprojectFilePath, updatedPyproject); return; } @@ -778,33 +742,6 @@ To fix this you will either need to add a pyproject.toml file at that location, } } - // Apply the new version of the dependency to the dependent (if not preserving locally linked package protocols) - // TODO - const shouldUpdateDependency = - !options.preserveLocalDependencyProtocols; - if (shouldUpdateDependency) { - const newDepVersion = `${versionPrefix}${newDependencyVersion}`; - if (dependentProject.dependencyCollection === 'dependencies') { - const mainGroup = updatedPyproject.tool.poetry.dependencies; - - if (typeof mainGroup[dependencyPackageName] === 'string') { - mainGroup[dependencyPackageName] = newDepVersion; - } else if (mainGroup[dependencyPackageName].version) { - mainGroup[dependencyPackageName].version = newDepVersion; - } - } else { - const group = - updatedPyproject.tool.poetry.group[dependentProject.groupKey] - ?.dependencies; - - if (typeof group[dependencyPackageName] === 'string') { - group[dependencyPackageName] = newDepVersion; - } else if (group[dependencyPackageName].version) { - group[dependencyPackageName].version = newDepVersion; - } - } - } - // Bump the dependent's version if applicable and record it in the version data if (forceVersionBump) { const newPackageVersion = deriveNewSemverVersion( @@ -812,7 +749,11 @@ To fix this you will either need to add a pyproject.toml file at that location, forceVersionBump, options.preid, ); - updatedPyproject.tool.poetry.version = newPackageVersion; + provider.updateVersion( + projectNameToPackageRootMap.get(dependentProject.source), + newPackageVersion, + tree, + ); // Look up any dependent projects from the transitiveLocalPackageDependents list const transitiveDependentProjects = @@ -827,8 +768,6 @@ To fix this you will either need to add a pyproject.toml file at that location, dependentProjects: transitiveDependentProjects, }; } - - writePyprojectToml(tree, updatedPyprojectFilePath, updatedPyproject); }; for (const dependentProject of dependentProjectsInCurrentBatch) { @@ -846,7 +785,6 @@ To fix this you will either need to add a pyproject.toml file at that location, updateDependentProjectAndAddToVersionData({ dependentProject, dependencyPackageName: packageName, - newDependencyVersion: newVersion, // We don't force bump because we know they will come later in the topologically sorted projects loop and may have their own version update logic to take into account forceVersionBump: false, }); @@ -867,7 +805,6 @@ To fix this you will either need to add a pyproject.toml file at that location, updateDependentProjectAndAddToVersionData({ dependentProject, dependencyPackageName: packageName, - newDependencyVersion: newVersion, // For these additional dependents, we need to update their package.json version as well because we know they will not come later in the topologically sorted projects loop // (Unless using version plans and the dependent is not filtered out by --projects) forceVersionBump: @@ -892,19 +829,14 @@ To fix this you will either need to add a pyproject.toml file at that location, `The project "${dependencyProjectName}" does not have a packageRoot available. Please report this issue on https://github.com/nrwl/nx`, ); } - const dependencyPyprojectTomlPath = joinPathFragments( + const dependencyMetadata = provider.getMetadata( dependencyPackageRoot, - 'pyproject.toml', - ); - const dependencyPyprojectToml = readPyprojectToml( tree, - dependencyPyprojectTomlPath, ); updateDependentProjectAndAddToVersionData({ dependentProject: transitiveDependentProject, - dependencyPackageName: dependencyPyprojectToml.tool.poetry.name, - newDependencyVersion: dependencyPyprojectToml.tool.poetry.version, + dependencyPackageName: dependencyMetadata.name, /** * For these additional dependents, we need to update their package.json version as well because we know they will not come later in the topologically sorted projects loop. * The one exception being if the dependent is part of a circular dependency, in which case we don't want to force a version bump as this would come in addition to the one @@ -938,20 +870,33 @@ To fix this you will either need to add a pyproject.toml file at that location, } changedFiles.push( - ...poetryLocks.map((lockDir) => path.join(lockDir, 'poetry.lock')), + ...updatedProjects + .map((lockDir) => { + if (tree.exists(path.join(lockDir, 'poetry.lock'))) { + return path.join(lockDir, 'poetry.lock'); + } else if (tree.exists(path.join(lockDir, 'uv.lock'))) { + return path.join(lockDir, 'uv.lock'); + } + + return ''; + }) + .filter((f) => !!f), ); if (!opts.dryRun) { - for (const lockDir of poetryLocks) { - runPoetry(['lock', '--no-update'], { - cwd: lockDir, - }); + for (const lockDir of updatedProjects) { + await provider.lock(lockDir); } } if (!opts.dryRun) { if (tree.exists('pyproject.toml')) { - changedFiles.push('poetry.lock'); - runPoetry(['lock', '--no-update'], { cwd: tree.root }); + if (tree.exists('poetry.lock')) { + changedFiles.push('poetry.lock'); + } else if (tree.exists('uv.lock')) { + changedFiles.push('uv.lock'); + } + + await provider.lock(tree.root); } } diff --git a/packages/nx-python/src/generators/release-version/test-utils/create-workspace-with-package-dependencies.ts b/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts similarity index 91% rename from packages/nx-python/src/generators/release-version/test-utils/create-workspace-with-package-dependencies.ts rename to packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts index 3dbd3d2..39375c4 100644 --- a/packages/nx-python/src/generators/release-version/test-utils/create-workspace-with-package-dependencies.ts +++ b/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts @@ -1,7 +1,7 @@ import { ProjectGraph, Tree } from '@nx/devkit'; -import { PyprojectToml } from '../../../graph/dependency-graph'; +import { PoetryPyprojectToml } from '../../../provider/poetry'; import path from 'path'; -import { writePyprojectToml } from '../../../executors/utils/poetry'; +import { writePyprojectToml } from '../../../provider/poetry/utils'; interface ProjectAndPackageData { [projectName: string]: { @@ -16,7 +16,7 @@ interface ProjectAndPackageData { }; } -export function createWorkspaceWithPackageDependencies( +export function createPoetryWorkspaceWithPackageDependencies( tree: Tree, projectAndPackageData: ProjectAndPackageData, ): ProjectGraph { @@ -33,7 +33,7 @@ export function createWorkspaceWithPackageDependencies( version: data.version, }, }, - } as PyprojectToml; + } as PoetryPyprojectToml; for (const dependency of data.localDependencies) { const dependencyPackageName = projectAndPackageData[dependency.projectName].packageName; diff --git a/packages/nx-python/src/generators/release-version/utils/package.ts b/packages/nx-python/src/generators/release-version/utils/package.ts index d28b346..d003d86 100644 --- a/packages/nx-python/src/generators/release-version/utils/package.ts +++ b/packages/nx-python/src/generators/release-version/utils/package.ts @@ -1,9 +1,5 @@ import { joinPathFragments, Tree } from '@nx/devkit'; -import { - PyprojectToml, - PyprojectTomlDependencies, -} from '../../../graph/dependency-graph'; -import { readPyprojectToml } from '../../../executors/utils/poetry'; +import { IProvider } from '../../../provider/base'; export class Package { name: string; @@ -12,12 +8,13 @@ export class Package { constructor( private tree: Tree, - private pyprojectToml: PyprojectToml, + private provider: IProvider, workspaceRoot: string, private workspaceRelativeLocation: string, ) { - this.name = pyprojectToml.tool.poetry.name; - this.version = pyprojectToml.tool.poetry.version; + const metadata = provider.getMetadata(workspaceRelativeLocation, tree); + this.name = metadata.name; + this.version = metadata.version; this.location = joinPathFragments(workspaceRoot, workspaceRelativeLocation); } @@ -26,62 +23,20 @@ export class Package { groupKey?: string; spec: string; } | null { - if (this.pyprojectToml.tool?.poetry?.dependencies?.[depName]) { - return { - collection: 'dependencies', - spec: extractDependencyVersion( - this.tree, - this.workspaceRelativeLocation, - this.pyprojectToml.tool?.poetry?.dependencies, - depName, - ), - }; - } - - for (const groupKey of Object.keys( - this.pyprojectToml.tool?.poetry?.group, - )) { - if ( - this.pyprojectToml.tool?.poetry?.group[groupKey]?.dependencies?.[ - depName - ] - ) { - return { - collection: - groupKey === 'dev' ? 'devDependencies' : 'optionalDependencies', - groupKey, - spec: extractDependencyVersion( - this.tree, - this.workspaceRelativeLocation, - this.pyprojectToml.tool?.poetry?.group[groupKey]?.dependencies, - depName, - ), - }; - } - } - - return null; + const depMatadata = this.provider.getDependencyMetadata( + this.workspaceRelativeLocation, + depName, + this.tree, + ); + + return { + collection: + depMatadata.group === 'main' + ? 'dependencies' + : depMatadata.group === 'dev' + ? 'devDependencies' + : 'optionalDependencies', + spec: depMatadata.version, + }; } } - -export function extractDependencyVersion( - tree: Tree, - projectLocation: string, - dependencyGroup: PyprojectTomlDependencies, - depName: string, -): string { - if (typeof dependencyGroup?.[depName] === 'string') { - return dependencyGroup?.[depName]; - } - - const dependentPyproject = readPyprojectToml( - tree, - joinPathFragments( - projectLocation, - dependencyGroup?.[depName].path, - 'pyproject.toml', - ), - ); - - return dependentPyproject.tool.poetry.version; -} diff --git a/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts b/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts index 7fbc017..85bcf94 100644 --- a/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts +++ b/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts @@ -3,12 +3,11 @@ import { ProjectGraphDependency, ProjectGraphProjectNode, Tree, - joinPathFragments, workspaceRoot, } from '@nx/devkit'; import { satisfies } from 'semver'; import { Package } from './package'; -import { readPyprojectToml } from '../../../executors/utils/poetry'; +import { IProvider } from '../../../provider/base'; export interface LocalPackageDependency extends ProjectGraphDependency { /** @@ -27,6 +26,7 @@ export function resolveLocalPackageDependencies( projectGraph: ProjectGraph, filteredProjects: ProjectGraphProjectNode[], projectNameToPackageRootMap: Map, + provider: IProvider, resolvePackageRoot: (projectNode: ProjectGraphProjectNode) => string, includeAll = false, ): Record { @@ -50,12 +50,7 @@ export function resolveLocalPackageDependencies( // Append it to the map for later use within the release version generator projectNameToPackageRootMap.set(projectNode.name, packageRoot); } - const pyprojectTomlPath = joinPathFragments(packageRoot, 'pyproject.toml'); - if (!tree.exists(pyprojectTomlPath)) { - continue; - } - const pyprojectToml = readPyprojectToml(tree, pyprojectTomlPath); - const pkg = new Package(tree, pyprojectToml, workspaceRoot, packageRoot); + const pkg = new Package(tree, provider, workspaceRoot, packageRoot); projectNodeToPackageMap.set(projectNode, pkg); } diff --git a/packages/nx-python/src/graph/dependency-graph.spec.ts b/packages/nx-python/src/graph/dependency-graph.spec.ts index 58607be..03694b3 100644 --- a/packages/nx-python/src/graph/dependency-graph.spec.ts +++ b/packages/nx-python/src/graph/dependency-graph.spec.ts @@ -1,5 +1,6 @@ import '../utils/mocks/fs.mock'; -import { createDependencies, getDependents } from './dependency-graph'; +import { getProvider } from '../provider'; +import { createDependencies } from './dependency-graph'; import { vol } from 'memfs'; import dedent from 'string-dedent'; @@ -9,429 +10,433 @@ describe('nx-python dependency graph', () => { vol.reset(); }); - describe('dependency graph', () => { - it('should progress the dependency graph', async () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + describe('poetry', () => { + describe('dependency graph', () => { + it('should progress the dependency graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + dep3: { + root: 'libs/dep3', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); }); - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, - }, - dep3: { - root: 'libs/dep3', - targets: {}, - }, - }; - - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, + it('should link dev dependencies in the graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.group.dev.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); }); - expect(result).toStrictEqual([ - { - source: 'app1', - target: 'dep1', - type: 'implicit', - }, - ]); - }); - - it('should link dev dependencies in the graph', async () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.group.dev.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - }); - - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - }; - - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, + it('should link arbitrary groups dependencies in the graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.group.example_group.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); }); - expect(result).toStrictEqual([ - { - source: 'app1', - target: 'dep1', - type: 'implicit', - }, - ]); - }); - - it('should link arbitrary groups dependencies in the graph', async () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.group.example_group.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, + it('should progress the dependency graph for an empty project', async () => { + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects: {}, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([]); }); - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - }; - - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, + it('should progress the dependency graph when there is an app that is not managed by @nxlv/python', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + 'apps/app2/pyproject.toml': dedent` + [tool.poetry] + name = "app2" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + dep3 = { path = "../../libs/dep3" } + `, + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + app2: { + root: 'apps/app2', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); }); - expect(result).toStrictEqual([ - { - source: 'app1', - target: 'dep1', - type: 'implicit', - }, - ]); - }); - - it('should progress the dependency graph for an empty project', async () => { - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects: {}, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, + it('should progress the dependency graph when there is an app with an empty pyproject.toml', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + 'apps/app2/pyproject.toml': '', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + 'libs/dep2/pyproject.toml': dedent` + [tool.poetry] + name = "dep2" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + + [tool.poetry.group.dev.dependencies] + pytest = "6.2.4" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + app2: { + root: 'apps/app2', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + dep3: { + root: 'libs/dep3', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); }); - - expect(result).toStrictEqual([]); - }); - - it('should progress the dependency graph when there is an app that is not managed by @nxlv/python', async () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - 'apps/app2/pyproject.toml': dedent` - [tool.poetry] - name = "app2" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - dep3 = { path = "../../libs/dep3" } - `, - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, - }); - - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - app2: { - root: 'apps/app2', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, - }, - }; - - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, - }); - - expect(result).toStrictEqual([ - { - source: 'app1', - target: 'dep1', - type: 'implicit', - }, - ]); }); - it('should progress the dependency graph when there is an app with an empty pyproject.toml', async () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - 'apps/app2/pyproject.toml': '', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - 'libs/dep2/pyproject.toml': dedent` - [tool.poetry] - name = "dep2" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - - [tool.poetry.group.dev.dependencies] - pytest = "6.2.4" - `, + describe('get dependents', () => { + it('should return the dependent projects', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [tool.poetry] + name = "app1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + dep1 = { path = "../../libs/dep1" } + `, + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const provider = await getProvider('.'); + const result = provider.getDependents('dep1', projects, '.'); + + expect(result).toStrictEqual(['app1']); }); - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - app2: { - root: 'apps/app2', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - dep2: { - root: 'libs/dep2', - targets: {}, - }, - dep3: { - root: 'libs/dep3', - targets: {}, - }, - }; - - const result = createDependencies(null, { - externalNodes: {}, - workspaceRoot: '.', - projects, - nxJsonConfiguration: {}, - fileMap: { - nonProjectFiles: [], - projectFileMap: {}, - }, - filesToProcess: { - nonProjectFiles: [], - projectFileMap: {}, - }, + it('should return not throw an error when the pyproject is invalid or empty', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': '', + 'libs/dep1/pyproject.toml': dedent` + [tool.poetry] + name = "dep1" + version = "1.0.0" + [tool.poetry.dependencies] + python = "^3.8" + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const provider = await getProvider('.'); + const result = provider.getDependents('dep1', projects, '.'); + + expect(result).toStrictEqual([]); }); - - expect(result).toStrictEqual([ - { - source: 'app1', - target: 'dep1', - type: 'implicit', - }, - ]); - }); - }); - - describe('get dependents', () => { - it('should return the dependent projects', () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': dedent` - [tool.poetry] - name = "app1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - dep1 = { path = "../../libs/dep1" } - `, - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - }); - - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - }; - - const result = getDependents('dep1', projects, '.'); - - expect(result).toStrictEqual(['app1']); - }); - - it('should return not throw an error when the pyproject is invalid or empty', () => { - vol.fromJSON({ - 'apps/app1/pyproject.toml': '', - 'libs/dep1/pyproject.toml': dedent` - [tool.poetry] - name = "dep1" - version = "1.0.0" - [tool.poetry.dependencies] - python = "^3.8" - `, - }); - - const projects = { - app1: { - root: 'apps/app1', - targets: {}, - }, - dep1: { - root: 'libs/dep1', - targets: {}, - }, - }; - - const result = getDependents('dep1', projects, '.'); - - expect(result).toStrictEqual([]); }); }); }); diff --git a/packages/nx-python/src/graph/dependency-graph.ts b/packages/nx-python/src/graph/dependency-graph.ts index 5c07d95..d6482f7 100644 --- a/packages/nx-python/src/graph/dependency-graph.ts +++ b/packages/nx-python/src/graph/dependency-graph.ts @@ -1,223 +1,20 @@ import { - joinPathFragments, - ProjectConfiguration, ImplicitDependency, DependencyType, CreateDependencies, } from '@nx/devkit'; -import { readFileSync, existsSync } from 'fs'; -import { parse } from '@iarna/toml'; -import path from 'path'; +import { getProvider } from '../provider'; -export type PyprojectTomlDependency = - | string - | { - path?: string; - version?: string; - markers?: string; - optional?: boolean; - extras?: string[]; - develop?: boolean; - git?: string; - rev?: string; - source?: string; - }; - -export type PyprojectTomlDependencies = { - [key: string]: PyprojectTomlDependency; -}; - -export type Dependency = { - name: string; - category: string; -}; - -export type PyprojectTomlSource = { - name: string; - url: string; -}; - -export type PyprojectToml = { - tool?: { - nx?: { - autoActivate?: boolean; - }; - poetry?: { - name: string; - version: string; - packages?: Array<{ - include: string; - from?: string; - }>; - dependencies?: PyprojectTomlDependencies; - group?: { - [key: string]: { - dependencies: PyprojectTomlDependencies; - }; - }; - extras?: { - [key: string]: string[]; - }; - plugins?: { - [key: string]: { - [key: string]: string; - }; - }; - source?: PyprojectTomlSource[]; - }; - }; -}; - -export const getDependents = ( - projectName: string, - projects: Record, - cwd: string, -): string[] => { - const deps: string[] = []; - - const { root } = projects[projectName]; - - for (const project in projects) { - if (checkProjectIsDependent(projects, project, root, cwd)) { - deps.push(project); - } - } - - return deps; -}; - -export const getDependencies = ( - projectName: string, - projects: Record, - cwd: string, -): Dependency[] => { - const projectData = projects[projectName]; - const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); - - const deps: Dependency[] = []; - - if (existsSync(pyprojectToml)) { - const tomlData = getPyprojectData(pyprojectToml); - - resolveDependencies( - tomlData.tool?.poetry?.dependencies, - projectData, - projects, - cwd, - deps, - 'main', - ); - for (const group in tomlData.tool?.poetry?.group || {}) { - resolveDependencies( - tomlData.tool.poetry.group[group].dependencies, - projectData, - projects, - cwd, - deps, - group, - ); - } - } - - return deps; -}; - -const getPyprojectData = (pyprojectToml: string) => { - const content = readFileSync(pyprojectToml).toString('utf-8'); - if (content.trim() === '') return {}; - - return parse(readFileSync(pyprojectToml).toString('utf-8')) as PyprojectToml; -}; - -const checkProjectIsDependent = ( - projects: Record, - project: string, - root: string, - cwd: string, -): boolean => { - const projectData = projects[project]; - const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); - - if (existsSync(pyprojectToml)) { - const tomlData = getPyprojectData(pyprojectToml); - - let isDep = isProjectDependent( - tomlData.tool?.poetry?.dependencies, - projectData, - root, - cwd, - ); - - if (isDep) return true; - - for (const group in tomlData.tool?.poetry?.group || {}) { - isDep = isProjectDependent( - tomlData.tool.poetry.group[group].dependencies, - projectData, - root, - cwd, - ); - - if (isDep) return true; - } - } - - return false; -}; - -const isProjectDependent = ( - dependencies: PyprojectTomlDependencies, - projectData: ProjectConfiguration, - root: string, - cwd: string, -): boolean => { - for (const dep in dependencies || {}) { - const depData = dependencies[dep]; - - if (depData instanceof Object && depData.path) { - const depAbsPath = path.resolve(projectData.root, depData.path); - - if ( - path.normalize(root) === path.normalize(path.relative(cwd, depAbsPath)) - ) { - return true; - } - } - } - return false; -}; - -const resolveDependencies = ( - dependencies: PyprojectTomlDependencies, - projectData: ProjectConfiguration, - projects: Record, - cwd: string, - deps: Dependency[], - category: string, -) => { - for (const dep in dependencies || {}) { - const depData = dependencies[dep]; - - if (depData instanceof Object && depData.path) { - const depAbsPath = path.resolve(projectData.root, depData.path); - const depProjectName = Object.keys(projects).find( - (proj) => - path.normalize(projects[proj].root) === - path.normalize(path.relative(cwd, depAbsPath)), - ); - - if (depProjectName) { - deps.push({ name: depProjectName, category }); - } - } - } -}; - -export const createDependencies: CreateDependencies = (_, context) => { +export const createDependencies: CreateDependencies = async (_, context) => { const result: ImplicitDependency[] = []; + const provider = await getProvider(context.workspaceRoot); for (const project in context.projects) { - const deps = getDependencies(project, context.projects, process.cwd()); + const deps = provider.getDependencies( + project, + context.projects, + process.cwd(), + ); deps.forEach((dep) => { result.push({ diff --git a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts index be4922b..a335c30 100644 --- a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts +++ b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts @@ -1,4 +1,4 @@ -import { vi, MockInstance } from 'vitest'; +import { MockInstance } from 'vitest'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, @@ -6,19 +6,14 @@ import { updateProjectConfiguration, } from '@nx/devkit'; import generator from '../../generators/poetry-project/generator'; -import * as poetryUtils from '../../executors/utils/poetry'; import update from './replace-nx-run-commands'; describe('16-1-0-replace-nx-run-commands migration', () => { let tree: Tree; - let checkPoetryExecutableMock: MockInstance; beforeEach(() => { tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); - checkPoetryExecutableMock = vi - .spyOn(poetryUtils, 'checkPoetryExecutable') - .mockResolvedValue(undefined); }); it('should run successfully', async () => { @@ -57,6 +52,5 @@ describe('16-1-0-replace-nx-run-commands migration', () => { expect(updatedProjectConfig.targets.test.executor).toEqual( '@nxlv/python:run-commands', ); - expect(checkPoetryExecutableMock).toHaveBeenCalled(); }); }); diff --git a/packages/nx-python/src/provider/base.ts b/packages/nx-python/src/provider/base.ts new file mode 100644 index 0000000..5f516b3 --- /dev/null +++ b/packages/nx-python/src/provider/base.ts @@ -0,0 +1,88 @@ +import { ExecutorContext, ProjectConfiguration, Tree } from '@nx/devkit'; +import { AddExecutorSchema } from '../executors/add/schema'; +import { SpawnSyncOptions } from 'child_process'; +import { UpdateExecutorSchema } from '../executors/update/schema'; +import { RemoveExecutorSchema } from '../executors/remove/schema'; +import { PublishExecutorSchema } from '../executors/publish/schema'; +import { InstallExecutorSchema } from '../executors/install/schema'; +import { BuildExecutorSchema } from '../executors/build/schema'; + +export type Dependency = { + name: string; + category: string; +}; + +export type ProjectMetadata = { + name: string; + version: string; +}; + +export type DependencyProjectMetadata = ProjectMetadata & { + group?: string; +}; + +export interface IProvider { + checkPrerequisites(): Promise; + + getMetadata(projectRoot: string, tree?: Tree): ProjectMetadata; + + getDependencyMetadata( + projectRoot: string, + dependencyName: string, + tree?: Tree, + ): DependencyProjectMetadata; + + updateVersion(projectRoot: string, newVersion: string, tree?: Tree): void; + + getDependencies( + projectName: string, + projects: Record, + cwd: string, + ): Dependency[]; + + getDependents( + projectName: string, + projects: Record, + cwd: string, + ): string[]; + + add(options: AddExecutorSchema, context: ExecutorContext): Promise; + + update( + options: UpdateExecutorSchema, + context: ExecutorContext, + ): Promise; + + remove( + options: RemoveExecutorSchema, + context: ExecutorContext, + ): Promise; + + publish( + options: PublishExecutorSchema, + context: ExecutorContext, + ): Promise; + + install( + options: InstallExecutorSchema, + context: ExecutorContext, + ): Promise; + + lock(projectRoot: string): Promise; + + build( + options: BuildExecutorSchema, + context: ExecutorContext, + ): Promise; + + run( + args: string[], + workspaceRoot: string, + options: { + log?: boolean; + error?: boolean; + } & SpawnSyncOptions, + ): Promise; + + activateVenv(workspaceRoot: string): void; +} diff --git a/packages/nx-python/src/provider/index.ts b/packages/nx-python/src/provider/index.ts new file mode 100644 index 0000000..1958e91 --- /dev/null +++ b/packages/nx-python/src/provider/index.ts @@ -0,0 +1 @@ +export * from './resolver'; diff --git a/packages/nx-python/src/executors/build/resolvers/index.ts b/packages/nx-python/src/provider/poetry/build/resolvers/index.ts similarity index 100% rename from packages/nx-python/src/executors/build/resolvers/index.ts rename to packages/nx-python/src/provider/poetry/build/resolvers/index.ts diff --git a/packages/nx-python/src/executors/build/resolvers/locked.ts b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts similarity index 94% rename from packages/nx-python/src/executors/build/resolvers/locked.ts rename to packages/nx-python/src/provider/poetry/build/resolvers/locked.ts index bd53ac3..fd6512e 100644 --- a/packages/nx-python/src/executors/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts @@ -1,14 +1,14 @@ -import { PyprojectToml } from '../../../graph/dependency-graph'; import { parse } from '@iarna/toml'; import { readFileSync, existsSync } from 'fs-extra'; import path, { join, relative } from 'path'; import { PoetryLock, Dependency, PoetryLockPackage } from './types'; -import { Logger } from '../../utils/logger'; import chalk from 'chalk'; -import { parseToml, runPoetry } from '../../utils/poetry'; import uri2path from 'file-uri-to-path'; import { getLoggingTab, includeDependencyPackage } from './utils'; -import { isWindows } from '../../utils/os'; +import { Logger } from '../../../../executors/utils/logger'; +import { PoetryPyprojectToml } from '../../types'; +import { parseToml, runPoetry } from '../../utils'; +import { isWindows } from '../../../../executors/utils/os'; export class LockedDependencyResolver { private logger: Logger; @@ -20,7 +20,7 @@ export class LockedDependencyResolver { public resolve( root: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, devDependencies: boolean, workspaceRoot: string, ): Dependency[] { @@ -38,7 +38,7 @@ export class LockedDependencyResolver { devDependencies: boolean, root: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, workspaceRoot: string, deps: Dependency[] = [], level = 1, @@ -119,7 +119,7 @@ export class LockedDependencyResolver { private getProjectRequirementsTxt( devDependencies: boolean, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, root: string, buildFolderPath: string, ): string { @@ -156,7 +156,7 @@ export class LockedDependencyResolver { lockData: PoetryLock, workspaceRoot: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, deps: Dependency[], ) { const { packageName, location } = @@ -240,13 +240,13 @@ export class LockedDependencyResolver { tab: string, packageName: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, ) { const rootFolder = relative(workspaceRoot, uri2path(localDepUrl)); const pyprojectToml = join(rootFolder, 'pyproject.toml'); const tomlData = parse( readFileSync(pyprojectToml).toString('utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; this.logger.info( chalk`${tab}• Adding {blue.bold ${packageName}} local dependency`, ); @@ -324,7 +324,7 @@ export class LockedDependencyResolver { return resolvedDeps; } - private getExtras(buildTomlData: PyprojectToml) { + private getExtras(buildTomlData: PoetryPyprojectToml) { if (buildTomlData.tool.poetry.extras) { return Object.keys(buildTomlData.tool.poetry.extras); } diff --git a/packages/nx-python/src/executors/build/resolvers/project.ts b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts similarity index 89% rename from packages/nx-python/src/executors/build/resolvers/project.ts rename to packages/nx-python/src/provider/poetry/build/resolvers/project.ts index dd00542..1c913de 100644 --- a/packages/nx-python/src/executors/build/resolvers/project.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts @@ -1,17 +1,14 @@ -import { - PyprojectToml, - PyprojectTomlSource, -} from '../../../graph/dependency-graph'; -import { Logger } from '../../utils/logger'; import chalk from 'chalk'; import { Dependency } from './types'; import { join, normalize, relative, resolve } from 'path'; import { readFileSync } from 'fs-extra'; import { parse } from '@iarna/toml'; -import { BuildExecutorSchema } from '../schema'; import { getLoggingTab, includeDependencyPackage } from './utils'; import { ExecutorContext } from '@nx/devkit'; import { createHash } from 'crypto'; +import { PoetryPyprojectToml, PoetryPyprojectTomlSource } from '../../types'; +import { BuildExecutorSchema } from '../../../../executors/build/schema'; +import { Logger } from '../../../../executors/utils/logger'; export class ProjectDependencyResolver { private logger: Logger; @@ -31,13 +28,13 @@ export class ProjectDependencyResolver { resolve( root: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, ): Dependency[] { this.logger.info(chalk` Resolving dependencies...`); const pyprojectPath = join(root, 'pyproject.toml'); const pyproject = parse( readFileSync(pyprojectPath).toString('utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; return this.resolveDependencies( pyproject, @@ -48,10 +45,10 @@ export class ProjectDependencyResolver { } private resolveDependencies( - pyproject: PyprojectToml, + pyproject: PoetryPyprojectToml, root: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, level = 1, ) { const tab = getLoggingTab(level); @@ -73,7 +70,7 @@ export class ProjectDependencyResolver { const depPyprojectPath = join(depPath, 'pyproject.toml'); const depPyproject = parse( readFileSync(depPyprojectPath).toString('utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; const config = this.getProjectConfig(depPath); const targetOptions: BuildExecutorSchema | undefined = @@ -127,7 +124,7 @@ export class ProjectDependencyResolver { } private addSource( - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, targetOptions: BuildExecutorSchema, ): string | undefined { if (!targetOptions?.customSourceUrl) return undefined; @@ -158,9 +155,9 @@ export class ProjectDependencyResolver { } private resolveDuplicateSources = ( - sources: PyprojectTomlSource[], - { name, url }: PyprojectTomlSource, - ): [PyprojectTomlSource[], string] => { + sources: PoetryPyprojectTomlSource[], + { name, url }: PoetryPyprojectTomlSource, + ): [PoetryPyprojectTomlSource[], string] => { if (!sources) { return [[{ name, url }], name]; } diff --git a/packages/nx-python/src/executors/build/resolvers/types.ts b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts similarity index 80% rename from packages/nx-python/src/executors/build/resolvers/types.ts rename to packages/nx-python/src/provider/poetry/build/resolvers/types.ts index 30f1d67..02b9230 100644 --- a/packages/nx-python/src/executors/build/resolvers/types.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts @@ -1,4 +1,4 @@ -import { PyprojectTomlDependency } from '../../../graph/dependency-graph'; +import { PoetryPyprojectTomlDependency } from '../../types'; export type Dependency = { name: string; @@ -17,7 +17,7 @@ export type PoetryLockPackage = { category: string; optional: boolean; dependencies?: { - [key: string]: PyprojectTomlDependency; + [key: string]: PoetryPyprojectTomlDependency; }; source?: { type: 'git' | 'directory' | 'file' | 'url'; diff --git a/packages/nx-python/src/executors/build/resolvers/utils.ts b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts similarity index 87% rename from packages/nx-python/src/executors/build/resolvers/utils.ts rename to packages/nx-python/src/provider/poetry/build/resolvers/utils.ts index 1959954..9411d1e 100644 --- a/packages/nx-python/src/executors/build/resolvers/utils.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts @@ -1,12 +1,12 @@ import { join } from 'path'; import { copySync } from 'fs-extra'; -import { PyprojectToml } from '../../../graph/dependency-graph'; +import { PoetryPyprojectToml } from '../../types'; export function includeDependencyPackage( - tomlData: PyprojectToml, + tomlData: PoetryPyprojectToml, root: string, buildFolderPath: string, - buildTomlData: PyprojectToml, + buildTomlData: PoetryPyprojectToml, ) { for (const pkg of tomlData.tool.poetry.packages) { const pkgFolder = join(root, pkg.from ?? '', pkg.include); diff --git a/packages/nx-python/src/provider/poetry/index.ts b/packages/nx-python/src/provider/poetry/index.ts new file mode 100644 index 0000000..c85d46a --- /dev/null +++ b/packages/nx-python/src/provider/poetry/index.ts @@ -0,0 +1,2 @@ +export * from './provider'; +export * from './types'; diff --git a/packages/nx-python/src/provider/poetry/provider.ts b/packages/nx-python/src/provider/poetry/provider.ts new file mode 100644 index 0000000..4384564 --- /dev/null +++ b/packages/nx-python/src/provider/poetry/provider.ts @@ -0,0 +1,763 @@ +import { + ExecutorContext, + ProjectConfiguration, + ProjectsConfigurations, + Tree, + joinPathFragments, + runExecutor, +} from '@nx/devkit'; +import { + Dependency, + DependencyProjectMetadata, + IProvider, + ProjectMetadata, +} from '../base'; +import fs from 'fs'; +import path, { join } from 'path'; +import { PoetryPyprojectToml, PoetryPyprojectTomlDependencies } from './types'; +import { AddExecutorSchema } from '../../executors/add/schema'; +import { + activateVenv, + addLocalProjectToPoetryProject, + checkPoetryExecutable, + getAllDependenciesFromPyprojectToml, + getLocalDependencyConfig, + getPoetryVersion, + getProjectPackageName, + getProjectTomlPath, + getPyprojectData, + parseToml, + POETRY_EXECUTABLE, + readPyprojectToml, + runPoetry, + RunPoetryOptions, + updateProject, + writePyprojectToml, +} from './utils'; +import chalk from 'chalk'; +import { parse, stringify } from '@iarna/toml'; +import { SpawnSyncOptions } from 'child_process'; +import { RemoveExecutorSchema } from '../../executors/remove/schema'; +import { UpdateExecutorSchema } from '../../executors/update/schema'; +import { PublishExecutorSchema } from '../../executors/publish/schema'; +import { + BuildExecutorOutput, + BuildExecutorSchema, +} from '../../executors/build/schema'; +import { Logger } from '../../executors/utils/logger'; +import { spawnPromise } from '../../executors/utils/cmd'; +import { InstallExecutorSchema } from '../../executors/install/schema'; +import { tmpdir } from 'os'; +import { v4 as uuid } from 'uuid'; +import { + readdirSync, + copySync, + readFileSync, + writeFileSync, + mkdirSync, + removeSync, +} from 'fs-extra'; +import { + LockedDependencyResolver, + ProjectDependencyResolver, +} from './build/resolvers'; + +export class PoetryProvider implements IProvider { + constructor(protected logger: Logger) {} + + public async checkPrerequisites(): Promise { + await checkPoetryExecutable(); + } + + public getMetadata(projectRoot: string, tree?: Tree): ProjectMetadata { + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + + const projectData = tree + ? readPyprojectToml(tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + return { + name: projectData?.tool?.poetry?.name as string, + version: projectData?.tool?.poetry?.version as string, + }; + } + + updateVersion(projectRoot: string, newVersion: string, tree?: Tree): void { + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + + const projectData = tree + ? readPyprojectToml(tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + if (!projectData.tool?.poetry) { + throw new Error('Poetry section not found in pyproject.toml'); + } + projectData.tool.poetry.version = newVersion; + + tree + ? writePyprojectToml(tree, pyprojectTomlPath, projectData) + : writeFileSync(pyprojectTomlPath, stringify(projectData)); + } + + public getDependencyMetadata( + projectRoot: string, + dependencyName: string, + tree?: Tree, + ): DependencyProjectMetadata { + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + + const projectData = tree + ? readPyprojectToml(tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + const main = projectData.tool?.poetry?.dependencies ?? {}; + if (typeof main[dependencyName] === 'object' && main[dependencyName].path) { + const dependentPyproject = readPyprojectToml( + tree, + joinPathFragments( + projectRoot, + main[dependencyName].path, + 'pyproject.toml', + ), + ); + + return { + name: dependentPyproject.tool?.poetry?.name as string, + version: dependentPyproject.tool?.poetry?.version as string, + group: 'main', + }; + } + + for (const key in projectData.tool?.poetry?.group ?? {}) { + const group = projectData.tool?.poetry?.group[key].dependencies; + + if ( + typeof group[dependencyName] === 'object' && + group[dependencyName].path + ) { + const depPyprojectTomlPath = joinPathFragments( + projectRoot, + group[dependencyName].path, + 'pyproject.toml', + ); + + const dependentPyproject = readPyprojectToml( + tree, + depPyprojectTomlPath, + ); + + return { + name: dependentPyproject.tool?.poetry?.name as string, + version: dependentPyproject.tool?.poetry?.version as string, + group: key, + }; + } + } + + console.log('Dependency not found', projectRoot, dependencyName); + + throw new Error('Dependency not found'); + } + + public getDependencies( + projectName: string, + projects: Record, + cwd: string, + ): Dependency[] { + const projectData = projects[projectName]; + const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); + + const deps: Dependency[] = []; + + console.log('pyprojectToml', pyprojectToml, fs.existsSync(pyprojectToml)); + if (fs.existsSync(pyprojectToml)) { + const tomlData = getPyprojectData(pyprojectToml); + + this.resolveDependencies( + tomlData.tool?.poetry?.dependencies, + projectData, + projects, + cwd, + deps, + 'main', + ); + for (const group in tomlData.tool?.poetry?.group || {}) { + this.resolveDependencies( + tomlData.tool.poetry.group[group].dependencies, + projectData, + projects, + cwd, + deps, + group, + ); + } + } + + return deps; + } + + public getDependents( + projectName: string, + projects: Record, + cwd: string, + ): string[] { + const deps: string[] = []; + + const { root } = projects[projectName]; + + for (const project in projects) { + if (this.checkProjectIsDependent(projects, project, root, cwd)) { + deps.push(project); + } + } + + return deps; + } + + public async add( + options: AddExecutorSchema, + context: ExecutorContext, + ): Promise { + activateVenv(context.root); + await checkPoetryExecutable(); + const projectConfig = + context.projectsConfigurations.projects[context.projectName]; + const rootPyprojectToml = fs.existsSync('pyproject.toml'); + + if (options.local) { + this.logger.info( + chalk`\n {bold Adding {bgBlue ${options.name} } workspace dependency...}\n`, + ); + this.updateLocalProject( + context, + options.name, + projectConfig, + rootPyprojectToml, + options.group, + options.extras, + ); + } else { + this.logger.info( + chalk`\n {bold Adding {bgBlue ${options.name} } dependency...}\n`, + ); + const installArgs = ['add', options.name] + .concat(options.group ? ['--group', options.group] : []) + .concat(options.args ? options.args.split(' ') : []) + .concat( + options.extras ? options.extras.map((ex) => `--extras=${ex}`) : [], + ) + .concat(rootPyprojectToml ? ['--lock'] : []); + + runPoetry(installArgs, { cwd: projectConfig.root }); + } + + this.updateDependencyTree(context); + + this.logger.info( + chalk`\n {green.bold '${options.name}'} {green dependency has been successfully added to the project}\n`, + ); + } + + public async update( + options: UpdateExecutorSchema, + context: ExecutorContext, + ): Promise { + activateVenv(context.root); + await checkPoetryExecutable(); + const projectConfig = + context.projectsConfigurations.projects[context.projectName]; + const rootPyprojectToml = fs.existsSync('pyproject.toml'); + + if (options.local && options.name) { + this.logger.info( + chalk`\n {bold Updating {bgBlue ${options.name} } workspace dependency...}\n`, + ); + + if ( + !Object.keys(context.projectsConfigurations.projects).some( + (projectName) => options.name === projectName, + ) + ) { + throw new Error( + chalk`\n {red.bold ${options.name}} workspace project does not exist\n`, + ); + } + + updateProject(projectConfig.root, rootPyprojectToml); + } else { + if (options.name) { + this.logger.info( + chalk`\n {bold Updating {bgBlue ${options.name} } dependency...}\n`, + ); + } else { + this.logger.info(chalk`\n {bold Updating project dependencies...}\n`); + } + + const updateArgs = ['update'] + .concat(options.name ? [options.name] : []) + .concat(options.args ? options.args.split(' ') : []) + .concat(rootPyprojectToml ? ['--lock'] : []); + runPoetry(updateArgs, { cwd: projectConfig.root }); + } + + this.updateDependencyTree(context); + + this.logger.info( + chalk`\n {green.bold '${options.name}'} {green dependency has been successfully added to the project}\n`, + ); + } + + public async remove( + options: RemoveExecutorSchema, + context: ExecutorContext, + ): Promise { + activateVenv(context.root); + await checkPoetryExecutable(); + const rootPyprojectToml = fs.existsSync('pyproject.toml'); + const projectConfig = + context.projectsConfigurations.projects[context.projectName]; + this.logger.info( + chalk`\n {bold Removing {bgBlue ${options.name} } dependency...}\n`, + ); + + let dependencyName = options.name; + if (options.local) { + const dependencyConfig = getLocalDependencyConfig(context, options.name); + + const pyprojectTomlPath = getProjectTomlPath(dependencyConfig); + const { + tool: { + poetry: { name }, + }, + } = parseToml(pyprojectTomlPath); + + dependencyName = name; + } + + const poetryVersion = await getPoetryVersion(); + const hasLockOption = poetryVersion >= '1.5.0'; + + const removeArgs = ['remove', dependencyName] + .concat(options.args ? options.args.split(' ') : []) + .concat(rootPyprojectToml && hasLockOption ? ['--lock'] : []); + runPoetry(removeArgs, { cwd: projectConfig.root }); + + this.updateDependencyTree(context); + + this.logger.info( + chalk`\n {green.bold '${options.name}'} {green dependency has been successfully removed}\n`, + ); + } + + public async publish( + options: PublishExecutorSchema, + context: ExecutorContext, + ): Promise { + let buildFolderPath = ''; + + try { + activateVenv(context.root); + await checkPoetryExecutable(); + + for await (const output of await runExecutor( + { + project: context.projectName, + target: options.buildTarget, + configuration: context.configurationName, + }, + { + keepBuildFolder: true, + }, + context, + )) { + if (!output.success) { + throw new Error('Build failed'); + } + + buildFolderPath = output.buildFolderPath; + } + + if (!buildFolderPath) { + throw new Error('Cannot find the temporary build folder'); + } + + this.logger.info( + chalk`\n {bold Publishing project {bgBlue ${context.projectName} }...}\n`, + ); + + const commandArgs = [ + 'publish', + ...(options.dryRun ? ['--dry-run'] : []), + ...(options.__unparsed__ ?? []), + ]; + const commandStr = `${POETRY_EXECUTABLE} ${commandArgs.join(' ')}`; + + this.logger.info( + chalk`{bold Running command}: ${commandStr} ${ + buildFolderPath && buildFolderPath !== '.' + ? chalk`at {bold ${buildFolderPath}} folder` + : '' + }\n`, + ); + + await spawnPromise(commandStr, buildFolderPath); + removeSync(buildFolderPath); + } catch (error) { + if (buildFolderPath) { + removeSync(buildFolderPath); + } + + if (typeof error === 'object' && 'code' in error && 'output' in error) { + if (error.code !== 0 && error.output.includes('File already exists')) { + this.logger.info( + chalk`\n {bgYellow.bold WARNING } {bold The package is already published}\n`, + ); + + return; + } + } + + throw error; + } + } + + public async install( + options: InstallExecutorSchema, + context: ExecutorContext, + ): Promise { + await checkPoetryExecutable(); + const projectConfig = + context.projectsConfigurations.projects[context.projectName]; + let verboseArg = '-v'; + + if (options.debug) { + verboseArg = '-vvv'; + } else if (options.verbose) { + verboseArg = '-vv'; + } + + const installArgs = ['install', verboseArg].concat( + options.args ? options.args.split(' ') : [], + ); + + const execOpts: RunPoetryOptions = { + cwd: projectConfig.root, + }; + + if (options.cacheDir) { + execOpts.env = { + ...process.env, + POETRY_CACHE_DIR: path.resolve(options.cacheDir), + }; + } + + runPoetry(installArgs, execOpts); + } + + public async lock(projectRoot: string): Promise { + runPoetry(['lock', '--no-update'], { cwd: projectRoot }); + } + + public async build( + options: BuildExecutorSchema, + context: ExecutorContext, + ): Promise { + activateVenv(context.root); + await checkPoetryExecutable(); + if ( + options.lockedVersions === true && + options.bundleLocalDependencies === false + ) { + throw new Error( + 'Not supported operations, you cannot use lockedVersions without bundleLocalDependencies', + ); + } + + this.logger.info( + chalk`\n {bold Building project {bgBlue ${context.projectName} }...}\n`, + ); + + const { root } = + context.projectsConfigurations.projects[context.projectName]; + + const buildFolderPath = join(tmpdir(), 'nx-python', 'build', uuid()); + + mkdirSync(buildFolderPath, { recursive: true }); + + this.logger.info(chalk` Copying project files to a temporary folder`); + readdirSync(root).forEach((file) => { + if (!options.ignorePaths.includes(file)) { + const source = join(root, file); + const target = join(buildFolderPath, file); + copySync(source, target); + } + }); + + const buildPyProjectToml = join(buildFolderPath, 'pyproject.toml'); + + const buildTomlData = parse( + readFileSync(buildPyProjectToml).toString('utf-8'), + ) as PoetryPyprojectToml; + + const deps = options.lockedVersions + ? new LockedDependencyResolver(this.logger).resolve( + root, + buildFolderPath, + buildTomlData, + options.devDependencies, + context.root, + ) + : new ProjectDependencyResolver(this.logger, options, context).resolve( + root, + buildFolderPath, + buildTomlData, + ); + + const pythonDependency = buildTomlData.tool.poetry.dependencies.python; + + buildTomlData.tool.poetry.dependencies = {}; + buildTomlData.tool.poetry.group = { + dev: { + dependencies: {}, + }, + }; + + if (pythonDependency) { + buildTomlData.tool.poetry.dependencies['python'] = pythonDependency; + } + + for (const dep of deps) { + const pyprojectDep = + dep.markers || dep.optional || dep.extras || dep.git || dep.source + ? { + version: dep.version, + markers: dep.markers, + optional: dep.optional, + extras: dep.extras, + git: dep.git, + rev: dep.rev, + source: dep.source, + } + : dep.version; + buildTomlData.tool.poetry.dependencies[dep.name] = pyprojectDep; + } + + writeFileSync(buildPyProjectToml, stringify(buildTomlData)); + const distFolder = join(buildFolderPath, 'dist'); + + removeSync(distFolder); + + this.logger.info(chalk` Generating sdist and wheel artifacts`); + const buildArgs = ['build']; + runPoetry(buildArgs, { cwd: buildFolderPath }); + + removeSync(options.outputPath); + mkdirSync(options.outputPath, { recursive: true }); + this.logger.info( + chalk` Artifacts generated at {bold ${options.outputPath}} folder`, + ); + copySync(distFolder, options.outputPath); + + if (!options.keepBuildFolder) { + removeSync(buildFolderPath); + } + + return buildFolderPath; + } + + public async run( + args: string[], + workspaceRoot: string, + options: { + log?: boolean; + error?: boolean; + } & SpawnSyncOptions, + ): Promise { + activateVenv(workspaceRoot); + await checkPoetryExecutable(); + + return await runPoetry(['run', ...args], options); + } + + public activateVenv(workspaceRoot: string): void { + activateVenv(workspaceRoot); + } + + private updateLocalProject( + context: ExecutorContext, + dependencyName: string, + projectConfig: ProjectConfiguration, + updateLockOnly: boolean, + group?: string, + extras?: string[], + ) { + const dependencyConfig = getLocalDependencyConfig(context, dependencyName); + + const dependencyPath = path.relative( + projectConfig.root, + dependencyConfig.root, + ); + + addLocalProjectToPoetryProject( + projectConfig, + dependencyConfig, + dependencyPath, + group, + extras, + ); + updateProject(projectConfig.root, updateLockOnly); + } + + private updateDependencyTree(context: ExecutorContext) { + const rootPyprojectToml = fs.existsSync('pyproject.toml'); + const pkgName = getProjectPackageName(context, context.projectName); + + this.updateDependents( + context, + context.projectsConfigurations, + context.projectName, + rootPyprojectToml, + context.root, + ); + + if (rootPyprojectToml) { + const rootPyprojectToml = parse( + readFileSync('pyproject.toml', { encoding: 'utf-8' }), + ) as PoetryPyprojectToml; + + const allRootDependencyNames = Object.keys( + getAllDependenciesFromPyprojectToml(rootPyprojectToml), + ); + + if (allRootDependencyNames.includes(pkgName)) { + this.logger.info( + chalk`\nUpdating root {bold pyproject.toml} dependency {bold ${pkgName}}`, + ); + + runPoetry(['lock', '--no-update']); + runPoetry(['install', '--no-root']); + } + } + } + + private updateDependents( + context: ExecutorContext, + workspace: ProjectsConfigurations, + projectName: string, + updateLockOnly: boolean, + workspaceRoot: string, + updatedProjects: string[] = [], + ) { + updatedProjects.push(projectName); + const deps = this.getDependents( + projectName, + workspace.projects, + workspaceRoot, + ); + + for (const dep of deps) { + if (updatedProjects.includes(dep)) { + continue; + } + + this.logger.info(chalk`\nUpdating project {bold ${dep}}`); + const depConfig = workspace.projects[dep]; + + updateProject(depConfig.root, updateLockOnly); + + this.updateDependents( + context, + workspace, + dep, + updateLockOnly, + workspaceRoot, + updatedProjects, + ); + } + } + + private checkProjectIsDependent( + projects: Record, + project: string, + root: string, + cwd: string, + ): boolean { + const projectData = projects[project]; + const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); + + if (fs.existsSync(pyprojectToml)) { + const tomlData = getPyprojectData(pyprojectToml); + + let isDep = this.isProjectDependent( + tomlData.tool?.poetry?.dependencies, + projectData, + root, + cwd, + ); + + if (isDep) return true; + + for (const group in tomlData.tool?.poetry?.group || {}) { + isDep = this.isProjectDependent( + tomlData.tool.poetry.group[group].dependencies, + projectData, + root, + cwd, + ); + + if (isDep) return true; + } + } + + return false; + } + + private isProjectDependent = ( + dependencies: PoetryPyprojectTomlDependencies, + projectData: ProjectConfiguration, + root: string, + cwd: string, + ): boolean => { + for (const dep in dependencies || {}) { + const depData = dependencies[dep]; + + if (depData instanceof Object && depData.path) { + const depAbsPath = path.resolve(projectData.root, depData.path); + + if ( + path.normalize(root) === + path.normalize(path.relative(cwd, depAbsPath)) + ) { + return true; + } + } + } + return false; + }; + + private resolveDependencies( + dependencies: PoetryPyprojectTomlDependencies, + projectData: ProjectConfiguration, + projects: Record, + cwd: string, + deps: Dependency[], + category: string, + ) { + for (const dep in dependencies || {}) { + const depData = dependencies[dep]; + + if (depData instanceof Object && depData.path) { + const depAbsPath = path.resolve(projectData.root, depData.path); + const depProjectName = Object.keys(projects).find( + (proj) => + path.normalize(projects[proj].root) === + path.normalize(path.relative(cwd, depAbsPath)), + ); + + if (depProjectName) { + deps.push({ name: depProjectName, category }); + } + } + } + } +} diff --git a/packages/nx-python/src/provider/poetry/types.ts b/packages/nx-python/src/provider/poetry/types.ts new file mode 100644 index 0000000..9e6d1ac --- /dev/null +++ b/packages/nx-python/src/provider/poetry/types.ts @@ -0,0 +1,53 @@ +export type PoetryPyprojectTomlDependency = + | string + | { + path?: string; + version?: string; + markers?: string; + optional?: boolean; + extras?: string[]; + develop?: boolean; + git?: string; + rev?: string; + source?: string; + }; + +export type PoetryPyprojectTomlDependencies = { + [key: string]: PoetryPyprojectTomlDependency; +}; + +export type PoetryPyprojectTomlSource = { + name: string; + url: string; +}; + +export type PoetryPyprojectToml = { + tool?: { + nx?: { + autoActivate?: boolean; + }; + poetry?: { + name: string; + version: string; + packages?: Array<{ + include: string; + from?: string; + }>; + dependencies?: PoetryPyprojectTomlDependencies; + group?: { + [key: string]: { + dependencies: PoetryPyprojectTomlDependencies; + }; + }; + extras?: { + [key: string]: string[]; + }; + plugins?: { + [key: string]: { + [key: string]: string; + }; + }; + source?: PoetryPyprojectTomlSource[]; + }; + }; +}; diff --git a/packages/nx-python/src/executors/utils/poetry.spec.ts b/packages/nx-python/src/provider/poetry/utils.spec.ts similarity index 99% rename from packages/nx-python/src/executors/utils/poetry.spec.ts rename to packages/nx-python/src/provider/poetry/utils.spec.ts index eb6514a..30e56bc 100644 --- a/packages/nx-python/src/executors/utils/poetry.spec.ts +++ b/packages/nx-python/src/provider/poetry/utils.spec.ts @@ -10,7 +10,7 @@ vi.mock('command-exists', () => { }; }); -import * as poetryUtils from './poetry'; +import * as poetryUtils from './utils'; import dedent from 'string-dedent'; import chalk from 'chalk'; import path from 'path'; diff --git a/packages/nx-python/src/executors/utils/poetry.ts b/packages/nx-python/src/provider/poetry/utils.ts similarity index 74% rename from packages/nx-python/src/executors/utils/poetry.ts rename to packages/nx-python/src/provider/poetry/utils.ts index 3cb89a9..1715654 100644 --- a/packages/nx-python/src/executors/utils/poetry.ts +++ b/packages/nx-python/src/provider/poetry/utils.ts @@ -3,10 +3,10 @@ import chalk from 'chalk'; import spawn from 'cross-spawn'; import path from 'path'; import toml, { parse } from '@iarna/toml'; -import fs from 'fs'; -import { PyprojectToml } from '../../graph/dependency-graph'; +import fs, { readFileSync } from 'fs'; import commandExists from 'command-exists'; import { SpawnSyncOptions } from 'child_process'; +import { PoetryPyprojectToml, PoetryPyprojectTomlDependencies } from './types'; export const POETRY_EXECUTABLE = 'poetry'; @@ -81,7 +81,7 @@ export function getProjectTomlPath(targetConfig: ProjectConfiguration) { } export function parseToml(tomlFile: string) { - return toml.parse(fs.readFileSync(tomlFile, 'utf-8')) as PyprojectToml; + return toml.parse(fs.readFileSync(tomlFile, 'utf-8')) as PoetryPyprojectToml; } export function readPyprojectToml(tree: Tree, tomlFile: string) { @@ -90,13 +90,13 @@ export function readPyprojectToml(tree: Tree, tomlFile: string) { return null; } - return toml.parse(content) as PyprojectToml; + return toml.parse(content) as PoetryPyprojectToml; } export function writePyprojectToml( tree: Tree, tomlFile: string, - data: PyprojectToml, + data: PoetryPyprojectToml, ) { tree.write(tomlFile, toml.stringify(data)); } @@ -143,7 +143,7 @@ export function runPoetry( const result = spawn.sync(POETRY_EXECUTABLE, args, { ...options, - shell: false, + shell: options.shell ?? false, stdio: 'inherit', }); @@ -161,7 +161,7 @@ export function activateVenv(workspaceRoot: string) { if (fs.existsSync(rootPyproject)) { const rootConfig = parse( fs.readFileSync(rootPyproject, 'utf-8'), - ) as PyprojectToml; + ) as PoetryPyprojectToml; const autoActivate = rootConfig.tool.nx?.autoActivate ?? false; if (autoActivate) { console.log( @@ -175,3 +175,48 @@ export function activateVenv(workspaceRoot: string) { } } } + +export const getPyprojectData = ( + pyprojectToml: string, +): PoetryPyprojectToml => { + const content = readFileSync(pyprojectToml).toString('utf-8'); + if (content.trim() === '') return {}; + + return parse(readFileSync(pyprojectToml).toString('utf-8')); +}; + +export const getProjectPackageName = ( + context: ExecutorContext, + projectName: string, +): string => { + const projectConfig = context.projectsConfigurations.projects[projectName]; + const projectToml = getProjectTomlPath(projectConfig); + const { + tool: { + poetry: { name }, + }, + } = parseToml(projectToml); + + return name; +}; + +/** + * Parses all dependency names from a Pyproject.toml file + * and returns a flattened collection of dependencies + * + * Optionally you may supply a list of groups to ignore + */ +export const getAllDependenciesFromPyprojectToml = ( + tomlData: PoetryPyprojectToml, + /** optional dependency groups to omit from collection */ + omitGroups: string[] = [], +): PoetryPyprojectTomlDependencies => { + return { + ...(tomlData.tool?.poetry?.dependencies ?? {}), + ...Object.fromEntries( + Object.entries(tomlData.tool?.poetry?.group ?? {}) + .filter(([name]) => !omitGroups.includes(name)) + .flatMap(([, group]) => Object.entries(group.dependencies ?? {})), + ), + }; +}; diff --git a/packages/nx-python/src/provider/resolver.ts b/packages/nx-python/src/provider/resolver.ts new file mode 100644 index 0000000..66341b7 --- /dev/null +++ b/packages/nx-python/src/provider/resolver.ts @@ -0,0 +1,20 @@ +import fs from 'fs'; +import path from 'path'; +import { IProvider } from './base'; +import { UVProvider } from './uv'; +import { PoetryProvider } from './poetry'; +import { Logger } from '../executors/utils/logger'; + +export const getProvider = async ( + workspaceCwd: string, + logger?: Logger, +): Promise => { + const loggerInstance = logger ?? new Logger(); + + const isUv = fs.existsSync(path.join(workspaceCwd, 'uv.lock')); + if (isUv) { + return new UVProvider(loggerInstance); + } + + return new PoetryProvider(loggerInstance); +}; diff --git a/packages/nx-python/src/provider/uv/index.ts b/packages/nx-python/src/provider/uv/index.ts new file mode 100644 index 0000000..03be03e --- /dev/null +++ b/packages/nx-python/src/provider/uv/index.ts @@ -0,0 +1 @@ +export * from './provider'; diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts new file mode 100644 index 0000000..f54f0e7 --- /dev/null +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -0,0 +1,120 @@ +import { ExecutorContext, ProjectConfiguration, Tree } from '@nx/devkit'; +import { + Dependency, + DependencyProjectMetadata, + IProvider, + ProjectMetadata, +} from '../base'; +import { AddExecutorSchema } from '../../executors/add/schema'; +import { SpawnSyncOptions } from 'child_process'; +import { Logger } from '../../executors/utils/logger'; +import { PublishExecutorSchema } from '../../executors/publish/schema'; +import { RemoveExecutorSchema } from '../../executors/remove/schema'; +import { UpdateExecutorSchema } from '../../executors/update/schema'; +import { BuildExecutorSchema } from '../../executors/build/schema'; +import { InstallExecutorSchema } from '../../executors/install/schema'; + +export class UVProvider implements IProvider { + constructor(protected logger: Logger) {} + + public async checkPrerequisites(): Promise { + throw new Error('Method not implemented.'); + } + + public getMetadata(projectRoot: string): ProjectMetadata { + throw new Error('Method not implemented.'); + } + + public getDependencyMetadata( + projectRoot: string, + dependencyName: string, + tree?: Tree, + ): DependencyProjectMetadata { + throw new Error('Method not implemented.'); + } + + public updateVersion( + projectRoot: string, + newVersion: string, + tree?: Tree, + ): void { + throw new Error('Method not implemented.'); + } + + public getDependencies( + projectName: string, + projects: Record, + cwd: string, + ): Dependency[] { + throw new Error('Method not implemented.'); + } + + public getDependents( + projectName: string, + projects: Record, + cwd: string, + ): string[] { + throw new Error('Method not implemented.'); + } + + public async add( + options: AddExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public async update( + options: UpdateExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public async remove( + options: RemoveExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public async publish( + options: PublishExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public async install( + options: InstallExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public lock(projectRoot: string): Promise { + throw new Error('Method not implemented.'); + } + + public async build( + options: BuildExecutorSchema, + context: ExecutorContext, + ): Promise { + throw new Error('Method not implemented.'); + } + + public async run( + args: string[], + workspaceRoot: string, + options: { + log?: boolean; + error?: boolean; + } & SpawnSyncOptions, + ): Promise { + throw new Error('Method not implemented.'); + } + + public activateVenv(workspaceRoot: string): void { + throw new Error('Method not implemented.'); + } +} From e0453349735d078bbb96d2d1477119ebf34b79c0 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 12 Dec 2024 16:08:17 -0300 Subject: [PATCH 02/11] feat(nx-python): add uv support for add, update, remove, install, lock and release --- .../src/executors/add/executor.spec.ts | 231 ++ .../src/executors/flake8/executor.spec.ts | 276 ++ .../src/executors/install/executor.spec.ts | 183 ++ .../src/executors/publish/executor.spec.ts | 183 +- .../src/executors/remove/executor.spec.ts | 106 + .../src/executors/ruff-check/executor.spec.ts | 162 + .../src/executors/tox/executor.spec.ts | 201 +- .../src/executors/update/executor.spec.ts | 110 + .../release-version/release-version.spec.ts | 2735 ++++++++++++++++- .../release-version/release-version.ts | 15 +- ...try-workspace-with-package-dependencies.ts | 2 +- ...-uv-workspace-with-package-dependencies.ts | 125 + .../release-version/utils/package.ts | 6 +- .../resolve-local-package-dependencies.ts | 4 +- .../src/graph/dependency-graph.spec.ts | 671 ++++ .../nx-python/src/graph/dependency-graph.ts | 2 +- .../replace-nx-run-commands.spec.ts | 1 - packages/nx-python/src/provider/base.ts | 7 +- .../nx-python/src/provider/poetry/provider.ts | 87 +- .../nx-python/src/provider/poetry/utils.ts | 30 +- packages/nx-python/src/provider/resolver.ts | 24 +- packages/nx-python/src/provider/utils.ts | 27 + .../nx-python/src/provider/uv/provider.ts | 340 +- packages/nx-python/src/provider/uv/types.ts | 67 + packages/nx-python/src/provider/uv/utils.ts | 97 + 25 files changed, 5518 insertions(+), 174 deletions(-) create mode 100644 packages/nx-python/src/generators/release-version/test-utils/create-uv-workspace-with-package-dependencies.ts create mode 100644 packages/nx-python/src/provider/utils.ts create mode 100644 packages/nx-python/src/provider/uv/types.ts create mode 100644 packages/nx-python/src/provider/uv/utils.ts diff --git a/packages/nx-python/src/executors/add/executor.spec.ts b/packages/nx-python/src/executors/add/executor.spec.ts index f12ef5f..3277c52 100644 --- a/packages/nx-python/src/executors/add/executor.spec.ts +++ b/packages/nx-python/src/executors/add/executor.spec.ts @@ -3,6 +3,7 @@ import { vol } from 'memfs'; import '../../utils/mocks/cross-spawn.mock'; import '../../utils/mocks/fs.mock'; import * as poetryUtils from '../../provider/poetry/utils'; +import { UVProvider } from '../../provider/uv/provider'; import executor from './executor'; import chalk from 'chalk'; import { parseToml } from '../../provider/poetry/utils'; @@ -1303,4 +1304,234 @@ describe('Add Executor', () => { expect(output.success).toBe(true); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('run add target and should add the dependency to the project', async () => { + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['add', 'numpy', '--project', 'apps/app'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('run add target and should add the dependency to the project group dev', async () => { + const options = { + name: 'numpy', + local: false, + group: 'dev', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['add', 'numpy', '--project', 'apps/app', '--group', 'dev'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('run add target and should add the dependency to the project extras', async () => { + const options = { + name: 'numpy', + local: false, + extras: ['dev'], + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['add', 'numpy', '--project', 'apps/app', '--extra', 'dev'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('run add target and should throw an exception', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake error'); + }); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['add', 'numpy', '--project', 'apps/app'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(false); + }); + }); }); diff --git a/packages/nx-python/src/executors/flake8/executor.spec.ts b/packages/nx-python/src/executors/flake8/executor.spec.ts index ca4d59f..3968eba 100644 --- a/packages/nx-python/src/executors/flake8/executor.spec.ts +++ b/packages/nx-python/src/executors/flake8/executor.spec.ts @@ -11,6 +11,7 @@ import { v4 as uuid } from 'uuid'; import { mkdirsSync, writeFileSync } from 'fs-extra'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; describe('Flake8 Executor', () => { let tmppath = null; @@ -266,4 +267,279 @@ describe('Flake8 Executor', () => { expect(output.success).toBe(false); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + tmppath = join(tmpdir(), 'nx-python', 'flake8', uuid()); + + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + outputFile: 'reports', + silent: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should execute flake8 linting', async () => { + const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, '', { encoding: 'utf8' }); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'flake8', '--output-file', outputFile], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('should execute flake8 linting when the reports folder already exists', async () => { + mkdirsSync(join(tmppath, 'reports/apps/app')); + const outputFile = join(tmppath, 'reports/apps/app/pylint.vi.mocked(txt'); + + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, '', { encoding: 'utf8' }); + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'flake8', '--output-file', outputFile], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('should returns a error when run the flake8 CLI', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('Some error'); + }); + + const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'flake8', '--output-file', outputFile], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(false); + }); + + it('should execute flake8 linting with pylint content more than 1 line', async () => { + mkdirsSync(join(tmppath, 'reports/apps/app')); + const outputFile = join(tmppath, 'reports/apps/app/pylint.txt'); + vi.mocked(spawn.sync).mockImplementation(() => { + writeFileSync(outputFile, 'test\n', { encoding: 'utf8' }); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const output = await executor( + { + outputFile, + silent: false, + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'flake8', '--output-file', outputFile], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(false); + }); + }); }); diff --git a/packages/nx-python/src/executors/install/executor.spec.ts b/packages/nx-python/src/executors/install/executor.spec.ts index 77e57c4..843680f 100644 --- a/packages/nx-python/src/executors/install/executor.spec.ts +++ b/packages/nx-python/src/executors/install/executor.spec.ts @@ -1,10 +1,13 @@ import { vi, MockInstance } from 'vitest'; +import { vol } from 'memfs'; +import '../../utils/mocks/fs.mock'; import '../../utils/mocks/cross-spawn.mock'; import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import path from 'path'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; describe('Install Executor', () => { const context: ExecutorContext = { @@ -28,6 +31,10 @@ describe('Install Executor', () => { }, }; + afterEach(() => { + vol.reset(); + }); + describe('poetry', () => { let checkPoetryExecutableMock: MockInstance; @@ -202,4 +209,180 @@ describe('Install Executor', () => { expect(output.success).toBe(false); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + vi.resetAllMocks(); + + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + silent: false, + debug: false, + verbose: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should install the dependencies using default values', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['sync'], { + stdio: 'inherit', + shell: false, + cwd: '.', + }); + expect(output.success).toBe(true); + }); + + it('should install the dependencies with args', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + args: '--no-dev', + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['sync', '--no-dev'], { + stdio: 'inherit', + shell: false, + cwd: '.', + }); + expect(output.success).toBe(true); + }); + + it('should install the dependencies with verbose flag', async () => { + const options = { + silent: false, + debug: false, + verbose: true, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['sync', '-v'], { + stdio: 'inherit', + shell: false, + cwd: '.', + }); + expect(output.success).toBe(true); + }); + + it('should install the dependencies with debug flag', async () => { + const options = { + silent: false, + debug: true, + verbose: false, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['sync', '-vvv'], { + stdio: 'inherit', + shell: false, + cwd: '.', + }); + expect(output.success).toBe(true); + }); + + it('should install the dependencies with custom cache dir', async () => { + const options = { + silent: false, + debug: false, + verbose: false, + cacheDir: 'apps/app/.cache/custom', + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['sync', '--cache-dir', 'apps/app/.cache/custom'], + { + stdio: 'inherit', + cwd: '.', + shell: false, + }, + ); + expect(output.success).toBe(true); + }); + + it('should not install when the command fail', async () => { + vi.mocked(spawn.sync).mockImplementation(() => { + throw new Error('fake'); + }); + + const options = { + silent: false, + debug: false, + verbose: false, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['sync'], { + stdio: 'inherit', + shell: false, + cwd: '.', + }); + expect(output.success).toBe(false); + }); + }); }); diff --git a/packages/nx-python/src/executors/publish/executor.spec.ts b/packages/nx-python/src/executors/publish/executor.spec.ts index ba5265a..a3120fe 100644 --- a/packages/nx-python/src/executors/publish/executor.spec.ts +++ b/packages/nx-python/src/executors/publish/executor.spec.ts @@ -1,4 +1,6 @@ import { vi, MockInstance } from 'vitest'; +import { vol } from 'memfs'; +import '../../utils/mocks/cross-spawn.mock'; const fsExtraMocks = vi.hoisted(() => { return { @@ -26,10 +28,20 @@ vi.mock('@nx/devkit', async (importOriginal) => { }; }); -vi.mock('fs-extra', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('fs', async () => { + const memfs = (await vi.importActual('memfs')) as typeof import('memfs'); + return { - ...actual, + default: memfs.fs, + ...memfs.fs, + }; +}); + +vi.mock('fs-extra', async () => { + const memfs = (await vi.importActual('memfs')) as typeof import('memfs'); + return { + default: memfs.fs, + ...memfs.fs, ...fsExtraMocks, }; }); @@ -47,6 +59,8 @@ import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import { EventEmitter } from 'events'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; +import spawn from 'cross-spawn'; describe('Publish Executor', () => { beforeAll(() => { @@ -344,4 +358,167 @@ describe('Publish Executor', () => { ); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should return success false when the build target fails', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: false }]); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should return success false when the build target does not return the temp folder', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([{ success: true }]); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + __unparsed__: [], + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should run poetry publish command without agrs', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + }; + + const spawnEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: vi.fn().mockImplementation((event, callback) => { + spawnEvent.on(event, callback); + spawnEvent.emit('close', 0); + }), + }); + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['publish'], { + cwd: 'tmp', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); + expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); + }); + + it('should run poetry publish command with agrs', async () => { + nxDevkitMocks.runExecutor.mockResolvedValueOnce([ + { success: true, buildFolderPath: 'tmp' }, + ]); + fsExtraMocks.removeSync.mockReturnValue(undefined); + + const options = { + buildTarget: 'build', + silent: false, + dryRun: false, + __unparsed__: ['-vvv'], + }; + + const spawnEvent = new EventEmitter(); + childProcessMocks.spawn.mockReturnValue({ + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: vi.fn().mockImplementation((event, callback) => { + spawnEvent.on(event, callback); + spawnEvent.emit('close', 0); + }), + }); + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['publish', '-vvv'], { + cwd: 'tmp', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); + expect(nxDevkitMocks.runExecutor).toHaveBeenCalledWith( + { + configuration: undefined, + project: 'app', + target: 'build', + }, + { + keepBuildFolder: true, + }, + context, + ); + expect(fsExtraMocks.removeSync).toHaveBeenCalledWith('tmp'); + }); + }); }); diff --git a/packages/nx-python/src/executors/remove/executor.spec.ts b/packages/nx-python/src/executors/remove/executor.spec.ts index 159a38d..fd1092d 100644 --- a/packages/nx-python/src/executors/remove/executor.spec.ts +++ b/packages/nx-python/src/executors/remove/executor.spec.ts @@ -8,6 +8,7 @@ import executor from './executor'; import dedent from 'string-dedent'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; describe('Delete Executor', () => { afterEach(() => { @@ -665,4 +666,109 @@ describe('Delete Executor', () => { expect(output.success).toBe(true); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + name: 'shared1', + local: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should remove external dependency with args', async () => { + const options = { + name: 'click', + local: false, + args: '-vvv', + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + ['remove', 'click', '--project', 'apps/app', '-vvv'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + }); }); diff --git a/packages/nx-python/src/executors/ruff-check/executor.spec.ts b/packages/nx-python/src/executors/ruff-check/executor.spec.ts index 1a57548..35a486b 100644 --- a/packages/nx-python/src/executors/ruff-check/executor.spec.ts +++ b/packages/nx-python/src/executors/ruff-check/executor.spec.ts @@ -7,6 +7,7 @@ import * as poetryUtils from '../../provider/poetry/utils'; import executor from './executor'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; describe('Ruff Check Executor', () => { beforeAll(() => { @@ -182,4 +183,165 @@ describe('Ruff Check Executor', () => { expect(output.success).toBe(false); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + lintFilePatterns: ['app'], + __unparsed__: [], + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should execute ruff check linting', async () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + lintFilePatterns: ['app'], + __unparsed__: [], + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'ruff', 'check', 'app'], + { + cwd: 'apps/app', + shell: true, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('should fail to execute ruff check linting ', async () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + status: 1, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + const output = await executor( + { + lintFilePatterns: ['app'], + __unparsed__: [], + }, + { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }, + ); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(1); + expect(spawn.sync).toHaveBeenCalledWith( + 'uv', + ['run', 'ruff', 'check', 'app'], + { + cwd: 'apps/app', + shell: true, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(false); + }); + }); }); diff --git a/packages/nx-python/src/executors/tox/executor.spec.ts b/packages/nx-python/src/executors/tox/executor.spec.ts index 2c03aba..005202f 100644 --- a/packages/nx-python/src/executors/tox/executor.spec.ts +++ b/packages/nx-python/src/executors/tox/executor.spec.ts @@ -9,6 +9,7 @@ import executor from './executor'; import chalk from 'chalk'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; const options: ToxExecutorSchema = { silent: false, @@ -42,14 +43,19 @@ describe('Tox Executor', () => { console.log(chalk`init chalk`); }); - let checkPoetryExecutableMock: MockInstance; - let activateVenvMock: MockInstance; - beforeEach(() => { buildExecutorMock = vi.spyOn(buildExecutor, 'default'); }); + afterEach(() => { + vol.reset(); + vi.resetAllMocks(); + }); + describe('poetry', () => { + let checkPoetryExecutableMock: MockInstance; + let activateVenvMock: MockInstance; + beforeEach(() => { checkPoetryExecutableMock = vi .spyOn(poetryUtils, 'checkPoetryExecutable') @@ -69,11 +75,6 @@ describe('Tox Executor', () => { vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); - afterEach(() => { - vol.reset(); - vi.resetAllMocks(); - }); - it('should return success false when the poetry is not installed', async () => { checkPoetryExecutableMock.mockRejectedValue( new Error('poetry not found'), @@ -263,4 +264,188 @@ describe('Tox Executor', () => { expect(output.success).toBe(false); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(buildExecutorMock).not.toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should build and run tox successfully', async () => { + buildExecutorMock.mockResolvedValue({ + success: true, + }); + + vol.fromJSON({ + 'apps/app/dist/package.tar.gz': 'fake', + }); + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).toBeCalledWith( + 'uv', + ['run', 'tox', '--installpkg', 'dist/package.tar.gz'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('should build and run tox successfully with args', async () => { + buildExecutorMock.mockResolvedValue({ + success: true, + }); + + vol.fromJSON({ + 'apps/app/dist/package.tar.gz': 'fake', + }); + + const output = await executor( + { + silent: false, + args: '-e linters', + }, + context, + ); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).toBeCalledWith( + 'uv', + ['run', 'tox', '--installpkg', 'dist/package.tar.gz', '-e', 'linters'], + { + cwd: 'apps/app', + shell: false, + stdio: 'inherit', + }, + ); + expect(output.success).toBe(true); + }); + + it('should failure the build and not run tox command', async () => { + buildExecutorMock.mockResolvedValue({ + success: false, + }); + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).not.toBeCalled(); + expect(output.success).toBe(false); + }); + + it('should not generate the tar.gz and not run tox command', async () => { + vol.fromJSON({ + 'apps/app/dist/something.txt': 'fake', + }); + + buildExecutorMock.mockResolvedValue({ + success: true, + }); + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(buildExecutorMock).toBeCalledWith( + { + silent: options.silent, + keepBuildFolder: false, + ignorePaths: ['.venv', '.tox', 'tests'], + outputPath: 'apps/app/dist', + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }, + context, + ); + expect(spawn.sync).not.toBeCalled(); + expect(output.success).toBe(false); + }); + }); }); diff --git a/packages/nx-python/src/executors/update/executor.spec.ts b/packages/nx-python/src/executors/update/executor.spec.ts index 9e96bc8..04b449d 100644 --- a/packages/nx-python/src/executors/update/executor.spec.ts +++ b/packages/nx-python/src/executors/update/executor.spec.ts @@ -9,6 +9,7 @@ import { parseToml } from '../../provider/poetry/utils'; import dedent from 'string-dedent'; import spawn from 'cross-spawn'; import { ExecutorContext } from '@nx/devkit'; +import { UVProvider } from '../../provider/uv'; describe('Update Executor', () => { afterEach(() => { @@ -916,4 +917,113 @@ describe('Update Executor', () => { expect(output.success).toBe(true); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + + beforeEach(() => { + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('run update target and should update the dependency to the project', async () => { + const options = { + name: 'numpy', + local: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + ['lock', '--upgrade-package', 'numpy', '--project', 'apps/app'], + { + cwd: '.', + shell: false, + stdio: 'inherit', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + cwd: '.', + shell: false, + stdio: 'inherit', + }); + expect(output.success).toBe(true); + }); + }); }); diff --git a/packages/nx-python/src/generators/release-version/release-version.spec.ts b/packages/nx-python/src/generators/release-version/release-version.spec.ts index 05c4fae..f330158 100644 --- a/packages/nx-python/src/generators/release-version/release-version.spec.ts +++ b/packages/nx-python/src/generators/release-version/release-version.spec.ts @@ -22,9 +22,12 @@ const enquirerMocks = vi.hoisted(() => { import { output, ProjectGraph, Tree } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createPoetryWorkspaceWithPackageDependencies } from './test-utils/create-poetry-workspace-with-package-dependencies'; +import { createUvWorkspaceWithPackageDependencies } from './test-utils/create-uv-workspace-with-package-dependencies'; import { releaseVersionGenerator } from './release-version'; import { ReleaseGroupWithName } from 'nx/src/command-line/release/config/filter-release-groups'; -import { readPyprojectToml } from '../../provider/poetry/utils'; +import { readPyprojectToml } from '../../provider/utils'; +import { PoetryPyprojectToml } from '../../provider/poetry/types'; +import { UVPyprojectToml } from '../../provider/uv/types'; process.env.NX_DAEMON = 'false'; @@ -238,8 +241,10 @@ To fix this you will either need to add a pyproject.toml file at that location, describe('fixed release group', () => { it(`should work with semver keywords and exact semver versions`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -249,8 +254,10 @@ To fix this you will either need to add a pyproject.toml file at that location, releaseGroup: createReleaseGroup('fixed'), }); expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('1.0.0'); await releaseVersionGenerator(tree, { @@ -261,8 +268,10 @@ To fix this you will either need to add a pyproject.toml file at that location, releaseGroup: createReleaseGroup('fixed'), }); expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('1.1.0'); await releaseVersionGenerator(tree, { @@ -273,8 +282,10 @@ To fix this you will either need to add a pyproject.toml file at that location, releaseGroup: createReleaseGroup('fixed'), }); expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('1.1.1'); await releaseVersionGenerator(tree, { @@ -285,8 +296,10 @@ To fix this you will either need to add a pyproject.toml file at that location, releaseGroup: createReleaseGroup('fixed'), }); expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('1.2.3'); }); @@ -373,17 +386,19 @@ To fix this you will either need to add a pyproject.toml file at that location, .mockResolvedValueOnce({ specifier: '1.2.3' }); expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( - readPyprojectToml( + readPyprojectToml( tree, 'libs/project-with-dependency-on-my-pkg/pyproject.toml', ).tool.poetry.version, ).toEqual('0.0.1'); expect( - readPyprojectToml( + readPyprojectToml( tree, 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', ).tool.poetry.version, @@ -460,17 +475,19 @@ To fix this you will either need to add a pyproject.toml file at that location, it(`should respect an explicit user CLI specifier for all, even when projects are independent, and apply the version updates across all pyproject.toml files`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( - readPyprojectToml( + readPyprojectToml( tree, 'libs/project-with-dependency-on-my-pkg/pyproject.toml', ).tool.poetry.version, ).toEqual('0.0.1'); expect( - readPyprojectToml( + readPyprojectToml( tree, 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', ).tool.poetry.version, @@ -548,8 +565,10 @@ To fix this you will either need to add a pyproject.toml file at that location, describe('updateDependentsOptions', () => { it(`should not update dependents when filtering to a subset of projects by default`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( readPyprojectToml( @@ -669,8 +688,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it(`should not update dependents when filtering to a subset of projects by default, if "updateDependents" is set to "never"`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( readPyprojectToml( @@ -791,8 +812,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it(`should update dependents even when filtering to a subset of projects which do not include those dependents, if "updateDependents" is "auto"`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( readPyprojectToml( @@ -917,8 +940,10 @@ To fix this you will either need to add a pyproject.toml file at that location, describe('leading v in version', () => { it(`should strip a leading v from the provided specifier`, async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1042,8 +1067,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it('should work with an empty prefix', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1140,8 +1167,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it('should work with a ^ prefix', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1238,8 +1267,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it('should work with a ~ prefix', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1336,8 +1367,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it('should respect any existing prefix when set to "auto"', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1434,8 +1467,10 @@ To fix this you will either need to add a pyproject.toml file at that location, it('should use the behavior of "auto" by default', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); await releaseVersionGenerator(tree, { projects: Object.values(projectGraph.nodes), // version all projects @@ -1603,8 +1638,10 @@ Valid values are: "auto", "", "~", "^", "="`, it('should not update transitive dependents when updateDependents is set to "never" and the transitive dependents are not in the same batch', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( readPyprojectToml( @@ -1742,8 +1779,10 @@ Valid values are: "auto", "", "~", "^", "="`, it('should always update transitive dependents when updateDependents is set to "auto"', async () => { expect( - readPyprojectToml(tree, 'libs/my-lib/pyproject.toml').tool.poetry - .version, + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).tool.poetry.version, ).toEqual('0.0.1'); expect( readPyprojectToml( @@ -2403,6 +2442,2620 @@ Valid values are: "auto", "", "~", "^", "="`, }); }); }); + + describe('uv', () => { + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + + projectGraph = createUvWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-pkg': { + projectRoot: 'libs/project-with-dependency-on-my-pkg', + packageName: 'project-with-dependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/project-with-devDependency-on-my-pkg', + packageName: 'project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return a versionData object', async () => { + expect( + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "0.0.1", + "source": "project-with-dependency-on-my-pkg", + "target": "my-lib", + "type": "static", + }, + { + "dependencyCollection": "devDependencies", + "rawVersionSpec": "0.0.1", + "source": "project-with-devDependency-on-my-pkg", + "target": "my-lib", + "type": "static", + }, + ], + "newVersion": "1.0.0", + }, + "project-with-dependency-on-my-pkg": { + "currentVersion": "0.0.1", + "dependentProjects": [], + "newVersion": "1.0.0", + }, + "project-with-devDependency-on-my-pkg": { + "currentVersion": "0.0.1", + "dependentProjects": [], + "newVersion": "1.0.0", + }, + }, + } + `); + }); + + describe('not all given projects have pyproject.toml files', () => { + beforeEach(() => { + tree.delete('libs/my-lib/pyproject.toml'); + }); + + it(`should exit with code one and print guidance when not all of the given projects are appropriate for Python versioning`, async () => { + stubProcessExit = true; + + const outputSpy = vi + .spyOn(output, 'error') + .mockImplementationOnce(() => { + return undefined as never; + }); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + + expect(outputSpy).toHaveBeenCalledWith({ + title: `The project "my-lib" does not have a pyproject.toml available at libs/my-lib/pyproject.toml. + +To fix this you will either need to add a pyproject.toml file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the pyproject.toml should be.`, + }); + + outputSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + + stubProcessExit = false; + }); + }); + + describe('package with mixed "prod" and "dev" dependencies', () => { + beforeEach(() => { + projectGraph = createUvWorkspaceWithPackageDependencies(tree, { + 'my-app': { + projectRoot: 'libs/my-app', + packageName: 'my-app', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-app/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib-1', + dependencyCollection: 'dependencies', + }, + { + projectName: 'my-lib-2', + dependencyCollection: 'dev', + }, + ], + }, + 'my-lib-1': { + projectRoot: 'libs/my-lib-1', + packageName: 'my-lib-1', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib-1/pyproject.toml', + localDependencies: [], + }, + 'my-lib-2': { + projectRoot: 'libs/my-lib-2', + packageName: 'my-lib-2', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib-2/pyproject.toml', + localDependencies: [], + }, + }); + }); + + it('should update local dependencies only where it needs to', async () => { + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + + expect(readPyprojectToml(tree, 'libs/my-app/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib-2", + ], + }, + "project": { + "dependencies": [ + "my-lib-1", + ], + "name": "my-app", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "my-lib-1": { + "workspace": true, + }, + "my-lib-2": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + + describe('fixed release group', () => { + it(`should work with semver keywords and exact semver versions`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('1.0.0'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'minor', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('1.1.0'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'patch', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('1.1.1'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '1.2.3', // exact version + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('1.2.3'); + }); + + it(`should apply the updated version to the projects, including updating dependents`, async () => { + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + + describe('independent release group', () => { + describe('specifierSource: prompt', () => { + it(`should appropriately prompt for each project independently and apply the version updates across all pyproject.toml files`, async () => { + enquirerMocks.prompt + // First project will be minor + .mockResolvedValueOnce({ specifier: 'minor' }) + // Next project will be patch + .mockResolvedValueOnce({ specifier: 'patch' }) + // Final project will be custom explicit version + .mockResolvedValueOnce({ specifier: 'custom' }) + .mockResolvedValueOnce({ specifier: '1.2.3' }); + + expect( + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '', // no specifier override set, each individual project will be prompted + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "0.1.0", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.2", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "1.2.3", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it(`should respect an explicit user CLI specifier for all, even when projects are independent, and apply the version updates across all pyproject.toml files`, async () => { + expect( + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '4.5.6', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "4.5.6", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "4.5.6", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "4.5.6", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + describe('updateDependentsOptions', () => { + it(`should not update dependents when filtering to a subset of projects by default`, async () => { + expect( + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it(`should not update dependents when filtering to a subset of projects by default, if "updateDependents" is set to "never"`, async () => { + expect( + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it(`should update dependents even when filtering to a subset of projects which do not include those dependents, if "updateDependents" is "auto"`, async () => { + expect( + readPyprojectToml( + tree, + 'libs/my-lib/pyproject.toml', + ).project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.2", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.2", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + }); + }); + + describe('leading v in version', () => { + it(`should strip a leading v from the provided specifier`, async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'v8.8.8', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "8.8.8", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "8.8.8", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "8.8.8", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + + describe('dependent version prefix', () => { + beforeEach(() => { + projectGraph = createUvWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-pkg': { + projectRoot: 'libs/project-with-dependency-on-my-pkg', + packageName: 'project-with-dependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/project-with-devDependency-on-my-pkg', + packageName: 'project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + 'another-project-with-devDependency-on-my-pkg': { + projectRoot: 'libs/another-project-with-devDependency-on-my-pkg', + packageName: 'another-project-with-devDependency-on-my-pkg', + version: '0.0.1', + pyprojectTomlPath: + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); + }); + + it('should work with an empty prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "another-project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should work with a ^ prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '^', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "another-project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should work with a ~ prefix', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '~', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "another-project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should respect any existing prefix when set to "auto"', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: 'auto', + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "another-project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should use the behavior of "auto" by default', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: undefined, + }); + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/another-project-with-devDependency-on-my-pkg/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "my-lib", + ], + }, + "project": { + "name": "another-project-with-devDependency-on-my-pkg", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it(`should exit with code one and print guidance for invalid prefix values`, async () => { + stubProcessExit = true; + + const outputSpy = vi + .spyOn(output, 'error') + .mockImplementationOnce(() => { + return undefined as never; + }); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup('fixed'), + versionPrefix: '$' as never, + }); + + expect(outputSpy).toHaveBeenCalledWith({ + title: `Invalid value for version.generatorOptions.versionPrefix: "$" + +Valid values are: "auto", "", "~", "^", "="`, + }); + + outputSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + + stubProcessExit = false; + }); + }); + + describe('transitive updateDependents', () => { + beforeEach(() => { + projectGraph = createUvWorkspaceWithPackageDependencies(tree, { + 'my-lib': { + projectRoot: 'libs/my-lib', + packageName: 'my-lib', + version: '0.0.1', + pyprojectTomlPath: 'libs/my-lib/pyproject.toml', + localDependencies: [], + }, + 'project-with-dependency-on-my-lib': { + projectRoot: 'libs/project-with-dependency-on-my-lib', + packageName: 'project-with-dependency-on-my-lib', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + localDependencies: [ + { + projectName: 'my-lib', + dependencyCollection: 'dependencies', + }, + ], + }, + 'project-with-transitive-dependency-on-my-lib': { + projectRoot: 'libs/project-with-transitive-dependency-on-my-lib', + packageName: 'project-with-transitive-dependency-on-my-lib', + version: '0.0.1', + pyprojectTomlPath: + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + localDependencies: [ + { + // Depends on my-lib via the project-with-dependency-on-my-lib + projectName: 'project-with-dependency-on-my-lib', + dependencyCollection: 'dev', + }, + ], + }, + }); + }); + + it('should not update transitive dependents when updateDependents is set to "never" and the transitive dependents are not in the same batch', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "project-with-dependency-on-my-lib", + ], + }, + "project": { + "name": "project-with-transitive-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "project-with-dependency-on-my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + // It should not include transitive dependents in the versionData because we are filtering to only my-lib and updateDependents is set to "never" + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [], + "newVersion": "9.9.9", + }, + }, + } + `); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + // The version of project-with-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + // The version of project-with-transitive-dependency-on-my-lib is untouched because it is not in the same batch as my-lib and updateDependents is set to "never" + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "project-with-dependency-on-my-lib", + ], + }, + "project": { + "name": "project-with-transitive-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "project-with-dependency-on-my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should always update transitive dependents when updateDependents is set to "auto"', async () => { + expect( + readPyprojectToml(tree, 'libs/my-lib/pyproject.toml') + .project.version, + ).toEqual('0.0.1'); + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "project-with-dependency-on-my-lib", + ], + }, + "project": { + "name": "project-with-transitive-dependency-on-my-lib", + "version": "0.0.1", + }, + "tool": { + "uv": { + "sources": { + "project-with-dependency-on-my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + // It should include the appropriate versionData for transitive dependents + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "0.0.1", + "source": "project-with-dependency-on-my-lib", + "target": "my-lib", + "type": "static", + }, + ], + "newVersion": "9.9.9", + }, + "project-with-dependency-on-my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [ + { + "dependencyCollection": "devDependencies", + "groupKey": undefined, + "rawVersionSpec": "0.0.1", + "source": "project-with-transitive-dependency-on-my-lib", + "target": "project-with-dependency-on-my-lib", + "type": "static", + }, + ], + "newVersion": "0.0.2", + }, + "project-with-transitive-dependency-on-my-lib": { + "currentVersion": "0.0.1", + "dependentProjects": [], + "newVersion": "0.0.2", + }, + }, + } + `); + + expect(readPyprojectToml(tree, 'libs/my-lib/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "name": "my-lib", + "version": "9.9.9", + }, + "tool": { + "uv": { + "sources": {}, + }, + }, + } + `); + + // The version of project-with-dependency-on-my-lib gets bumped by a patch number and the dependencies reference is updated to the new version of my-lib + expect( + readPyprojectToml( + tree, + 'libs/project-with-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "my-lib", + ], + "name": "project-with-dependency-on-my-lib", + "version": "0.0.2", + }, + "tool": { + "uv": { + "sources": { + "my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + + // The version of project-with-transitive-dependency-on-my-lib gets bumped by a patch number and the devDependencies reference is updated to the new version of project-with-dependency-on-my-lib because of the transitive dependency on my-lib + expect( + readPyprojectToml( + tree, + 'libs/project-with-transitive-dependency-on-my-lib/pyproject.toml', + ), + ).toMatchInlineSnapshot(` + { + "dependency-groups": { + "dev": [ + "project-with-dependency-on-my-lib", + ], + }, + "project": { + "name": "project-with-transitive-dependency-on-my-lib", + "version": "0.0.2", + }, + "tool": { + "uv": { + "sources": { + "project-with-dependency-on-my-lib": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + + describe('circular dependencies', () => { + beforeEach(() => { + // package-a <-> package-b + projectGraph = createUvWorkspaceWithPackageDependencies(tree, { + 'package-a': { + projectRoot: 'packages/package-a', + packageName: 'package-a', + version: '1.0.0', + pyprojectTomlPath: 'packages/package-a/pyproject.toml', + localDependencies: [ + { + projectName: 'package-b', + dependencyCollection: 'dependencies', + }, + ], + }, + 'package-b': { + projectRoot: 'packages/package-b', + packageName: 'package-b', + version: '1.0.0', + pyprojectTomlPath: 'packages/package-b/pyproject.toml', + localDependencies: [ + { + projectName: 'package-a', + dependencyCollection: 'dependencies', + }, + ], + }, + }); + }); + + describe("updateDependents: 'never'", () => { + it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['package-a']], // version only package-a + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "package-a": { + "currentVersion": "1.0.0", + "dependentProjects": [], + "newVersion": "2.0.0", + }, + }, + } + `); + + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + // package-b is unchanged + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + + expect( + await releaseVersionGenerator(tree, { + // version both packages + projects: [ + projectGraph.nodes['package-a'], + projectGraph.nodes['package-b'], + ], + + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'never', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "package-a": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "1.0.0", + "source": "package-b", + "target": "package-a", + "type": "static", + }, + ], + "newVersion": "2.0.0", + }, + "package-b": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "1.0.0", + "source": "package-a", + "target": "package-b", + "type": "static", + }, + ], + "newVersion": "2.0.0", + }, + }, + } + `); + + // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + + describe("updateDependents: 'auto'", () => { + it('should allow versioning of circular dependencies when not all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + + expect( + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['package-a']], // version only package-a + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "package-a": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "1.0.0", + "source": "package-b", + "target": "package-a", + "type": "static", + }, + ], + "newVersion": "2.0.0", + }, + "package-b": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "groupKey": undefined, + "rawVersionSpec": "1.0.0", + "source": "package-a", + "target": "package-b", + "type": "static", + }, + ], + "newVersion": "1.0.1", + }, + }, + } + `); + + // The version of package-a has been updated to 2.0.0, and the dependency on package-b has been updated to 1.0.1 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + // The version of package-b has been patched to 1.0.1, and the dependency on package-a has been updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.1", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + + it('should allow versioning of circular dependencies when all projects are included in the current batch', async () => { + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "1.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + + expect( + await releaseVersionGenerator(tree, { + // version both packages + projects: [ + projectGraph.nodes['package-a'], + projectGraph.nodes['package-b'], + ], + projectGraph, + specifier: '2.0.0', + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: 'auto', + }), + ).toMatchInlineSnapshot(` + { + "callback": [Function], + "data": { + "package-a": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "1.0.0", + "source": "package-b", + "target": "package-a", + "type": "static", + }, + ], + "newVersion": "2.0.0", + }, + "package-b": { + "currentVersion": "1.0.0", + "dependentProjects": [ + { + "dependencyCollection": "dependencies", + "rawVersionSpec": "1.0.0", + "source": "package-a", + "target": "package-b", + "type": "static", + }, + ], + "newVersion": "2.0.0", + }, + }, + } + `); + + // Both the version of package-a, and the dependency on package-b are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-a/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-b", + ], + "name": "package-a", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-b": { + "workspace": true, + }, + }, + }, + }, + } + `); + // Both the version of package-b, and the dependency on package-a are updated to 2.0.0 + expect(readPyprojectToml(tree, 'packages/package-b/pyproject.toml')) + .toMatchInlineSnapshot(` + { + "project": { + "dependencies": [ + "package-a", + ], + "name": "package-b", + "version": "2.0.0", + }, + "tool": { + "uv": { + "sources": { + "package-a": { + "workspace": true, + }, + }, + }, + }, + } + `); + }); + }); + }); + }); }); function createReleaseGroup( diff --git a/packages/nx-python/src/generators/release-version/release-version.ts b/packages/nx-python/src/generators/release-version/release-version.ts index 85afa09..fed421a 100644 --- a/packages/nx-python/src/generators/release-version/release-version.ts +++ b/packages/nx-python/src/generators/release-version/release-version.ts @@ -45,7 +45,7 @@ export async function releaseVersionGenerator( options: ReleaseVersionGeneratorSchema, ): Promise { let logger: ProjectLogger | undefined; - const provider = await getProvider(tree.root); + const provider = await getProvider(tree.root, undefined, tree); const updatedProjects: string[] = []; try { @@ -153,7 +153,7 @@ To fix this you will either need to add a pyproject.toml file at that location, logger = new ProjectLogger(projectName, color); const { name: packageName, version: currentVersionFromDisk } = - provider.getMetadata(packageRoot, tree); + provider.getMetadata(packageRoot); logger.buffer( `🔍 Reading data for package "${packageName}" from ${pyprojectTomlPath}`, ); @@ -528,7 +528,6 @@ To fix this you will either need to add a pyproject.toml file at that location, // Resolve any local package dependencies for this project (before applying the new version or updating the versionData) const localPackageDependencies = resolveLocalPackageDependencies( - tree, options.projectGraph, projects, projectNameToPackageRootMap, @@ -659,7 +658,7 @@ To fix this you will either need to add a pyproject.toml file at that location, updatedProjects.push(dirname(pyprojectTomlPath)); - provider.updateVersion(packageRoot, newVersion, tree); + provider.updateVersion(packageRoot, newVersion); logger.buffer( `✍️ New version ${newVersion} written to ${pyprojectTomlPath}`, @@ -699,7 +698,6 @@ To fix this you will either need to add a pyproject.toml file at that location, const projectMetadata = provider.getMetadata( projectNameToPackageRootMap.get(dependentProject.source), - tree, ); // Auto (i.e.infer existing) by default @@ -707,7 +705,6 @@ To fix this you will either need to add a pyproject.toml file at that location, const currentDependencyVersion = provider.getDependencyMetadata( projectNameToPackageRootMap.get(dependentProject.source), dependencyPackageName, - tree, ).version; const currentPackageVersion = projectMetadata.version; @@ -752,7 +749,6 @@ To fix this you will either need to add a pyproject.toml file at that location, provider.updateVersion( projectNameToPackageRootMap.get(dependentProject.source), newPackageVersion, - tree, ); // Look up any dependent projects from the transitiveLocalPackageDependents list @@ -829,10 +825,7 @@ To fix this you will either need to add a pyproject.toml file at that location, `The project "${dependencyProjectName}" does not have a packageRoot available. Please report this issue on https://github.com/nrwl/nx`, ); } - const dependencyMetadata = provider.getMetadata( - dependencyPackageRoot, - tree, - ); + const dependencyMetadata = provider.getMetadata(dependencyPackageRoot); updateDependentProjectAndAddToVersionData({ dependentProject: transitiveDependentProject, diff --git a/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts b/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts index 39375c4..aab0869 100644 --- a/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts +++ b/packages/nx-python/src/generators/release-version/test-utils/create-poetry-workspace-with-package-dependencies.ts @@ -1,7 +1,7 @@ import { ProjectGraph, Tree } from '@nx/devkit'; import { PoetryPyprojectToml } from '../../../provider/poetry'; +import { writePyprojectToml } from '../../../provider/utils'; import path from 'path'; -import { writePyprojectToml } from '../../../provider/poetry/utils'; interface ProjectAndPackageData { [projectName: string]: { diff --git a/packages/nx-python/src/generators/release-version/test-utils/create-uv-workspace-with-package-dependencies.ts b/packages/nx-python/src/generators/release-version/test-utils/create-uv-workspace-with-package-dependencies.ts new file mode 100644 index 0000000..bb58439 --- /dev/null +++ b/packages/nx-python/src/generators/release-version/test-utils/create-uv-workspace-with-package-dependencies.ts @@ -0,0 +1,125 @@ +import { ProjectGraph, Tree } from '@nx/devkit'; +import { UVPyprojectToml } from '../../../provider/uv/types'; +import { writePyprojectToml } from '../../../provider/utils'; +import toml from '@iarna/toml'; +import path from 'path'; + +interface ProjectAndPackageData { + [projectName: string]: { + projectRoot: string; + packageName: string; + version: string; + pyprojectTomlPath: string; + localDependencies: { + projectName: string; + dependencyCollection: 'dependencies' | string; + }[]; + }; +} + +export function createUvWorkspaceWithPackageDependencies( + tree: Tree, + projectAndPackageData: ProjectAndPackageData, +): ProjectGraph { + const projectGraph: ProjectGraph = { + nodes: {}, + dependencies: {}, + }; + + const uvLock = { + package: [], + }; + + for (const [projectName, data] of Object.entries(projectAndPackageData)) { + const lockData = { + name: data.packageName, + version: data.version, + dependencies: [], + 'dev-dependencies': {}, + metadata: { + 'requires-dist': [], + 'requires-dev': {}, + }, + }; + uvLock.package.push(lockData); + + const pyprojectTomlContents = { + project: { + name: data.packageName, + version: data.version, + }, + tool: { + uv: { + sources: {}, + }, + }, + } as UVPyprojectToml; + for (const dependency of data.localDependencies) { + const dependencyPackageName = + projectAndPackageData[dependency.projectName].packageName; + + pyprojectTomlContents.tool.uv.sources ??= {}; + + pyprojectTomlContents.tool.uv.sources[dependencyPackageName] = { + workspace: true, + }; + + if (dependency.dependencyCollection === 'dependencies') { + pyprojectTomlContents.project.dependencies ??= []; + pyprojectTomlContents.project.dependencies.push(dependencyPackageName); + + lockData.metadata['requires-dist'].push({ + name: dependencyPackageName, + specifier: '*', + editable: projectAndPackageData[dependency.projectName].projectRoot, + }); + lockData.dependencies.push({ name: dependencyPackageName }); + } else { + pyprojectTomlContents['dependency-groups'] ??= {}; + pyprojectTomlContents['dependency-groups'][ + dependency.dependencyCollection + ] ??= []; + + pyprojectTomlContents['dependency-groups'][ + dependency.dependencyCollection + ].push(dependencyPackageName); + + lockData.metadata['requires-dev'][dependency.dependencyCollection] ??= + []; + lockData.metadata['requires-dev'][dependency.dependencyCollection].push( + { + name: dependencyPackageName, + specifier: '*', + editable: projectAndPackageData[dependency.projectName].projectRoot, + }, + ); + + lockData['dev-dependencies'][dependency.dependencyCollection] ??= []; + lockData['dev-dependencies'][dependency.dependencyCollection].push({ + name: dependencyPackageName, + }); + } + } + // add the project and its nx project level dependencies to the projectGraph + projectGraph.nodes[projectName] = { + name: projectName, + type: 'lib', + data: { + root: data.projectRoot, + }, + }; + projectGraph.dependencies[projectName] = data.localDependencies.map( + (dependency) => ({ + source: projectName, + target: dependency.projectName, + type: 'static', + }), + ); + // create the pyproject.toml in the tree + writePyprojectToml(tree, data.pyprojectTomlPath, pyprojectTomlContents); + } + + tree.write(path.join(tree.root, 'uv.lock'), toml.stringify(uvLock)); + + return projectGraph; +} diff --git a/packages/nx-python/src/generators/release-version/utils/package.ts b/packages/nx-python/src/generators/release-version/utils/package.ts index d003d86..26e95f0 100644 --- a/packages/nx-python/src/generators/release-version/utils/package.ts +++ b/packages/nx-python/src/generators/release-version/utils/package.ts @@ -1,4 +1,4 @@ -import { joinPathFragments, Tree } from '@nx/devkit'; +import { joinPathFragments } from '@nx/devkit'; import { IProvider } from '../../../provider/base'; export class Package { @@ -7,12 +7,11 @@ export class Package { location: string; constructor( - private tree: Tree, private provider: IProvider, workspaceRoot: string, private workspaceRelativeLocation: string, ) { - const metadata = provider.getMetadata(workspaceRelativeLocation, tree); + const metadata = provider.getMetadata(workspaceRelativeLocation); this.name = metadata.name; this.version = metadata.version; this.location = joinPathFragments(workspaceRoot, workspaceRelativeLocation); @@ -26,7 +25,6 @@ export class Package { const depMatadata = this.provider.getDependencyMetadata( this.workspaceRelativeLocation, depName, - this.tree, ); return { diff --git a/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts b/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts index 85bcf94..2ba892a 100644 --- a/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts +++ b/packages/nx-python/src/generators/release-version/utils/resolve-local-package-dependencies.ts @@ -2,7 +2,6 @@ import { ProjectGraph, ProjectGraphDependency, ProjectGraphProjectNode, - Tree, workspaceRoot, } from '@nx/devkit'; import { satisfies } from 'semver'; @@ -22,7 +21,6 @@ export interface LocalPackageDependency extends ProjectGraphDependency { } export function resolveLocalPackageDependencies( - tree: Tree, projectGraph: ProjectGraph, filteredProjects: ProjectGraphProjectNode[], projectNameToPackageRootMap: Map, @@ -50,7 +48,7 @@ export function resolveLocalPackageDependencies( // Append it to the map for later use within the release version generator projectNameToPackageRootMap.set(projectNode.name, packageRoot); } - const pkg = new Package(tree, provider, workspaceRoot, packageRoot); + const pkg = new Package(provider, workspaceRoot, packageRoot); projectNodeToPackageMap.set(projectNode, pkg); } diff --git a/packages/nx-python/src/graph/dependency-graph.spec.ts b/packages/nx-python/src/graph/dependency-graph.spec.ts index 03694b3..85b68a8 100644 --- a/packages/nx-python/src/graph/dependency-graph.spec.ts +++ b/packages/nx-python/src/graph/dependency-graph.spec.ts @@ -439,4 +439,675 @@ describe('nx-python dependency graph', () => { }); }); }); + + describe('uv', () => { + describe('dependency graph', () => { + it('should progress the dependency graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "flake8>=7.1.1", + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + { name = "dep1" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "flake8" }, + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "flake8", specifier = ">=7.1.1" }, + { name = "ruff", specifier = ">=0.8.2" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + dep3: { + root: 'libs/dep3', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); + }); + + it('should link dev dependencies in the graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "flake8>=7.1.1", + "ruff>=0.8.2", + "dep1" + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [] + + [package.dev-dependencies] + dev = [ + { name = "flake8" }, + { name = "ruff" }, + { name = "dep1" }, + ] + + [package.metadata] + requires-dist = [] + + [package.metadata.requires-dev] + dev = [ + { name = "flake8", specifier = ">=7.1.1" }, + { name = "ruff", specifier = ">=0.8.2" }, + { name = "dep1", editable = "libs/dep1" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); + }); + + it('should link arbitrary groups dependencies in the graph', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "flake8>=7.1.1", + "ruff>=0.8.2", + ] + other = [ + "dep1", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + + ] + + [package.dev-dependencies] + dev = [ + { name = "flake8" }, + { name = "ruff" }, + ] + other = [ + { name = "dep1" }, + ] + + [package.metadata] + requires-dist = [] + + [package.metadata.requires-dev] + dev = [ + { name = "flake8", specifier = ">=7.1.1" }, + { name = "ruff", specifier = ">=0.8.2" }, + ] + other = [ + { name = "dep1", editable = "libs/dep1" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); + }); + + it('should progress the dependency graph for an empty project', async () => { + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects: {}, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([]); + }); + + it('should progress the dependency graph when there is an app that is not managed by @nxlv/python', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "dep1", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'apps/app2/pyproject.toml': dedent` + [project] + name = "app2" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "dep3", + ] + + [tool.uv.sources] + dep3 = { workspace = true } + `, + + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + { name = "dep1" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "flake8" }, + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "flake8", specifier = ">=7.1.1" }, + { name = "ruff", specifier = ">=0.8.2" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + app2: { + root: 'apps/app2', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); + }); + + it('should progress the dependency graph when there is an app with an empty pyproject.toml', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "dep1", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + 'apps/app2/pyproject.toml': '', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + { name = "dep1" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + app2: { + root: 'apps/app2', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + dep2: { + root: 'libs/dep2', + targets: {}, + }, + dep3: { + root: 'libs/dep3', + targets: {}, + }, + }; + + const result = await createDependencies(null, { + externalNodes: {}, + workspaceRoot: '.', + projects, + nxJsonConfiguration: {}, + fileMap: { + nonProjectFiles: [], + projectFileMap: {}, + }, + filesToProcess: { + nonProjectFiles: [], + projectFileMap: {}, + }, + }); + + expect(result).toStrictEqual([ + { + source: 'app1', + target: 'dep1', + type: 'implicit', + }, + ]); + }); + }); + + describe('get dependents', () => { + it('should return the dependent projects', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "dep1", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + { name = "dep1" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const provider = await getProvider('.'); + const result = provider.getDependents('dep1', projects, '.'); + + expect(result).toStrictEqual(['app1']); + }); + + it('should return not throw an error when the pyproject is invalid or empty', async () => { + vol.fromJSON({ + 'apps/app1/pyproject.toml': '', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + `, + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app1" + version = "0.1.0" + source = { editable = "apps/app1" } + dependencies = [ + { name = "dep1" }, + ] + `, + }); + + const projects = { + app1: { + root: 'apps/app1', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }; + + const provider = await getProvider('.'); + const result = provider.getDependents('dep1', projects, '.'); + + expect(result).toStrictEqual([]); + }); + }); + }); }); diff --git a/packages/nx-python/src/graph/dependency-graph.ts b/packages/nx-python/src/graph/dependency-graph.ts index d6482f7..95e8d60 100644 --- a/packages/nx-python/src/graph/dependency-graph.ts +++ b/packages/nx-python/src/graph/dependency-graph.ts @@ -13,7 +13,7 @@ export const createDependencies: CreateDependencies = async (_, context) => { const deps = provider.getDependencies( project, context.projects, - process.cwd(), + context.workspaceRoot, ); deps.forEach((dep) => { diff --git a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts index a335c30..b7d89bc 100644 --- a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts +++ b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts @@ -1,4 +1,3 @@ -import { MockInstance } from 'vitest'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, diff --git a/packages/nx-python/src/provider/base.ts b/packages/nx-python/src/provider/base.ts index 5f516b3..842a16a 100644 --- a/packages/nx-python/src/provider/base.ts +++ b/packages/nx-python/src/provider/base.ts @@ -1,4 +1,4 @@ -import { ExecutorContext, ProjectConfiguration, Tree } from '@nx/devkit'; +import { ExecutorContext, ProjectConfiguration } from '@nx/devkit'; import { AddExecutorSchema } from '../executors/add/schema'; import { SpawnSyncOptions } from 'child_process'; import { UpdateExecutorSchema } from '../executors/update/schema'; @@ -24,15 +24,14 @@ export type DependencyProjectMetadata = ProjectMetadata & { export interface IProvider { checkPrerequisites(): Promise; - getMetadata(projectRoot: string, tree?: Tree): ProjectMetadata; + getMetadata(projectRoot: string): ProjectMetadata; getDependencyMetadata( projectRoot: string, dependencyName: string, - tree?: Tree, ): DependencyProjectMetadata; - updateVersion(projectRoot: string, newVersion: string, tree?: Tree): void; + updateVersion(projectRoot: string, newVersion: string): void; getDependencies( projectName: string, diff --git a/packages/nx-python/src/provider/poetry/provider.ts b/packages/nx-python/src/provider/poetry/provider.ts index 4384564..77258b6 100644 --- a/packages/nx-python/src/provider/poetry/provider.ts +++ b/packages/nx-python/src/provider/poetry/provider.ts @@ -25,14 +25,11 @@ import { getPoetryVersion, getProjectPackageName, getProjectTomlPath, - getPyprojectData, parseToml, POETRY_EXECUTABLE, - readPyprojectToml, runPoetry, RunPoetryOptions, updateProject, - writePyprojectToml, } from './utils'; import chalk from 'chalk'; import { parse, stringify } from '@iarna/toml'; @@ -61,20 +58,29 @@ import { LockedDependencyResolver, ProjectDependencyResolver, } from './build/resolvers'; +import { + getPyprojectData, + readPyprojectToml, + writePyprojectToml, +} from '../utils'; export class PoetryProvider implements IProvider { - constructor(protected logger: Logger) {} + constructor( + protected workspaceRoot: string, + protected logger: Logger, + protected tree?: Tree, + ) {} public async checkPrerequisites(): Promise { await checkPoetryExecutable(); } - public getMetadata(projectRoot: string, tree?: Tree): ProjectMetadata { + public getMetadata(projectRoot: string): ProjectMetadata { const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); - const projectData = tree - ? readPyprojectToml(tree, pyprojectTomlPath) - : getPyprojectData(pyprojectTomlPath); + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); return { name: projectData?.tool?.poetry?.name as string, @@ -82,38 +88,37 @@ export class PoetryProvider implements IProvider { }; } - updateVersion(projectRoot: string, newVersion: string, tree?: Tree): void { + updateVersion(projectRoot: string, newVersion: string): void { const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); - const projectData = tree - ? readPyprojectToml(tree, pyprojectTomlPath) - : getPyprojectData(pyprojectTomlPath); + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); if (!projectData.tool?.poetry) { throw new Error('Poetry section not found in pyproject.toml'); } projectData.tool.poetry.version = newVersion; - tree - ? writePyprojectToml(tree, pyprojectTomlPath, projectData) + this.tree + ? writePyprojectToml(this.tree, pyprojectTomlPath, projectData) : writeFileSync(pyprojectTomlPath, stringify(projectData)); } public getDependencyMetadata( projectRoot: string, dependencyName: string, - tree?: Tree, ): DependencyProjectMetadata { const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); - const projectData = tree - ? readPyprojectToml(tree, pyprojectTomlPath) - : getPyprojectData(pyprojectTomlPath); + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); const main = projectData.tool?.poetry?.dependencies ?? {}; if (typeof main[dependencyName] === 'object' && main[dependencyName].path) { - const dependentPyproject = readPyprojectToml( - tree, + const dependentPyproject = readPyprojectToml( + this.tree, joinPathFragments( projectRoot, main[dependencyName].path, @@ -141,8 +146,8 @@ export class PoetryProvider implements IProvider { 'pyproject.toml', ); - const dependentPyproject = readPyprojectToml( - tree, + const dependentPyproject = readPyprojectToml( + this.tree, depPyprojectTomlPath, ); @@ -169,26 +174,27 @@ export class PoetryProvider implements IProvider { const deps: Dependency[] = []; - console.log('pyprojectToml', pyprojectToml, fs.existsSync(pyprojectToml)); if (fs.existsSync(pyprojectToml)) { - const tomlData = getPyprojectData(pyprojectToml); + const tomlData = getPyprojectData(pyprojectToml); - this.resolveDependencies( - tomlData.tool?.poetry?.dependencies, - projectData, - projects, - cwd, - deps, - 'main', - ); - for (const group in tomlData.tool?.poetry?.group || {}) { - this.resolveDependencies( - tomlData.tool.poetry.group[group].dependencies, + deps.push( + ...this.resolveDependencies( + tomlData.tool?.poetry?.dependencies, projectData, projects, cwd, - deps, - group, + 'main', + ), + ); + for (const group in tomlData.tool?.poetry?.group || {}) { + deps.push( + ...this.resolveDependencies( + tomlData.tool.poetry.group[group].dependencies, + projectData, + projects, + cwd, + group, + ), ); } } @@ -686,7 +692,7 @@ export class PoetryProvider implements IProvider { const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); if (fs.existsSync(pyprojectToml)) { - const tomlData = getPyprojectData(pyprojectToml); + const tomlData = getPyprojectData(pyprojectToml); let isDep = this.isProjectDependent( tomlData.tool?.poetry?.dependencies, @@ -740,9 +746,10 @@ export class PoetryProvider implements IProvider { projectData: ProjectConfiguration, projects: Record, cwd: string, - deps: Dependency[], category: string, ) { + const deps: Dependency[] = []; + for (const dep in dependencies || {}) { const depData = dependencies[dep]; @@ -759,5 +766,7 @@ export class PoetryProvider implements IProvider { } } } + + return deps; } } diff --git a/packages/nx-python/src/provider/poetry/utils.ts b/packages/nx-python/src/provider/poetry/utils.ts index 1715654..d9edd7c 100644 --- a/packages/nx-python/src/provider/poetry/utils.ts +++ b/packages/nx-python/src/provider/poetry/utils.ts @@ -1,9 +1,9 @@ -import { ExecutorContext, ProjectConfiguration, Tree } from '@nx/devkit'; +import { ExecutorContext, ProjectConfiguration } from '@nx/devkit'; import chalk from 'chalk'; import spawn from 'cross-spawn'; import path from 'path'; import toml, { parse } from '@iarna/toml'; -import fs, { readFileSync } from 'fs'; +import fs from 'fs'; import commandExists from 'command-exists'; import { SpawnSyncOptions } from 'child_process'; import { PoetryPyprojectToml, PoetryPyprojectTomlDependencies } from './types'; @@ -84,23 +84,6 @@ export function parseToml(tomlFile: string) { return toml.parse(fs.readFileSync(tomlFile, 'utf-8')) as PoetryPyprojectToml; } -export function readPyprojectToml(tree: Tree, tomlFile: string) { - const content = tree.read(tomlFile, 'utf-8'); - if (!content) { - return null; - } - - return toml.parse(content) as PoetryPyprojectToml; -} - -export function writePyprojectToml( - tree: Tree, - tomlFile: string, - data: PoetryPyprojectToml, -) { - tree.write(tomlFile, toml.stringify(data)); -} - export function getLocalDependencyConfig( context: ExecutorContext, dependencyName: string, @@ -176,15 +159,6 @@ export function activateVenv(workspaceRoot: string) { } } -export const getPyprojectData = ( - pyprojectToml: string, -): PoetryPyprojectToml => { - const content = readFileSync(pyprojectToml).toString('utf-8'); - if (content.trim() === '') return {}; - - return parse(readFileSync(pyprojectToml).toString('utf-8')); -}; - export const getProjectPackageName = ( context: ExecutorContext, projectName: string, diff --git a/packages/nx-python/src/provider/resolver.ts b/packages/nx-python/src/provider/resolver.ts index 66341b7..84c48a0 100644 --- a/packages/nx-python/src/provider/resolver.ts +++ b/packages/nx-python/src/provider/resolver.ts @@ -4,17 +4,31 @@ import { IProvider } from './base'; import { UVProvider } from './uv'; import { PoetryProvider } from './poetry'; import { Logger } from '../executors/utils/logger'; +import { Tree } from '@nx/devkit'; export const getProvider = async ( - workspaceCwd: string, + workspaceRoot: string, logger?: Logger, + tree?: Tree, ): Promise => { const loggerInstance = logger ?? new Logger(); - const isUv = fs.existsSync(path.join(workspaceCwd, 'uv.lock')); - if (isUv) { - return new UVProvider(loggerInstance); + const uvLockPath = path.join(workspaceRoot, 'uv.lock'); + const poetryLockPath = path.join(workspaceRoot, 'poetry.lock'); + + const isUv = tree ? tree.exists(uvLockPath) : fs.existsSync(uvLockPath); + const isPoetry = tree + ? tree.exists(poetryLockPath) + : fs.existsSync(poetryLockPath); + if (isUv && isPoetry) { + throw new Error( + 'Both poetry.lock and uv.lock files found. Please remove one of them.', + ); } - return new PoetryProvider(loggerInstance); + if (isUv) { + return new UVProvider(workspaceRoot, loggerInstance, tree); + } else { + return new PoetryProvider(workspaceRoot, loggerInstance, tree); + } }; diff --git a/packages/nx-python/src/provider/utils.ts b/packages/nx-python/src/provider/utils.ts new file mode 100644 index 0000000..4195cce --- /dev/null +++ b/packages/nx-python/src/provider/utils.ts @@ -0,0 +1,27 @@ +import toml, { JsonMap } from '@iarna/toml'; +import { Tree } from '@nx/devkit'; +import { readFileSync } from 'fs'; + +export const getPyprojectData = (pyprojectToml: string): T => { + const content = readFileSync(pyprojectToml).toString('utf-8'); + if (content.trim() === '') return {} as T; + + return toml.parse(readFileSync(pyprojectToml).toString('utf-8')) as T; +}; + +export const readPyprojectToml = (tree: Tree, tomlFile: string): T => { + const content = tree.read(tomlFile, 'utf-8'); + if (!content) { + return null; + } + + return toml.parse(content) as T; +}; + +export function writePyprojectToml( + tree: Tree, + tomlFile: string, + data: JsonMap, +) { + tree.write(tomlFile, toml.stringify(data)); +} diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index f54f0e7..0a3878f 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -1,4 +1,10 @@ -import { ExecutorContext, ProjectConfiguration, Tree } from '@nx/devkit'; +import { + ExecutorContext, + joinPathFragments, + ProjectConfiguration, + runExecutor, + Tree, +} from '@nx/devkit'; import { Dependency, DependencyProjectMetadata, @@ -11,42 +17,139 @@ import { Logger } from '../../executors/utils/logger'; import { PublishExecutorSchema } from '../../executors/publish/schema'; import { RemoveExecutorSchema } from '../../executors/remove/schema'; import { UpdateExecutorSchema } from '../../executors/update/schema'; -import { BuildExecutorSchema } from '../../executors/build/schema'; +import { + BuildExecutorOutput, + BuildExecutorSchema, +} from '../../executors/build/schema'; import { InstallExecutorSchema } from '../../executors/install/schema'; +import { checkUvExecutable, getUvLockfile, runUv } from './utils'; +import path from 'path'; +import chalk from 'chalk'; +import { removeSync, writeFileSync } from 'fs-extra'; +import { + getPyprojectData, + readPyprojectToml, + writePyprojectToml, +} from '../utils'; +import { UVLockfile, UVPyprojectToml } from './types'; +import toml from '@iarna/toml'; +import fs from 'fs'; export class UVProvider implements IProvider { - constructor(protected logger: Logger) {} + protected _rootLockfile: UVLockfile; + + constructor( + protected workspaceRoot: string, + protected logger: Logger, + protected tree?: Tree, + ) {} + + private get rootLockfile(): UVLockfile { + if (!this._rootLockfile) { + this._rootLockfile = getUvLockfile( + joinPathFragments(this.workspaceRoot, 'uv.lock'), + this.tree, + ); + } + + return this._rootLockfile; + } public async checkPrerequisites(): Promise { - throw new Error('Method not implemented.'); + await checkUvExecutable(); } public getMetadata(projectRoot: string): ProjectMetadata { - throw new Error('Method not implemented.'); + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + return { + name: projectData?.project?.name as string, + version: projectData?.project?.version as string, + }; } public getDependencyMetadata( projectRoot: string, dependencyName: string, - tree?: Tree, ): DependencyProjectMetadata { - throw new Error('Method not implemented.'); + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + const data = this.rootLockfile.package[projectData.project.name]; + console.log('data', data); + + const group = data?.dependencies?.find( + (item) => item.name === dependencyName, + ) + ? 'main' + : Object.entries(data?.['dev-dependencies'] ?? {}).find( + ([, value]) => !!value.find((item) => item.name === dependencyName), + )?.[0]; + + return { + name: this.rootLockfile.package[dependencyName].name, + version: this.rootLockfile.package[dependencyName].version, + group, + }; } - public updateVersion( - projectRoot: string, - newVersion: string, - tree?: Tree, - ): void { - throw new Error('Method not implemented.'); + public updateVersion(projectRoot: string, newVersion: string): void { + const pyprojectTomlPath = joinPathFragments(projectRoot, 'pyproject.toml'); + + const projectData = this.tree + ? readPyprojectToml(this.tree, pyprojectTomlPath) + : getPyprojectData(pyprojectTomlPath); + + if (!projectData.project) { + throw new Error('project section not found in pyproject.toml'); + } + projectData.project.version = newVersion; + + this.tree + ? writePyprojectToml(this.tree, pyprojectTomlPath, projectData) + : writeFileSync(pyprojectTomlPath, toml.stringify(projectData)); } public getDependencies( projectName: string, projects: Record, - cwd: string, ): Dependency[] { - throw new Error('Method not implemented.'); + const projectData = projects[projectName]; + const pyprojectToml = joinPathFragments(projectData.root, 'pyproject.toml'); + + const deps: Dependency[] = []; + + if (fs.existsSync(pyprojectToml)) { + const tomlData = getPyprojectData(pyprojectToml); + + deps.push( + ...this.resolveDependencies( + tomlData, + tomlData?.project?.dependencies || [], + 'main', + projects, + ), + ); + + for (const group in tomlData['dependency-groups']) { + deps.push( + ...this.resolveDependencies( + tomlData, + tomlData['dependency-groups'][group], + group, + projects, + ), + ); + } + } + + return deps; } public getDependents( @@ -54,46 +157,178 @@ export class UVProvider implements IProvider { projects: Record, cwd: string, ): string[] { - throw new Error('Method not implemented.'); + const result: string[] = []; + + const { root } = projects[projectName]; + + Object.values(this.rootLockfile.package).forEach((pkg) => { + const deps = [ + ...Object.values(pkg.metadata['requires-dist'] ?? {}), + ...Object.values(pkg.metadata['requires-dev'] ?? {}) + .map((dev) => Object.values(dev)) + .flat(), + ]; + + for (const dep of deps) { + if ( + dep.editable && + path.normalize(dep.editable) === path.normalize(root) + ) { + result.push(pkg.name); + } + } + }); + + return result; } public async add( options: AddExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + + const projectRoot = + context.projectsConfigurations.projects[context.projectName].root; + + const args = ['add', options.name, '--project', projectRoot]; + if (options.group) { + args.push('--group', options.group); + } + + for (const extra of options.extras ?? []) { + args.push('--extra', extra); + } + + args.push(...(options.args ?? '').split(' ').filter((arg) => !!arg)); + + runUv(args, { + cwd: context.root, + }); } public async update( options: UpdateExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + + const projectRoot = + context.projectsConfigurations.projects[context.projectName].root; + + const args = [ + 'lock', + '--upgrade-package', + options.name, + '--project', + projectRoot, + ]; + runUv(args, { + cwd: context.root, + }); + runUv(['sync'], { + cwd: context.root, + }); } public async remove( options: RemoveExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + + const projectRoot = + context.projectsConfigurations.projects[context.projectName].root; + + const args = ['remove', options.name, '--project', projectRoot]; + args.push(...(options.args ?? '').split(' ').filter((arg) => !!arg)); + runUv(args, { + cwd: context.root, + }); } public async publish( options: PublishExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + let buildFolderPath = ''; + + try { + await this.checkPrerequisites(); + + for await (const output of await runExecutor( + { + project: context.projectName, + target: options.buildTarget, + configuration: context.configurationName, + }, + { + keepBuildFolder: true, + }, + context, + )) { + if (!output.success) { + throw new Error('Build failed'); + } + + buildFolderPath = output.buildFolderPath; + } + + if (!buildFolderPath) { + throw new Error('Cannot find the temporary build folder'); + } + + this.logger.info( + chalk`\n {bold Publishing project {bgBlue ${context.projectName} }...}\n`, + ); + + if (options.dryRun) { + this.logger.info( + chalk`\n {bgYellow.bold WARNING } {bold Dry run is currently not supported by uv}\n`, + ); + } + + const args = ['publish', ...(options.__unparsed__ ?? [])]; + runUv(args, { + cwd: buildFolderPath, + }); + + removeSync(buildFolderPath); + } catch (error) { + if (buildFolderPath) { + removeSync(buildFolderPath); + } + + throw error; + } } public async install( options: InstallExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + + const args = ['sync']; + if (options.verbose) { + args.push('-v'); + } else if (options.debug) { + args.push('-vvv'); + } + + args.push(...(options.args ?? '').split(' ').filter((arg) => !!arg)); + + if (options.cacheDir) { + args.push('--cache-dir', options.cacheDir); + } + + runUv(args, { + cwd: context.root, + }); } - public lock(projectRoot: string): Promise { - throw new Error('Method not implemented.'); + public async lock(projectRoot: string): Promise { + runUv(['lock'], { cwd: projectRoot }); } public async build( @@ -111,10 +346,65 @@ export class UVProvider implements IProvider { error?: boolean; } & SpawnSyncOptions, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + + runUv(['run', ...args], { + ...options, + }); } public activateVenv(workspaceRoot: string): void { - throw new Error('Method not implemented.'); + if (!process.env.VIRTUAL_ENV) { + const virtualEnv = path.resolve(workspaceRoot, '.venv'); + process.env.VIRTUAL_ENV = virtualEnv; + process.env.PATH = `${virtualEnv}/bin:${process.env.PATH}`; + delete process.env.PYTHONHOME; + } + } + + private resolveDependencies( + pyprojectToml: UVPyprojectToml | undefined, + dependencies: string[], + category: string, + projects: Record, + ) { + if (!pyprojectToml) { + return []; + } + + const deps: Dependency[] = []; + const sources = pyprojectToml?.tool?.uv?.sources ?? {}; + + for (const dep of dependencies) { + if (!sources[dep]?.workspace) { + continue; + } + + const packageMetadata = + this.rootLockfile.package[pyprojectToml?.project?.name]?.metadata; + + const depMetadata = + category === 'main' + ? packageMetadata?.['requires-dist']?.[dep] + : packageMetadata?.['requires-dev']?.[category]?.[dep]; + + if (!depMetadata || !depMetadata.editable) { + continue; + } + + const depProjectName = Object.keys(projects).find( + (proj) => + path.normalize(projects[proj].root) === + path.normalize(depMetadata.editable), + ); + + if (!depProjectName) { + continue; + } + + deps.push({ name: depProjectName, category }); + } + + return deps; } } diff --git a/packages/nx-python/src/provider/uv/types.ts b/packages/nx-python/src/provider/uv/types.ts new file mode 100644 index 0000000..92e44b4 --- /dev/null +++ b/packages/nx-python/src/provider/uv/types.ts @@ -0,0 +1,67 @@ +export type UVPyprojectToml = { + project?: { + name: string; + version: string; + dependencies: string[]; + }; + 'dependency-groups': { + [key: string]: string[]; + }; + tool?: { + hatch?: { + build?: { + targets?: { + wheel?: { + packages: string[]; + }; + }; + }; + }; + uv?: { + sources?: { + [key: string]: { + workspace?: boolean; + }; + }; + }; + }; +}; + +export type UVLockfilePackageLocalSource = { + editable?: boolean; +}; + +export type UVLockfilePackageDependency = { + name: string; + extra?: string[]; +}; + +export type UVLockfilePackageMetadata = { + 'requires-dist': Record; + 'requires-dev': Record< + string, + Record + >; +}; + +export type UVLockfilePackageMetadataRequiresDist = { + name: string; + specifier: string; + extras?: string[]; + editable?: string; +}; + +export type UVLockfilePackage = { + name: string; + version: string; + source: UVLockfilePackageLocalSource; + dependencies: UVLockfilePackageDependency[]; + 'dev-dependencies': { + [key: string]: UVLockfilePackageDependency[]; + }; + metadata: UVLockfilePackageMetadata; +}; + +export type UVLockfile = { + package: Record; +}; diff --git a/packages/nx-python/src/provider/uv/utils.ts b/packages/nx-python/src/provider/uv/utils.ts new file mode 100644 index 0000000..e76c26d --- /dev/null +++ b/packages/nx-python/src/provider/uv/utils.ts @@ -0,0 +1,97 @@ +import chalk from 'chalk'; +import { SpawnSyncOptions } from 'child_process'; +import commandExists from 'command-exists'; +import spawn from 'cross-spawn'; +import { UVLockfile } from './types'; +import toml from '@iarna/toml'; +import { readFileSync } from 'fs-extra'; +import { Tree } from '@nx/devkit'; + +export const UV_EXECUTABLE = 'uv'; + +export async function checkUvExecutable() { + try { + await commandExists(UV_EXECUTABLE); + } catch (e) { + throw new Error( + 'UV is not installed. Please install UV before running this command.', + ); + } +} + +export type RunUvOptions = { + log?: boolean; + error?: boolean; +} & SpawnSyncOptions; + +export function runUv(args: string[], options: RunUvOptions = {}): void { + const log = options.log ?? true; + const error = options.error ?? true; + delete options.log; + delete options.error; + + const commandStr = `${UV_EXECUTABLE} ${args.join(' ')}`; + + if (log) { + console.log( + chalk`{bold Running command}: ${commandStr} ${ + options.cwd && options.cwd !== '.' + ? chalk`at {bold ${options.cwd}} folder` + : '' + }\n`, + ); + } + + const result = spawn.sync(UV_EXECUTABLE, args, { + ...options, + shell: options.shell ?? false, + stdio: 'inherit', + }); + + if (error && result.status !== 0) { + throw new Error( + chalk`{bold ${commandStr}} command failed with exit code {bold ${result.status}}`, + ); + } +} + +export function getUvLockfile(lockfilePath: string, tree?: Tree): UVLockfile { + const data = toml.parse( + tree + ? tree.read(lockfilePath, 'utf-8') + : readFileSync(lockfilePath, 'utf-8'), + ); + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + package: (data.package as any[]).reduce( + (acc, pkg) => { + acc[pkg.name] = { + ...pkg, + metadata: { + ...(pkg.metadata ?? {}), + 'requires-dist': (pkg.metadata?.['requires-dist'] ?? []).reduce( + (acc, req) => { + acc[req.name] = req; + return acc; + }, + {}, + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'requires-dev': Object.entries( + pkg.metadata?.['requires-dev'] ?? {}, + ).reduce((acc, [key, values]) => { + acc[key] = values.reduce((acc, req) => { + acc[req.name] = req; + return acc; + }, {}); + return acc; + }, {}), + }, + }; + return acc; + }, + {} as UVLockfile['package'], + ), + }; +} From eb853d40a1e08f7677d9adbb4f15e56445bf6e17 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 11:41:57 -0300 Subject: [PATCH 03/11] feat(nx-python): add support for UV project generation and testing --- package.json | 3 +- packages/nx-python/generators.json | 5 + packages/nx-python/package.json | 3 +- .../src/executors/build/executor.spec.ts | 1627 ++++++++- packages/nx-python/src/generators/consts.ts | 9 + .../enable-releases/generator.spec.ts | 27 +- .../__snapshots__/generator.spec.ts.snap | 214 +- .../poetry-project/files/base/pyproject.toml | 14 +- .../poetry-project/generator.spec.ts | 67 +- .../generators/poetry-project/generator.ts | 192 +- .../{poetry-project/schema.d.ts => types.ts} | 43 +- packages/nx-python/src/generators/utils.ts | 154 + .../__snapshots__/generator.spec.ts.snap | 3184 +++++++++++++++++ .../__test__/custom-template/pyproject.toml | 17 + .../uv-project/files/base/README.md | 3 + .../files/base/__dot__python-version.template | 1 + .../files/base/__moduleName__/__init__.py | 1 + .../files/base/__moduleName__/hello.py | 6 + .../uv-project/files/base/pyproject.toml | 73 + .../files/flake8/__dot__flake8.template | 11 + .../uv-project/files/pytest/tests/__init__.py | 1 + .../uv-project/files/pytest/tests/conftest.py | 3 + .../files/pytest/tests/test_hello.py | 8 + .../generators/uv-project/generator.spec.ts | 652 ++++ .../src/generators/uv-project/generator.ts | 414 +++ .../src/generators/uv-project/schema.json | 130 + packages/nx-python/src/provider/base.ts | 11 + .../provider/poetry/build/resolvers/locked.ts | 24 +- .../poetry/build/resolvers/project.ts | 11 +- .../provider/poetry/build/resolvers/types.ts | 11 - .../provider/poetry/build/resolvers/utils.ts | 4 - packages/nx-python/src/provider/utils.ts | 4 + .../src/provider/uv/build/resolvers/index.ts | 2 + .../src/provider/uv/build/resolvers/locked.ts | 115 + .../provider/uv/build/resolvers/project.ts | 196 + .../src/provider/uv/build/resolvers/utils.ts | 26 + .../nx-python/src/provider/uv/provider.ts | 104 +- packages/nx-python/src/provider/uv/types.ts | 12 +- pnpm-lock.yaml | 7 + 39 files changed, 7026 insertions(+), 363 deletions(-) create mode 100644 packages/nx-python/src/generators/consts.ts rename packages/nx-python/src/generators/{poetry-project/schema.d.ts => types.ts} (68%) create mode 100644 packages/nx-python/src/generators/utils.ts create mode 100644 packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap create mode 100644 packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml create mode 100644 packages/nx-python/src/generators/uv-project/files/base/README.md create mode 100644 packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template create mode 100644 packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py create mode 100644 packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py create mode 100644 packages/nx-python/src/generators/uv-project/files/base/pyproject.toml create mode 100644 packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template create mode 100644 packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py create mode 100644 packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py create mode 100644 packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py create mode 100644 packages/nx-python/src/generators/uv-project/generator.spec.ts create mode 100644 packages/nx-python/src/generators/uv-project/generator.ts create mode 100644 packages/nx-python/src/generators/uv-project/schema.json create mode 100644 packages/nx-python/src/provider/uv/build/resolvers/index.ts create mode 100644 packages/nx-python/src/provider/uv/build/resolvers/locked.ts create mode 100644 packages/nx-python/src/provider/uv/build/resolvers/project.ts create mode 100644 packages/nx-python/src/provider/uv/build/resolvers/utils.ts diff --git a/package.json b/package.json index 37defe7..97d4fae 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "prompts": "^2.4.2", "semver": "^7.5.3", "tslib": "^2.3.0", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "wildcard-match": "^5.1.3" }, "config": { "commitizen": { diff --git a/packages/nx-python/generators.json b/packages/nx-python/generators.json index 8d8c356..4b3de29 100644 --- a/packages/nx-python/generators.json +++ b/packages/nx-python/generators.json @@ -18,6 +18,11 @@ "schema": "./src/generators/poetry-project/schema.json", "description": "Python Poetry Project" }, + "uv-project": { + "factory": "./src/generators/uv-project/generator", + "schema": "./src/generators/uv-project/schema.json", + "description": "Python UV Project" + }, "release-version": { "factory": "./src/generators/release-version/release-version", "schema": "./src/generators/release-version/schema.json", diff --git a/packages/nx-python/package.json b/packages/nx-python/package.json index e3de04c..6322d0b 100644 --- a/packages/nx-python/package.json +++ b/packages/nx-python/package.json @@ -23,7 +23,8 @@ "lodash": "^4.17.21", "@nx/devkit": "^20.0.0", "ora": "5.3.0", - "semver": "^7.5.3" + "semver": "^7.5.3", + "wildcard-match": "^5.1.3" }, "nx-migrations": { "migrations": "./migrations.json" diff --git a/packages/nx-python/src/executors/build/executor.spec.ts b/packages/nx-python/src/executors/build/executor.spec.ts index c05c33b..1d38432 100644 --- a/packages/nx-python/src/executors/build/executor.spec.ts +++ b/packages/nx-python/src/executors/build/executor.spec.ts @@ -17,26 +17,43 @@ import spawn from 'cross-spawn'; import { SpawnSyncOptions } from 'child_process'; import { ExecutorContext } from '@nx/devkit'; import { PoetryPyprojectToml } from '../../provider/poetry'; +import { UVProvider } from '../../provider/uv'; +import { getPyprojectData } from '../../provider/utils'; +import { UVPyprojectToml } from '../../provider/uv/types'; describe('Build Executor', () => { + let buildPath = null; + beforeAll(() => { console.log(chalk`init chalk`); }); + beforeEach(() => { + uuidMock.mockReturnValue('abc'); + buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + afterEach(() => { vol.reset(); vi.resetAllMocks(); }); describe('poetry', () => { - let buildPath = null; let checkPoetryExecutableMock: MockInstance; let activateVenvMock: MockInstance; beforeEach(() => { - uuidMock.mockReturnValue('abc'); - buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); - checkPoetryExecutableMock = vi .spyOn(poetryUtils, 'checkPoetryExecutable') .mockResolvedValue(undefined); @@ -44,16 +61,6 @@ describe('Build Executor', () => { activateVenvMock = vi .spyOn(poetryUtils, 'activateVenv') .mockReturnValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); it('should return success false when the poetry is not installed', async () => { @@ -2975,6 +2982,1598 @@ describe('Build Executor', () => { }); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should throw an error when the lockedVersion is set to true and bundleLocalDependencies to false', async () => { + const options = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + describe('locked resolver', () => { + it('should skip the local project dependency if the pyproject is not found', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + console.log('buildPath', buildPath); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local and dev dependencies', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + ruff>=0.8.2 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + console.log('buildPath', buildPath); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + 'ruff>=0.8.2', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies and delete the build folder', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: false, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(existsSync(buildPath)).not.toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + expect(output.success).toBe(true); + }); + }); + + describe('project resolver', () => { + it('should build the project without locked versions and without bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0" + ] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "numpy", specifier = ">=1.21.0" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'dep1==1.0.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "numpy", specifier = ">=1.21.0" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'numpy>=1.21.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle only local dependency and not the second level', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0", + "dep2" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + + [tool.uv.sources] + dep2 = { workspace = true } + `, + + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep2"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "dep2" }, + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "dep2", editable = "libs/dep2" }, + { name = "numpy", specifier = ">=1.21.0" }, + ] + + [[package]] + name = "dep2" + version = "1.0.0" + source = { editable = "libs/dep2" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, + }, + }, + }, + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'numpy>=1.21.0', + 'dep2==1.0.0', + ]); + + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({ + dep2: { index: 'foo' }, + }); + + expect(projectTomlData.tool.uv.index).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/bar', + }, + ]); + }); + + it('should build the project without locked versions and handle duplicate sources', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + "dep2", + "dep3", + "dep4", + "dep5", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + dep2 = { workspace = true } + dep3 = { workspace = true } + dep4 = { workspace = true } + dep5 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0", + "dep2" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + + [tool.uv.sources] + dep2 = { workspace = true } + `, + + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep2"] + `, + 'libs/dep3/dep3/index.py': 'print("Hello from dep3")', + 'libs/dep3/pyproject.toml': dedent` + [project] + name = "dep3" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep3"] + `, + 'libs/dep4/dep4/index.py': 'print("Hello from dep4")', + 'libs/dep4/pyproject.toml': dedent` + [project] + name = "dep4" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep4"] + `, + 'libs/dep5/dep5/index.py': 'print("Hello from dep5")', + 'libs/dep5/pyproject.toml': dedent` + [project] + name = "dep5" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep5"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "dep2" }, + { name = "dep3" }, + { name = "dep4" }, + { name = "dep5" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + { name = "dep2", editable = "libs/dep2" }, + { name = "dep3", editable = "libs/dep3" }, + { name = "dep4", editable = "libs/dep4" }, + { name = "dep5", editable = "libs/dep5" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "dep2" }, + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "dep2", editable = "libs/dep2" }, + { name = "numpy", specifier = ">=1.21.0" }, + ] + + [[package]] + name = "dep2" + version = "1.0.0" + source = { editable = "libs/dep2" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep3" + version = "1.0.0" + source = { editable = "libs/dep3" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep4" + version = "1.0.0" + source = { editable = "libs/dep4" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep5" + version = "1.0.0" + source = { editable = "libs/dep5" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/foo', + }, + }, + }, + }, + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + dep3: { + root: 'libs/dep3', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + dep4: { + root: 'libs/dep4', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, + }, + }, + }, + dep5: { + root: 'libs/dep5', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect(projectTomlData.tool.uv.index).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/foo', + }, + { + name: 'foo-198fb9d8236b3d9116a180365e447b05', + url: 'http://example.com/bar', + }, + { + name: 'another', + url: 'http://example.com/another', + }, + ]); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'dep1==1.0.0', + 'dep2==1.0.0', + 'dep3==1.0.0', + 'dep4==1.0.0', + 'dep5==1.0.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({ + dep1: { + index: 'foo', + }, + dep2: { + index: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep3: { + index: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep4: { + index: 'another', + }, + dep5: { + index: 'another', + }, + }); + }); + }); + }); }); function spawnBuildMockImpl(opts: SpawnSyncOptions) { diff --git a/packages/nx-python/src/generators/consts.ts b/packages/nx-python/src/generators/consts.ts new file mode 100644 index 0000000..554393b --- /dev/null +++ b/packages/nx-python/src/generators/consts.ts @@ -0,0 +1,9 @@ +export const DEV_DEPENDENCIES_VERSION_MAP = { + autopep8: '2.3.1', + flake8: '7.1.1', + ruff: '0.8.2', + pytest: '8.3.4', + 'pytest-cov': '6.0.0', + 'pytest-sugar': '1.0.0', + 'pytest-html': '4.1.1', +}; diff --git a/packages/nx-python/src/generators/enable-releases/generator.spec.ts b/packages/nx-python/src/generators/enable-releases/generator.spec.ts index 71ba835..80ec675 100644 --- a/packages/nx-python/src/generators/enable-releases/generator.spec.ts +++ b/packages/nx-python/src/generators/enable-releases/generator.spec.ts @@ -11,13 +11,26 @@ describe('nx-python enable-releases', () => { beforeEach(() => { appTree = createTreeWithEmptyWorkspace({}); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; }); }); diff --git a/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap b/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap index cf39e22..24125b8 100644 --- a/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap +++ b/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap @@ -79,7 +79,7 @@ readme = 'README.md' include = "my_app_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -98,7 +98,7 @@ def hello(): `; exports[`application generator > as-provided > should run successfully minimal configuration 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -181,7 +181,7 @@ readme = 'README.md' include = "my_app_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -200,7 +200,7 @@ def hello(): `; exports[`application generator > as-provided > should run successfully minimal configuration without directory 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -273,7 +273,7 @@ description = "Automatically generated by Nx." include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" autopep8 = "^1.5.7" [build-system] @@ -367,7 +367,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -386,7 +386,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -469,7 +469,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -488,7 +488,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration as a library 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -571,7 +571,7 @@ readme = 'README.md' include = "subdir_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -590,7 +590,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration custom directory 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -676,7 +676,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -695,7 +695,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration with tags 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -787,11 +787,11 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" [build-system] requires = ["poetry-core"] @@ -810,7 +810,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -939,15 +939,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -966,7 +966,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html coverage report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1107,15 +1107,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1134,7 +1134,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1275,15 +1275,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1302,7 +1302,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1443,15 +1443,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1470,7 +1470,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1611,15 +1611,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1638,7 +1638,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1768,13 +1768,13 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" [build-system] requires = ["poetry-core"] @@ -1793,7 +1793,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with no reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1934,7 +1934,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -1957,7 +1957,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (flake8) and testing options with a dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2008,13 +2008,13 @@ readme = "README.md" include = "shared_dev_lib" [tool.poetry.dependencies] - python = ">=3.9,<3.11" - flake8 = "6.0.0" - autopep8 = "2.0.2" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + python = ">=3.9,<4" + flake8 = "7.1.1" + autopep8 = "2.3.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = [ "poetry-core" ] @@ -2033,7 +2033,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (flake8) and testing options with a dev dependency project 11`] = ` -"3.9.5 +"3.9.7 " `; @@ -2148,7 +2148,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2171,7 +2171,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (ruff) and testing options with a dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2207,13 +2207,13 @@ readme = "README.md" include = "shared_dev_lib" [tool.poetry.dependencies] - python = ">=3.9,<3.11" - ruff = "0.1.5" - autopep8 = "2.0.2" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + python = ">=3.9,<4" + ruff = "0.8.2" + autopep8 = "2.3.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = [ "poetry-core" ] @@ -2232,7 +2232,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (ruff) and testing options with a dev dependency project 10`] = ` -"3.9.5 +"3.9.7 " `; @@ -2260,7 +2260,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.custom-shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2382,7 +2382,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2405,7 +2405,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting and testing options with an existing dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2482,7 +2482,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting and testing options with an existing dev dependency project 11`] = ` -"3.9.5 +"3.9.7 " `; @@ -2574,7 +2574,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -2625,7 +2625,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with ruff linter 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2729,13 +2729,13 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - ruff = "0.1.5" - pytest = "7.3.1" - pytest-sugar = "0.9.7" + autopep8 = "2.3.1" + ruff = "0.8.2" + pytest = "8.3.4" + pytest-sugar = "1.0.0" [build-system] requires = ["poetry-core"] @@ -2786,7 +2786,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with ruff linter and pytest with no reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2881,7 +2881,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -2900,7 +2900,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2916,7 +2916,7 @@ name = "workspace" develop = true [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [build-system] requires = [ "poetry-core" ] @@ -3003,7 +3003,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3022,7 +3022,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options with custom rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3034,7 +3034,7 @@ name = "workspace" python = ">=3.9,<3.11" [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [tool.poetry.group.dev.dependencies.test] path = "apps/test" @@ -3125,7 +3125,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3144,7 +3144,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3157,7 +3157,7 @@ name = "workspace" [tool.poetry.group.dev.dependencies] flake8 = "6.0.0" -autopep8 = "2.0.2" +autopep8 = "2.3.1" [tool.poetry.group.dev.dependencies.test] path = "apps/test" @@ -3248,7 +3248,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3267,7 +3267,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options without rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3283,7 +3283,7 @@ name = "workspace" develop = true [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [build-system] requires = [ "poetry-core" ] diff --git a/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml b/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml index bd1d5c4..3e4b49b 100644 --- a/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml +++ b/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml @@ -29,22 +29,22 @@ readme = 'README.md' <%if (((individualPackage && !devDependenciesProject) && linter === 'flake8') || ((individualPackage && !devDependenciesProject) && unitTestRunner === 'pytest')) { -%> [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" + autopep8 = "<%- versionMap['autopep8'] %>" <%if (individualPackage && !devDependenciesProject && linter === 'flake8') { -%> - flake8 = "6.0.0" + flake8 = "<%- versionMap['flake8'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && linter === 'ruff') { -%> - ruff = "0.1.5" + ruff = "<%- versionMap['ruff'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest') { -%> - pytest = "7.3.1" - pytest-sugar = "0.9.7" + pytest = "<%- versionMap['pytest'] %>" + pytest-sugar = "<%- versionMap['pytest-sugar'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest' && codeCoverage) { -%> - pytest-cov = "4.1.0" + pytest-cov = "<%- versionMap['pytest-cov'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest' && codeCoverage && codeCoverageHtmlReport) { -%> - pytest-html = "3.2.0" + pytest-html = "<%- versionMap['pytest-html'] %>" <% } -%> <% } -%> diff --git a/packages/nx-python/src/generators/poetry-project/generator.spec.ts b/packages/nx-python/src/generators/poetry-project/generator.spec.ts index a75f17e..d45bdbb 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.spec.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.spec.ts @@ -5,17 +5,17 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, readProjectConfiguration } from '@nx/devkit'; import generator from './generator'; -import { PoetryProjectGeneratorSchema } from './schema'; import dedent from 'string-dedent'; import { parse, stringify } from '@iarna/toml'; import path from 'path'; import spawn from 'cross-spawn'; import { PoetryPyprojectToml } from '../../provider/poetry'; +import { BasePythonProjectGeneratorSchema } from '../types'; describe('application generator', () => { let checkPoetryExecutableMock: MockInstance; let appTree: Tree; - const options: PoetryProjectGeneratorSchema = { + const options: BasePythonProjectGeneratorSchema = { name: 'test', projectType: 'application', pyprojectPythonDependency: '', @@ -40,13 +40,26 @@ describe('application generator', () => { appTree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); checkPoetryExecutableMock = vi.spyOn(poetryUtils, 'checkPoetryExecutable'); checkPoetryExecutableMock.mockResolvedValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; }); }); @@ -490,9 +503,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -500,7 +516,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -542,9 +558,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -552,7 +571,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -594,9 +613,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -604,7 +626,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -649,9 +671,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -659,7 +684,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); diff --git a/packages/nx-python/src/generators/poetry-project/generator.ts b/packages/nx-python/src/generators/poetry-project/generator.ts index f661db2..23e2fc4 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.ts @@ -2,7 +2,6 @@ import { addProjectConfiguration, formatFiles, generateFiles, - getWorkspaceLayout, names, offsetFromRoot, ProjectConfiguration, @@ -10,7 +9,6 @@ import { Tree, } from '@nx/devkit'; import * as path from 'path'; -import { PoetryProjectGeneratorSchema } from './schema'; import { parse, stringify } from '@iarna/toml'; import chalk from 'chalk'; import _ from 'lodash'; @@ -19,157 +17,56 @@ import { PoetryPyprojectTomlDependencies, } from '../../provider/poetry'; import { checkPoetryExecutable, runPoetry } from '../../provider/poetry/utils'; +import { + BaseNormalizedSchema, + BasePythonProjectGeneratorSchema, +} from '../types'; +import { + normalizeOptions as baseNormalizeOptions, + getPyprojectTomlByProjectName, +} from '../utils'; +import { DEV_DEPENDENCIES_VERSION_MAP } from '../consts'; -interface NormalizedSchema extends PoetryProjectGeneratorSchema { - projectName: string; - projectRoot: string; - individualPackage: boolean; +interface NormalizedSchema extends BaseNormalizedSchema { devDependenciesProjectPath?: string; devDependenciesProjectPkgName?: string; - pythonAddopts?: string; - parsedTags: string[]; + individualPackage: boolean; } function normalizeOptions( tree: Tree, - options: PoetryProjectGeneratorSchema, + options: BasePythonProjectGeneratorSchema, ): NormalizedSchema { - const { projectName, projectRoot } = calculateProjectNameAndRoot( - options, - tree, - ); - - const parsedTags = options.tags - ? options.tags.split(',').map((s) => s.trim()) - : []; - - const newOptions = _.clone(options) as NormalizedSchema; - - if (!options.pyprojectPythonDependency) { - newOptions.pyprojectPythonDependency = '>=3.9,<3.11'; - } - - if (!options.pyenvPythonVersion) { - newOptions.pyenvPythonVersion = '3.9.5'; - } - - if (!options.moduleName) { - newOptions.moduleName = projectName.replace(/-/g, '_'); - } - - if (!options.packageName) { - newOptions.packageName = projectName; - } + const newOptions = baseNormalizeOptions(tree, options); - if (!options.description) { - newOptions.description = 'Automatically generated by Nx.'; - } + let devDependenciesProjectPkgName: string | undefined; + let devDependenciesProjectPath: string | undefined; if (options.devDependenciesProject) { const projectConfig = readProjectConfiguration( tree, options.devDependenciesProject, ); - newOptions.devDependenciesProjectPath = path.relative( - projectRoot, + const { pyprojectToml } = + getPyprojectTomlByProjectName( + tree, + options.devDependenciesProject, + ); + devDependenciesProjectPkgName = pyprojectToml.tool.poetry.name; + devDependenciesProjectPath = path.relative( + newOptions.projectRoot, projectConfig.root, ); } - const pythonAddopts = getPyTestAddopts(options, projectRoot); - - if (options.unitTestRunner === 'none') { - newOptions.unitTestHtmlReport = false; - newOptions.unitTestJUnitReport = false; - newOptions.codeCoverage = false; - newOptions.codeCoverageHtmlReport = false; - newOptions.codeCoverageXmlReport = false; - newOptions.codeCoverageThreshold = undefined; - } - - let devDependenciesProjectPkgName: string | undefined; - if (options.devDependenciesProject) { - const { pyprojectToml } = getPyprojectTomlByProjectName( - tree, - options.devDependenciesProject, - ); - devDependenciesProjectPkgName = pyprojectToml.tool.poetry.name; - } - return { - ...options, ...newOptions, - devDependenciesProject: options.devDependenciesProject || '', - individualPackage: !tree.exists('pyproject.toml'), + devDependenciesProject: options.devDependenciesProject ?? '', + devDependenciesProjectPath, devDependenciesProjectPkgName, - pythonAddopts, - projectName, - projectRoot, - parsedTags, + individualPackage: !tree.exists('pyproject.toml'), }; } -function calculateProjectNameAndRoot( - options: PoetryProjectGeneratorSchema, - tree: Tree, -) { - let projectName = options.name; - let projectRoot = options.directory || options.name; - - if (options.projectNameAndRootFormat === 'derived') { - const name = names(options.name).fileName; - const projectDirectory = options.directory - ? `${names(options.directory).fileName}/${name}` - : name; - projectName = projectDirectory.replace(/\//g, '-'); - projectRoot = `${ - options.projectType === 'application' - ? getWorkspaceLayout(tree).appsDir - : getWorkspaceLayout(tree).libsDir - }/${projectDirectory}`; - } - - return { projectName, projectRoot }; -} - -function getPyTestAddopts( - options: PoetryProjectGeneratorSchema, - projectRoot: string, -): string | undefined { - if (options.unitTestRunner === 'pytest') { - const args = []; - const offset = offsetFromRoot(projectRoot); - if (options.codeCoverage) { - args.push('--cov'); - } - if (options.codeCoverageThreshold) { - args.push(`--cov-fail-under=${options.codeCoverageThreshold}`); - } - if (options.codeCoverage && options.codeCoverageHtmlReport) { - args.push(`--cov-report html:'${offset}coverage/${projectRoot}/html'`); - } - - if (options.codeCoverage && options.codeCoverageXmlReport) { - args.push( - `--cov-report xml:'${offset}coverage/${projectRoot}/coverage.xml'`, - ); - } - - if (options.unitTestHtmlReport) { - args.push( - `--html='${offset}reports/${projectRoot}/unittests/html/index.html'`, - ); - } - - if (options.unitTestJUnitReport) { - args.push( - `--junitxml='${offset}reports/${projectRoot}/unittests/junit.xml'`, - ); - } - - return args.join(' '); - } -} - function addFiles(tree: Tree, options: NormalizedSchema) { const templateOptions = { ...options, @@ -177,6 +74,7 @@ function addFiles(tree: Tree, options: NormalizedSchema) { offsetFromRoot: offsetFromRoot(options.projectRoot), template: '', dot: '.', + versionMap: DEV_DEPENDENCIES_VERSION_MAP, }; if (options.templateDir) { generateFiles( @@ -274,10 +172,11 @@ function updateDevDependenciesProject( normalizedOptions: NormalizedSchema, ) { if (normalizedOptions.devDependenciesProject) { - const { pyprojectToml, pyprojectTomlPath } = getPyprojectTomlByProjectName( - host, - normalizedOptions.devDependenciesProject, - ); + const { pyprojectToml, pyprojectTomlPath } = + getPyprojectTomlByProjectName( + host, + normalizedOptions.devDependenciesProject, + ); const { changed, dependencies } = addTestDependencies( pyprojectToml.tool.poetry.dependencies, @@ -295,17 +194,6 @@ function updateDevDependenciesProject( } } -function getPyprojectTomlByProjectName(host: Tree, projectName: string) { - const projectConfig = readProjectConfiguration(host, projectName); - const pyprojectTomlPath = path.join(projectConfig.root, 'pyproject.toml'); - - const pyprojectToml = parse( - host.read(pyprojectTomlPath, 'utf-8'), - ) as PoetryPyprojectToml; - - return { pyprojectToml, pyprojectTomlPath }; -} - function addTestDependencies( dependencies: PoetryPyprojectTomlDependencies, normalizedOptions: NormalizedSchema, @@ -313,28 +201,28 @@ function addTestDependencies( const originalDependencies = _.clone(dependencies); if (normalizedOptions.linter === 'flake8' && !dependencies['flake8']) { - dependencies['flake8'] = '6.0.0'; + dependencies['flake8'] = DEV_DEPENDENCIES_VERSION_MAP.flake8; } if (normalizedOptions.linter === 'ruff' && !dependencies['ruff']) { - dependencies['ruff'] = '0.1.5'; + dependencies['ruff'] = DEV_DEPENDENCIES_VERSION_MAP.ruff; } if (!dependencies['autopep8']) { - dependencies['autopep8'] = '2.0.2'; + dependencies['autopep8'] = DEV_DEPENDENCIES_VERSION_MAP.autopep8; } if ( normalizedOptions.unitTestRunner === 'pytest' && !dependencies['pytest'] ) { - dependencies['pytest'] = '7.3.1'; + dependencies['pytest'] = DEV_DEPENDENCIES_VERSION_MAP.pytest; } if ( normalizedOptions.unitTestRunner === 'pytest' && !dependencies['pytest-sugar'] ) { - dependencies['pytest-sugar'] = '0.9.7'; + dependencies['pytest-sugar'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-sugar']; } if ( @@ -342,7 +230,7 @@ function addTestDependencies( normalizedOptions.codeCoverage && !dependencies['pytest-cov'] ) { - dependencies['pytest-cov'] = '4.1.0'; + dependencies['pytest-cov'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-cov']; } if ( @@ -350,7 +238,7 @@ function addTestDependencies( normalizedOptions.codeCoverageHtmlReport && !dependencies['pytest-html'] ) { - dependencies['pytest-html'] = '3.2.0'; + dependencies['pytest-html'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-html']; } return { @@ -370,7 +258,7 @@ function updateRootPoetryLock(host: Tree) { export default async function ( tree: Tree, - options: PoetryProjectGeneratorSchema, + options: BasePythonProjectGeneratorSchema, ) { await checkPoetryExecutable(); diff --git a/packages/nx-python/src/generators/poetry-project/schema.d.ts b/packages/nx-python/src/generators/types.ts similarity index 68% rename from packages/nx-python/src/generators/poetry-project/schema.d.ts rename to packages/nx-python/src/generators/types.ts index 8d551d3..46d4f19 100644 --- a/packages/nx-python/src/generators/poetry-project/schema.d.ts +++ b/packages/nx-python/src/generators/types.ts @@ -1,26 +1,37 @@ -export interface PoetryProjectGeneratorSchema { +export interface PytestGeneratorSchema { + unitTestRunner: 'pytest' | 'none'; + codeCoverage: boolean; + codeCoverageHtmlReport: boolean; + codeCoverageXmlReport: boolean; + codeCoverageThreshold?: number; + unitTestHtmlReport: boolean; + unitTestJUnitReport: boolean; +} + +export interface BasePythonProjectGeneratorSchema + extends PytestGeneratorSchema { name: string; - projectType: 'application' | 'library'; - packageName?: string; - moduleName?: string; - description?: string; - pyprojectPythonDependency: string; - pyenvPythonVersion: string; publishable: boolean; buildLockedVersions: boolean; buildBundleLocalDependencies: boolean; linter: 'flake8' | 'ruff' | 'none'; - unitTestRunner: 'pytest' | 'none'; devDependenciesProject?: string; rootPyprojectDependencyGroup: string; - unitTestHtmlReport: boolean; - unitTestJUnitReport: boolean; - codeCoverage: boolean; - codeCoverageHtmlReport: boolean; - codeCoverageXmlReport: boolean; - codeCoverageThreshold?: number; - tags?: string; - directory?: string; templateDir?: string; + pyprojectPythonDependency: string; + projectType: 'application' | 'library'; projectNameAndRootFormat: 'as-provided' | 'derived'; + packageName?: string; + description?: string; + moduleName?: string; + pyenvPythonVersion?: string; + tags?: string; + directory?: string; +} + +export interface BaseNormalizedSchema extends BasePythonProjectGeneratorSchema { + projectName: string; + projectRoot: string; + pythonAddopts?: string; + parsedTags: string[]; } diff --git a/packages/nx-python/src/generators/utils.ts b/packages/nx-python/src/generators/utils.ts new file mode 100644 index 0000000..d293913 --- /dev/null +++ b/packages/nx-python/src/generators/utils.ts @@ -0,0 +1,154 @@ +import { + getWorkspaceLayout, + names, + offsetFromRoot, + readProjectConfiguration, + Tree, +} from '@nx/devkit'; +import { + BasePythonProjectGeneratorSchema, + PytestGeneratorSchema, + BaseNormalizedSchema, +} from './types'; +import spawn from 'cross-spawn'; +import _ from 'lodash'; +import path from 'path'; +import { parse } from '@iarna/toml'; + +export function getPyTestAddopts( + options: PytestGeneratorSchema, + projectRoot: string, +): string | undefined { + if (options.unitTestRunner === 'pytest') { + const args = []; + const offset = offsetFromRoot(projectRoot); + if (options.codeCoverage) { + args.push('--cov'); + } + if (options.codeCoverageThreshold) { + args.push(`--cov-fail-under=${options.codeCoverageThreshold}`); + } + if (options.codeCoverage && options.codeCoverageHtmlReport) { + args.push(`--cov-report html:'${offset}coverage/${projectRoot}/html'`); + } + + if (options.codeCoverage && options.codeCoverageXmlReport) { + args.push( + `--cov-report xml:'${offset}coverage/${projectRoot}/coverage.xml'`, + ); + } + + if (options.unitTestHtmlReport) { + args.push( + `--html='${offset}reports/${projectRoot}/unittests/html/index.html'`, + ); + } + + if (options.unitTestJUnitReport) { + args.push( + `--junitxml='${offset}reports/${projectRoot}/unittests/junit.xml'`, + ); + } + + return args.join(' '); + } +} + +export function calculateProjectNameAndRoot( + options: BasePythonProjectGeneratorSchema, + tree: Tree, +) { + let projectName = options.name; + let projectRoot = options.directory || options.name; + + if (options.projectNameAndRootFormat === 'derived') { + const name = names(options.name).fileName; + const projectDirectory = options.directory + ? `${names(options.directory).fileName}/${name}` + : name; + projectName = projectDirectory.replace(/\//g, '-'); + projectRoot = `${ + options.projectType === 'application' + ? getWorkspaceLayout(tree).appsDir + : getWorkspaceLayout(tree).libsDir + }/${projectDirectory}`; + } + + return { projectName, projectRoot }; +} + +export function normalizeOptions( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +): BaseNormalizedSchema { + const { projectName, projectRoot } = calculateProjectNameAndRoot( + options, + tree, + ); + + const parsedTags = options.tags + ? options.tags.split(',').map((s) => s.trim()) + : []; + + const newOptions = _.clone(options) as BaseNormalizedSchema; + + if (!options.pyprojectPythonDependency) { + newOptions.pyprojectPythonDependency = '>=3.9,<4'; + } + + if (!options.pyenvPythonVersion) { + const result = spawn.sync('python', ['--version'], { + stdio: 'pipe', + }); + + newOptions.pyenvPythonVersion = + result.status === 0 + ? result.stdout.toString('utf-8').replace('Python ', '').trim() + : '3.9.5'; + } + + if (!options.moduleName) { + newOptions.moduleName = projectName.replace(/-/g, '_'); + } + + if (!options.packageName) { + newOptions.packageName = projectName; + } + + if (!options.description) { + newOptions.description = 'Automatically generated by Nx.'; + } + + const pythonAddopts = getPyTestAddopts(options, projectRoot); + + if (options.unitTestRunner === 'none') { + newOptions.unitTestHtmlReport = false; + newOptions.unitTestJUnitReport = false; + newOptions.codeCoverage = false; + newOptions.codeCoverageHtmlReport = false; + newOptions.codeCoverageXmlReport = false; + newOptions.codeCoverageThreshold = undefined; + } + + return { + ...options, + ...newOptions, + devDependenciesProject: options.devDependenciesProject || '', + pythonAddopts, + projectName, + projectRoot, + parsedTags, + }; +} + +export function getPyprojectTomlByProjectName( + tree: Tree, + projectName: string, +) { + const projectConfig = readProjectConfiguration(tree, projectName); + const pyprojectTomlPath = path.join(projectConfig.root, 'pyproject.toml'); + + const pyprojectToml = parse(tree.read(pyprojectTomlPath, 'utf-8')) as T; + + return { pyprojectToml, pyprojectTomlPath }; +} diff --git a/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap b/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap new file mode 100644 index 0000000..ade2e1b --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap @@ -0,0 +1,3184 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`application generator > as-provided > should run successfully minimal configuration 1`] = ` +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "my-app-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "src/app/test", + "sourceRoot": "src/app/test/my_app_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "src/app/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "src/app/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 2`] = ` +"# my-app-test + +Project description here. +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 3`] = ` +"[project] +name = "my-app-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["my_app_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello my-app-test" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 1`] = ` +{ + "$schema": "../node_modules/nx/schemas/project-schema.json", + "name": "my-app-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "my-app-test", + "sourceRoot": "my-app-test/my_app_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "my-app-test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "my-app-test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 2`] = ` +"# my-app-test + +Project description here. +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 3`] = ` +"[project] +name = "my-app-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["my_app_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello my-app-test" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 2`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "autopep8==1.5.7", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 3`] = `null`; + +exports[`application generator > should run successfully minimal configuration as a library 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "library", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "libs/test", + "sourceRoot": "libs/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "libs/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "libs/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration as a library 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*" ] +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 1`] = ` +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "subdir-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/subdir/test", + "sourceRoot": "apps/subdir/test/subdir_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/subdir/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/subdir/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration custom directory 2`] = ` +"# subdir-test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 3`] = ` +"[project] +name = "subdir-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["subdir_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello subdir-test" +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "subdir-test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.subdir-test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [ + "one", + "two", + ], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration with tags 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter 7`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "flake8>=7.1.1", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report html:'../../coverage/apps/test/html'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 8`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 9`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 10`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 11`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 12`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + "tests", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 6`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 7`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 8`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "ruff>=0.8.2", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 9`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 10`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 11`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting and testing options with a dev dependency project with custom package name 1`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "custom-shared-dev-lib" +] + +[tool.uv.sources] +custom-shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with a dev dependency project with custom package name 2`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "custom-shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.custom-shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 8`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 9`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "autopep8>=1.0.0", + "pytest>=1.0.0", + "pytest-sugar=>1.0.0", + "pytest-cov=>1.0.0", + "pytest-html=>1.0.0", + "flake8=>1.0.0", + "flake8-isort=>1.0.0" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 10`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 11`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ ] + +[dependency-groups] +dev = [ "test", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ ] + +[dependency-groups] +dev = [ "requests>=2.3.1", "test", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with ruff linter 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with ruff linter 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with ruff linter 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with ruff linter 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with ruff linter 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "ruff>=0.8.2", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + "tests", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 6`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 7`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "ruff>=0.8.2", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; diff --git a/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml b/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml new file mode 100644 index 0000000..44e4618 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "<%= packageName %>" +version = "1.0.0" +description = "<%= description %>" +requires-python = "<%- pyprojectPythonDependency %>" + +[tool.hatch.build.targets.wheel] +packages = ["<%= moduleName %>"] + +[dependency-groups] +dev = [ + "autopep8==1.5.7", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/nx-python/src/generators/uv-project/files/base/README.md b/packages/nx-python/src/generators/uv-project/files/base/README.md new file mode 100644 index 0000000..1a9566e --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/README.md @@ -0,0 +1,3 @@ +# <%= projectName %> + +Project description here. diff --git a/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template b/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template new file mode 100644 index 0000000..a8884cb --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template @@ -0,0 +1 @@ +<%= pyenvPythonVersion %> diff --git a/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py new file mode 100644 index 0000000..6303732 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py @@ -0,0 +1 @@ +"""<%= description %>""" diff --git a/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py new file mode 100644 index 0000000..59cd85b --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py @@ -0,0 +1,6 @@ +"""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello <%= projectName %>" diff --git a/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml b/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml new file mode 100644 index 0000000..bee72bd --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml @@ -0,0 +1,73 @@ +<%if (codeCoverage) { -%> +[tool.coverage.run] +branch = true +source = [ "<%= moduleName %>" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +<% } -%> +<%if (unitTestRunner === 'pytest' && pythonAddopts) { -%> +[tool.pytest.ini_options] +addopts = "<%- pythonAddopts %>" + +<% } -%> +[project] +name = "<%= packageName %>" +version = "1.0.0" +description = "<%= description %>" +requires-python = "<%- pyprojectPythonDependency %>" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["<%= moduleName %>"] + +<%if (devDependenciesProject !== '') { -%> +[dependency-groups] +dev = [ + "<%- devDependenciesProjectPkgName %>" +] + +[tool.uv.sources] +<%- devDependenciesProjectPkgName %> = { workspace = true } + +<% } -%> +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +<%if (linter === 'ruff') { -%> + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +<% } -%> diff --git a/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template b/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template new file mode 100644 index 0000000..3da962c --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template @@ -0,0 +1,11 @@ +[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py new file mode 100644 index 0000000..d8e96c6 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py @@ -0,0 +1 @@ +"""unit tests.""" diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py new file mode 100644 index 0000000..547aa99 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py @@ -0,0 +1,3 @@ +"""Unit tests configuration module.""" + +pytest_plugins = [] diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py new file mode 100644 index 0000000..3873969 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py @@ -0,0 +1,8 @@ +"""Hello unit test module.""" + +from <%= moduleName %>.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello <%= projectName %>" diff --git a/packages/nx-python/src/generators/uv-project/generator.spec.ts b/packages/nx-python/src/generators/uv-project/generator.spec.ts new file mode 100644 index 0000000..d660a26 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/generator.spec.ts @@ -0,0 +1,652 @@ +import { vi, MockInstance } from 'vitest'; +import '../../utils/mocks/cross-spawn.mock'; +import * as uvUtils from '../../provider/uv/utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Tree, readProjectConfiguration } from '@nx/devkit'; + +import generator from './generator'; +import dedent from 'string-dedent'; +import { parse, stringify } from '@iarna/toml'; +import path from 'path'; +import spawn from 'cross-spawn'; +import { UVPyprojectToml } from '../../provider/uv/types'; +import { BasePythonProjectGeneratorSchema } from '../types'; + +describe('application generator', () => { + let checkUvExecutable: MockInstance; + let appTree: Tree; + const options: BasePythonProjectGeneratorSchema = { + name: 'test', + projectType: 'application', + pyprojectPythonDependency: '', + publishable: false, + buildLockedVersions: false, + buildBundleLocalDependencies: false, + linter: 'none', + unitTestRunner: 'none', + rootPyprojectDependencyGroup: 'main', + unitTestHtmlReport: false, + unitTestJUnitReport: false, + codeCoverage: false, + codeCoverageHtmlReport: false, + codeCoverageXmlReport: false, + projectNameAndRootFormat: 'derived', + }; + + beforeEach(() => { + vi.resetAllMocks(); + + appTree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + checkUvExecutable = vi.spyOn(uvUtils, 'checkUvExecutable'); + checkUvExecutable.mockResolvedValue(undefined); + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + }); + + it('should throw an exception when the poetry is not installed', async () => { + checkUvExecutable.mockRejectedValue(new Error('poetry not found')); + + expect(generator(appTree, options)).rejects.toThrow('poetry not found'); + + expect(checkUvExecutable).toHaveBeenCalled(); + }); + + describe('as-provided', () => { + it('should run successfully minimal configuration', async () => { + await generator(appTree, { + ...options, + name: 'my-app-test', + directory: 'src/app/test', + projectNameAndRootFormat: 'as-provided', + }); + const config = readProjectConfiguration(appTree, 'my-app-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'src/app/test'; + const moduleName = 'my_app_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + }); + + it('should run successfully minimal configuration without directory', async () => { + await generator(appTree, { + ...options, + name: 'my-app-test', + projectNameAndRootFormat: 'as-provided', + }); + const config = readProjectConfiguration(appTree, 'my-app-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'my-app-test'; + const moduleName = 'my_app_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + }); + }); + + it('should run successfully with minimal options', async () => { + const callbackTask = await generator(appTree, options); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options without rootPyprojectDependencyGroup', async () => { + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: undefined, + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options with custom rootPyprojectDependencyGroup', async () => { + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: 'dev', + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options with existing custom rootPyprojectDependencyGroup', async () => { + appTree.write( + 'pyproject.toml', + dedent` + [project] + name = "nx-workspace" + version = "1.0.0" + dependencies = [ ] + + [dependency-groups] + dev = [ "requests>=2.3.1" ] + `, + ); + + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: 'dev', + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully minimal configuration as a library', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'libs/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully minimal configuration with tags', async () => { + await generator(appTree, { + ...options, + tags: 'one,two', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully minimal configuration custom directory', async () => { + await generator(appTree, { + ...options, + directory: 'subdir', + }); + const config = readProjectConfiguration(appTree, 'subdir-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/subdir/test'; + const moduleName = 'subdir_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with ruff linter', async () => { + await generator(appTree, { + ...options, + linter: 'ruff', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with no reports', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with ruff linter and pytest with no reports', async () => { + await generator(appTree, { + ...options, + linter: 'ruff', + unitTestRunner: 'pytest', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html coverage report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting (flake8) and testing options with a dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting (ruff) and testing options with a dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + await generator(appTree, { + ...options, + linter: 'ruff', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting and testing options with a dev dependency project with custom package name', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + packageName: 'custom-shared-dev-lib', + }); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + expect(appTree.exists(`apps/test/pyproject.toml`)).toBeTruthy(); + expect(appTree.read(`apps/test/pyproject.toml`, 'utf8')).toMatchSnapshot(); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting and testing options with an existing dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + const pyprojectToml = parse( + appTree.read('libs/shared/dev-lib/pyproject.toml', 'utf-8'), + ) as UVPyprojectToml; + + pyprojectToml.project.dependencies = [ + 'autopep8>=1.0.0', + 'pytest>=1.0.0', + 'pytest-sugar=>1.0.0', + 'pytest-cov=>1.0.0', + 'pytest-html=>1.0.0', + 'flake8=>1.0.0', + 'flake8-isort=>1.0.0', + ]; + + appTree.write( + 'libs/shared/dev-lib/pyproject.toml', + stringify(pyprojectToml), + ); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + }); + + describe('custom template dir', () => { + it('should run successfully with custom template dir', async () => { + await generator(appTree, { + ...options, + templateDir: path.join(__dirname, '__test__/custom-template'), + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + expect( + appTree.read('apps/test/pyproject.toml', 'utf-8'), + ).toMatchSnapshot(); + + expect(appTree.read('apps/test/poetry.toml', 'utf-8')).toMatchSnapshot(); + }); + }); +}); + +function assertGeneratedFilesBase( + appTree: Tree, + projectDirectory: string, + moduleName: string, +) { + expect(appTree.exists(`${projectDirectory}/README.md`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/README.md`, 'utf8'), + ).toMatchSnapshot(); + + expect(appTree.exists(`${projectDirectory}/pyproject.toml`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/pyproject.toml`, 'utf8'), + ).toMatchSnapshot(); + + expect( + appTree.exists(`${projectDirectory}/${moduleName}/hello.py`), + ).toBeTruthy(); + + expect( + appTree.read(`${projectDirectory}/${moduleName}/hello.py`, 'utf-8'), + ).toMatchSnapshot(); + + expect( + appTree.read(`${projectDirectory}/.python-version`, 'utf-8'), + ).toMatchSnapshot(); +} + +function assertGeneratedFilesFlake8(appTree: Tree, projectDirectory: string) { + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/.flake8`, 'utf-8'), + ).toMatchSnapshot(); +} + +function assertGeneratedFilesPyTest(appTree: Tree, projectDirectory: string) { + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeTruthy(); + + expect( + appTree.read(`${projectDirectory}/tests/test_hello.py`, 'utf-8'), + ).toMatchSnapshot(); +} diff --git a/packages/nx-python/src/generators/uv-project/generator.ts b/packages/nx-python/src/generators/uv-project/generator.ts new file mode 100644 index 0000000..17b366a --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/generator.ts @@ -0,0 +1,414 @@ +import { + addProjectConfiguration, + formatFiles, + generateFiles, + names, + offsetFromRoot, + ProjectConfiguration, + readProjectConfiguration, + Tree, +} from '@nx/devkit'; +import * as path from 'path'; +import { parse, stringify } from '@iarna/toml'; +import chalk from 'chalk'; +import _ from 'lodash'; +import { UVPyprojectToml } from '../../provider/uv/types'; +import { checkUvExecutable, runUv } from '../../provider/uv/utils'; +import { DEV_DEPENDENCIES_VERSION_MAP } from '../consts'; +import wcmatch from 'wildcard-match'; +import { + normalizeOptions as baseNormalizeOptions, + getPyprojectTomlByProjectName, +} from '../utils'; +import { + BaseNormalizedSchema, + BasePythonProjectGeneratorSchema, +} from '../types'; + +interface NormalizedSchema extends BaseNormalizedSchema { + devDependenciesProjectPath?: string; + devDependenciesProjectPkgName?: string; +} + +function normalizeOptions( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +): NormalizedSchema { + const newOptions = baseNormalizeOptions(tree, options); + + let devDependenciesProjectPkgName: string | undefined; + let devDependenciesProjectPath: string | undefined; + if (options.devDependenciesProject) { + const projectConfig = readProjectConfiguration( + tree, + options.devDependenciesProject, + ); + const { pyprojectToml } = getPyprojectTomlByProjectName( + tree, + options.devDependenciesProject, + ); + devDependenciesProjectPkgName = pyprojectToml.project.name; + devDependenciesProjectPath = path.relative( + newOptions.projectRoot, + projectConfig.root, + ); + } + + return { + ...newOptions, + devDependenciesProject: options.devDependenciesProject ?? '', + devDependenciesProjectPath, + devDependenciesProjectPkgName, + }; +} + +function addFiles(tree: Tree, options: NormalizedSchema) { + const templateOptions = { + ...options, + ...names(options.name), + offsetFromRoot: offsetFromRoot(options.projectRoot), + template: '', + dot: '.', + versionMap: DEV_DEPENDENCIES_VERSION_MAP, + }; + if (options.templateDir) { + generateFiles( + tree, + path.join(options.templateDir), + options.projectRoot, + templateOptions, + ); + return; + } + + generateFiles( + tree, + path.join(__dirname, 'files', 'base'), + options.projectRoot, + templateOptions, + ); + + if (options.unitTestRunner === 'pytest') { + generateFiles( + tree, + path.join(__dirname, 'files', 'pytest'), + options.projectRoot, + templateOptions, + ); + } + + if (options.linter === 'flake8') { + generateFiles( + tree, + path.join(__dirname, 'files', 'flake8'), + options.projectRoot, + templateOptions, + ); + } +} + +function updateRootPyprojectToml( + tree: Tree, + normalizedOptions: NormalizedSchema, +) { + const rootPyprojectToml: UVPyprojectToml = tree.exists('./pyproject.toml') + ? (parse(tree.read('./pyproject.toml', 'utf-8')) as UVPyprojectToml) + : { + project: { name: 'nx-workspace', version: '1.0.0', dependencies: [] }, + 'dependency-groups': {}, + tool: { + uv: { + sources: {}, + workspace: { + members: [`${normalizedOptions.projectRoot.split('/')[0]}/*`], + }, + }, + }, + }; + + const group = normalizedOptions.rootPyprojectDependencyGroup ?? 'main'; + + if (group === 'main') { + rootPyprojectToml.project.dependencies ??= []; + rootPyprojectToml.project.dependencies.push(normalizedOptions.packageName); + } else { + rootPyprojectToml['dependency-groups'] ??= {}; + rootPyprojectToml['dependency-groups'][group] ??= []; + rootPyprojectToml['dependency-groups'][group].push( + normalizedOptions.packageName, + ); + } + + rootPyprojectToml.tool ??= {}; + rootPyprojectToml.tool.uv ??= {}; + rootPyprojectToml.tool.uv.sources ??= {}; + rootPyprojectToml.tool.uv.sources[normalizedOptions.packageName] = { + workspace: true, + }; + + if (!normalizedOptions.devDependenciesProject) { + rootPyprojectToml['dependency-groups'] ??= {}; + rootPyprojectToml['dependency-groups'].dev ??= []; + + const { changed, dependencies } = addTestDependencies( + rootPyprojectToml['dependency-groups'].dev ?? [], + normalizedOptions, + true, + ); + + if (changed) { + rootPyprojectToml['dependency-groups'].dev = dependencies; + } + } + + rootPyprojectToml.tool ??= {}; + rootPyprojectToml.tool.uv ??= {}; + rootPyprojectToml.tool.uv.workspace ??= { + members: [], + }; + + if (rootPyprojectToml.tool.uv.workspace.members.length === 0) { + rootPyprojectToml.tool.uv.workspace.members.push( + `${normalizedOptions.projectRoot.split('/')[0]}/*`, + ); + } else { + for (const memberPattern of rootPyprojectToml.tool.uv.workspace.members) { + if ( + !wcmatch( + memberPattern.endsWith('**') + ? memberPattern + : memberPattern.endsWith('*') + ? `${memberPattern}*` + : memberPattern, + )(normalizedOptions.projectRoot) + ) { + rootPyprojectToml.tool.uv.workspace.members.push( + `${normalizedOptions.projectRoot.split('/')[0]}/*`, + ); + } + } + } + + tree.write('./pyproject.toml', stringify(rootPyprojectToml)); +} + +function updateDevDependenciesProject( + tree: Tree, + normalizedOptions: NormalizedSchema, +) { + if (normalizedOptions.devDependenciesProject) { + const { pyprojectToml, pyprojectTomlPath } = + getPyprojectTomlByProjectName( + tree, + normalizedOptions.devDependenciesProject, + ); + + const { changed, dependencies } = addTestDependencies( + pyprojectToml.project.dependencies, + normalizedOptions, + false, + ); + + if (changed) { + pyprojectToml.project.dependencies = dependencies; + + tree.write(pyprojectTomlPath, stringify(pyprojectToml)); + } + } +} + +function addTestDependencies( + dependencies: string[], + normalizedOptions: NormalizedSchema, + rootPyproject: boolean, +) { + const newDependencies = [...dependencies]; + const dependencyNames = dependencies + .map((dep) => dep.match(/^[a-zA-Z0-9-]+/)?.[0]) + .filter((d) => !!d); + + if ( + normalizedOptions.linter === 'flake8' && + !dependencyNames.includes('flake8') + ) { + newDependencies.push(`flake8>=${DEV_DEPENDENCIES_VERSION_MAP.flake8}`); + } + + if ( + normalizedOptions.linter === 'ruff' && + !dependencyNames.includes('ruff') + ) { + newDependencies.push(`ruff>=${DEV_DEPENDENCIES_VERSION_MAP.ruff}`); + } + + if ( + !dependencyNames.includes('autopep8') && + ((rootPyproject && !normalizedOptions.devDependenciesProject) || + !rootPyproject) + ) { + newDependencies.push(`autopep8>=${DEV_DEPENDENCIES_VERSION_MAP.autopep8}`); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + !dependencyNames.includes('pytest') + ) { + newDependencies.push(`pytest>=${DEV_DEPENDENCIES_VERSION_MAP.pytest}`); + } + if ( + normalizedOptions.unitTestRunner === 'pytest' && + !dependencyNames.includes('pytest-sugar') + ) { + newDependencies.push( + `pytest-sugar>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-sugar']}`, + ); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + normalizedOptions.codeCoverage && + !dependencyNames.includes('pytest-cov') + ) { + newDependencies.push( + `pytest-cov>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-cov']}`, + ); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + normalizedOptions.codeCoverageHtmlReport && + !dependencyNames.includes('pytest-html') + ) { + newDependencies.push( + `pytest-html>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-html']}`, + ); + } + + return { + changed: !_.isEqual(dependencies, newDependencies), + dependencies: newDependencies, + }; +} + +function updateRootUvLock() { + console.log(chalk` Updating root {bgBlue uv.lock}...`); + runUv(['sync'], { log: false }); + console.log(chalk`\n {bgBlue uv.lock} updated.\n`); +} + +export default async function ( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +) { + await checkUvExecutable(); + + const normalizedOptions = normalizeOptions(tree, options); + + const targets: ProjectConfiguration['targets'] = { + lock: { + executor: '@nxlv/python:run-commands', + options: { + command: 'uv lock', + cwd: normalizedOptions.projectRoot, + }, + }, + add: { + executor: '@nxlv/python:add', + options: {}, + }, + update: { + executor: '@nxlv/python:update', + options: {}, + }, + remove: { + executor: '@nxlv/python:remove', + options: {}, + }, + build: { + executor: '@nxlv/python:build', + outputs: ['{projectRoot}/dist'], + options: { + outputPath: `${normalizedOptions.projectRoot}/dist`, + publish: normalizedOptions.publishable, + lockedVersions: normalizedOptions.buildLockedVersions, + bundleLocalDependencies: normalizedOptions.buildBundleLocalDependencies, + }, + }, + }; + + if (options.linter === 'flake8') { + targets.lint = { + executor: '@nxlv/python:flake8', + outputs: [ + `{workspaceRoot}/reports/${normalizedOptions.projectRoot}/pylint.txt`, + ], + options: { + outputFile: `reports/${normalizedOptions.projectRoot}/pylint.txt`, + }, + }; + } + + if (options.linter === 'ruff') { + targets.lint = { + executor: '@nxlv/python:ruff-check', + outputs: [], + options: { + lintFilePatterns: [normalizedOptions.moduleName].concat( + options.unitTestRunner === 'pytest' ? ['tests'] : [], + ), + }, + }; + } + + if (options.unitTestRunner === 'pytest') { + targets.test = { + executor: '@nxlv/python:run-commands', + outputs: [ + `{workspaceRoot}/reports/${normalizedOptions.projectRoot}/unittests`, + `{workspaceRoot}/coverage/${normalizedOptions.projectRoot}`, + ], + options: { + command: `uv run pytest tests/`, + cwd: normalizedOptions.projectRoot, + }, + }; + } + + const projectConfiguration: ProjectConfiguration = { + root: normalizedOptions.projectRoot, + projectType: normalizedOptions.projectType, + sourceRoot: `${normalizedOptions.projectRoot}/${normalizedOptions.moduleName}`, + targets, + tags: normalizedOptions.parsedTags, + }; + + if (normalizedOptions.publishable) { + projectConfiguration.targets ??= {}; + projectConfiguration.targets['nx-release-publish'] = { + executor: '@nxlv/python:publish', + options: {}, + outputs: [], + }; + } + + projectConfiguration.release = { + version: { + generator: '@nxlv/python:release-version', + }, + }; + + addProjectConfiguration( + tree, + normalizedOptions.projectName, + projectConfiguration, + ); + + addFiles(tree, normalizedOptions); + updateDevDependenciesProject(tree, normalizedOptions); + updateRootPyprojectToml(tree, normalizedOptions); + await formatFiles(tree); + + return () => { + updateRootUvLock(); + }; +} diff --git a/packages/nx-python/src/generators/uv-project/schema.json b/packages/nx-python/src/generators/uv-project/schema.json new file mode 100644 index 0000000..e31be06 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "UVPythonProject", + "title": "Generate a new UV Python Project.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use?" + }, + "projectType": { + "type": "string", + "description": "Project type", + "default": "application", + "enum": ["application", "library"] + }, + "templateDir": { + "type": "string", + "description": "Custom template directory, this will override the default template, if not provided the default template will be used" + }, + "packageName": { + "type": "string", + "description": "Python package name" + }, + "moduleName": { + "type": "string", + "description": "Python module name" + }, + "description": { + "type": "string", + "description": "Project short description" + }, + "pyprojectPythonDependency": { + "type": "string", + "description": "Pyproject python dependency version range", + "default": ">=3.9,<4" + }, + "pyenvPythonVersion": { + "type": "string", + "description": "Pyenv .python-version content (default to current python version)" + }, + "publishable": { + "type": "boolean", + "description": "Project is publishable", + "default": false + }, + "buildLockedVersions": { + "type": "boolean", + "description": "Use locked versions for build dependencies", + "default": true + }, + "buildBundleLocalDependencies": { + "type": "boolean", + "description": "Bundle local dependencies", + "default": true + }, + "linter": { + "type": "string", + "description": "Project linter", + "default": "ruff", + "enum": ["flake8", "ruff", "none"] + }, + "unitTestRunner": { + "type": "string", + "description": "Project unit test runner", + "default": "pytest", + "enum": ["pytest", "none"] + }, + "devDependenciesProject": { + "type": "string", + "description": "This approach installs all the missing dev dependencies in a separate project (optional)", + "x-dropdown": "projects" + }, + "rootPyprojectDependencyGroup": { + "type": "string", + "description": "If a shared pyproject.toml is used, which dependency group does this new project should belong to", + "default": "main" + }, + "unitTestHtmlReport": { + "type": "boolean", + "description": "Generate html report for unit tests", + "default": true + }, + "unitTestJUnitReport": { + "type": "boolean", + "description": "Generate junit report for unit tests", + "default": true + }, + "codeCoverage": { + "type": "boolean", + "description": "Generate code coverage report", + "default": true + }, + "codeCoverageHtmlReport": { + "type": "boolean", + "description": "Generate html report for code coverage", + "default": true + }, + "codeCoverageXmlReport": { + "type": "boolean", + "description": "Generate Xml report for code coverage", + "default": true + }, + "codeCoverageThreshold": { + "type": "number", + "description": "Code coverage threshold" + }, + "tags": { + "type": "string", + "description": "Add tags to the project (used for linting)", + "alias": "t" + }, + "directory": { + "type": "string", + "description": "A directory where the project is placed" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"], + "default": "as-provided" + } + }, + "required": ["name", "projectType"] +} diff --git a/packages/nx-python/src/provider/base.ts b/packages/nx-python/src/provider/base.ts index 842a16a..289fe94 100644 --- a/packages/nx-python/src/provider/base.ts +++ b/packages/nx-python/src/provider/base.ts @@ -12,6 +12,17 @@ export type Dependency = { category: string; }; +export type PackageDependency = { + name: string; + version?: string; + markers?: string; + optional?: boolean; + extras?: string[]; + git?: string; + rev?: string; + source?: string; +}; + export type ProjectMetadata = { name: string; version: string; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts index fd6512e..dc83498 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts @@ -1,14 +1,16 @@ import { parse } from '@iarna/toml'; import { readFileSync, existsSync } from 'fs-extra'; import path, { join, relative } from 'path'; -import { PoetryLock, Dependency, PoetryLockPackage } from './types'; +import { PoetryLock, PoetryLockPackage } from './types'; import chalk from 'chalk'; import uri2path from 'file-uri-to-path'; -import { getLoggingTab, includeDependencyPackage } from './utils'; +import { includeDependencyPackage } from './utils'; import { Logger } from '../../../../executors/utils/logger'; import { PoetryPyprojectToml } from '../../types'; import { parseToml, runPoetry } from '../../utils'; import { isWindows } from '../../../../executors/utils/os'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab } from '../../../utils'; export class LockedDependencyResolver { private logger: Logger; @@ -23,7 +25,7 @@ export class LockedDependencyResolver { buildTomlData: PoetryPyprojectToml, devDependencies: boolean, workspaceRoot: string, - ): Dependency[] { + ): PackageDependency[] { this.logger.info(chalk` Resolving dependencies...`); return this.resolveDependencies( devDependencies, @@ -40,9 +42,9 @@ export class LockedDependencyResolver { buildFolderPath: string, buildTomlData: PoetryPyprojectToml, workspaceRoot: string, - deps: Dependency[] = [], + deps: PackageDependency[] = [], level = 1, - ): Dependency[] { + ): PackageDependency[] { const tab = getLoggingTab(level); const requerimentsTxt = this.getProjectRequirementsTxt( devDependencies, @@ -58,7 +60,7 @@ export class LockedDependencyResolver { const requerimentsLines = requerimentsTxt.split('\n'); for (const line of requerimentsLines) { if (line.trim()) { - const dep = {} as Dependency; + const dep = {} as PackageDependency; const elements = line.split(';'); if (elements.length > 1) { @@ -152,12 +154,12 @@ export class LockedDependencyResolver { private resolveSourceDependency( tab: string, exportedLineElements: string[], - dep: Dependency, + dep: PackageDependency, lockData: PoetryLock, workspaceRoot: string, buildFolderPath: string, buildTomlData: PoetryPyprojectToml, - deps: Dependency[], + deps: PackageDependency[], ) { const { packageName, location } = this.extractLocalPackageInfo(exportedLineElements); @@ -211,7 +213,7 @@ export class LockedDependencyResolver { return { packageName, location }; } - private resolvePackageExtras(dep: Dependency) { + private resolvePackageExtras(dep: PackageDependency) { if (dep.name.indexOf('[') !== -1) { dep.extras = dep.name .substring(dep.name.indexOf('[') + 1, dep.name.lastIndexOf(']')) @@ -261,8 +263,8 @@ export class LockedDependencyResolver { private includeGitDependency( tab: string, lockedPkg: PoetryLockPackage, - dep: Dependency, - deps: Dependency[], + dep: PackageDependency, + deps: PackageDependency[], ) { dep.git = lockedPkg.source.url; dep.optional = lockedPkg.optional; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts index 1c913de..baafc0e 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts @@ -1,14 +1,15 @@ import chalk from 'chalk'; -import { Dependency } from './types'; import { join, normalize, relative, resolve } from 'path'; import { readFileSync } from 'fs-extra'; import { parse } from '@iarna/toml'; -import { getLoggingTab, includeDependencyPackage } from './utils'; +import { includeDependencyPackage } from './utils'; import { ExecutorContext } from '@nx/devkit'; import { createHash } from 'crypto'; import { PoetryPyprojectToml, PoetryPyprojectTomlSource } from '../../types'; import { BuildExecutorSchema } from '../../../../executors/build/schema'; import { Logger } from '../../../../executors/utils/logger'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab } from '../../../utils'; export class ProjectDependencyResolver { private logger: Logger; @@ -29,7 +30,7 @@ export class ProjectDependencyResolver { root: string, buildFolderPath: string, buildTomlData: PoetryPyprojectToml, - ): Dependency[] { + ): PackageDependency[] { this.logger.info(chalk` Resolving dependencies...`); const pyprojectPath = join(root, 'pyproject.toml'); const pyproject = parse( @@ -52,14 +53,14 @@ export class ProjectDependencyResolver { level = 1, ) { const tab = getLoggingTab(level); - const deps: Dependency[] = []; + const deps: PackageDependency[] = []; const dependencies = Object.entries( pyproject.tool.poetry.dependencies, ).filter(([name]) => name != 'python'); for (const [name, data] of dependencies) { - const dep = {} as Dependency; + const dep = {} as PackageDependency; dep.name = name; if (typeof data === 'string') { diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/types.ts b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts index 02b9230..3bda3f4 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/types.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts @@ -1,16 +1,5 @@ import { PoetryPyprojectTomlDependency } from '../../types'; -export type Dependency = { - name: string; - version: string; - markers?: string; - optional: boolean; - extras?: string[]; - git?: string; - rev?: string; - source?: string; -}; - export type PoetryLockPackage = { name: string; version: string; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts index 9411d1e..2f8c244 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts @@ -30,7 +30,3 @@ export function includeDependencyPackage( } } } - -export function getLoggingTab(level: number): string { - return ' '.repeat(level); -} diff --git a/packages/nx-python/src/provider/utils.ts b/packages/nx-python/src/provider/utils.ts index 4195cce..06a4c20 100644 --- a/packages/nx-python/src/provider/utils.ts +++ b/packages/nx-python/src/provider/utils.ts @@ -25,3 +25,7 @@ export function writePyprojectToml( ) { tree.write(tomlFile, toml.stringify(data)); } + +export function getLoggingTab(level: number): string { + return ' '.repeat(level); +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/index.ts b/packages/nx-python/src/provider/uv/build/resolvers/index.ts new file mode 100644 index 0000000..b4a5a21 --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/index.ts @@ -0,0 +1,2 @@ +export * from './locked'; +export * from './project'; diff --git a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts new file mode 100644 index 0000000..e6b9b1e --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts @@ -0,0 +1,115 @@ +import { join } from 'path'; +import chalk from 'chalk'; +import { Logger } from '../../../../executors/utils/logger'; +import { UVPyprojectToml } from '../../types'; +import { UV_EXECUTABLE } from '../../utils'; +import spawn from 'cross-spawn'; +import { getPyprojectData } from '../../../utils'; +import { PackageDependency } from '../../../base'; +import { includeDependencyPackage } from './utils'; +import { existsSync } from 'fs'; + +export class LockedDependencyResolver { + constructor(private logger: Logger) {} + + public resolve( + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + devDependencies: boolean, + workspaceRoot: string, + ): PackageDependency[] { + const result: PackageDependency[] = []; + this.logger.info(chalk` Resolving dependencies...`); + + const requerimentsTxt = this.getProjectRequirementsTxt( + devDependencies, + projectRoot, + workspaceRoot, + ); + + const requerimentsLines = requerimentsTxt.split('\n'); + for (const line of requerimentsLines) { + if (!line.trim()) { + continue; + } + + if (line.startsWith('-e')) { + const location = line.replace('-e', '').trim(); + const dependencyPyprojectPath = join( + workspaceRoot, + location, + 'pyproject.toml', + ); + + if (!existsSync(dependencyPyprojectPath)) { + this.logger.info( + chalk` • Skipping local dependency {blue.bold ${location}} as pyproject.toml not found`, + ); + continue; + } + + const projectData = getPyprojectData( + dependencyPyprojectPath, + ); + + this.logger.info( + chalk` • Adding {blue.bold ${projectData.project.name}} local dependency`, + ); + + includeDependencyPackage( + projectData, + location, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + + continue; + } + + result.push({ + name: line.trim(), + }); + } + + return result; + } + + private getProjectRequirementsTxt( + devDependencies: boolean, + projectRoot: string, + workspaceRoot: string, + ): string { + const exportArgs = [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + projectRoot, + ]; + + if (!devDependencies) { + exportArgs.push('--no-dev'); + } + + const result = spawn.sync(UV_EXECUTABLE, exportArgs, { + cwd: workspaceRoot, + shell: true, + stdio: 'pipe', + }); + + if (result.status !== 0) { + throw new Error( + chalk`{bold failed to export requirements txt with exit code {bold ${result.status}}`, + ); + } + + return result.stdout.toString('utf-8'); + } +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/project.ts b/packages/nx-python/src/provider/uv/build/resolvers/project.ts new file mode 100644 index 0000000..85aba7c --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/project.ts @@ -0,0 +1,196 @@ +import chalk from 'chalk'; +import { join, normalize } from 'path'; +import { existsSync } from 'fs-extra'; +import { UVLockfile, UVPyprojectToml, UVPyprojectTomlIndex } from '../../types'; +import { Logger } from '../../../../executors/utils/logger'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab, getPyprojectData } from '../../../utils'; +import { getUvLockfile } from '../../utils'; +import { includeDependencyPackage } from './utils'; +import { BuildExecutorSchema } from '../../../..//executors/build/schema'; +import { ExecutorContext } from '@nx/devkit'; +import { createHash } from 'crypto'; + +export class ProjectDependencyResolver { + constructor( + private logger: Logger, + private options: BuildExecutorSchema, + private context: ExecutorContext, + ) {} + + resolve( + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, + ): PackageDependency[] { + this.logger.info(chalk` Resolving dependencies...`); + const pyprojectPath = join(projectRoot, 'pyproject.toml'); + const projectData = getPyprojectData(pyprojectPath); + const rootUvLook = getUvLockfile(join(workspaceRoot, 'uv.lock')); + + return this.resolveDependencies( + projectData, + rootUvLook, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + } + + private resolveDependencies( + pyproject: UVPyprojectToml, + rootUvLook: UVLockfile, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, + deps: PackageDependency[] = [], + depMap: Record = {}, + level = 1, + ) { + const tab = getLoggingTab(level); + + for (const dependency of pyproject.project.dependencies) { + if (pyproject.tool?.uv?.sources[dependency]) { + const dependencyPath = rootUvLook.package[dependency]?.source?.editable; + if (!dependencyPath) { + continue; + } + + const dependencyPyprojectPath = join(dependencyPath, 'pyproject.toml'); + if (!existsSync(dependencyPyprojectPath)) { + this.logger.info( + chalk`${tab}• Skipping local dependency {blue.bold ${dependency}} as pyproject.toml not found`, + ); + continue; + } + + const dependencyPyproject = getPyprojectData( + dependencyPyprojectPath, + ); + + const config = this.getProjectConfig(dependencyPath); + const targetOptions: BuildExecutorSchema | undefined = + config.targets?.build?.options; + + const publisable = targetOptions?.publish ?? true; + + if ( + this.options.bundleLocalDependencies === true || + publisable === false + ) { + this.logger.info( + chalk`${tab}• Adding {blue.bold ${dependency}} local dependency`, + ); + + includeDependencyPackage( + dependencyPyproject, + dependencyPath, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + + this.resolveDependencies( + dependencyPyproject, + rootUvLook, + buildFolderPath, + buildTomlData, + workspaceRoot, + deps, + depMap, + level + 1, + ); + } else { + deps.push({ + name: dependencyPyproject.project.name, + version: dependencyPyproject.project.version, + source: this.addIndex(buildTomlData, targetOptions), + }); + } + continue; + } + + const match = dependency.match(/^[a-zA-Z0-9-]+/); + if (match) { + if (depMap[match[0]]) { + continue; + } + + depMap[match[0]] = dependency; + } + + this.logger.info( + chalk`${tab}• Adding {blue.bold ${dependency}} dependency`, + ); + deps.push({ + name: dependency, + }); + } + + return deps; + } + + private addIndex( + buildTomlData: UVPyprojectToml, + targetOptions: BuildExecutorSchema, + ): string | undefined { + if (!targetOptions?.customSourceUrl) return undefined; + + const [newIndexes, newIndexName] = this.resolveDuplicateIndexes( + buildTomlData.tool.uv.index, + { + name: targetOptions.customSourceName, + url: targetOptions.customSourceUrl, + }, + ); + + buildTomlData.tool.uv.index = newIndexes; + + return newIndexName; + } + + private getProjectConfig(root: string) { + for (const [, config] of Object.entries( + this.context.projectsConfigurations.projects, + )) { + if (normalize(config.root) === normalize(root)) { + return config; + } + } + + throw new Error(`Could not find project config for ${root}`); + } + + private resolveDuplicateIndexes = ( + indexes: UVPyprojectTomlIndex[], + { name, url }: UVPyprojectTomlIndex, + ): [UVPyprojectTomlIndex[], string] => { + if (!indexes) { + return [[{ name, url }], name]; + } + + const existing = indexes.find((s) => s.name === name); + + if (existing) { + if (existing.url === url) { + return [indexes, name]; + } + + const hash = createHash('md5').update(url).digest('hex'); + const newName = `${name}-${hash}`; + + this.logger.info( + chalk` Duplicate index for {blue.bold ${name}} renamed to ${newName}`, + ); + + if (indexes.find((s) => s.name === newName)) { + return [indexes, newName]; + } + + return [[...indexes, { name: newName, url }], newName]; + } + + return [[...indexes, { name, url }], name]; + }; +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/utils.ts b/packages/nx-python/src/provider/uv/build/resolvers/utils.ts new file mode 100644 index 0000000..2542b7f --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/utils.ts @@ -0,0 +1,26 @@ +import { join } from 'path'; +import { UVPyprojectToml } from '../../types'; +import { copySync } from 'fs-extra'; + +export function includeDependencyPackage( + projectData: UVPyprojectToml, + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, +) { + for (const pkg of projectData.tool?.hatch?.build?.targets?.wheel?.packages ?? + []) { + const pkgFolder = join(workspaceRoot, projectRoot, pkg); + copySync(pkgFolder, join(buildFolderPath, pkg)); + + buildTomlData.tool ??= {}; + buildTomlData.tool.hatch ??= {}; + buildTomlData.tool.hatch.build ??= {}; + buildTomlData.tool.hatch.build.targets ??= {}; + buildTomlData.tool.hatch.build.targets.wheel ??= { + packages: [], + }; + buildTomlData.tool.hatch.build.targets.wheel.packages.push(pkg); + } +} diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index 0a3878f..f408f78 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -23,9 +23,9 @@ import { } from '../../executors/build/schema'; import { InstallExecutorSchema } from '../../executors/install/schema'; import { checkUvExecutable, getUvLockfile, runUv } from './utils'; -import path from 'path'; +import path, { join } from 'path'; import chalk from 'chalk'; -import { removeSync, writeFileSync } from 'fs-extra'; +import { copySync, removeSync, writeFileSync } from 'fs-extra'; import { getPyprojectData, readPyprojectToml, @@ -33,7 +33,13 @@ import { } from '../utils'; import { UVLockfile, UVPyprojectToml } from './types'; import toml from '@iarna/toml'; -import fs from 'fs'; +import fs, { mkdirSync, readdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { v4 as uuid } from 'uuid'; +import { + LockedDependencyResolver, + ProjectDependencyResolver, +} from './build/resolvers'; export class UVProvider implements IProvider { protected _rootLockfile: UVLockfile; @@ -155,7 +161,6 @@ export class UVProvider implements IProvider { public getDependents( projectName: string, projects: Record, - cwd: string, ): string[] { const result: string[] = []; @@ -335,7 +340,96 @@ export class UVProvider implements IProvider { options: BuildExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + if ( + options.lockedVersions === true && + options.bundleLocalDependencies === false + ) { + throw new Error( + 'Not supported operations, you cannot use lockedVersions without bundleLocalDependencies', + ); + } + + this.logger.info( + chalk`\n {bold Building project {bgBlue ${context.projectName} }...}\n`, + ); + + const { root } = + context.projectsConfigurations.projects[context.projectName]; + + const buildFolderPath = join(tmpdir(), 'nx-python', 'build', uuid()); + + mkdirSync(buildFolderPath, { recursive: true }); + + this.logger.info(chalk` Copying project files to a temporary folder`); + readdirSync(root).forEach((file) => { + if (!options.ignorePaths.includes(file)) { + const source = join(root, file); + const target = join(buildFolderPath, file); + copySync(source, target); + } + }); + + const buildPyProjectToml = join(buildFolderPath, 'pyproject.toml'); + const buildTomlData = getPyprojectData(buildPyProjectToml); + + const deps = options.lockedVersions + ? new LockedDependencyResolver(this.logger).resolve( + root, + buildFolderPath, + buildTomlData, + options.devDependencies, + context.root, + ) + : new ProjectDependencyResolver(this.logger, options, context).resolve( + root, + buildFolderPath, + buildTomlData, + context.root, + ); + + buildTomlData.project.dependencies = []; + buildTomlData['dependency-groups'] = {}; + + if (buildTomlData.tool?.uv?.sources) { + buildTomlData.tool.uv.sources = {}; + } + + for (const dep of deps) { + if (dep.version) { + buildTomlData.project.dependencies.push(`${dep.name}==${dep.version}`); + } else { + buildTomlData.project.dependencies.push(dep.name); + } + + if (dep.source) { + buildTomlData.tool.uv.sources[dep.name] = { + index: dep.source, + }; + } + } + + writeFileSync(buildPyProjectToml, toml.stringify(buildTomlData)); + const distFolder = join(buildFolderPath, 'dist'); + + removeSync(distFolder); + + this.logger.info(chalk` Generating sdist and wheel artifacts`); + const buildArgs = ['build']; + runUv(buildArgs, { cwd: buildFolderPath }); + + removeSync(options.outputPath); + mkdirSync(options.outputPath, { recursive: true }); + this.logger.info( + chalk` Artifacts generated at {bold ${options.outputPath}} folder`, + ); + copySync(distFolder, options.outputPath); + + if (!options.keepBuildFolder) { + removeSync(buildFolderPath); + } + + return buildFolderPath; } public async run( diff --git a/packages/nx-python/src/provider/uv/types.ts b/packages/nx-python/src/provider/uv/types.ts index 92e44b4..a91e656 100644 --- a/packages/nx-python/src/provider/uv/types.ts +++ b/packages/nx-python/src/provider/uv/types.ts @@ -21,14 +21,24 @@ export type UVPyprojectToml = { sources?: { [key: string]: { workspace?: boolean; + index?: string; }; }; + index?: UVPyprojectTomlIndex[]; + workspace?: { + members: string[]; + }; }; }; }; +export type UVPyprojectTomlIndex = { + name: string; + url: string; +}; + export type UVLockfilePackageLocalSource = { - editable?: boolean; + editable?: string; }; export type UVLockfilePackageDependency = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3215bbb..efcac20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ dependencies: uuid: specifier: ^9.0.1 version: 9.0.1 + wildcard-match: + specifier: ^5.1.3 + version: 5.1.3 devDependencies: '@commitlint/cli': @@ -12909,6 +12912,10 @@ packages: dev: true optional: true + /wildcard-match@5.1.3: + resolution: {integrity: sha512-a95hPUk+BNzSGLntNXYxsjz2Hooi5oL7xOfJR6CKwSsSALh7vUNuTlzsrZowtYy38JNduYFRVhFv19ocqNOZlg==} + dev: false + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} From 31e1096b51f4c63529bf8aa62f1ba2f44c2bc1b5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 11:47:51 -0300 Subject: [PATCH 04/11] test(nx-python): mock checkPoetryExecutable in executor and generator tests --- packages/nx-python/src/executors/sls-deploy/executor.spec.ts | 2 ++ packages/nx-python/src/executors/sls-package/executor.spec.ts | 2 ++ .../src/generators/enable-releases/generator.spec.ts | 3 +++ .../migrations/update-16-1-0/replace-nx-run-commands.spec.ts | 4 +++- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/nx-python/src/executors/sls-deploy/executor.spec.ts b/packages/nx-python/src/executors/sls-deploy/executor.spec.ts index b85803b..52ac58e 100644 --- a/packages/nx-python/src/executors/sls-deploy/executor.spec.ts +++ b/packages/nx-python/src/executors/sls-deploy/executor.spec.ts @@ -47,6 +47,8 @@ describe('Serverless Framework Deploy Executor', () => { .spyOn(poetryUtils, 'activateVenv') .mockReturnValue(undefined); vi.spyOn(process, 'chdir').mockReturnValue(undefined); + + vi.spyOn(poetryUtils, 'checkPoetryExecutable').mockReturnValue(undefined); }); it('should throw an exception when the dist folder is empty', async () => { diff --git a/packages/nx-python/src/executors/sls-package/executor.spec.ts b/packages/nx-python/src/executors/sls-package/executor.spec.ts index 6a967fe..a2bb4e9 100644 --- a/packages/nx-python/src/executors/sls-package/executor.spec.ts +++ b/packages/nx-python/src/executors/sls-package/executor.spec.ts @@ -47,6 +47,8 @@ describe('Serverless Framework Package Executor', () => { .spyOn(poetryUtils, 'activateVenv') .mockReturnValue(undefined); vi.spyOn(process, 'chdir').mockReturnValue(undefined); + + vi.spyOn(poetryUtils, 'checkPoetryExecutable').mockReturnValue(undefined); }); it('should throw an exception when the dist folder is empty', async () => { diff --git a/packages/nx-python/src/generators/enable-releases/generator.spec.ts b/packages/nx-python/src/generators/enable-releases/generator.spec.ts index 80ec675..bd17609 100644 --- a/packages/nx-python/src/generators/enable-releases/generator.spec.ts +++ b/packages/nx-python/src/generators/enable-releases/generator.spec.ts @@ -5,12 +5,15 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import generator from './generator'; import projectGenerator from '../poetry-project/generator'; import spawn from 'cross-spawn'; +import * as poetryUtils from '../../provider/poetry/utils'; describe('nx-python enable-releases', () => { let appTree: Tree; beforeEach(() => { appTree = createTreeWithEmptyWorkspace({}); + + vi.spyOn(poetryUtils, 'checkPoetryExecutable').mockReturnValue(undefined); vi.mocked(spawn.sync).mockImplementation((command) => { if (command === 'python') { return { diff --git a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts index b7d89bc..7234577 100644 --- a/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts +++ b/packages/nx-python/src/migrations/update-16-1-0/replace-nx-run-commands.spec.ts @@ -5,7 +5,7 @@ import { updateProjectConfiguration, } from '@nx/devkit'; import generator from '../../generators/poetry-project/generator'; - +import * as poetryUtils from '../../provider/poetry/utils'; import update from './replace-nx-run-commands'; describe('16-1-0-replace-nx-run-commands migration', () => { @@ -13,6 +13,8 @@ describe('16-1-0-replace-nx-run-commands migration', () => { beforeEach(() => { tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + vi.spyOn(poetryUtils, 'checkPoetryExecutable').mockReturnValue(undefined); }); it('should run successfully', async () => { From 3225f7a00abceef03c8ea1bd0d6bfee0ad27a9ef Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 11:59:18 -0300 Subject: [PATCH 05/11] refactor(nx-python): sonarcloud code smells fixes --- .../generators/release-version/utils/package.ts | 2 +- .../src/provider/poetry/build/resolvers/locked.ts | 6 +----- .../src/provider/poetry/build/resolvers/project.ts | 10 +++------- packages/nx-python/src/provider/poetry/provider.ts | 14 +++++++------- .../src/provider/uv/build/resolvers/locked.ts | 2 +- .../src/provider/uv/build/resolvers/project.ts | 8 ++++---- packages/nx-python/src/provider/uv/provider.ts | 4 ++-- 7 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/nx-python/src/generators/release-version/utils/package.ts b/packages/nx-python/src/generators/release-version/utils/package.ts index 26e95f0..538c212 100644 --- a/packages/nx-python/src/generators/release-version/utils/package.ts +++ b/packages/nx-python/src/generators/release-version/utils/package.ts @@ -7,7 +7,7 @@ export class Package { location: string; constructor( - private provider: IProvider, + private readonly provider: IProvider, workspaceRoot: string, private workspaceRelativeLocation: string, ) { diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts index dc83498..bf11482 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts @@ -13,11 +13,7 @@ import { PackageDependency } from '../../../base'; import { getLoggingTab } from '../../../utils'; export class LockedDependencyResolver { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } + constructor(private readonly logger: Logger) {} public resolve( root: string, diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts index baafc0e..b0f84fa 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts @@ -12,14 +12,10 @@ import { PackageDependency } from '../../../base'; import { getLoggingTab } from '../../../utils'; export class ProjectDependencyResolver { - private logger: Logger; - private options: BuildExecutorSchema; - private context: ExecutorContext; - constructor( - logger: Logger, - options: BuildExecutorSchema, - context: ExecutorContext, + private readonly logger: Logger, + private readonly options: BuildExecutorSchema, + private readonly context: ExecutorContext, ) { this.logger = logger; this.options = options; diff --git a/packages/nx-python/src/provider/poetry/provider.ts b/packages/nx-python/src/provider/poetry/provider.ts index 77258b6..c5bcc99 100644 --- a/packages/nx-python/src/provider/poetry/provider.ts +++ b/packages/nx-python/src/provider/poetry/provider.ts @@ -83,8 +83,8 @@ export class PoetryProvider implements IProvider { : getPyprojectData(pyprojectTomlPath); return { - name: projectData?.tool?.poetry?.name as string, - version: projectData?.tool?.poetry?.version as string, + name: projectData?.tool?.poetry?.name, + version: projectData?.tool?.poetry?.version, }; } @@ -127,8 +127,8 @@ export class PoetryProvider implements IProvider { ); return { - name: dependentPyproject.tool?.poetry?.name as string, - version: dependentPyproject.tool?.poetry?.version as string, + name: dependentPyproject.tool?.poetry?.name, + version: dependentPyproject.tool?.poetry?.version, group: 'main', }; } @@ -152,8 +152,8 @@ export class PoetryProvider implements IProvider { ); return { - name: dependentPyproject.tool?.poetry?.name as string, - version: dependentPyproject.tool?.poetry?.version as string, + name: dependentPyproject.tool?.poetry?.name, + version: dependentPyproject.tool?.poetry?.version, group: key, }; } @@ -582,7 +582,7 @@ export class PoetryProvider implements IProvider { activateVenv(workspaceRoot); await checkPoetryExecutable(); - return await runPoetry(['run', ...args], options); + runPoetry(['run', ...args], options); } public activateVenv(workspaceRoot: string): void { diff --git a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts index e6b9b1e..324939a 100644 --- a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts @@ -10,7 +10,7 @@ import { includeDependencyPackage } from './utils'; import { existsSync } from 'fs'; export class LockedDependencyResolver { - constructor(private logger: Logger) {} + constructor(private readonly logger: Logger) {} public resolve( projectRoot: string, diff --git a/packages/nx-python/src/provider/uv/build/resolvers/project.ts b/packages/nx-python/src/provider/uv/build/resolvers/project.ts index 85aba7c..f9a86ba 100644 --- a/packages/nx-python/src/provider/uv/build/resolvers/project.ts +++ b/packages/nx-python/src/provider/uv/build/resolvers/project.ts @@ -13,9 +13,9 @@ import { createHash } from 'crypto'; export class ProjectDependencyResolver { constructor( - private logger: Logger, - private options: BuildExecutorSchema, - private context: ExecutorContext, + private readonly logger: Logger, + private readonly options: BuildExecutorSchema, + private readonly context: ExecutorContext, ) {} resolve( @@ -111,7 +111,7 @@ export class ProjectDependencyResolver { continue; } - const match = dependency.match(/^[a-zA-Z0-9-]+/); + const match = /^[a-zA-Z0-9-]+/.exec(dependency); if (match) { if (depMap[match[0]]) { continue; diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index f408f78..399236b 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -73,8 +73,8 @@ export class UVProvider implements IProvider { : getPyprojectData(pyprojectTomlPath); return { - name: projectData?.project?.name as string, - version: projectData?.project?.version as string, + name: projectData?.project?.name, + version: projectData?.project?.version, }; } From 09694fd9a259292087fec0b444ab3ac568b0829e Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 12:05:20 -0300 Subject: [PATCH 06/11] refactor(nx-python): improve regex usage and optional chaining in dependency handling --- packages/nx-python/src/generators/uv-project/generator.ts | 2 +- packages/nx-python/src/provider/poetry/utils.ts | 2 +- packages/nx-python/src/provider/uv/provider.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nx-python/src/generators/uv-project/generator.ts b/packages/nx-python/src/generators/uv-project/generator.ts index 17b366a..a9e7e9b 100644 --- a/packages/nx-python/src/generators/uv-project/generator.ts +++ b/packages/nx-python/src/generators/uv-project/generator.ts @@ -224,7 +224,7 @@ function addTestDependencies( ) { const newDependencies = [...dependencies]; const dependencyNames = dependencies - .map((dep) => dep.match(/^[a-zA-Z0-9-]+/)?.[0]) + .map((dep) => /^[a-zA-Z0-9-]+/.exec(dep)?.[0]) .filter((d) => !!d); if ( diff --git a/packages/nx-python/src/provider/poetry/utils.ts b/packages/nx-python/src/provider/poetry/utils.ts index d9edd7c..88d6bfc 100644 --- a/packages/nx-python/src/provider/poetry/utils.ts +++ b/packages/nx-python/src/provider/poetry/utils.ts @@ -29,7 +29,7 @@ export async function getPoetryVersion() { } const versionRegex = /version (\d+\.\d+\.\d+)/; const match = result.stdout.toString().trim().match(versionRegex); - const version = match && match[1]; + const version = match?.[1]; return version; } diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index 399236b..6673d14 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -482,7 +482,7 @@ export class UVProvider implements IProvider { ? packageMetadata?.['requires-dist']?.[dep] : packageMetadata?.['requires-dev']?.[category]?.[dep]; - if (!depMetadata || !depMetadata.editable) { + if (!depMetadata?.editable) { continue; } From 855c62cc0267a38bb379b7cf713df8397237dbbf Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 12:19:08 -0300 Subject: [PATCH 07/11] feat(nx-python): add logging for added dependencies in LockedDependencyResolver --- packages/nx-python/src/provider/uv/build/resolvers/locked.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts index 324939a..68ce676 100644 --- a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts @@ -68,6 +68,10 @@ export class LockedDependencyResolver { continue; } + this.logger.info( + chalk` • Adding {blue.bold ${line.trim()}} dependency`, + ); + result.push({ name: line.trim(), }); From 0a7b0d202a8c038ed15c8659fed81f2f8eb60b0e Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 12:29:27 -0300 Subject: [PATCH 08/11] fix(nx-python): trim whitespace from dependency version in LockedDependencyResolver --- .../nx-python/src/provider/poetry/build/resolvers/locked.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts index bf11482..084c5b1 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts @@ -78,7 +78,7 @@ export class LockedDependencyResolver { } dep.name = elements[0].split('==')[0]; - dep.version = elements[0].split('==')[1]; + dep.version = elements[0].split('==')[1]?.trim(); this.logger.info( chalk`${tab}• Adding {blue.bold ${dep.name}==${dep.version}} dependency`, ); From 986470e7921d72fe049a689cf2aec53fc5e15c89 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 16:16:25 -0300 Subject: [PATCH 09/11] fix(nx-python): update root path for project graph in release version generator --- .../src/generators/release-version/release-version.spec.ts | 1 + .../nx-python/src/generators/release-version/release-version.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nx-python/src/generators/release-version/release-version.spec.ts b/packages/nx-python/src/generators/release-version/release-version.spec.ts index f330158..7e6d8f8 100644 --- a/packages/nx-python/src/generators/release-version/release-version.spec.ts +++ b/packages/nx-python/src/generators/release-version/release-version.spec.ts @@ -2446,6 +2446,7 @@ Valid values are: "auto", "", "~", "^", "="`, describe('uv', () => { beforeEach(() => { tree = createTreeWithEmptyWorkspace(); + tree.root = '.'; projectGraph = createUvWorkspaceWithPackageDependencies(tree, { 'my-lib': { diff --git a/packages/nx-python/src/generators/release-version/release-version.ts b/packages/nx-python/src/generators/release-version/release-version.ts index fed421a..271453b 100644 --- a/packages/nx-python/src/generators/release-version/release-version.ts +++ b/packages/nx-python/src/generators/release-version/release-version.ts @@ -45,7 +45,7 @@ export async function releaseVersionGenerator( options: ReleaseVersionGeneratorSchema, ): Promise { let logger: ProjectLogger | undefined; - const provider = await getProvider(tree.root, undefined, tree); + const provider = await getProvider('.', undefined, tree); const updatedProjects: string[] = []; try { From 34fa9e0b257ab7c7a2aa46beab2a28ee804e8051 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 16:49:45 -0300 Subject: [PATCH 10/11] fix(nx-python): prevent generator from running on Uv projects --- .../src/generators/migrate-to-shared-venv/generator.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts index b5d2f5b..0b69386 100644 --- a/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts +++ b/packages/nx-python/src/generators/migrate-to-shared-venv/generator.ts @@ -110,6 +110,12 @@ function updateRootPoetryLock() { } async function generator(host: Tree, options: Schema) { + if (host.exists('uv.lock')) { + throw new Error( + 'Uv project detected, this generator is only for poetry projects.', + ); + } + await checkPoetryExecutable(); await addFiles(host, options); From fa85f03b9d77e4d7bad8e8d1765eb3b5b25667a8 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 13 Dec 2024 16:50:07 -0300 Subject: [PATCH 11/11] docs(nx-python): add uv docs --- packages/nx-python/README.md | 275 +++++++++++++++++++++++------------ 1 file changed, 178 insertions(+), 97 deletions(-) diff --git a/packages/nx-python/README.md b/packages/nx-python/README.md index 3e74883..49f4cff 100644 --- a/packages/nx-python/README.md +++ b/packages/nx-python/README.md @@ -2,13 +2,36 @@ ![Coverage Badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/lucasvieirasilva/64d6f926915811aa067f60e6a70735c8/raw/coverage-packages-nx-python.json) -`@nxlv/python` plugin is designed to extend the Nx features to work with Python projects based on Poetry. +`@nxlv/python` plugin is designed to extend the Nx features to work with Python projects based on [Uv](https://docs.astral.sh/uv/) or [Poetry](https://python-poetry.org/). -Check this article for more details: +Check this article for a step by step tutorial with Poetry: ## What is @nxlv/python -🔎 An Nx Custom Plugin to generate Python projects using Poetry, Tox and a custom dependency tree plugin +🔎 An Nx Plugin to generate and manage Python projects in the Nx workspace. + +### Main Features + +- Generate Python projects using Uv or Poetry. +- Manage local and external dependencies. +- Build and publish Python packages. +- Bundle local dependencies, like ESBuild for JavaScript. +- Linting and Unit Testing. +- Nx Dependency graph integration. + +### Motivation + +Nx is a powerful tool to manage monorepos, however, it doesn't have built-in support for Python projects, this plugin aims to fill this gap by providing a set of executors to manage Python projects in the Nx workspace, and levearge the Nx features like dependency graph, affected commands, local cache, remote cache, releases, and more to Python projects. + +### Why Bundle Local Dependencies + +When working with monorepos, it's common to have local dependencies, like shared libraries, that are used across multiple projects and don't necessarily need to be published to a package repository, the `@nxlv/python` plugin provides a way to bundle these local dependencies in the same wheel file. + +This approach helps to deploy the projects to Cloud services like AWS Lambda, Google Cloud Functions, Azure Functions, and others, where the deployment package needs to be a single file. + +### News + +- We now support [Uv](https://docs.astral.sh/uv/) package manager. ## Getting Started @@ -42,6 +65,7 @@ for Nx 20.x or higher, use the following pattern: { ... "plugins": [ + ... { "plugin": "@nxlv/python" } @@ -50,6 +74,8 @@ for Nx 20.x or higher, use the following pattern: } ``` +### Poetry + #### Add a new Python Project ```shell @@ -70,7 +96,7 @@ Example 2: `nx generate @nxlv/python:poetry-project myproject --directory=api` w Nx documentation reference: -#### Options +##### Options | Option | Type | Description | Required | Default | | -------------------------------- | :-------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------- | @@ -197,82 +223,49 @@ To add a new dependency to the project use the `nx run {project}:add` command de nx run {project}:add --name {dependencyName} ``` -### Executors - -#### sls-deploy +### Uv -The `@nxlv/python:sls-deploy` executor handles `npx sls deploy` command for serverless framework projects. - -This executor uses the `@nxlv/python:build` artifacts to generate a `requirements.txt` and to be used with `serverless-python-requirements` plugin. - -Serverless YAML example: - -```yaml -service: myservice - -plugins: - - serverless-python-requirements - -custom: - pythonRequirements: - usePoetry: false -``` - -The property `usePoetry` must be `false`, so, the `serverless-python-requirements` uses the `requirements.txt` file generated by this executor, this is required when the project has more than 2 levels of local dependencies. - -Example: - -```text -- root: - - sls-app - - local-lib1 - - local-lib2 +```shell +nx generate @nxlv/python:uv-project myproject ``` -Using the native `serverless-python-requirements` plugin with `poetry` the 2 levels of local dependencies are not supported. +##### Options -`project.json` example: +| Option | Type | Description | Required | Default | +| -------------------------------- | :-------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------- | +| `--directory` | `string` | A directory where the project is placed | `false` | N/A | +| `--tags` | `string` | Add tags to the project | `false` | N/A | +| `--projectType` | `string` | Project type `application` or `library` | `true` | `application` | +| `--packageName` | `string` | Package name | `false` | `name` property (provided in the CLI) | +| `--moduleName` | `string` | Project Source Module | `false` | `name` property using `_` instead of `-` | +| `--description` | `string` | Project description | `false` | N/A | +| `--pyprojectPythonDependency` | `string` | Python version range used in the `pyproject.toml` | `false` | `>=3.9,<3.11` (Poetry syntax) | +| `--pyenvPythonVersion` | `string` | `.python-version` pyenv file content | `false` | `3.9.5` | +| `--publishable` | `boolean` | Specifies if the project is publishable or not | `false` | `true` | +| `--buildLockedVersions` | `boolean` | Use locked versions for build dependencies | `false` | `true` | +| `--buildBundleLocalDependencies` | `boolean` | Bundle local dependencies | `false` | `true` | +| `--linter` | `string` | Linter framework (`flake8`, `ruff` or `none`) | `false` | `ruff` | +| `--unitTestRunner` | `string` | Unit Test Runner (`pytest` or `none`) | `false` | `pytest` | +| `--unitTestHtmlReport` | `boolean` | Enable HTML Pytest Reports | `false` | `true` | +| `--unitTestJUnitReport` | `boolean` | Enable JUnit Pytest Reports | `false` | `true` | +| `--codeCoverage` | `boolean` | Enable Code Coverage Reports | `false` | `true` | +| `--codeCoverageHtmlReport` | `boolean` | Enable Code Coverage HTML Reports | `false` | `true` | +| `--codeCoverageXmlReport` | `boolean` | Enable Code Coverage XML Reports | `false` | `true` | +| `--codeCoverageThreshold` | `number` | Minimum Code Coverage Threshold | `false` | N/A | +| `--projectNameAndRootFormat` | `string` | Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`). | `false` | `as-provided` | -```json -{ - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "projectType": "application", - "sourceRoot": "apps/myapp/lambda_functions", - "targets": { - "deploy": { - "executor": "@nxlv/python:sls-deploy", - "dependsOn": ["build"], - "options": {} - }, - "package": { - "executor": "@nxlv/python:sls-package", - "dependsOn": ["build"], - "options": {} - }, - ... - "build": { - "executor": "@nxlv/python:build", - "outputs": ["apps/myapp/dist"], - "options": { - "outputPath": "apps/myapp/dist", - "publish": false - } - }, - } -} -``` +Because the Uv package manager has native support for workspaces, the `@nxlv/python` plugin enforces the use of a shared virtual environment by default, so, all the projects in the workspace are using the same virtual environment. -##### Options +**IMPORTANT**: The `@nxlv/python:migrate-to-shared-venv` generator is not available for Uv projects, since it's already enforced by default. -| Option | Type | Description | Required | Default | -| ----------- | :-------: | ------------------------------------- | -------- | ------- | -| `--stage` | `string` | Serverless Framework stahe name | `true` | | -| `--verbose` | `boolean` | Serverless Framework CLI verbose flag | `false` | `true` | -| `--force` | `boolean` | Serverless Framework CLI force flag | `false` | `false` | +### Executors #### add -The `@nxlv/python:add` executor handles `poetry add` command to provide a level of abstraction and control in the monorepo projects. +The `@nxlv/python:add` executor handles `add` command to provide a level of abstraction and control in the monorepo projects. + +- `poetry`: `poetry add {args}` +- `uv`: `uv add {args} --project {projectPath}` ##### Features @@ -283,34 +276,42 @@ The `@nxlv/python:add` executor handles `poetry add` command to provide a level ##### Options -| Option | Type | Description | Required | Default | -| --------- | :-------: | ------------------------------------------------------------- | ---------------------------------------------------- | ------- | -| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `true` | | -| `--args` | `string` | Custom args to be used in the `poetry add` command | `false` | | -| `--local` | `boolean` | Specifies if the dependency is local | `false` (only if the `--name` is a local dependency) | | +| Option | Type | Description | Required | Default | +| --------- | :-------: | ------------------------------------------------------------------- | ---------------------------------------------------- | ------- | +| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `true` | | +| `--args` | `string` | Custom args to be used in the `add` command | `false` | | +| `--local` | `boolean` | Specifies if the dependency is local, not necessary for Uv projects | `false` (only if the `--name` is a local dependency) | | #### update -The `@nxlv/python:update` executor handles `poetry update` command to provide a level of abstraction and control in the monorepo projects. +The `@nxlv/python:update` executor handles `update` command to provide a level of abstraction and control in the monorepo projects. + +- `poetry`: `poetry update {args}` +- `uv`: Uv doesn't have a native update command, so, the executor runs the following commands: + - `uv lock --upgrade-package {name} --project {projectPath}` + - `uv sync` ##### Features - Update external dependencies -- Update local dependencies +- Update local dependencies (Poetry only) > Both features updates the local workspace dependency tree to keep the lock/venv updated. ##### Options -| Option | Type | Description | Required | Default | -| --------- | :-------: | ------------------------------------------------------------- | ---------------------------------------------------- | ------- | -| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `false` | | -| `--args` | `string` | Custom args to be used in the `poetry update` command | `false` | | -| `--local` | `boolean` | Specifies if the dependency is local | `false` (only if the `--name` is a local dependency) | | +| Option | Type | Description | Required | Default | +| --------- | :-------: | ------------------------------------------------------------------- | ---------------------------------------------------- | ------- | +| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `false` | | +| `--args` | `string` | Custom args to be used in the `update` command | `false` | | +| `--local` | `boolean` | Specifies if the dependency is local, not necessary for Uv projects | `false` (only if the `--name` is a local dependency) | | #### remove -The `@nxlv/python:remove` executor handles `poetry remove` command to provide a level of abstraction and control in the monorepo projects. +The `@nxlv/python:remove` executor handles `remove` command to provide a level of abstraction and control in the monorepo projects. + +- `poetry`: `poetry remove {args}` +- `uv`: `uv remove {args} --project {projectPath}` ##### Features @@ -321,11 +322,11 @@ The `@nxlv/python:remove` executor handles `poetry remove` command to provide a ##### Options -| Option | Type | Description | Required | Default | -| --------- | :-------: | ------------------------------------------------------------- | ---------------------------------------------------- | ------- | -| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `true` | | -| `--args` | `string` | Custom args to be used in the `poetry remove` command | `false` | | -| `--local` | `boolean` | Specifies if the dependency is local | `false` (only if the `--name` is a local dependency) | | +| Option | Type | Description | Required | Default | +| --------- | :-------: | ------------------------------------------------------------------- | ---------------------------------------------------- | ------- | +| `--name` | `string` | Dependency name (if local dependency use the Nx project name) | `true` | | +| `--args` | `string` | Custom args to be used in the `remove` command | `false` | | +| `--local` | `boolean` | Specifies if the dependency is local, not necessary for Uv projects | `false` (only if the `--name` is a local dependency) | | #### build @@ -346,6 +347,8 @@ The `@nxlv/python:build` command handles the `sdist` and `wheel` build generatio Using the default (`lockedVersions` and `bundleLocalDependencies`) options, the executor uses the locked versions across all the dependencies and bundles the local dependencies in the same wheel file. +###### Poetry Example + `packages/proj1/pyproject.toml` ```toml @@ -429,6 +432,8 @@ Note, that `python-dateutil` is a dependency of `pendulum`, and the `pymonorepo_ Using the `--lockedVersions=false` option, the executor uses the versions from the `pyproject.toml` file across all the dependencies and bundles the local dependencies in the same wheel file. +###### Poetry Example + `packages/proj1/dist/pymonorepo-proj1-1.0.0.tar.gz/pyproject.toml` ```toml @@ -512,11 +517,11 @@ The `@nxlv/python:flake8` handles the `flake8` linting tasks and reporting gener | `--silent` | `boolean` | Hide output text | `false` | `false` | | `--outputFile` | `string` | Output pylint file path | `true` | | -#### install +#### install (poetry only) The `@nxlv/python:install` handles the `poetry install` command for a project. -#### Options +##### Options | Option | Type | Description | Required | Default | | ------------ | :-------: | ---------------------------------------------------- | -------- | ------- | @@ -528,9 +533,12 @@ The `@nxlv/python:install` handles the `poetry install` command for a project. #### publish -The `@nxlv/python:publish` executor handles the `poetry publish` command for a project. +The `@nxlv/python:publish` executor handles the `publish` command for a project. + +- `poetry`: `poetry publish {args}` +- `uv`: `uv publish {args}` -#### Options +##### Options | Option | Type | Description | Required | Default | | --------------- | :-------: | ----------------------------------------------------------------------------------- | -------- | ------- | @@ -539,15 +547,17 @@ The `@nxlv/python:publish` executor handles the `poetry publish` command for a p This executor first executes the `build` target to generate the tar/whl files and uses the `--keepBuildFolder` flag to keep the build folder after the build process. -For must scenarios, running the `poetry publish` with `@nxlv/python:run-commands` executor is enough, -however, when the project has local dependencies and the `--bundleLocalDependencies=false` option is used, the default `poetry publish` command doesn't work properly, because the `poetry publish` command uses the current `pyproject.toml` file, which doesn't have the local dependencies resolved, the `@nxlv/python:publish` executor solves this issue by running the `poetry publish` command inside the temporary build folder generated by the `@nxlv/python:build` executor, so, the `pyproject.toml` file has all the dependencies resolved. +For must scenarios, running the `poetry/uv publish` with `@nxlv/python:run-commands` executor is enough, +however, when the project has local dependencies and the `--bundleLocalDependencies=false` option is used, the default `poetry/uv publish` command doesn't work properly, because the `poetry/uv publish` command uses the current `pyproject.toml` file, which doesn't have the local dependencies resolved, the `@nxlv/python:publish` executor solves this issue by running the `poetry publish` command inside the temporary build folder generated by the `@nxlv/python:build` executor, so, the `pyproject.toml` file has all the dependencies resolved. #### run-commands (same as `nx:run-commands`) -The `@nxlv/python:run-commands` wraps the `nx:run-commands` default Nx executor and if the `autoActivate` option is set to `true` in the root `pyproject.toml` file, it will verify the the virtual environment is not activated, if no, it will activate the virtual environment before running the commands. +The `@nxlv/python:run-commands` wraps the `nx:run-commands` default Nx executor and activates the virtual environment before running the command. -> NOTE: This executor only changes the default `nx:run-commands` if the workspace is configured to use the Shared virtual environment mode and the `autoActivate` option is set to `true` in the root `pyproject.toml` file. -> NOTE: The `autoActivate` option is set to `false` by default. +> POETRY NOTE: This executor only changes the default `nx:run-commands` if the workspace is configured to use the Shared virtual environment mode and the `autoActivate` option is set to `true` in the root `pyproject.toml` file. +> POETRY NOTE: The `autoActivate` option is set to `false` by default. + +**POETRY ONLY:** root `pyproject.toml` @@ -563,6 +573,77 @@ The options and behavior are the same as the `nx:run-commands` executor. [See the Nx documentation for more information](https://nx.dev/packages/nx/executors/run-commands) +#### sls-deploy + +The `@nxlv/python:sls-deploy` executor handles `npx sls deploy` command for serverless framework projects. + +This executor uses the `@nxlv/python:build` artifacts to generate a `requirements.txt` and to be used with `serverless-python-requirements` plugin. + +Serverless YAML example: + +```yaml +service: myservice + +plugins: + - serverless-python-requirements + +custom: + pythonRequirements: + usePoetry: false +``` + +The property `usePoetry` must be `false`, so, the `serverless-python-requirements` uses the `requirements.txt` file generated by this executor, this is required when the project has more than 2 levels of local dependencies. + +Example: + +```text +- root: + - sls-app + - local-lib1 + - local-lib2 +``` + +Using the native `serverless-python-requirements` plugin with `poetry` the 2 levels of local dependencies are not supported. + +`project.json` example: + +```json +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/myapp/lambda_functions", + "targets": { + "deploy": { + "executor": "@nxlv/python:sls-deploy", + "dependsOn": ["build"], + "options": {} + }, + "package": { + "executor": "@nxlv/python:sls-package", + "dependsOn": ["build"], + "options": {} + }, + ... + "build": { + "executor": "@nxlv/python:build", + "outputs": ["apps/myapp/dist"], + "options": { + "outputPath": "apps/myapp/dist", + "publish": false + } + }, + } +} +``` + +##### Options + +| Option | Type | Description | Required | Default | +| ----------- | :-------: | ------------------------------------- | -------- | ------- | +| `--stage` | `string` | Serverless Framework stahe name | `true` | | +| `--verbose` | `boolean` | Serverless Framework CLI verbose flag | `false` | `true` | +| `--force` | `boolean` | Serverless Framework CLI force flag | `false` | `false` | + #### Releases This plugin supports the [Nx releases](https://nx.dev/features/manage-releases) feature.