From 4a3d24c9b18ed4a6644f383823b69fedc1691d34 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 17 Jan 2025 23:27:07 -0300 Subject: [PATCH 1/2] fix: change dependency graph to detect non-workspace uv structure --- packages/nx-python/README.md | 21 ++++ .../nx-python/src/graph/dependency-graph.ts | 14 ++- packages/nx-python/src/provider/resolver.ts | 15 +++ .../nx-python/src/provider/uv/provider.ts | 105 ++++++++++++++---- packages/nx-python/src/types.ts | 3 + 5 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 packages/nx-python/src/types.ts diff --git a/packages/nx-python/README.md b/packages/nx-python/README.md index f7252c9..03f6b5f 100644 --- a/packages/nx-python/README.md +++ b/packages/nx-python/README.md @@ -74,6 +74,27 @@ for Nx 20.x or higher, use the following pattern: } ``` +> **IMPORTANT**: To use `uv` package manager without workspaces, set the `packageManager` option to `uv` in the `nx.json` file, as shown below: + +````json +```json +{ + ... + "plugins": [ + ... + { + "plugin": "@nxlv/python", + "options": { + "packageManager": "uv" + } + } + ] + ... +} +```` + +**NOTE**: The default package manager is `poetry`, but it's automatically detected if the repository is configured to use `uv` workspaces since the `uv.lock` filw will be present in the root directory. + ### Poetry #### Add a new Python Project diff --git a/packages/nx-python/src/graph/dependency-graph.ts b/packages/nx-python/src/graph/dependency-graph.ts index 95e8d60..a28ec5d 100644 --- a/packages/nx-python/src/graph/dependency-graph.ts +++ b/packages/nx-python/src/graph/dependency-graph.ts @@ -4,10 +4,20 @@ import { CreateDependencies, } from '@nx/devkit'; import { getProvider } from '../provider'; +import { PluginOptions } from '../types'; -export const createDependencies: CreateDependencies = async (_, context) => { +export const createDependencies: CreateDependencies = async ( + options, + context, +) => { const result: ImplicitDependency[] = []; - const provider = await getProvider(context.workspaceRoot); + const provider = await getProvider( + context.workspaceRoot, + undefined, + undefined, + undefined, + options, + ); for (const project in context.projects) { const deps = provider.getDependencies( diff --git a/packages/nx-python/src/provider/resolver.ts b/packages/nx-python/src/provider/resolver.ts index a50f36a..45a180c 100644 --- a/packages/nx-python/src/provider/resolver.ts +++ b/packages/nx-python/src/provider/resolver.ts @@ -7,15 +7,30 @@ import { Logger } from '../executors/utils/logger'; import { ExecutorContext, joinPathFragments, Tree } from '@nx/devkit'; import { getPyprojectData } from './utils'; import { UVPyprojectToml } from './uv/types'; +import { PluginOptions } from '../types'; export const getProvider = async ( workspaceRoot: string, logger?: Logger, tree?: Tree, context?: ExecutorContext, + options?: PluginOptions, ): Promise => { const loggerInstance = logger ?? new Logger(); + if (options?.packageManager) { + switch (options.packageManager) { + case 'poetry': + return new UVProvider(workspaceRoot, loggerInstance, tree); + case 'uv': + return new UVProvider(workspaceRoot, loggerInstance, tree); + default: + throw new Error( + `Plugin option "packageManager" must be either "poetry" or "uv". Received "${options.packageManager}".`, + ); + } + } + const uv = isUv(workspaceRoot, context, tree); const poetry = isPoetry(workspaceRoot, context, tree); if (uv && poetry) { diff --git a/packages/nx-python/src/provider/uv/provider.ts b/packages/nx-python/src/provider/uv/provider.ts index 870d866..3634e15 100644 --- a/packages/nx-python/src/provider/uv/provider.ts +++ b/packages/nx-python/src/provider/uv/provider.ts @@ -144,6 +144,7 @@ export class UVProvider implements IProvider { deps.push( ...this.resolveDependencies( tomlData, + projects[projectName], tomlData?.project?.dependencies || [], 'main', projects, @@ -154,6 +155,7 @@ export class UVProvider implements IProvider { deps.push( ...this.resolveDependencies( tomlData, + projects[projectName], tomlData['dependency-groups'][group], group, projects, @@ -525,6 +527,7 @@ export class UVProvider implements IProvider { private resolveDependencies( pyprojectToml: UVPyprojectToml | undefined, + projectData: ProjectConfiguration, dependencies: string[], category: string, projects: Record, @@ -537,36 +540,98 @@ export class UVProvider implements IProvider { const sources = pyprojectToml?.tool?.uv?.sources ?? {}; for (const dep of dependencies) { - if (!sources[dep]?.workspace) { + if (!sources[dep]) { continue; } - const packageMetadata = - this.rootLockfile.package[pyprojectToml?.project?.name]?.metadata; + if (this.isWorkspace) { + this.appendWorkspaceDependencyToDeps( + pyprojectToml, + dep, + category, + sources, + projects, + deps, + ); + } else { + this.appendIndividualDependencyToDeps( + projectData, + dep, + category, + sources, + projects, + deps, + ); + } + } - const depMetadata = - category === 'main' - ? packageMetadata?.['requires-dist']?.[dep] - : packageMetadata?.['requires-dev']?.[category]?.[dep]; + return deps; + } - if (!depMetadata?.editable) { - continue; - } + private appendWorkspaceDependencyToDeps( + pyprojectToml: UVPyprojectToml | undefined, + dependencyName: string, + category: string, + sources: UVPyprojectToml['tool']['uv']['sources'], + projects: Record, + deps: Dependency[], + ): void { + if (!sources[dependencyName]?.workspace) { + return; + } - const depProjectName = Object.keys(projects).find( - (proj) => - path.normalize(projects[proj].root) === - path.normalize(depMetadata.editable), - ); + const packageMetadata = + this.rootLockfile.package[pyprojectToml?.project?.name]?.metadata; - if (!depProjectName) { - continue; - } + const depMetadata = + category === 'main' + ? packageMetadata?.['requires-dist']?.[dependencyName] + : packageMetadata?.['requires-dev']?.[category]?.[dependencyName]; - deps.push({ name: depProjectName, category }); + if (!depMetadata?.editable) { + return; } - return deps; + const depProjectName = Object.keys(projects).find( + (proj) => + path.normalize(projects[proj].root) === + path.normalize(depMetadata.editable), + ); + + if (!depProjectName) { + return; + } + + deps.push({ name: depProjectName, category }); + } + + private appendIndividualDependencyToDeps( + projectData: ProjectConfiguration, + dependencyName: string, + category: string, + sources: UVPyprojectToml['tool']['uv']['sources'], + projects: Record, + deps: Dependency[], + ) { + if (!sources[dependencyName]?.path) { + return; + } + + const depAbsPath = path.resolve( + projectData.root, + sources[dependencyName].path, + ); + const depProjectName = Object.keys(projects).find( + (proj) => + path.normalize(projects[proj].root) === + path.normalize(path.relative(this.workspaceRoot, depAbsPath)), + ); + + if (!depProjectName) { + return; + } + + deps.push({ name: depProjectName, category }); } private getProjectRoot(context: ExecutorContext) { diff --git a/packages/nx-python/src/types.ts b/packages/nx-python/src/types.ts new file mode 100644 index 0000000..0c5532d --- /dev/null +++ b/packages/nx-python/src/types.ts @@ -0,0 +1,3 @@ +export type PluginOptions = { + packageManager: 'poetry' | 'uv'; +}; From 733cfca0abfb951f3a2bc25b131d639df21262ff Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 17 Jan 2025 23:27:30 -0300 Subject: [PATCH 2/2] fix: generate uv.lock file if it does not exist before export requirements txt --- .../src/executors/build/executor.spec.ts | 347 +++++++++++++----- .../src/provider/uv/build/resolvers/locked.ts | 15 + 2 files changed, 263 insertions(+), 99 deletions(-) diff --git a/packages/nx-python/src/executors/build/executor.spec.ts b/packages/nx-python/src/executors/build/executor.spec.ts index 9cc1afe..4d026a8 100644 --- a/packages/nx-python/src/executors/build/executor.spec.ts +++ b/packages/nx-python/src/executors/build/executor.spec.ts @@ -3081,27 +3081,29 @@ describe('Build Executor', () => { '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", - ] + [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"] + [tool.hatch.build.targets.wheel] + packages = ["app1"] - [dependency-groups] - dev = [ - "ruff>=0.8.2", - ] + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] - [tool.uv.sources] - dep1 = { workspace = true } - `, + [tool.uv.sources] + dep1 = { workspace = true } + `, + + 'apps/app/uv.lock': '', }); vi.mocked(spawn.sync) @@ -3218,40 +3220,42 @@ describe('Build Executor', () => { '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", - ] + [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"] + [tool.hatch.build.targets.wheel] + packages = ["app1"] - [dependency-groups] - dev = [ - "ruff>=0.8.2", - ] + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] - [tool.uv.sources] - dep1 = { workspace = true } - `, + [tool.uv.sources] + dep1 = { workspace = true } + `, + 'apps/app/uv.lock': '', '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 = [] + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] - [tool.hatch.build.targets.wheel] - packages = ["dep1"] - `, + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + 'libs/dep1/uv.lock': '', }); vi.mocked(spawn.sync) @@ -3369,40 +3373,42 @@ describe('Build Executor', () => { '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", - ] + [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"] + [tool.hatch.build.targets.wheel] + packages = ["app1"] - [dependency-groups] - dev = [ - "ruff>=0.8.2", - ] + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] - [tool.uv.sources] - dep1 = { workspace = true } - `, + [tool.uv.sources] + dep1 = { workspace = true } + `, + 'apps/app/uv.lock': '', '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 = [] + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] - [tool.hatch.build.targets.wheel] - packages = ["dep1"] - `, + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + 'libs/dep1/uv.lock': '', }); vi.mocked(spawn.sync) @@ -3521,40 +3527,42 @@ describe('Build Executor', () => { '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", - ] + [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"] + [tool.hatch.build.targets.wheel] + packages = ["app1"] - [dependency-groups] - dev = [ - "ruff>=0.8.2", - ] + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] - [tool.uv.sources] - dep1 = { workspace = true } - `, + [tool.uv.sources] + dep1 = { workspace = true } + `, + 'apps/app/uv.lock': '', '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 = [] + [project] + name = "dep1" + version = "0.1.0" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] - [tool.hatch.build.targets.wheel] - packages = ["dep1"] - `, + [tool.hatch.build.targets.wheel] + packages = ["dep1"] + `, + 'libs/dep1/uv.lock': '', }); vi.mocked(spawn.sync) @@ -3649,6 +3657,145 @@ describe('Build Executor', () => { expect(output.success).toBe(true); }); + + it('should run uv lock command before executing the export command', 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" + ] + + [tool.hatch.build.targets.wheel] + packages = ["app1"] + + [dependency-groups] + dev = [ + "ruff>=0.8.2", + ] + `, + }); + + vi.mocked(spawn.sync) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: null, + }) + .mockReturnValueOnce({ + status: 0, + output: [''], + pid: 0, + signal: null, + stderr: null, + stdout: Buffer.from(dedent` + 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: {}, + }, + }, + }, + nxJsonConfiguration: {}, + projectGraph: { + dependencies: {}, + nodes: {}, + }, + }); + + expect(checkPrerequisites).toHaveBeenCalled(); + expect(existsSync(buildPath)).toBeTruthy(); + expect(existsSync(`${buildPath}/app1`)).toBeTruthy(); + expect(existsSync(`${buildPath}/dist/app.fake`)).toBeTruthy(); + expect(spawn.sync).toHaveBeenCalledTimes(3); + expect(spawn.sync).toHaveBeenNthCalledWith(1, 'uv', ['lock'], { + cwd: 'apps/app', + shell: true, + stdio: 'inherit', + }); + expect(spawn.sync).toHaveBeenNthCalledWith( + 2, + '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(3, '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); + }); }); describe('project', () => { @@ -3682,6 +3829,7 @@ describe('Build Executor', () => { [tool.uv.sources] dep1 = { path = "../../libs/dep1" } `, + 'apps/app/uv.lock': '', 'libs/dep1/dep1/index.py': 'print("Hello from dep1")', 'libs/dep1/pyproject.toml': dedent` @@ -3695,6 +3843,7 @@ describe('Build Executor', () => { [tool.hatch.build.targets.wheel] packages = ["dep1"] `, + 'libs/dep1/uv.lock': '', }); vi.mocked(spawn.sync) diff --git a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts index 76ab418..8421709 100644 --- a/packages/nx-python/src/provider/uv/build/resolvers/locked.ts +++ b/packages/nx-python/src/provider/uv/build/resolvers/locked.ts @@ -108,6 +108,21 @@ export class LockedDependencyResolver { exportArgs.push('--no-dev'); } + if (!existsSync(path.join(projectRoot, 'uv.lock'))) { + this.logger.info(' Generating uv.lock file'); + const lockCmd = spawn.sync(UV_EXECUTABLE, ['lock'], { + cwd: projectRoot, + shell: true, + stdio: 'inherit', + }); + + if (lockCmd.status !== 0) { + throw new Error( + chalk`{bold failed to generate uv.lock file with exit code {bold ${lockCmd.status}}}`, + ); + } + } + const result = spawn.sync(UV_EXECUTABLE, exportArgs, { cwd: workspaceRoot, shell: true,