diff --git a/package.json b/package.json index 37defe7..97d4fae 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "prompts": "^2.4.2", "semver": "^7.5.3", "tslib": "^2.3.0", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "wildcard-match": "^5.1.3" }, "config": { "commitizen": { diff --git a/packages/nx-python/generators.json b/packages/nx-python/generators.json index 8d8c356..4b3de29 100644 --- a/packages/nx-python/generators.json +++ b/packages/nx-python/generators.json @@ -18,6 +18,11 @@ "schema": "./src/generators/poetry-project/schema.json", "description": "Python Poetry Project" }, + "uv-project": { + "factory": "./src/generators/uv-project/generator", + "schema": "./src/generators/uv-project/schema.json", + "description": "Python UV Project" + }, "release-version": { "factory": "./src/generators/release-version/release-version", "schema": "./src/generators/release-version/schema.json", diff --git a/packages/nx-python/package.json b/packages/nx-python/package.json index e3de04c..6322d0b 100644 --- a/packages/nx-python/package.json +++ b/packages/nx-python/package.json @@ -23,7 +23,8 @@ "lodash": "^4.17.21", "@nx/devkit": "^20.0.0", "ora": "5.3.0", - "semver": "^7.5.3" + "semver": "^7.5.3", + "wildcard-match": "^5.1.3" }, "nx-migrations": { "migrations": "./migrations.json" diff --git a/packages/nx-python/src/executors/build/executor.spec.ts b/packages/nx-python/src/executors/build/executor.spec.ts index c05c33b..1d38432 100644 --- a/packages/nx-python/src/executors/build/executor.spec.ts +++ b/packages/nx-python/src/executors/build/executor.spec.ts @@ -17,26 +17,43 @@ import spawn from 'cross-spawn'; import { SpawnSyncOptions } from 'child_process'; import { ExecutorContext } from '@nx/devkit'; import { PoetryPyprojectToml } from '../../provider/poetry'; +import { UVProvider } from '../../provider/uv'; +import { getPyprojectData } from '../../provider/utils'; +import { UVPyprojectToml } from '../../provider/uv/types'; describe('Build Executor', () => { + let buildPath = null; + beforeAll(() => { console.log(chalk`init chalk`); }); + beforeEach(() => { + uuidMock.mockReturnValue('abc'); + buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); + + vi.mocked(spawn.sync).mockReturnValue({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }); + + vi.spyOn(process, 'chdir').mockReturnValue(undefined); + }); + afterEach(() => { vol.reset(); vi.resetAllMocks(); }); describe('poetry', () => { - let buildPath = null; let checkPoetryExecutableMock: MockInstance; let activateVenvMock: MockInstance; beforeEach(() => { - uuidMock.mockReturnValue('abc'); - buildPath = join(tmpdir(), 'nx-python', 'build', 'abc'); - checkPoetryExecutableMock = vi .spyOn(poetryUtils, 'checkPoetryExecutable') .mockResolvedValue(undefined); @@ -44,16 +61,6 @@ describe('Build Executor', () => { activateVenvMock = vi .spyOn(poetryUtils, 'activateVenv') .mockReturnValue(undefined); - - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, - }); - vi.spyOn(process, 'chdir').mockReturnValue(undefined); }); it('should return success false when the poetry is not installed', async () => { @@ -2975,6 +2982,1598 @@ describe('Build Executor', () => { }); }); }); + + describe('uv', () => { + let checkPrerequisites: MockInstance; + + beforeEach(() => { + checkPrerequisites = vi + .spyOn(UVProvider.prototype, 'checkPrerequisites') + .mockResolvedValue(undefined); + + vol.fromJSON({ + 'uv.lock': '', + }); + }); + + it('should return success false when the uv is not installed', async () => { + checkPrerequisites.mockRejectedValue(new Error('uv not found')); + + const options = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + it('should throw an error when the lockedVersion is set to true and bundleLocalDependencies to false', async () => { + const options = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: false, + }; + + const context: ExecutorContext = { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }; + + const output = await executor(options, context); + expect(checkPrerequisites).toHaveBeenCalled(); + expect(spawn.sync).not.toHaveBeenCalled(); + expect(output.success).toBe(false); + }); + + describe('locked resolver', () => { + it('should skip the local project dependency if the pyproject is not found', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + console.log('buildPath', buildPath); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local and dev dependencies', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + ruff>=0.8.2 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: true, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + console.log('buildPath', buildPath); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app1', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django==5.1.4', + 'ruff>=0.8.2', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + + expect(output.success).toBe(true); + }); + + it('should build python project with local dependencies and delete the build folder', async () => { + vol.fromJSON({ + 'apps/app/app1/index.py': 'print("Hello from app")', + + 'apps/app/pyproject.toml': dedent` + [project] + name = "app1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + -e ./libs/dep1 + django==5.1.4 + `), + }) + .mockImplementationOnce((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: false, + devDependencies: false, + lockedVersions: true, + bundleLocalDependencies: true, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(existsSync(buildPath)).not.toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith( + 1, + 'uv', + [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + 'apps/app', + '--no-dev', + ], + { + cwd: '.', + shell: true, + stdio: 'pipe', + }, + ); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + expect(output.success).toBe(true); + }); + }); + + describe('project resolver', () => { + it('should build the project without locked versions and without bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0" + ] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "numpy", specifier = ">=1.21.0" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).not.toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'dep1==1.0.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle the local dependencies', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "numpy", specifier = ">=1.21.0" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'numpy>=1.21.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({}); + }); + + it('should build the project without locked versions and bundle only local dependency and not the second level', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0", + "dep2" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + + [tool.uv.sources] + dep2 = { workspace = true } + `, + + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep2"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "dep2" }, + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "dep2", editable = "libs/dep2" }, + { name = "numpy", specifier = ">=1.21.0" }, + ] + + [[package]] + name = "dep2" + version = "1.0.0" + source = { editable = "libs/dep2" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: false, + }, + }, + }, + }, + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dep1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app', 'dep1']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'numpy>=1.21.0', + 'dep2==1.0.0', + ]); + + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({ + dep2: { index: 'foo' }, + }); + + expect(projectTomlData.tool.uv.index).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/bar', + }, + ]); + }); + + it('should build the project without locked versions and handle duplicate sources', async () => { + vol.fromJSON({ + 'apps/app/app/index.py': 'print("Hello from app")', + 'apps/app/pyproject.toml': dedent` + [project] + name = "app" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "django>=5.1.4", + "dep1", + "dep2", + "dep3", + "dep4", + "dep5", + ] + + [tool.hatch.build.targets.wheel] + packages = ["app"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + + [tool.uv.sources] + dep1 = { workspace = true } + dep2 = { workspace = true } + dep3 = { workspace = true } + dep4 = { workspace = true } + dep5 = { workspace = true } + `, + + 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', + 'libs/dep1/pyproject.toml': dedent` + [project] + name = "dep1" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "numpy>=1.21.0", + "dep2" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + + [tool.uv.sources] + dep2 = { workspace = true } + `, + + 'libs/dep2/dep2/index.py': 'print("Hello from dep2")', + 'libs/dep2/pyproject.toml': dedent` + [project] + name = "dep2" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep2"] + `, + 'libs/dep3/dep3/index.py': 'print("Hello from dep3")', + 'libs/dep3/pyproject.toml': dedent` + [project] + name = "dep3" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep3"] + `, + 'libs/dep4/dep4/index.py': 'print("Hello from dep4")', + 'libs/dep4/pyproject.toml': dedent` + [project] + name = "dep4" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep4"] + `, + 'libs/dep5/dep5/index.py': 'print("Hello from dep5")', + 'libs/dep5/pyproject.toml': dedent` + [project] + name = "dep5" + version = "1.0.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "requests>=2.32.3" + ] + + [tool.hatch.build.targets.wheel] + packages = ["dep5"] + `, + + 'uv.lock': dedent` + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "app" + version = "0.1.0" + source = { editable = "apps/app" } + dependencies = [ + { name = "dep1" }, + { name = "dep2" }, + { name = "dep3" }, + { name = "dep4" }, + { name = "dep5" }, + { name = "django" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "ruff" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dep1", editable = "libs/dep1" }, + { name = "dep2", editable = "libs/dep2" }, + { name = "dep3", editable = "libs/dep3" }, + { name = "dep4", editable = "libs/dep4" }, + { name = "dep5", editable = "libs/dep5" }, + ] + + [package.metadata.requires-dev] + dev = [ + { name = "ruff", specifier = ">=0.8.2" }, + ] + + [[package]] + name = "dep1" + version = "1.0.0" + source = { editable = "libs/dep1" } + dependencies = [ + { name = "dep2" }, + { name = "numpy" } + ] + + [package.metadata] + requires-dist = [ + { name = "dep2", editable = "libs/dep2" }, + { name = "numpy", specifier = ">=1.21.0" }, + ] + + [[package]] + name = "dep2" + version = "1.0.0" + source = { editable = "libs/dep2" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep3" + version = "1.0.0" + source = { editable = "libs/dep3" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep4" + version = "1.0.0" + source = { editable = "libs/dep4" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + + [[package]] + name = "dep5" + version = "1.0.0" + source = { editable = "libs/dep5" } + dependencies = [ + { name = "requests" } + ] + + [package.metadata] + requires-dist = [ + { name = "requests", specifier = ">=2.32.3" }, + ] + `, + }); + + vi.mocked(spawn.sync).mockImplementation((_, args, opts) => { + spawnBuildMockImpl(opts); + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + + const options: BuildExecutorSchema = { + ignorePaths: ['.venv', '.tox', 'tests/'], + silent: false, + outputPath: 'dist/apps/app', + keepBuildFolder: true, + devDependencies: false, + lockedVersions: false, + bundleLocalDependencies: false, + }; + + const output = await executor(options, { + cwd: '', + root: '.', + isVerbose: false, + projectName: 'app', + projectsConfigurations: { + version: 2, + projects: { + app: { + root: 'apps/app', + targets: {}, + }, + dep1: { + root: 'libs/dep1', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/foo', + }, + }, + }, + }, + dep2: { + root: 'libs/dep2', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + dep3: { + root: 'libs/dep3', + targets: { + build: { + options: { + publish: true, + customSourceName: 'foo', + customSourceUrl: 'http://example.com/bar', + }, + }, + }, + }, + dep4: { + root: 'libs/dep4', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, + }, + }, + }, + dep5: { + root: 'libs/dep5', + targets: { + build: { + options: { + publish: true, + customSourceName: 'another', + customSourceUrl: 'http://example.com/another', + }, + }, + }, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(output.success).toBe(true); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledWith('uv', ['build'], { + cwd: buildPath, + shell: false, + stdio: 'inherit', + }); + + const projectTomlData = getPyprojectData( + `${buildPath}/pyproject.toml`, + ); + + expect(projectTomlData.tool.uv.index).toStrictEqual([ + { + name: 'foo', + url: 'http://example.com/foo', + }, + { + name: 'foo-198fb9d8236b3d9116a180365e447b05', + url: 'http://example.com/bar', + }, + { + name: 'another', + url: 'http://example.com/another', + }, + ]); + + expect( + projectTomlData.tool.hatch.build.targets.wheel.packages, + ).toStrictEqual(['app']); + + expect(projectTomlData.project.dependencies).toStrictEqual([ + 'django>=5.1.4', + 'dep1==1.0.0', + 'dep2==1.0.0', + 'dep3==1.0.0', + 'dep4==1.0.0', + 'dep5==1.0.0', + ]); + expect(projectTomlData['dependency-groups']).toStrictEqual({}); + expect(projectTomlData.tool.uv.sources).toStrictEqual({ + dep1: { + index: 'foo', + }, + dep2: { + index: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep3: { + index: 'foo-198fb9d8236b3d9116a180365e447b05', + }, + dep4: { + index: 'another', + }, + dep5: { + index: 'another', + }, + }); + }); + }); + }); }); function spawnBuildMockImpl(opts: SpawnSyncOptions) { diff --git a/packages/nx-python/src/generators/consts.ts b/packages/nx-python/src/generators/consts.ts new file mode 100644 index 0000000..554393b --- /dev/null +++ b/packages/nx-python/src/generators/consts.ts @@ -0,0 +1,9 @@ +export const DEV_DEPENDENCIES_VERSION_MAP = { + autopep8: '2.3.1', + flake8: '7.1.1', + ruff: '0.8.2', + pytest: '8.3.4', + 'pytest-cov': '6.0.0', + 'pytest-sugar': '1.0.0', + 'pytest-html': '4.1.1', +}; diff --git a/packages/nx-python/src/generators/enable-releases/generator.spec.ts b/packages/nx-python/src/generators/enable-releases/generator.spec.ts index 71ba835..80ec675 100644 --- a/packages/nx-python/src/generators/enable-releases/generator.spec.ts +++ b/packages/nx-python/src/generators/enable-releases/generator.spec.ts @@ -11,13 +11,26 @@ describe('nx-python enable-releases', () => { beforeEach(() => { appTree = createTreeWithEmptyWorkspace({}); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; }); }); diff --git a/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap b/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap index cf39e22..24125b8 100644 --- a/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap +++ b/packages/nx-python/src/generators/poetry-project/__snapshots__/generator.spec.ts.snap @@ -79,7 +79,7 @@ readme = 'README.md' include = "my_app_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -98,7 +98,7 @@ def hello(): `; exports[`application generator > as-provided > should run successfully minimal configuration 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -181,7 +181,7 @@ readme = 'README.md' include = "my_app_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -200,7 +200,7 @@ def hello(): `; exports[`application generator > as-provided > should run successfully minimal configuration without directory 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -273,7 +273,7 @@ description = "Automatically generated by Nx." include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" autopep8 = "^1.5.7" [build-system] @@ -367,7 +367,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -386,7 +386,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -469,7 +469,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -488,7 +488,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration as a library 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -571,7 +571,7 @@ readme = 'README.md' include = "subdir_test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -590,7 +590,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration custom directory 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -676,7 +676,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -695,7 +695,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully minimal configuration with tags 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -787,11 +787,11 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" [build-system] requires = ["poetry-core"] @@ -810,7 +810,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -939,15 +939,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -966,7 +966,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html coverage report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1107,15 +1107,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1134,7 +1134,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1275,15 +1275,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1302,7 +1302,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1443,15 +1443,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1470,7 +1470,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1611,15 +1611,15 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = ["poetry-core"] @@ -1638,7 +1638,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1768,13 +1768,13 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - flake8 = "6.0.0" - pytest = "7.3.1" - pytest-sugar = "0.9.7" + autopep8 = "2.3.1" + flake8 = "7.1.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" [build-system] requires = ["poetry-core"] @@ -1793,7 +1793,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with flake8 linter and pytest with no reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -1934,7 +1934,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -1957,7 +1957,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (flake8) and testing options with a dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2008,13 +2008,13 @@ readme = "README.md" include = "shared_dev_lib" [tool.poetry.dependencies] - python = ">=3.9,<3.11" - flake8 = "6.0.0" - autopep8 = "2.0.2" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + python = ">=3.9,<4" + flake8 = "7.1.1" + autopep8 = "2.3.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = [ "poetry-core" ] @@ -2033,7 +2033,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (flake8) and testing options with a dev dependency project 11`] = ` -"3.9.5 +"3.9.7 " `; @@ -2148,7 +2148,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2171,7 +2171,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (ruff) and testing options with a dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2207,13 +2207,13 @@ readme = "README.md" include = "shared_dev_lib" [tool.poetry.dependencies] - python = ">=3.9,<3.11" - ruff = "0.1.5" - autopep8 = "2.0.2" - pytest = "7.3.1" - pytest-sugar = "0.9.7" - pytest-cov = "4.1.0" - pytest-html = "3.2.0" + python = ">=3.9,<4" + ruff = "0.8.2" + autopep8 = "2.3.1" + pytest = "8.3.4" + pytest-sugar = "1.0.0" + pytest-cov = "6.0.0" + pytest-html = "4.1.1" [build-system] requires = [ "poetry-core" ] @@ -2232,7 +2232,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting (ruff) and testing options with a dev dependency project 10`] = ` -"3.9.5 +"3.9.7 " `; @@ -2260,7 +2260,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.custom-shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2382,7 +2382,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies.shared-dev-lib] path = "../../libs/shared/dev-lib" @@ -2405,7 +2405,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting and testing options with an existing dev dependency project 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2482,7 +2482,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with linting and testing options with an existing dev dependency project 11`] = ` -"3.9.5 +"3.9.7 " `; @@ -2574,7 +2574,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -2625,7 +2625,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with ruff linter 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2729,13 +2729,13 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" - ruff = "0.1.5" - pytest = "7.3.1" - pytest-sugar = "0.9.7" + autopep8 = "2.3.1" + ruff = "0.8.2" + pytest = "8.3.4" + pytest-sugar = "1.0.0" [build-system] requires = ["poetry-core"] @@ -2786,7 +2786,7 @@ def hello(): `; exports[`application generator > individual package > should run successfully with ruff linter and pytest with no reports 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2881,7 +2881,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -2900,7 +2900,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -2916,7 +2916,7 @@ name = "workspace" develop = true [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [build-system] requires = [ "poetry-core" ] @@ -3003,7 +3003,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3022,7 +3022,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options with custom rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3034,7 +3034,7 @@ name = "workspace" python = ">=3.9,<3.11" [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [tool.poetry.group.dev.dependencies.test] path = "apps/test" @@ -3125,7 +3125,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3144,7 +3144,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3157,7 +3157,7 @@ name = "workspace" [tool.poetry.group.dev.dependencies] flake8 = "6.0.0" -autopep8 = "2.0.2" +autopep8 = "2.3.1" [tool.poetry.group.dev.dependencies.test] path = "apps/test" @@ -3248,7 +3248,7 @@ readme = 'README.md' include = "test" [tool.poetry.dependencies] - python = ">=3.9,<3.11" + python = ">=3.9,<4" [build-system] requires = ["poetry-core"] @@ -3267,7 +3267,7 @@ def hello(): `; exports[`application generator > shared virtual environment > should run successfully with minimal options without rootPyprojectDependencyGroup 5`] = ` -"3.9.5 +"3.9.7 " `; @@ -3283,7 +3283,7 @@ name = "workspace" develop = true [tool.poetry.group.dev.dependencies] -autopep8 = "2.0.2" +autopep8 = "2.3.1" [build-system] requires = [ "poetry-core" ] diff --git a/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml b/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml index bd1d5c4..3e4b49b 100644 --- a/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml +++ b/packages/nx-python/src/generators/poetry-project/files/base/pyproject.toml @@ -29,22 +29,22 @@ readme = 'README.md' <%if (((individualPackage && !devDependenciesProject) && linter === 'flake8') || ((individualPackage && !devDependenciesProject) && unitTestRunner === 'pytest')) { -%> [tool.poetry.group.dev.dependencies] - autopep8 = "2.0.2" + autopep8 = "<%- versionMap['autopep8'] %>" <%if (individualPackage && !devDependenciesProject && linter === 'flake8') { -%> - flake8 = "6.0.0" + flake8 = "<%- versionMap['flake8'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && linter === 'ruff') { -%> - ruff = "0.1.5" + ruff = "<%- versionMap['ruff'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest') { -%> - pytest = "7.3.1" - pytest-sugar = "0.9.7" + pytest = "<%- versionMap['pytest'] %>" + pytest-sugar = "<%- versionMap['pytest-sugar'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest' && codeCoverage) { -%> - pytest-cov = "4.1.0" + pytest-cov = "<%- versionMap['pytest-cov'] %>" <% } -%> <%if (individualPackage && !devDependenciesProject && unitTestRunner === 'pytest' && codeCoverage && codeCoverageHtmlReport) { -%> - pytest-html = "3.2.0" + pytest-html = "<%- versionMap['pytest-html'] %>" <% } -%> <% } -%> diff --git a/packages/nx-python/src/generators/poetry-project/generator.spec.ts b/packages/nx-python/src/generators/poetry-project/generator.spec.ts index a75f17e..d45bdbb 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.spec.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.spec.ts @@ -5,17 +5,17 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { Tree, readProjectConfiguration } from '@nx/devkit'; import generator from './generator'; -import { PoetryProjectGeneratorSchema } from './schema'; import dedent from 'string-dedent'; import { parse, stringify } from '@iarna/toml'; import path from 'path'; import spawn from 'cross-spawn'; import { PoetryPyprojectToml } from '../../provider/poetry'; +import { BasePythonProjectGeneratorSchema } from '../types'; describe('application generator', () => { let checkPoetryExecutableMock: MockInstance; let appTree: Tree; - const options: PoetryProjectGeneratorSchema = { + const options: BasePythonProjectGeneratorSchema = { name: 'test', projectType: 'application', pyprojectPythonDependency: '', @@ -40,13 +40,26 @@ describe('application generator', () => { appTree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); checkPoetryExecutableMock = vi.spyOn(poetryUtils, 'checkPoetryExecutable'); checkPoetryExecutableMock.mockResolvedValue(undefined); - vi.mocked(spawn.sync).mockReturnValue({ - status: 0, - output: [''], - pid: 0, - signal: null, - stderr: null, - stdout: null, + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; }); }); @@ -490,9 +503,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -500,7 +516,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -542,9 +558,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -552,7 +571,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -594,9 +613,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -604,7 +626,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); @@ -649,9 +671,12 @@ describe('application generator', () => { expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); - expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); expect(spawn.sync).toHaveBeenNthCalledWith( - 1, + 2, 'poetry', ['lock', '--no-update'], { @@ -659,7 +684,7 @@ describe('application generator', () => { stdio: 'inherit', }, ); - expect(spawn.sync).toHaveBeenNthCalledWith(2, 'poetry', ['install'], { + expect(spawn.sync).toHaveBeenNthCalledWith(3, 'poetry', ['install'], { shell: false, stdio: 'inherit', }); diff --git a/packages/nx-python/src/generators/poetry-project/generator.ts b/packages/nx-python/src/generators/poetry-project/generator.ts index f661db2..23e2fc4 100644 --- a/packages/nx-python/src/generators/poetry-project/generator.ts +++ b/packages/nx-python/src/generators/poetry-project/generator.ts @@ -2,7 +2,6 @@ import { addProjectConfiguration, formatFiles, generateFiles, - getWorkspaceLayout, names, offsetFromRoot, ProjectConfiguration, @@ -10,7 +9,6 @@ import { Tree, } from '@nx/devkit'; import * as path from 'path'; -import { PoetryProjectGeneratorSchema } from './schema'; import { parse, stringify } from '@iarna/toml'; import chalk from 'chalk'; import _ from 'lodash'; @@ -19,157 +17,56 @@ import { PoetryPyprojectTomlDependencies, } from '../../provider/poetry'; import { checkPoetryExecutable, runPoetry } from '../../provider/poetry/utils'; +import { + BaseNormalizedSchema, + BasePythonProjectGeneratorSchema, +} from '../types'; +import { + normalizeOptions as baseNormalizeOptions, + getPyprojectTomlByProjectName, +} from '../utils'; +import { DEV_DEPENDENCIES_VERSION_MAP } from '../consts'; -interface NormalizedSchema extends PoetryProjectGeneratorSchema { - projectName: string; - projectRoot: string; - individualPackage: boolean; +interface NormalizedSchema extends BaseNormalizedSchema { devDependenciesProjectPath?: string; devDependenciesProjectPkgName?: string; - pythonAddopts?: string; - parsedTags: string[]; + individualPackage: boolean; } function normalizeOptions( tree: Tree, - options: PoetryProjectGeneratorSchema, + options: BasePythonProjectGeneratorSchema, ): NormalizedSchema { - const { projectName, projectRoot } = calculateProjectNameAndRoot( - options, - tree, - ); - - const parsedTags = options.tags - ? options.tags.split(',').map((s) => s.trim()) - : []; - - const newOptions = _.clone(options) as NormalizedSchema; - - if (!options.pyprojectPythonDependency) { - newOptions.pyprojectPythonDependency = '>=3.9,<3.11'; - } - - if (!options.pyenvPythonVersion) { - newOptions.pyenvPythonVersion = '3.9.5'; - } - - if (!options.moduleName) { - newOptions.moduleName = projectName.replace(/-/g, '_'); - } - - if (!options.packageName) { - newOptions.packageName = projectName; - } + const newOptions = baseNormalizeOptions(tree, options); - if (!options.description) { - newOptions.description = 'Automatically generated by Nx.'; - } + let devDependenciesProjectPkgName: string | undefined; + let devDependenciesProjectPath: string | undefined; if (options.devDependenciesProject) { const projectConfig = readProjectConfiguration( tree, options.devDependenciesProject, ); - newOptions.devDependenciesProjectPath = path.relative( - projectRoot, + const { pyprojectToml } = + getPyprojectTomlByProjectName( + tree, + options.devDependenciesProject, + ); + devDependenciesProjectPkgName = pyprojectToml.tool.poetry.name; + devDependenciesProjectPath = path.relative( + newOptions.projectRoot, projectConfig.root, ); } - const pythonAddopts = getPyTestAddopts(options, projectRoot); - - if (options.unitTestRunner === 'none') { - newOptions.unitTestHtmlReport = false; - newOptions.unitTestJUnitReport = false; - newOptions.codeCoverage = false; - newOptions.codeCoverageHtmlReport = false; - newOptions.codeCoverageXmlReport = false; - newOptions.codeCoverageThreshold = undefined; - } - - let devDependenciesProjectPkgName: string | undefined; - if (options.devDependenciesProject) { - const { pyprojectToml } = getPyprojectTomlByProjectName( - tree, - options.devDependenciesProject, - ); - devDependenciesProjectPkgName = pyprojectToml.tool.poetry.name; - } - return { - ...options, ...newOptions, - devDependenciesProject: options.devDependenciesProject || '', - individualPackage: !tree.exists('pyproject.toml'), + devDependenciesProject: options.devDependenciesProject ?? '', + devDependenciesProjectPath, devDependenciesProjectPkgName, - pythonAddopts, - projectName, - projectRoot, - parsedTags, + individualPackage: !tree.exists('pyproject.toml'), }; } -function calculateProjectNameAndRoot( - options: PoetryProjectGeneratorSchema, - tree: Tree, -) { - let projectName = options.name; - let projectRoot = options.directory || options.name; - - if (options.projectNameAndRootFormat === 'derived') { - const name = names(options.name).fileName; - const projectDirectory = options.directory - ? `${names(options.directory).fileName}/${name}` - : name; - projectName = projectDirectory.replace(/\//g, '-'); - projectRoot = `${ - options.projectType === 'application' - ? getWorkspaceLayout(tree).appsDir - : getWorkspaceLayout(tree).libsDir - }/${projectDirectory}`; - } - - return { projectName, projectRoot }; -} - -function getPyTestAddopts( - options: PoetryProjectGeneratorSchema, - projectRoot: string, -): string | undefined { - if (options.unitTestRunner === 'pytest') { - const args = []; - const offset = offsetFromRoot(projectRoot); - if (options.codeCoverage) { - args.push('--cov'); - } - if (options.codeCoverageThreshold) { - args.push(`--cov-fail-under=${options.codeCoverageThreshold}`); - } - if (options.codeCoverage && options.codeCoverageHtmlReport) { - args.push(`--cov-report html:'${offset}coverage/${projectRoot}/html'`); - } - - if (options.codeCoverage && options.codeCoverageXmlReport) { - args.push( - `--cov-report xml:'${offset}coverage/${projectRoot}/coverage.xml'`, - ); - } - - if (options.unitTestHtmlReport) { - args.push( - `--html='${offset}reports/${projectRoot}/unittests/html/index.html'`, - ); - } - - if (options.unitTestJUnitReport) { - args.push( - `--junitxml='${offset}reports/${projectRoot}/unittests/junit.xml'`, - ); - } - - return args.join(' '); - } -} - function addFiles(tree: Tree, options: NormalizedSchema) { const templateOptions = { ...options, @@ -177,6 +74,7 @@ function addFiles(tree: Tree, options: NormalizedSchema) { offsetFromRoot: offsetFromRoot(options.projectRoot), template: '', dot: '.', + versionMap: DEV_DEPENDENCIES_VERSION_MAP, }; if (options.templateDir) { generateFiles( @@ -274,10 +172,11 @@ function updateDevDependenciesProject( normalizedOptions: NormalizedSchema, ) { if (normalizedOptions.devDependenciesProject) { - const { pyprojectToml, pyprojectTomlPath } = getPyprojectTomlByProjectName( - host, - normalizedOptions.devDependenciesProject, - ); + const { pyprojectToml, pyprojectTomlPath } = + getPyprojectTomlByProjectName( + host, + normalizedOptions.devDependenciesProject, + ); const { changed, dependencies } = addTestDependencies( pyprojectToml.tool.poetry.dependencies, @@ -295,17 +194,6 @@ function updateDevDependenciesProject( } } -function getPyprojectTomlByProjectName(host: Tree, projectName: string) { - const projectConfig = readProjectConfiguration(host, projectName); - const pyprojectTomlPath = path.join(projectConfig.root, 'pyproject.toml'); - - const pyprojectToml = parse( - host.read(pyprojectTomlPath, 'utf-8'), - ) as PoetryPyprojectToml; - - return { pyprojectToml, pyprojectTomlPath }; -} - function addTestDependencies( dependencies: PoetryPyprojectTomlDependencies, normalizedOptions: NormalizedSchema, @@ -313,28 +201,28 @@ function addTestDependencies( const originalDependencies = _.clone(dependencies); if (normalizedOptions.linter === 'flake8' && !dependencies['flake8']) { - dependencies['flake8'] = '6.0.0'; + dependencies['flake8'] = DEV_DEPENDENCIES_VERSION_MAP.flake8; } if (normalizedOptions.linter === 'ruff' && !dependencies['ruff']) { - dependencies['ruff'] = '0.1.5'; + dependencies['ruff'] = DEV_DEPENDENCIES_VERSION_MAP.ruff; } if (!dependencies['autopep8']) { - dependencies['autopep8'] = '2.0.2'; + dependencies['autopep8'] = DEV_DEPENDENCIES_VERSION_MAP.autopep8; } if ( normalizedOptions.unitTestRunner === 'pytest' && !dependencies['pytest'] ) { - dependencies['pytest'] = '7.3.1'; + dependencies['pytest'] = DEV_DEPENDENCIES_VERSION_MAP.pytest; } if ( normalizedOptions.unitTestRunner === 'pytest' && !dependencies['pytest-sugar'] ) { - dependencies['pytest-sugar'] = '0.9.7'; + dependencies['pytest-sugar'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-sugar']; } if ( @@ -342,7 +230,7 @@ function addTestDependencies( normalizedOptions.codeCoverage && !dependencies['pytest-cov'] ) { - dependencies['pytest-cov'] = '4.1.0'; + dependencies['pytest-cov'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-cov']; } if ( @@ -350,7 +238,7 @@ function addTestDependencies( normalizedOptions.codeCoverageHtmlReport && !dependencies['pytest-html'] ) { - dependencies['pytest-html'] = '3.2.0'; + dependencies['pytest-html'] = DEV_DEPENDENCIES_VERSION_MAP['pytest-html']; } return { @@ -370,7 +258,7 @@ function updateRootPoetryLock(host: Tree) { export default async function ( tree: Tree, - options: PoetryProjectGeneratorSchema, + options: BasePythonProjectGeneratorSchema, ) { await checkPoetryExecutable(); diff --git a/packages/nx-python/src/generators/poetry-project/schema.d.ts b/packages/nx-python/src/generators/types.ts similarity index 68% rename from packages/nx-python/src/generators/poetry-project/schema.d.ts rename to packages/nx-python/src/generators/types.ts index 8d551d3..46d4f19 100644 --- a/packages/nx-python/src/generators/poetry-project/schema.d.ts +++ b/packages/nx-python/src/generators/types.ts @@ -1,26 +1,37 @@ -export interface PoetryProjectGeneratorSchema { +export interface PytestGeneratorSchema { + unitTestRunner: 'pytest' | 'none'; + codeCoverage: boolean; + codeCoverageHtmlReport: boolean; + codeCoverageXmlReport: boolean; + codeCoverageThreshold?: number; + unitTestHtmlReport: boolean; + unitTestJUnitReport: boolean; +} + +export interface BasePythonProjectGeneratorSchema + extends PytestGeneratorSchema { name: string; - projectType: 'application' | 'library'; - packageName?: string; - moduleName?: string; - description?: string; - pyprojectPythonDependency: string; - pyenvPythonVersion: string; publishable: boolean; buildLockedVersions: boolean; buildBundleLocalDependencies: boolean; linter: 'flake8' | 'ruff' | 'none'; - unitTestRunner: 'pytest' | 'none'; devDependenciesProject?: string; rootPyprojectDependencyGroup: string; - unitTestHtmlReport: boolean; - unitTestJUnitReport: boolean; - codeCoverage: boolean; - codeCoverageHtmlReport: boolean; - codeCoverageXmlReport: boolean; - codeCoverageThreshold?: number; - tags?: string; - directory?: string; templateDir?: string; + pyprojectPythonDependency: string; + projectType: 'application' | 'library'; projectNameAndRootFormat: 'as-provided' | 'derived'; + packageName?: string; + description?: string; + moduleName?: string; + pyenvPythonVersion?: string; + tags?: string; + directory?: string; +} + +export interface BaseNormalizedSchema extends BasePythonProjectGeneratorSchema { + projectName: string; + projectRoot: string; + pythonAddopts?: string; + parsedTags: string[]; } diff --git a/packages/nx-python/src/generators/utils.ts b/packages/nx-python/src/generators/utils.ts new file mode 100644 index 0000000..d293913 --- /dev/null +++ b/packages/nx-python/src/generators/utils.ts @@ -0,0 +1,154 @@ +import { + getWorkspaceLayout, + names, + offsetFromRoot, + readProjectConfiguration, + Tree, +} from '@nx/devkit'; +import { + BasePythonProjectGeneratorSchema, + PytestGeneratorSchema, + BaseNormalizedSchema, +} from './types'; +import spawn from 'cross-spawn'; +import _ from 'lodash'; +import path from 'path'; +import { parse } from '@iarna/toml'; + +export function getPyTestAddopts( + options: PytestGeneratorSchema, + projectRoot: string, +): string | undefined { + if (options.unitTestRunner === 'pytest') { + const args = []; + const offset = offsetFromRoot(projectRoot); + if (options.codeCoverage) { + args.push('--cov'); + } + if (options.codeCoverageThreshold) { + args.push(`--cov-fail-under=${options.codeCoverageThreshold}`); + } + if (options.codeCoverage && options.codeCoverageHtmlReport) { + args.push(`--cov-report html:'${offset}coverage/${projectRoot}/html'`); + } + + if (options.codeCoverage && options.codeCoverageXmlReport) { + args.push( + `--cov-report xml:'${offset}coverage/${projectRoot}/coverage.xml'`, + ); + } + + if (options.unitTestHtmlReport) { + args.push( + `--html='${offset}reports/${projectRoot}/unittests/html/index.html'`, + ); + } + + if (options.unitTestJUnitReport) { + args.push( + `--junitxml='${offset}reports/${projectRoot}/unittests/junit.xml'`, + ); + } + + return args.join(' '); + } +} + +export function calculateProjectNameAndRoot( + options: BasePythonProjectGeneratorSchema, + tree: Tree, +) { + let projectName = options.name; + let projectRoot = options.directory || options.name; + + if (options.projectNameAndRootFormat === 'derived') { + const name = names(options.name).fileName; + const projectDirectory = options.directory + ? `${names(options.directory).fileName}/${name}` + : name; + projectName = projectDirectory.replace(/\//g, '-'); + projectRoot = `${ + options.projectType === 'application' + ? getWorkspaceLayout(tree).appsDir + : getWorkspaceLayout(tree).libsDir + }/${projectDirectory}`; + } + + return { projectName, projectRoot }; +} + +export function normalizeOptions( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +): BaseNormalizedSchema { + const { projectName, projectRoot } = calculateProjectNameAndRoot( + options, + tree, + ); + + const parsedTags = options.tags + ? options.tags.split(',').map((s) => s.trim()) + : []; + + const newOptions = _.clone(options) as BaseNormalizedSchema; + + if (!options.pyprojectPythonDependency) { + newOptions.pyprojectPythonDependency = '>=3.9,<4'; + } + + if (!options.pyenvPythonVersion) { + const result = spawn.sync('python', ['--version'], { + stdio: 'pipe', + }); + + newOptions.pyenvPythonVersion = + result.status === 0 + ? result.stdout.toString('utf-8').replace('Python ', '').trim() + : '3.9.5'; + } + + if (!options.moduleName) { + newOptions.moduleName = projectName.replace(/-/g, '_'); + } + + if (!options.packageName) { + newOptions.packageName = projectName; + } + + if (!options.description) { + newOptions.description = 'Automatically generated by Nx.'; + } + + const pythonAddopts = getPyTestAddopts(options, projectRoot); + + if (options.unitTestRunner === 'none') { + newOptions.unitTestHtmlReport = false; + newOptions.unitTestJUnitReport = false; + newOptions.codeCoverage = false; + newOptions.codeCoverageHtmlReport = false; + newOptions.codeCoverageXmlReport = false; + newOptions.codeCoverageThreshold = undefined; + } + + return { + ...options, + ...newOptions, + devDependenciesProject: options.devDependenciesProject || '', + pythonAddopts, + projectName, + projectRoot, + parsedTags, + }; +} + +export function getPyprojectTomlByProjectName( + tree: Tree, + projectName: string, +) { + const projectConfig = readProjectConfiguration(tree, projectName); + const pyprojectTomlPath = path.join(projectConfig.root, 'pyproject.toml'); + + const pyprojectToml = parse(tree.read(pyprojectTomlPath, 'utf-8')) as T; + + return { pyprojectToml, pyprojectTomlPath }; +} diff --git a/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap b/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap new file mode 100644 index 0000000..ade2e1b --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/__snapshots__/generator.spec.ts.snap @@ -0,0 +1,3184 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`application generator > as-provided > should run successfully minimal configuration 1`] = ` +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "my-app-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "src/app/test", + "sourceRoot": "src/app/test/my_app_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "src/app/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "src/app/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 2`] = ` +"# my-app-test + +Project description here. +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 3`] = ` +"[project] +name = "my-app-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["my_app_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello my-app-test" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 1`] = ` +{ + "$schema": "../node_modules/nx/schemas/project-schema.json", + "name": "my-app-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "my-app-test", + "sourceRoot": "my-app-test/my_app_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "my-app-test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "my-app-test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 2`] = ` +"# my-app-test + +Project description here. +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 3`] = ` +"[project] +name = "my-app-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["my_app_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello my-app-test" +" +`; + +exports[`application generator > as-provided > should run successfully minimal configuration without directory 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 2`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "autopep8==1.5.7", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > custom template dir > should run successfully with custom template dir 3`] = `null`; + +exports[`application generator > should run successfully minimal configuration as a library 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "library", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "libs/test", + "sourceRoot": "libs/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "libs/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "libs/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration as a library 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration as a library 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*" ] +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 1`] = ` +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "subdir-test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/subdir/test", + "sourceRoot": "apps/subdir/test/subdir_test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/subdir/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/subdir/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration custom directory 2`] = ` +"# subdir-test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 3`] = ` +"[project] +name = "subdir-test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["subdir_test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello subdir-test" +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration custom directory 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "subdir-test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.subdir-test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [ + "one", + "two", + ], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully minimal configuration with tags 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully minimal configuration with tags 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter 7`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "flake8>=7.1.1", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report html:'../../coverage/apps/test/html'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html coverage report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with flake8 linter and pytest with no reports 8`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 8`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 9`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "flake8>=7.1.1", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 10`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 11`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (flake8) and testing options with a dev dependency project 12`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + "tests", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 6`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 7`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 8`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "ruff>=0.8.2", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0", + "pytest-cov>=6.0.0", + "pytest-html>=4.1.1" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 9`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 10`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting (ruff) and testing options with a dev dependency project 11`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting and testing options with a dev dependency project with custom package name 1`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "custom-shared-dev-lib" +] + +[tool.uv.sources] +custom-shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with a dev dependency project with custom package name 2`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "custom-shared-dev-lib", "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.custom-shared-dev-lib] +workspace = true + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "libs/*", "apps/*" ] +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:flake8", + "options": { + "outputFile": "reports/apps/test/pylint.txt", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/pylint.txt", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 3`] = ` +"[tool.coverage.run] +branch = true +source = [ "test" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-fail-under=100 --cov-report html:'../../coverage/apps/test/html' --cov-report xml:'../../coverage/apps/test/coverage.xml' --html='../../reports/apps/test/unittests/html/index.html' --junitxml='../../reports/apps/test/unittests/junit.xml'" + +[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[dependency-groups] +dev = [ + "shared-dev-lib" +] + +[tool.uv.sources] +shared-dev-lib = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 6`] = ` +"[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 7`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 8`] = ` +"# shared-dev-lib + +Project description here. +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 9`] = ` +"[project] +name = "shared-dev-lib" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = "README.md" +dependencies = [ + "autopep8>=1.0.0", + "pytest>=1.0.0", + "pytest-sugar=>1.0.0", + "pytest-cov=>1.0.0", + "pytest-html=>1.0.0", + "flake8=>1.0.0", + "flake8-isort=>1.0.0" +] + +[tool.hatch.build.targets.wheel] +packages = [ "shared_dev_lib" ] + +[build-system] +requires = [ "hatchling" ] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 10`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello shared-dev-lib" +" +`; + +exports[`application generator > should run successfully with linting and testing options with an existing dev dependency project 11`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options with custom rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ ] + +[dependency-groups] +dev = [ "test", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options with existing custom rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ ] + +[dependency-groups] +dev = [ "requests>=2.3.1", "test", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with minimal options without rootPyprojectDependencyGroup 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with ruff linter 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with ruff linter 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with ruff linter 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with ruff linter 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with ruff linter 6`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ "ruff>=0.8.2", "autopep8>=2.3.1" ] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 1`] = ` +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "test", + "projectType": "application", + "release": { + "version": { + "generator": "@nxlv/python:release-version", + }, + }, + "root": "apps/test", + "sourceRoot": "apps/test/test", + "tags": [], + "targets": { + "add": { + "executor": "@nxlv/python:add", + "options": {}, + }, + "build": { + "executor": "@nxlv/python:build", + "options": { + "bundleLocalDependencies": false, + "lockedVersions": false, + "outputPath": "apps/test/dist", + "publish": false, + }, + "outputs": [ + "{projectRoot}/dist", + ], + }, + "lint": { + "executor": "@nxlv/python:ruff-check", + "options": { + "lintFilePatterns": [ + "test", + "tests", + ], + }, + "outputs": [], + }, + "lock": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv lock", + "cwd": "apps/test", + }, + }, + "remove": { + "executor": "@nxlv/python:remove", + "options": {}, + }, + "test": { + "executor": "@nxlv/python:run-commands", + "options": { + "command": "uv run pytest tests/", + "cwd": "apps/test", + }, + "outputs": [ + "{workspaceRoot}/reports/apps/test/unittests", + "{workspaceRoot}/coverage/apps/test", + ], + }, + "update": { + "executor": "@nxlv/python:update", + "options": {}, + }, + }, +} +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 2`] = ` +"# test + +Project description here. +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 3`] = ` +"[project] +name = "test" +version = "1.0.0" +description = "Automatically generated by Nx." +requires-python = ">=3.9,<4" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["test"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 4`] = ` +""""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 5`] = ` +"3.9.7 +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 6`] = ` +""""Hello unit test module.""" + +from test.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello test" +" +`; + +exports[`application generator > should run successfully with ruff linter and pytest with no reports 7`] = ` +"[project] +name = "nx-workspace" +version = "1.0.0" +dependencies = [ "test" ] + +[dependency-groups] +dev = [ + "ruff>=0.8.2", + "autopep8>=2.3.1", + "pytest>=8.3.4", + "pytest-sugar>=1.0.0" +] + +[tool.uv.sources.test] +workspace = true + +[tool.uv.workspace] +members = [ "apps/*" ] +" +`; diff --git a/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml b/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml new file mode 100644 index 0000000..44e4618 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/__test__/custom-template/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "<%= packageName %>" +version = "1.0.0" +description = "<%= description %>" +requires-python = "<%- pyprojectPythonDependency %>" + +[tool.hatch.build.targets.wheel] +packages = ["<%= moduleName %>"] + +[dependency-groups] +dev = [ + "autopep8==1.5.7", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/nx-python/src/generators/uv-project/files/base/README.md b/packages/nx-python/src/generators/uv-project/files/base/README.md new file mode 100644 index 0000000..1a9566e --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/README.md @@ -0,0 +1,3 @@ +# <%= projectName %> + +Project description here. diff --git a/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template b/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template new file mode 100644 index 0000000..a8884cb --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__dot__python-version.template @@ -0,0 +1 @@ +<%= pyenvPythonVersion %> diff --git a/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py new file mode 100644 index 0000000..6303732 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/__init__.py @@ -0,0 +1 @@ +"""<%= description %>""" diff --git a/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py new file mode 100644 index 0000000..59cd85b --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/__moduleName__/hello.py @@ -0,0 +1,6 @@ +"""Sample Hello World application.""" + + +def hello(): + """Return a friendly greeting.""" + return "Hello <%= projectName %>" diff --git a/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml b/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml new file mode 100644 index 0000000..bee72bd --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/base/pyproject.toml @@ -0,0 +1,73 @@ +<%if (codeCoverage) { -%> +[tool.coverage.run] +branch = true +source = [ "<%= moduleName %>" ] + +[tool.coverage.report] +exclude_lines = ['if TYPE_CHECKING:'] +show_missing = true + +<% } -%> +<%if (unitTestRunner === 'pytest' && pythonAddopts) { -%> +[tool.pytest.ini_options] +addopts = "<%- pythonAddopts %>" + +<% } -%> +[project] +name = "<%= packageName %>" +version = "1.0.0" +description = "<%= description %>" +requires-python = "<%- pyprojectPythonDependency %>" +readme = 'README.md' +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["<%= moduleName %>"] + +<%if (devDependenciesProject !== '') { -%> +[dependency-groups] +dev = [ + "<%- devDependenciesProjectPkgName %>" +] + +[tool.uv.sources] +<%- devDependenciesProjectPkgName %> = { workspace = true } + +<% } -%> +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +<%if (linter === 'ruff') { -%> + +[tool.ruff] +exclude = [ + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "dist", +] + +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [] + +fixable = ["ALL"] +unfixable = [] +<% } -%> diff --git a/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template b/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template new file mode 100644 index 0000000..3da962c --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/flake8/__dot__flake8.template @@ -0,0 +1,11 @@ +[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-line-length = 120 diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py new file mode 100644 index 0000000..d8e96c6 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/__init__.py @@ -0,0 +1 @@ +"""unit tests.""" diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py new file mode 100644 index 0000000..547aa99 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/conftest.py @@ -0,0 +1,3 @@ +"""Unit tests configuration module.""" + +pytest_plugins = [] diff --git a/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py b/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py new file mode 100644 index 0000000..3873969 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/files/pytest/tests/test_hello.py @@ -0,0 +1,8 @@ +"""Hello unit test module.""" + +from <%= moduleName %>.hello import hello + + +def test_hello(): + """Test the hello function.""" + assert hello() == "Hello <%= projectName %>" diff --git a/packages/nx-python/src/generators/uv-project/generator.spec.ts b/packages/nx-python/src/generators/uv-project/generator.spec.ts new file mode 100644 index 0000000..d660a26 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/generator.spec.ts @@ -0,0 +1,652 @@ +import { vi, MockInstance } from 'vitest'; +import '../../utils/mocks/cross-spawn.mock'; +import * as uvUtils from '../../provider/uv/utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Tree, readProjectConfiguration } from '@nx/devkit'; + +import generator from './generator'; +import dedent from 'string-dedent'; +import { parse, stringify } from '@iarna/toml'; +import path from 'path'; +import spawn from 'cross-spawn'; +import { UVPyprojectToml } from '../../provider/uv/types'; +import { BasePythonProjectGeneratorSchema } from '../types'; + +describe('application generator', () => { + let checkUvExecutable: MockInstance; + let appTree: Tree; + const options: BasePythonProjectGeneratorSchema = { + name: 'test', + projectType: 'application', + pyprojectPythonDependency: '', + publishable: false, + buildLockedVersions: false, + buildBundleLocalDependencies: false, + linter: 'none', + unitTestRunner: 'none', + rootPyprojectDependencyGroup: 'main', + unitTestHtmlReport: false, + unitTestJUnitReport: false, + codeCoverage: false, + codeCoverageHtmlReport: false, + codeCoverageXmlReport: false, + projectNameAndRootFormat: 'derived', + }; + + beforeEach(() => { + vi.resetAllMocks(); + + appTree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + checkUvExecutable = vi.spyOn(uvUtils, 'checkUvExecutable'); + checkUvExecutable.mockResolvedValue(undefined); + vi.mocked(spawn.sync).mockImplementation((command) => { + if (command === 'python') { + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from('Python 3.9.7'), + }; + } + + return { + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }; + }); + }); + + it('should throw an exception when the poetry is not installed', async () => { + checkUvExecutable.mockRejectedValue(new Error('poetry not found')); + + expect(generator(appTree, options)).rejects.toThrow('poetry not found'); + + expect(checkUvExecutable).toHaveBeenCalled(); + }); + + describe('as-provided', () => { + it('should run successfully minimal configuration', async () => { + await generator(appTree, { + ...options, + name: 'my-app-test', + directory: 'src/app/test', + projectNameAndRootFormat: 'as-provided', + }); + const config = readProjectConfiguration(appTree, 'my-app-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'src/app/test'; + const moduleName = 'my_app_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + }); + + it('should run successfully minimal configuration without directory', async () => { + await generator(appTree, { + ...options, + name: 'my-app-test', + projectNameAndRootFormat: 'as-provided', + }); + const config = readProjectConfiguration(appTree, 'my-app-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'my-app-test'; + const moduleName = 'my_app_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + }); + }); + + it('should run successfully with minimal options', async () => { + const callbackTask = await generator(appTree, options); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options without rootPyprojectDependencyGroup', async () => { + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: undefined, + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options with custom rootPyprojectDependencyGroup', async () => { + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: 'dev', + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully with minimal options with existing custom rootPyprojectDependencyGroup', async () => { + appTree.write( + 'pyproject.toml', + dedent` + [project] + name = "nx-workspace" + version = "1.0.0" + dependencies = [ ] + + [dependency-groups] + dev = [ "requests>=2.3.1" ] + `, + ); + + const callbackTask = await generator(appTree, { + ...options, + rootPyprojectDependencyGroup: 'dev', + }); + callbackTask(); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + + expect(spawn.sync).toHaveBeenCalledTimes(2); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'python', ['--version'], { + stdio: 'pipe', + }); + expect(spawn.sync).toHaveBeenNthCalledWith(2, 'uv', ['sync'], { + shell: false, + stdio: 'inherit', + }); + }); + + it('should run successfully minimal configuration as a library', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'libs/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeFalsy(); + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeFalsy(); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully minimal configuration with tags', async () => { + await generator(appTree, { + ...options, + tags: 'one,two', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/test'; + const moduleName = 'test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully minimal configuration custom directory', async () => { + await generator(appTree, { + ...options, + directory: 'subdir', + }); + const config = readProjectConfiguration(appTree, 'subdir-test'); + expect(config).toMatchSnapshot(); + + const projectDirectory = 'apps/subdir/test'; + const moduleName = 'subdir_test'; + + assertGeneratedFilesBase(appTree, projectDirectory, moduleName); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with ruff linter', async () => { + await generator(appTree, { + ...options, + linter: 'ruff', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with no reports', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with ruff linter and pytest with no reports', async () => { + await generator(appTree, { + ...options, + linter: 'ruff', + unitTestRunner: 'pytest', + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html coverage report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports and threshold', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with flake8 linter and pytest with html,xml coverage reports, threshold and junit,html report', async () => { + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting (flake8) and testing options with a dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting (ruff) and testing options with a dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + await generator(appTree, { + ...options, + linter: 'ruff', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting and testing options with a dev dependency project with custom package name', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + packageName: 'custom-shared-dev-lib', + }); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + expect(appTree.exists(`apps/test/pyproject.toml`)).toBeTruthy(); + expect(appTree.read(`apps/test/pyproject.toml`, 'utf8')).toMatchSnapshot(); + expect(appTree.read('pyproject.toml', 'utf-8')).toMatchSnapshot(); + }); + + it('should run successfully with linting and testing options with an existing dev dependency project', async () => { + await generator(appTree, { + ...options, + projectType: 'library', + name: 'dev-lib', + directory: 'shared', + }); + + const pyprojectToml = parse( + appTree.read('libs/shared/dev-lib/pyproject.toml', 'utf-8'), + ) as UVPyprojectToml; + + pyprojectToml.project.dependencies = [ + 'autopep8>=1.0.0', + 'pytest>=1.0.0', + 'pytest-sugar=>1.0.0', + 'pytest-cov=>1.0.0', + 'pytest-html=>1.0.0', + 'flake8=>1.0.0', + 'flake8-isort=>1.0.0', + ]; + + appTree.write( + 'libs/shared/dev-lib/pyproject.toml', + stringify(pyprojectToml), + ); + + await generator(appTree, { + ...options, + linter: 'flake8', + unitTestRunner: 'pytest', + codeCoverage: true, + codeCoverageHtmlReport: true, + codeCoverageXmlReport: true, + codeCoverageThreshold: 100, + unitTestJUnitReport: true, + unitTestHtmlReport: true, + devDependenciesProject: 'shared-dev-lib', + }); + + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + assertGeneratedFilesBase(appTree, 'apps/test', 'test'); + assertGeneratedFilesFlake8(appTree, 'apps/test'); + assertGeneratedFilesPyTest(appTree, 'apps/test'); + + assertGeneratedFilesBase(appTree, 'libs/shared/dev-lib', 'shared_dev_lib'); + }); + + describe('custom template dir', () => { + it('should run successfully with custom template dir', async () => { + await generator(appTree, { + ...options, + templateDir: path.join(__dirname, '__test__/custom-template'), + }); + const config = readProjectConfiguration(appTree, 'test'); + expect(config).toMatchSnapshot(); + + expect( + appTree.read('apps/test/pyproject.toml', 'utf-8'), + ).toMatchSnapshot(); + + expect(appTree.read('apps/test/poetry.toml', 'utf-8')).toMatchSnapshot(); + }); + }); +}); + +function assertGeneratedFilesBase( + appTree: Tree, + projectDirectory: string, + moduleName: string, +) { + expect(appTree.exists(`${projectDirectory}/README.md`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/README.md`, 'utf8'), + ).toMatchSnapshot(); + + expect(appTree.exists(`${projectDirectory}/pyproject.toml`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/pyproject.toml`, 'utf8'), + ).toMatchSnapshot(); + + expect( + appTree.exists(`${projectDirectory}/${moduleName}/hello.py`), + ).toBeTruthy(); + + expect( + appTree.read(`${projectDirectory}/${moduleName}/hello.py`, 'utf-8'), + ).toMatchSnapshot(); + + expect( + appTree.read(`${projectDirectory}/.python-version`, 'utf-8'), + ).toMatchSnapshot(); +} + +function assertGeneratedFilesFlake8(appTree: Tree, projectDirectory: string) { + expect(appTree.exists(`${projectDirectory}/.flake8`)).toBeTruthy(); + expect( + appTree.read(`${projectDirectory}/.flake8`, 'utf-8'), + ).toMatchSnapshot(); +} + +function assertGeneratedFilesPyTest(appTree: Tree, projectDirectory: string) { + expect( + appTree.exists(`${projectDirectory}/tests/test_hello.py`), + ).toBeTruthy(); + + expect( + appTree.read(`${projectDirectory}/tests/test_hello.py`, 'utf-8'), + ).toMatchSnapshot(); +} diff --git a/packages/nx-python/src/generators/uv-project/generator.ts b/packages/nx-python/src/generators/uv-project/generator.ts new file mode 100644 index 0000000..17b366a --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/generator.ts @@ -0,0 +1,414 @@ +import { + addProjectConfiguration, + formatFiles, + generateFiles, + names, + offsetFromRoot, + ProjectConfiguration, + readProjectConfiguration, + Tree, +} from '@nx/devkit'; +import * as path from 'path'; +import { parse, stringify } from '@iarna/toml'; +import chalk from 'chalk'; +import _ from 'lodash'; +import { UVPyprojectToml } from '../../provider/uv/types'; +import { checkUvExecutable, runUv } from '../../provider/uv/utils'; +import { DEV_DEPENDENCIES_VERSION_MAP } from '../consts'; +import wcmatch from 'wildcard-match'; +import { + normalizeOptions as baseNormalizeOptions, + getPyprojectTomlByProjectName, +} from '../utils'; +import { + BaseNormalizedSchema, + BasePythonProjectGeneratorSchema, +} from '../types'; + +interface NormalizedSchema extends BaseNormalizedSchema { + devDependenciesProjectPath?: string; + devDependenciesProjectPkgName?: string; +} + +function normalizeOptions( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +): NormalizedSchema { + const newOptions = baseNormalizeOptions(tree, options); + + let devDependenciesProjectPkgName: string | undefined; + let devDependenciesProjectPath: string | undefined; + if (options.devDependenciesProject) { + const projectConfig = readProjectConfiguration( + tree, + options.devDependenciesProject, + ); + const { pyprojectToml } = getPyprojectTomlByProjectName( + tree, + options.devDependenciesProject, + ); + devDependenciesProjectPkgName = pyprojectToml.project.name; + devDependenciesProjectPath = path.relative( + newOptions.projectRoot, + projectConfig.root, + ); + } + + return { + ...newOptions, + devDependenciesProject: options.devDependenciesProject ?? '', + devDependenciesProjectPath, + devDependenciesProjectPkgName, + }; +} + +function addFiles(tree: Tree, options: NormalizedSchema) { + const templateOptions = { + ...options, + ...names(options.name), + offsetFromRoot: offsetFromRoot(options.projectRoot), + template: '', + dot: '.', + versionMap: DEV_DEPENDENCIES_VERSION_MAP, + }; + if (options.templateDir) { + generateFiles( + tree, + path.join(options.templateDir), + options.projectRoot, + templateOptions, + ); + return; + } + + generateFiles( + tree, + path.join(__dirname, 'files', 'base'), + options.projectRoot, + templateOptions, + ); + + if (options.unitTestRunner === 'pytest') { + generateFiles( + tree, + path.join(__dirname, 'files', 'pytest'), + options.projectRoot, + templateOptions, + ); + } + + if (options.linter === 'flake8') { + generateFiles( + tree, + path.join(__dirname, 'files', 'flake8'), + options.projectRoot, + templateOptions, + ); + } +} + +function updateRootPyprojectToml( + tree: Tree, + normalizedOptions: NormalizedSchema, +) { + const rootPyprojectToml: UVPyprojectToml = tree.exists('./pyproject.toml') + ? (parse(tree.read('./pyproject.toml', 'utf-8')) as UVPyprojectToml) + : { + project: { name: 'nx-workspace', version: '1.0.0', dependencies: [] }, + 'dependency-groups': {}, + tool: { + uv: { + sources: {}, + workspace: { + members: [`${normalizedOptions.projectRoot.split('/')[0]}/*`], + }, + }, + }, + }; + + const group = normalizedOptions.rootPyprojectDependencyGroup ?? 'main'; + + if (group === 'main') { + rootPyprojectToml.project.dependencies ??= []; + rootPyprojectToml.project.dependencies.push(normalizedOptions.packageName); + } else { + rootPyprojectToml['dependency-groups'] ??= {}; + rootPyprojectToml['dependency-groups'][group] ??= []; + rootPyprojectToml['dependency-groups'][group].push( + normalizedOptions.packageName, + ); + } + + rootPyprojectToml.tool ??= {}; + rootPyprojectToml.tool.uv ??= {}; + rootPyprojectToml.tool.uv.sources ??= {}; + rootPyprojectToml.tool.uv.sources[normalizedOptions.packageName] = { + workspace: true, + }; + + if (!normalizedOptions.devDependenciesProject) { + rootPyprojectToml['dependency-groups'] ??= {}; + rootPyprojectToml['dependency-groups'].dev ??= []; + + const { changed, dependencies } = addTestDependencies( + rootPyprojectToml['dependency-groups'].dev ?? [], + normalizedOptions, + true, + ); + + if (changed) { + rootPyprojectToml['dependency-groups'].dev = dependencies; + } + } + + rootPyprojectToml.tool ??= {}; + rootPyprojectToml.tool.uv ??= {}; + rootPyprojectToml.tool.uv.workspace ??= { + members: [], + }; + + if (rootPyprojectToml.tool.uv.workspace.members.length === 0) { + rootPyprojectToml.tool.uv.workspace.members.push( + `${normalizedOptions.projectRoot.split('/')[0]}/*`, + ); + } else { + for (const memberPattern of rootPyprojectToml.tool.uv.workspace.members) { + if ( + !wcmatch( + memberPattern.endsWith('**') + ? memberPattern + : memberPattern.endsWith('*') + ? `${memberPattern}*` + : memberPattern, + )(normalizedOptions.projectRoot) + ) { + rootPyprojectToml.tool.uv.workspace.members.push( + `${normalizedOptions.projectRoot.split('/')[0]}/*`, + ); + } + } + } + + tree.write('./pyproject.toml', stringify(rootPyprojectToml)); +} + +function updateDevDependenciesProject( + tree: Tree, + normalizedOptions: NormalizedSchema, +) { + if (normalizedOptions.devDependenciesProject) { + const { pyprojectToml, pyprojectTomlPath } = + getPyprojectTomlByProjectName( + tree, + normalizedOptions.devDependenciesProject, + ); + + const { changed, dependencies } = addTestDependencies( + pyprojectToml.project.dependencies, + normalizedOptions, + false, + ); + + if (changed) { + pyprojectToml.project.dependencies = dependencies; + + tree.write(pyprojectTomlPath, stringify(pyprojectToml)); + } + } +} + +function addTestDependencies( + dependencies: string[], + normalizedOptions: NormalizedSchema, + rootPyproject: boolean, +) { + const newDependencies = [...dependencies]; + const dependencyNames = dependencies + .map((dep) => dep.match(/^[a-zA-Z0-9-]+/)?.[0]) + .filter((d) => !!d); + + if ( + normalizedOptions.linter === 'flake8' && + !dependencyNames.includes('flake8') + ) { + newDependencies.push(`flake8>=${DEV_DEPENDENCIES_VERSION_MAP.flake8}`); + } + + if ( + normalizedOptions.linter === 'ruff' && + !dependencyNames.includes('ruff') + ) { + newDependencies.push(`ruff>=${DEV_DEPENDENCIES_VERSION_MAP.ruff}`); + } + + if ( + !dependencyNames.includes('autopep8') && + ((rootPyproject && !normalizedOptions.devDependenciesProject) || + !rootPyproject) + ) { + newDependencies.push(`autopep8>=${DEV_DEPENDENCIES_VERSION_MAP.autopep8}`); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + !dependencyNames.includes('pytest') + ) { + newDependencies.push(`pytest>=${DEV_DEPENDENCIES_VERSION_MAP.pytest}`); + } + if ( + normalizedOptions.unitTestRunner === 'pytest' && + !dependencyNames.includes('pytest-sugar') + ) { + newDependencies.push( + `pytest-sugar>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-sugar']}`, + ); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + normalizedOptions.codeCoverage && + !dependencyNames.includes('pytest-cov') + ) { + newDependencies.push( + `pytest-cov>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-cov']}`, + ); + } + + if ( + normalizedOptions.unitTestRunner === 'pytest' && + normalizedOptions.codeCoverageHtmlReport && + !dependencyNames.includes('pytest-html') + ) { + newDependencies.push( + `pytest-html>=${DEV_DEPENDENCIES_VERSION_MAP['pytest-html']}`, + ); + } + + return { + changed: !_.isEqual(dependencies, newDependencies), + dependencies: newDependencies, + }; +} + +function updateRootUvLock() { + console.log(chalk` Updating root {bgBlue uv.lock}...`); + runUv(['sync'], { log: false }); + console.log(chalk`\n {bgBlue uv.lock} updated.\n`); +} + +export default async function ( + tree: Tree, + options: BasePythonProjectGeneratorSchema, +) { + await checkUvExecutable(); + + const normalizedOptions = normalizeOptions(tree, options); + + const targets: ProjectConfiguration['targets'] = { + lock: { + executor: '@nxlv/python:run-commands', + options: { + command: 'uv lock', + cwd: normalizedOptions.projectRoot, + }, + }, + add: { + executor: '@nxlv/python:add', + options: {}, + }, + update: { + executor: '@nxlv/python:update', + options: {}, + }, + remove: { + executor: '@nxlv/python:remove', + options: {}, + }, + build: { + executor: '@nxlv/python:build', + outputs: ['{projectRoot}/dist'], + options: { + outputPath: `${normalizedOptions.projectRoot}/dist`, + publish: normalizedOptions.publishable, + lockedVersions: normalizedOptions.buildLockedVersions, + bundleLocalDependencies: normalizedOptions.buildBundleLocalDependencies, + }, + }, + }; + + if (options.linter === 'flake8') { + targets.lint = { + executor: '@nxlv/python:flake8', + outputs: [ + `{workspaceRoot}/reports/${normalizedOptions.projectRoot}/pylint.txt`, + ], + options: { + outputFile: `reports/${normalizedOptions.projectRoot}/pylint.txt`, + }, + }; + } + + if (options.linter === 'ruff') { + targets.lint = { + executor: '@nxlv/python:ruff-check', + outputs: [], + options: { + lintFilePatterns: [normalizedOptions.moduleName].concat( + options.unitTestRunner === 'pytest' ? ['tests'] : [], + ), + }, + }; + } + + if (options.unitTestRunner === 'pytest') { + targets.test = { + executor: '@nxlv/python:run-commands', + outputs: [ + `{workspaceRoot}/reports/${normalizedOptions.projectRoot}/unittests`, + `{workspaceRoot}/coverage/${normalizedOptions.projectRoot}`, + ], + options: { + command: `uv run pytest tests/`, + cwd: normalizedOptions.projectRoot, + }, + }; + } + + const projectConfiguration: ProjectConfiguration = { + root: normalizedOptions.projectRoot, + projectType: normalizedOptions.projectType, + sourceRoot: `${normalizedOptions.projectRoot}/${normalizedOptions.moduleName}`, + targets, + tags: normalizedOptions.parsedTags, + }; + + if (normalizedOptions.publishable) { + projectConfiguration.targets ??= {}; + projectConfiguration.targets['nx-release-publish'] = { + executor: '@nxlv/python:publish', + options: {}, + outputs: [], + }; + } + + projectConfiguration.release = { + version: { + generator: '@nxlv/python:release-version', + }, + }; + + addProjectConfiguration( + tree, + normalizedOptions.projectName, + projectConfiguration, + ); + + addFiles(tree, normalizedOptions); + updateDevDependenciesProject(tree, normalizedOptions); + updateRootPyprojectToml(tree, normalizedOptions); + await formatFiles(tree); + + return () => { + updateRootUvLock(); + }; +} diff --git a/packages/nx-python/src/generators/uv-project/schema.json b/packages/nx-python/src/generators/uv-project/schema.json new file mode 100644 index 0000000..e31be06 --- /dev/null +++ b/packages/nx-python/src/generators/uv-project/schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "UVPythonProject", + "title": "Generate a new UV Python Project.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use?" + }, + "projectType": { + "type": "string", + "description": "Project type", + "default": "application", + "enum": ["application", "library"] + }, + "templateDir": { + "type": "string", + "description": "Custom template directory, this will override the default template, if not provided the default template will be used" + }, + "packageName": { + "type": "string", + "description": "Python package name" + }, + "moduleName": { + "type": "string", + "description": "Python module name" + }, + "description": { + "type": "string", + "description": "Project short description" + }, + "pyprojectPythonDependency": { + "type": "string", + "description": "Pyproject python dependency version range", + "default": ">=3.9,<4" + }, + "pyenvPythonVersion": { + "type": "string", + "description": "Pyenv .python-version content (default to current python version)" + }, + "publishable": { + "type": "boolean", + "description": "Project is publishable", + "default": false + }, + "buildLockedVersions": { + "type": "boolean", + "description": "Use locked versions for build dependencies", + "default": true + }, + "buildBundleLocalDependencies": { + "type": "boolean", + "description": "Bundle local dependencies", + "default": true + }, + "linter": { + "type": "string", + "description": "Project linter", + "default": "ruff", + "enum": ["flake8", "ruff", "none"] + }, + "unitTestRunner": { + "type": "string", + "description": "Project unit test runner", + "default": "pytest", + "enum": ["pytest", "none"] + }, + "devDependenciesProject": { + "type": "string", + "description": "This approach installs all the missing dev dependencies in a separate project (optional)", + "x-dropdown": "projects" + }, + "rootPyprojectDependencyGroup": { + "type": "string", + "description": "If a shared pyproject.toml is used, which dependency group does this new project should belong to", + "default": "main" + }, + "unitTestHtmlReport": { + "type": "boolean", + "description": "Generate html report for unit tests", + "default": true + }, + "unitTestJUnitReport": { + "type": "boolean", + "description": "Generate junit report for unit tests", + "default": true + }, + "codeCoverage": { + "type": "boolean", + "description": "Generate code coverage report", + "default": true + }, + "codeCoverageHtmlReport": { + "type": "boolean", + "description": "Generate html report for code coverage", + "default": true + }, + "codeCoverageXmlReport": { + "type": "boolean", + "description": "Generate Xml report for code coverage", + "default": true + }, + "codeCoverageThreshold": { + "type": "number", + "description": "Code coverage threshold" + }, + "tags": { + "type": "string", + "description": "Add tags to the project (used for linting)", + "alias": "t" + }, + "directory": { + "type": "string", + "description": "A directory where the project is placed" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"], + "default": "as-provided" + } + }, + "required": ["name", "projectType"] +} diff --git a/packages/nx-python/src/provider/base.ts b/packages/nx-python/src/provider/base.ts index 842a16a..289fe94 100644 --- a/packages/nx-python/src/provider/base.ts +++ b/packages/nx-python/src/provider/base.ts @@ -12,6 +12,17 @@ export type Dependency = { category: string; }; +export type PackageDependency = { + name: string; + version?: string; + markers?: string; + optional?: boolean; + extras?: string[]; + git?: string; + rev?: string; + source?: string; +}; + export type ProjectMetadata = { name: string; version: string; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts index fd6512e..dc83498 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/locked.ts @@ -1,14 +1,16 @@ import { parse } from '@iarna/toml'; import { readFileSync, existsSync } from 'fs-extra'; import path, { join, relative } from 'path'; -import { PoetryLock, Dependency, PoetryLockPackage } from './types'; +import { PoetryLock, PoetryLockPackage } from './types'; import chalk from 'chalk'; import uri2path from 'file-uri-to-path'; -import { getLoggingTab, includeDependencyPackage } from './utils'; +import { includeDependencyPackage } from './utils'; import { Logger } from '../../../../executors/utils/logger'; import { PoetryPyprojectToml } from '../../types'; import { parseToml, runPoetry } from '../../utils'; import { isWindows } from '../../../../executors/utils/os'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab } from '../../../utils'; export class LockedDependencyResolver { private logger: Logger; @@ -23,7 +25,7 @@ export class LockedDependencyResolver { buildTomlData: PoetryPyprojectToml, devDependencies: boolean, workspaceRoot: string, - ): Dependency[] { + ): PackageDependency[] { this.logger.info(chalk` Resolving dependencies...`); return this.resolveDependencies( devDependencies, @@ -40,9 +42,9 @@ export class LockedDependencyResolver { buildFolderPath: string, buildTomlData: PoetryPyprojectToml, workspaceRoot: string, - deps: Dependency[] = [], + deps: PackageDependency[] = [], level = 1, - ): Dependency[] { + ): PackageDependency[] { const tab = getLoggingTab(level); const requerimentsTxt = this.getProjectRequirementsTxt( devDependencies, @@ -58,7 +60,7 @@ export class LockedDependencyResolver { const requerimentsLines = requerimentsTxt.split('\n'); for (const line of requerimentsLines) { if (line.trim()) { - const dep = {} as Dependency; + const dep = {} as PackageDependency; const elements = line.split(';'); if (elements.length > 1) { @@ -152,12 +154,12 @@ export class LockedDependencyResolver { private resolveSourceDependency( tab: string, exportedLineElements: string[], - dep: Dependency, + dep: PackageDependency, lockData: PoetryLock, workspaceRoot: string, buildFolderPath: string, buildTomlData: PoetryPyprojectToml, - deps: Dependency[], + deps: PackageDependency[], ) { const { packageName, location } = this.extractLocalPackageInfo(exportedLineElements); @@ -211,7 +213,7 @@ export class LockedDependencyResolver { return { packageName, location }; } - private resolvePackageExtras(dep: Dependency) { + private resolvePackageExtras(dep: PackageDependency) { if (dep.name.indexOf('[') !== -1) { dep.extras = dep.name .substring(dep.name.indexOf('[') + 1, dep.name.lastIndexOf(']')) @@ -261,8 +263,8 @@ export class LockedDependencyResolver { private includeGitDependency( tab: string, lockedPkg: PoetryLockPackage, - dep: Dependency, - deps: Dependency[], + dep: PackageDependency, + deps: PackageDependency[], ) { dep.git = lockedPkg.source.url; dep.optional = lockedPkg.optional; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts index 1c913de..baafc0e 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/project.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/project.ts @@ -1,14 +1,15 @@ import chalk from 'chalk'; -import { Dependency } from './types'; import { join, normalize, relative, resolve } from 'path'; import { readFileSync } from 'fs-extra'; import { parse } from '@iarna/toml'; -import { getLoggingTab, includeDependencyPackage } from './utils'; +import { includeDependencyPackage } from './utils'; import { ExecutorContext } from '@nx/devkit'; import { createHash } from 'crypto'; import { PoetryPyprojectToml, PoetryPyprojectTomlSource } from '../../types'; import { BuildExecutorSchema } from '../../../../executors/build/schema'; import { Logger } from '../../../../executors/utils/logger'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab } from '../../../utils'; export class ProjectDependencyResolver { private logger: Logger; @@ -29,7 +30,7 @@ export class ProjectDependencyResolver { root: string, buildFolderPath: string, buildTomlData: PoetryPyprojectToml, - ): Dependency[] { + ): PackageDependency[] { this.logger.info(chalk` Resolving dependencies...`); const pyprojectPath = join(root, 'pyproject.toml'); const pyproject = parse( @@ -52,14 +53,14 @@ export class ProjectDependencyResolver { level = 1, ) { const tab = getLoggingTab(level); - const deps: Dependency[] = []; + const deps: PackageDependency[] = []; const dependencies = Object.entries( pyproject.tool.poetry.dependencies, ).filter(([name]) => name != 'python'); for (const [name, data] of dependencies) { - const dep = {} as Dependency; + const dep = {} as PackageDependency; dep.name = name; if (typeof data === 'string') { diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/types.ts b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts index 02b9230..3bda3f4 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/types.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/types.ts @@ -1,16 +1,5 @@ import { PoetryPyprojectTomlDependency } from '../../types'; -export type Dependency = { - name: string; - version: string; - markers?: string; - optional: boolean; - extras?: string[]; - git?: string; - rev?: string; - source?: string; -}; - export type PoetryLockPackage = { name: string; version: string; diff --git a/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts index 9411d1e..2f8c244 100644 --- a/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts +++ b/packages/nx-python/src/provider/poetry/build/resolvers/utils.ts @@ -30,7 +30,3 @@ export function includeDependencyPackage( } } } - -export function getLoggingTab(level: number): string { - return ' '.repeat(level); -} diff --git a/packages/nx-python/src/provider/utils.ts b/packages/nx-python/src/provider/utils.ts index 4195cce..06a4c20 100644 --- a/packages/nx-python/src/provider/utils.ts +++ b/packages/nx-python/src/provider/utils.ts @@ -25,3 +25,7 @@ export function writePyprojectToml( ) { tree.write(tomlFile, toml.stringify(data)); } + +export function getLoggingTab(level: number): string { + return ' '.repeat(level); +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/index.ts b/packages/nx-python/src/provider/uv/build/resolvers/index.ts new file mode 100644 index 0000000..b4a5a21 --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/index.ts @@ -0,0 +1,2 @@ +export * from './locked'; +export * from './project'; diff --git a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts new file mode 100644 index 0000000..e6b9b1e --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts @@ -0,0 +1,115 @@ +import { join } from 'path'; +import chalk from 'chalk'; +import { Logger } from '../../../../executors/utils/logger'; +import { UVPyprojectToml } from '../../types'; +import { UV_EXECUTABLE } from '../../utils'; +import spawn from 'cross-spawn'; +import { getPyprojectData } from '../../../utils'; +import { PackageDependency } from '../../../base'; +import { includeDependencyPackage } from './utils'; +import { existsSync } from 'fs'; + +export class LockedDependencyResolver { + constructor(private logger: Logger) {} + + public resolve( + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + devDependencies: boolean, + workspaceRoot: string, + ): PackageDependency[] { + const result: PackageDependency[] = []; + this.logger.info(chalk` Resolving dependencies...`); + + const requerimentsTxt = this.getProjectRequirementsTxt( + devDependencies, + projectRoot, + workspaceRoot, + ); + + const requerimentsLines = requerimentsTxt.split('\n'); + for (const line of requerimentsLines) { + if (!line.trim()) { + continue; + } + + if (line.startsWith('-e')) { + const location = line.replace('-e', '').trim(); + const dependencyPyprojectPath = join( + workspaceRoot, + location, + 'pyproject.toml', + ); + + if (!existsSync(dependencyPyprojectPath)) { + this.logger.info( + chalk` • Skipping local dependency {blue.bold ${location}} as pyproject.toml not found`, + ); + continue; + } + + const projectData = getPyprojectData( + dependencyPyprojectPath, + ); + + this.logger.info( + chalk` • Adding {blue.bold ${projectData.project.name}} local dependency`, + ); + + includeDependencyPackage( + projectData, + location, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + + continue; + } + + result.push({ + name: line.trim(), + }); + } + + return result; + } + + private getProjectRequirementsTxt( + devDependencies: boolean, + projectRoot: string, + workspaceRoot: string, + ): string { + const exportArgs = [ + 'export', + '--format', + 'requirements-txt', + '--no-hashes', + '--no-header', + '--frozen', + '--no-emit-project', + '--all-extras', + '--project', + projectRoot, + ]; + + if (!devDependencies) { + exportArgs.push('--no-dev'); + } + + const result = spawn.sync(UV_EXECUTABLE, exportArgs, { + cwd: workspaceRoot, + shell: true, + stdio: 'pipe', + }); + + if (result.status !== 0) { + throw new Error( + chalk`{bold failed to export requirements txt with exit code {bold ${result.status}}`, + ); + } + + return result.stdout.toString('utf-8'); + } +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/project.ts b/packages/nx-python/src/provider/uv/build/resolvers/project.ts new file mode 100644 index 0000000..85aba7c --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/project.ts @@ -0,0 +1,196 @@ +import chalk from 'chalk'; +import { join, normalize } from 'path'; +import { existsSync } from 'fs-extra'; +import { UVLockfile, UVPyprojectToml, UVPyprojectTomlIndex } from '../../types'; +import { Logger } from '../../../../executors/utils/logger'; +import { PackageDependency } from '../../../base'; +import { getLoggingTab, getPyprojectData } from '../../../utils'; +import { getUvLockfile } from '../../utils'; +import { includeDependencyPackage } from './utils'; +import { BuildExecutorSchema } from '../../../..//executors/build/schema'; +import { ExecutorContext } from '@nx/devkit'; +import { createHash } from 'crypto'; + +export class ProjectDependencyResolver { + constructor( + private logger: Logger, + private options: BuildExecutorSchema, + private context: ExecutorContext, + ) {} + + resolve( + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, + ): PackageDependency[] { + this.logger.info(chalk` Resolving dependencies...`); + const pyprojectPath = join(projectRoot, 'pyproject.toml'); + const projectData = getPyprojectData(pyprojectPath); + const rootUvLook = getUvLockfile(join(workspaceRoot, 'uv.lock')); + + return this.resolveDependencies( + projectData, + rootUvLook, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + } + + private resolveDependencies( + pyproject: UVPyprojectToml, + rootUvLook: UVLockfile, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, + deps: PackageDependency[] = [], + depMap: Record = {}, + level = 1, + ) { + const tab = getLoggingTab(level); + + for (const dependency of pyproject.project.dependencies) { + if (pyproject.tool?.uv?.sources[dependency]) { + const dependencyPath = rootUvLook.package[dependency]?.source?.editable; + if (!dependencyPath) { + continue; + } + + const dependencyPyprojectPath = join(dependencyPath, 'pyproject.toml'); + if (!existsSync(dependencyPyprojectPath)) { + this.logger.info( + chalk`${tab}• Skipping local dependency {blue.bold ${dependency}} as pyproject.toml not found`, + ); + continue; + } + + const dependencyPyproject = getPyprojectData( + dependencyPyprojectPath, + ); + + const config = this.getProjectConfig(dependencyPath); + const targetOptions: BuildExecutorSchema | undefined = + config.targets?.build?.options; + + const publisable = targetOptions?.publish ?? true; + + if ( + this.options.bundleLocalDependencies === true || + publisable === false + ) { + this.logger.info( + chalk`${tab}• Adding {blue.bold ${dependency}} local dependency`, + ); + + includeDependencyPackage( + dependencyPyproject, + dependencyPath, + buildFolderPath, + buildTomlData, + workspaceRoot, + ); + + this.resolveDependencies( + dependencyPyproject, + rootUvLook, + buildFolderPath, + buildTomlData, + workspaceRoot, + deps, + depMap, + level + 1, + ); + } else { + deps.push({ + name: dependencyPyproject.project.name, + version: dependencyPyproject.project.version, + source: this.addIndex(buildTomlData, targetOptions), + }); + } + continue; + } + + const match = dependency.match(/^[a-zA-Z0-9-]+/); + if (match) { + if (depMap[match[0]]) { + continue; + } + + depMap[match[0]] = dependency; + } + + this.logger.info( + chalk`${tab}• Adding {blue.bold ${dependency}} dependency`, + ); + deps.push({ + name: dependency, + }); + } + + return deps; + } + + private addIndex( + buildTomlData: UVPyprojectToml, + targetOptions: BuildExecutorSchema, + ): string | undefined { + if (!targetOptions?.customSourceUrl) return undefined; + + const [newIndexes, newIndexName] = this.resolveDuplicateIndexes( + buildTomlData.tool.uv.index, + { + name: targetOptions.customSourceName, + url: targetOptions.customSourceUrl, + }, + ); + + buildTomlData.tool.uv.index = newIndexes; + + return newIndexName; + } + + private getProjectConfig(root: string) { + for (const [, config] of Object.entries( + this.context.projectsConfigurations.projects, + )) { + if (normalize(config.root) === normalize(root)) { + return config; + } + } + + throw new Error(`Could not find project config for ${root}`); + } + + private resolveDuplicateIndexes = ( + indexes: UVPyprojectTomlIndex[], + { name, url }: UVPyprojectTomlIndex, + ): [UVPyprojectTomlIndex[], string] => { + if (!indexes) { + return [[{ name, url }], name]; + } + + const existing = indexes.find((s) => s.name === name); + + if (existing) { + if (existing.url === url) { + return [indexes, name]; + } + + const hash = createHash('md5').update(url).digest('hex'); + const newName = `${name}-${hash}`; + + this.logger.info( + chalk` Duplicate index for {blue.bold ${name}} renamed to ${newName}`, + ); + + if (indexes.find((s) => s.name === newName)) { + return [indexes, newName]; + } + + return [[...indexes, { name: newName, url }], newName]; + } + + return [[...indexes, { name, url }], name]; + }; +} diff --git a/packages/nx-python/src/provider/uv/build/resolvers/utils.ts b/packages/nx-python/src/provider/uv/build/resolvers/utils.ts new file mode 100644 index 0000000..2542b7f --- /dev/null +++ b/packages/nx-python/src/provider/uv/build/resolvers/utils.ts @@ -0,0 +1,26 @@ +import { join } from 'path'; +import { UVPyprojectToml } from '../../types'; +import { copySync } from 'fs-extra'; + +export function includeDependencyPackage( + projectData: UVPyprojectToml, + projectRoot: string, + buildFolderPath: string, + buildTomlData: UVPyprojectToml, + workspaceRoot: string, +) { + for (const pkg of projectData.tool?.hatch?.build?.targets?.wheel?.packages ?? + []) { + const pkgFolder = join(workspaceRoot, projectRoot, pkg); + copySync(pkgFolder, join(buildFolderPath, pkg)); + + buildTomlData.tool ??= {}; + buildTomlData.tool.hatch ??= {}; + buildTomlData.tool.hatch.build ??= {}; + buildTomlData.tool.hatch.build.targets ??= {}; + buildTomlData.tool.hatch.build.targets.wheel ??= { + packages: [], + }; + buildTomlData.tool.hatch.build.targets.wheel.packages.push(pkg); + } +} diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index 0a3878f..f408f78 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -23,9 +23,9 @@ import { } from '../../executors/build/schema'; import { InstallExecutorSchema } from '../../executors/install/schema'; import { checkUvExecutable, getUvLockfile, runUv } from './utils'; -import path from 'path'; +import path, { join } from 'path'; import chalk from 'chalk'; -import { removeSync, writeFileSync } from 'fs-extra'; +import { copySync, removeSync, writeFileSync } from 'fs-extra'; import { getPyprojectData, readPyprojectToml, @@ -33,7 +33,13 @@ import { } from '../utils'; import { UVLockfile, UVPyprojectToml } from './types'; import toml from '@iarna/toml'; -import fs from 'fs'; +import fs, { mkdirSync, readdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { v4 as uuid } from 'uuid'; +import { + LockedDependencyResolver, + ProjectDependencyResolver, +} from './build/resolvers'; export class UVProvider implements IProvider { protected _rootLockfile: UVLockfile; @@ -155,7 +161,6 @@ export class UVProvider implements IProvider { public getDependents( projectName: string, projects: Record, - cwd: string, ): string[] { const result: string[] = []; @@ -335,7 +340,96 @@ export class UVProvider implements IProvider { options: BuildExecutorSchema, context: ExecutorContext, ): Promise { - throw new Error('Method not implemented.'); + await this.checkPrerequisites(); + if ( + options.lockedVersions === true && + options.bundleLocalDependencies === false + ) { + throw new Error( + 'Not supported operations, you cannot use lockedVersions without bundleLocalDependencies', + ); + } + + this.logger.info( + chalk`\n {bold Building project {bgBlue ${context.projectName} }...}\n`, + ); + + const { root } = + context.projectsConfigurations.projects[context.projectName]; + + const buildFolderPath = join(tmpdir(), 'nx-python', 'build', uuid()); + + mkdirSync(buildFolderPath, { recursive: true }); + + this.logger.info(chalk` Copying project files to a temporary folder`); + readdirSync(root).forEach((file) => { + if (!options.ignorePaths.includes(file)) { + const source = join(root, file); + const target = join(buildFolderPath, file); + copySync(source, target); + } + }); + + const buildPyProjectToml = join(buildFolderPath, 'pyproject.toml'); + const buildTomlData = getPyprojectData(buildPyProjectToml); + + const deps = options.lockedVersions + ? new LockedDependencyResolver(this.logger).resolve( + root, + buildFolderPath, + buildTomlData, + options.devDependencies, + context.root, + ) + : new ProjectDependencyResolver(this.logger, options, context).resolve( + root, + buildFolderPath, + buildTomlData, + context.root, + ); + + buildTomlData.project.dependencies = []; + buildTomlData['dependency-groups'] = {}; + + if (buildTomlData.tool?.uv?.sources) { + buildTomlData.tool.uv.sources = {}; + } + + for (const dep of deps) { + if (dep.version) { + buildTomlData.project.dependencies.push(`${dep.name}==${dep.version}`); + } else { + buildTomlData.project.dependencies.push(dep.name); + } + + if (dep.source) { + buildTomlData.tool.uv.sources[dep.name] = { + index: dep.source, + }; + } + } + + writeFileSync(buildPyProjectToml, toml.stringify(buildTomlData)); + const distFolder = join(buildFolderPath, 'dist'); + + removeSync(distFolder); + + this.logger.info(chalk` Generating sdist and wheel artifacts`); + const buildArgs = ['build']; + runUv(buildArgs, { cwd: buildFolderPath }); + + removeSync(options.outputPath); + mkdirSync(options.outputPath, { recursive: true }); + this.logger.info( + chalk` Artifacts generated at {bold ${options.outputPath}} folder`, + ); + copySync(distFolder, options.outputPath); + + if (!options.keepBuildFolder) { + removeSync(buildFolderPath); + } + + return buildFolderPath; } public async run( diff --git a/packages/nx-python/src/provider/uv/types.ts b/packages/nx-python/src/provider/uv/types.ts index 92e44b4..a91e656 100644 --- a/packages/nx-python/src/provider/uv/types.ts +++ b/packages/nx-python/src/provider/uv/types.ts @@ -21,14 +21,24 @@ export type UVPyprojectToml = { sources?: { [key: string]: { workspace?: boolean; + index?: string; }; }; + index?: UVPyprojectTomlIndex[]; + workspace?: { + members: string[]; + }; }; }; }; +export type UVPyprojectTomlIndex = { + name: string; + url: string; +}; + export type UVLockfilePackageLocalSource = { - editable?: boolean; + editable?: string; }; export type UVLockfilePackageDependency = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3215bbb..efcac20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ dependencies: uuid: specifier: ^9.0.1 version: 9.0.1 + wildcard-match: + specifier: ^5.1.3 + version: 5.1.3 devDependencies: '@commitlint/cli': @@ -12909,6 +12912,10 @@ packages: dev: true optional: true + /wildcard-match@5.1.3: + resolution: {integrity: sha512-a95hPUk+BNzSGLntNXYxsjz2Hooi5oL7xOfJR6CKwSsSALh7vUNuTlzsrZowtYy38JNduYFRVhFv19ocqNOZlg==} + dev: false + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'}