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'], + ), + }; +}