From c3cf91b8b91475bc87dc390eb07753d866e9f76b Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Fri, 16 Aug 2024 13:17:00 -0300
Subject: [PATCH] feat(pipenv): Use `@renovatebot/detect-tools` for constraints
 detection (#29787)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/pipenv/artifacts.spec.ts |  89 +++++++--
 lib/modules/manager/pipenv/artifacts.ts      | 179 ++-----------------
 lib/modules/manager/pipenv/extract.spec.ts   |  35 +++-
 lib/modules/manager/pipenv/extract.ts        |  24 ++-
 lib/modules/manager/pipenv/schema.ts         |  30 ----
 lib/modules/manager/pipenv/types.ts          |  11 ++
 package.json                                 |   1 +
 pnpm-lock.yaml                               |  13 ++
 8 files changed, 148 insertions(+), 234 deletions(-)
 delete mode 100644 lib/modules/manager/pipenv/schema.ts

diff --git a/lib/modules/manager/pipenv/artifacts.spec.ts b/lib/modules/manager/pipenv/artifacts.spec.ts
index d509a94b7d..5cdb047e91 100644
--- a/lib/modules/manager/pipenv/artifacts.spec.ts
+++ b/lib/modules/manager/pipenv/artifacts.spec.ts
@@ -24,7 +24,7 @@ import {
   extractEnvironmentVariableName,
   getMatchingHostRule,
 } from './artifacts';
-import type { PipfileLockSchema } from './schema';
+import { PipfileLock } from './types';
 import { updateArtifacts } from '.';
 
 const datasource = mocked(_datasource);
@@ -128,13 +128,14 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('returns null if unchanged', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': JSON.stringify({
         _meta: {
           requires: { python_full_version: '3.7.6' },
         },
-      } satisfies PipfileLockSchema),
+      } satisfies PipfileLock),
     });
     const execSnapshots = mockExecAll();
 
@@ -165,19 +166,24 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('gets python full version from Pipfile', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
+      '/Pipfile': Fixtures.get('Pipfile1'),
       '/Pipfile.lock': JSON.stringify({
         _meta: {
           requires: { python_full_version: '3.7.6' },
         },
-      } satisfies PipfileLockSchema),
+      } satisfies PipfileLock),
     });
 
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
@@ -213,19 +219,24 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
-      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
     ]);
   });
 
   it('gets python version from Pipfile', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
+      '/Pipfile': Fixtures.get('Pipfile2'),
       '/Pipfile.lock': JSON.stringify({
         _meta: {
           requires: { python_full_version: '3.7.6' },
         },
-      } satisfies PipfileLockSchema),
+      } satisfies PipfileLock),
     });
 
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
@@ -261,12 +272,16 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
-      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
     ]);
   });
 
   it('gets full python version from .python-version', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': '{}',
@@ -306,13 +321,17 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('gets python stream, from .python-version', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
 
@@ -351,13 +370,17 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('handles no constraint', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': 'unparseable pipfile lock',
@@ -394,12 +417,17 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('returns updated Pipfile.lock', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': ['current pipfile.lock', 'new pipfile.lock'],
@@ -448,6 +476,7 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
@@ -455,12 +484,13 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('supports docker mode', async () => {
     GlobalConfig.set(dockerAdminConfig);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     const pipFileLock = JSON.stringify({
       _meta: { requires: { python_version: '3.7' } },
-    } satisfies PipfileLockSchema);
+    } satisfies PipfileLock);
     mockFiles({
-      '/Pipfile.lock': [pipFileLock, 'new lock'],
+      '/Pipfile.lock': [pipFileLock, pipFileLock, 'new lock'],
     });
 
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
@@ -523,6 +553,9 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
@@ -530,10 +563,11 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('supports install mode', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     const pipFileLock = JSON.stringify({
       _meta: { requires: { python_version: '3.6' } },
-    } satisfies PipfileLockSchema);
+    } satisfies PipfileLock);
     mockFiles({
       '/Pipfile.lock': [pipFileLock, 'new lock'],
     });
@@ -582,6 +616,9 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
@@ -589,6 +626,7 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('defaults to latest if no lock constraints', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    fsExtra.stat.mockResolvedValueOnce({} as never);
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
 
     mockFiles({
@@ -637,14 +675,18 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('catches errors', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': 'Current Pipfile.lock',
@@ -666,13 +708,12 @@ describe('modules/manager/pipenv/artifacts', () => {
     ]);
 
     expect(fsExtra.ensureDir.mock.calls).toEqual([]);
-    expect(fsExtra.readFile.mock.calls).toEqual([
-      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
-    ]);
+    expect(fsExtra.readFile.mock.calls).toEqual([]);
   });
 
   it('returns updated Pipenv.lock when doing lockfile maintenance', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': ['Current Pipfile.lock', 'New Pipfile.lock'],
@@ -711,14 +752,15 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('uses pipenv version from Pipfile', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     GlobalConfig.set(dockerAdminConfig);
 
     const oldLock = JSON.stringify({
       default: { pipenv: { version: '==2020.8.13' } },
-    } satisfies PipfileLockSchema);
+    } satisfies PipfileLock);
     mockFiles({
-      '/Pipfile.lock': [oldLock, 'new lock'],
+      '/Pipfile.lock': [oldLock, oldLock, 'new lock'],
     });
 
     const execSnapshots = mockExecAll();
@@ -774,22 +816,26 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('uses pipenv version from Pipfile dev packages', async () => {
     GlobalConfig.set(dockerAdminConfig);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
 
     const oldLock = JSON.stringify({
       develop: { pipenv: { version: '==2020.8.13' } },
-    } satisfies PipfileLockSchema) as never;
+    } satisfies PipfileLock) as never;
     mockFiles({
-      '/Pipfile.lock': [oldLock, 'new lock'],
+      '/Pipfile.lock': [oldLock, oldLock, 'new lock'],
     });
 
     const execSnapshots = mockExecAll();
@@ -845,19 +891,23 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
     ]);
   });
 
   it('uses pipenv version from config', async () => {
     GlobalConfig.set(dockerAdminConfig);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
 
     const oldLock = JSON.stringify({
       default: { pipenv: { version: '==2020.8.13' } },
-    } satisfies PipfileLockSchema) as never;
+    } satisfies PipfileLock) as never;
     mockFiles({
       '/Pipfile.lock': [oldLock, 'new lock'],
     });
@@ -915,6 +965,7 @@ describe('modules/manager/pipenv/artifacts', () => {
       [expect.toEndWith(join('/tmp/renovate/cache/others/virtualenvs'))],
     ]);
     expect(fsExtra.readFile.mock.calls).toEqual([
+      [expect.toEndWith(join('/tmp/github/some/repo/Pipfile')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/.python-version')), 'utf8'],
       [expect.toEndWith(join('/tmp/github/some/repo/Pipfile.lock')), 'utf8'],
@@ -923,6 +974,7 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('passes private credential environment vars', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': ['current Pipfile.lock', 'New Pipfile.lock'],
@@ -985,7 +1037,7 @@ describe('modules/manager/pipenv/artifacts', () => {
     ${'${USERNAME}'}                | ${'USERNAME'}
     ${'${USERNAME:-default}'}       | ${'USERNAME'}
     ${'${COMPLEX_NAME_1:-default}'} | ${'COMPLEX_NAME_1'}
-  `('extractEnvironmentVariableName(%p)', ({ credential, result }) => {
+  `('extractEnvironmentVariableName($credential)', ({ credential, result }) => {
     expect(extractEnvironmentVariableName(credential)).toEqual(result);
   });
 
@@ -999,6 +1051,7 @@ describe('modules/manager/pipenv/artifacts', () => {
 
   it('updates extraEnv if variable names differ from default', async () => {
     fsExtra.ensureDir.mockResolvedValue(undefined as never);
+    fsExtra.stat.mockResolvedValueOnce({} as never);
 
     mockFiles({
       '/Pipfile.lock': ['current Pipfile.lock', 'New Pipfile.lock'],
diff --git a/lib/modules/manager/pipenv/artifacts.ts b/lib/modules/manager/pipenv/artifacts.ts
index aa05cbecbd..8d2760ce35 100644
--- a/lib/modules/manager/pipenv/artifacts.ts
+++ b/lib/modules/manager/pipenv/artifacts.ts
@@ -1,5 +1,5 @@
+import { pipenv as pipenvDetect } from '@renovatebot/detect-tools';
 import is from '@sindresorhus/is';
-import semver from 'semver';
 import { TEMPORARY_ERROR } from '../../../constants/error-messages';
 import { logger } from '../../../logger';
 import type { HostRule } from '../../../types';
@@ -8,168 +8,19 @@ import type { ExecOptions, ExtraEnv, Opt } from '../../../util/exec/types';
 import {
   deleteLocalFile,
   ensureCacheDir,
-  getSiblingFileName,
+  getParentDir,
+  localPathExists,
   readLocalFile,
   writeLocalFile,
 } from '../../../util/fs';
+import { ensureLocalPath } from '../../../util/fs/util';
 import { getRepoStatus } from '../../../util/git';
 import { find } from '../../../util/host-rules';
 import { regEx } from '../../../util/regex';
-import { parse as parseToml } from '../../../util/toml';
 import { parseUrl } from '../../../util/url';
 import { PypiDatasource } from '../../datasource/pypi';
-import pep440 from '../../versioning/pep440';
-import type {
-  UpdateArtifact,
-  UpdateArtifactsConfig,
-  UpdateArtifactsResult,
-} from '../types';
+import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
 import { extractPackageFile } from './extract';
-import { PipfileLockSchema } from './schema';
-
-export async function getPythonConstraint(
-  pipfileName: string,
-  pipfileContent: string,
-  existingLockFileContent: string,
-  config: UpdateArtifactsConfig,
-): Promise<string | undefined> {
-  const { constraints = {} } = config;
-  const { python } = constraints;
-
-  if (python) {
-    logger.debug(`Using python constraint ${python} from config`);
-    return python;
-  }
-
-  // Try Pipfile first because it may have had its Python version updated
-  try {
-    const pipfile = parseToml(pipfileContent) as any;
-    const pythonFullVersion = pipfile.requires.python_full_version;
-    if (pythonFullVersion) {
-      logger.debug(
-        `Using python full version ${pythonFullVersion} from Pipfile`,
-      );
-      return `== ${pythonFullVersion}`;
-    }
-    const pythonVersion = pipfile.requires.python_version;
-    if (pythonVersion) {
-      logger.debug(`Using python version ${pythonVersion} from Pipfile`);
-      return `== ${pythonVersion}.*`;
-    }
-  } catch (err) {
-    logger.warn({ err }, 'Error parsing Pipfile');
-  }
-
-  // Try Pipfile.lock next
-  try {
-    const result = PipfileLockSchema.safeParse(existingLockFileContent);
-    // istanbul ignore if: not easily testable
-    if (!result.success) {
-      logger.warn({ err: result.error }, 'Invalid Pipfile.lock');
-      return undefined;
-    }
-    // Exact python version has been included since 2022.10.9. It is more specific than the major.minor version
-    // https://github.com/pypa/pipenv/blob/main/CHANGELOG.md#2022109-2022-10-09
-    const pythonFullVersion = result.data._meta?.requires?.python_full_version;
-    if (pythonFullVersion) {
-      logger.debug(
-        `Using python full version ${pythonFullVersion} from Pipfile.lock`,
-      );
-      return `== ${pythonFullVersion}`;
-    }
-    // Before 2022.10.9, only the major.minor version was included
-    const pythonVersion = result.data._meta?.requires?.python_version;
-    if (pythonVersion) {
-      logger.debug(`Using python version ${pythonVersion} from Pipfile.lock`);
-      return `== ${pythonVersion}.*`;
-    }
-  } catch {
-    // Do nothing
-  }
-
-  // Try looking for the contents of .python-version
-  const pythonVersionFileName = getSiblingFileName(
-    pipfileName,
-    '.python-version',
-  );
-  try {
-    const pythonVersion = await readLocalFile(pythonVersionFileName, 'utf8');
-    let pythonVersionConstraint;
-    if (pythonVersion && pep440.isVersion(pythonVersion)) {
-      if (pythonVersion.split('.').length >= 3) {
-        pythonVersionConstraint = `== ${pythonVersion}`;
-      } else {
-        pythonVersionConstraint = `== ${pythonVersion}.*`;
-      }
-    }
-    if (pythonVersionConstraint) {
-      logger.debug(
-        `Using python version ${pythonVersionConstraint} from ${pythonVersionFileName}`,
-      );
-      return pythonVersionConstraint;
-    }
-  } catch {
-    // Do nothing
-  }
-
-  return undefined;
-}
-
-export function getPipenvConstraint(
-  existingLockFileContent: string,
-  config: UpdateArtifactsConfig,
-): string {
-  const { constraints = {} } = config;
-  const { pipenv } = constraints;
-
-  if (pipenv) {
-    logger.debug('Using pipenv constraint from config');
-    return pipenv;
-  }
-  try {
-    const result = PipfileLockSchema.safeParse(existingLockFileContent);
-    // istanbul ignore if: not easily testable
-    if (!result.success) {
-      logger.warn({ error: result.error }, 'Invalid Pipfile.lock');
-      return '';
-    }
-    if (result.data.default?.pipenv?.version) {
-      return result.data.default.pipenv.version;
-    }
-    if (result.data.develop?.pipenv?.version) {
-      return result.data.develop.pipenv.version;
-    }
-    // Exact python version has been included since 2022.10.9
-    const pythonFullVersion = result.data._meta?.requires?.python_full_version;
-    if (is.string(pythonFullVersion) && semver.valid(pythonFullVersion)) {
-      // python_full_version was added after 3.6 was already deprecated, so it should be impossible to have a 3.6 version
-      // https://github.com/pypa/pipenv/blob/main/CHANGELOG.md#2022109-2022-10-09
-      if (semver.satisfies(pythonFullVersion, '3.7.*')) {
-        // Python 3.7 support was dropped in pipenv 2023.10.20
-        // https://github.com/pypa/pipenv/blob/main/CHANGELOG.md#20231020-2023-10-20
-        return '< 2023.10.20';
-      }
-      // Future deprecations will go here
-    }
-    // Before 2022.10.9, only the major.minor version was included
-    const pythonVersion = result.data._meta?.requires?.python_version;
-    if (pythonVersion) {
-      if (pythonVersion === '3.6') {
-        // Python 3.6 was deprecated in 2022.4.20
-        // https://github.com/pypa/pipenv/blob/main/CHANGELOG.md#2022420-2022-04-20
-        return '< 2022.4.20';
-      }
-      if (pythonVersion === '3.7') {
-        // Python 3.7 was deprecated in 2023.10.20 but we shouldn't reach here unless we are < 2022.10.9
-        // https://github.com/pypa/pipenv/blob/main/CHANGELOG.md#20231020-2023-10-20
-        return '< 2022.10.9';
-      }
-    }
-  } catch {
-    // Do nothing
-  }
-  return '';
-}
 
 export function getMatchingHostRule(url: string): HostRule | null {
   const parsedUrl = parseUrl(url);
@@ -278,8 +129,7 @@ export async function updateArtifacts({
   logger.debug(`pipenv.updateArtifacts(${pipfileName})`);
 
   const lockFileName = pipfileName + '.lock';
-  const existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
-  if (!existingLockFileContent) {
+  if (!(await localPathExists(lockFileName))) {
     logger.debug('No Pipfile.lock found');
     return null;
   }
@@ -289,16 +139,13 @@ export async function updateArtifacts({
       await deleteLocalFile(lockFileName);
     }
     const cmd = 'pipenv lock';
-    const tagConstraint = await getPythonConstraint(
-      pipfileName,
-      newPipfileContent,
-      existingLockFileContent,
-      config,
-    );
-    const pipenvConstraint = getPipenvConstraint(
-      existingLockFileContent,
-      config,
-    );
+    const pipfileDir = getParentDir(ensureLocalPath(pipfileName));
+    const tagConstraint =
+      config.constraints?.python ??
+      (await pipenvDetect.getPythonConstraint(pipfileDir));
+    const pipenvConstraint =
+      config.constraints?.pipenv ??
+      (await pipenvDetect.getPipenvConstraint(pipfileDir));
     const extraEnv: Opt<ExtraEnv> = {
       PIPENV_CACHE_DIR: await ensureCacheDir('pipenv'),
       PIP_CACHE_DIR: await ensureCacheDir('pip'),
diff --git a/lib/modules/manager/pipenv/extract.spec.ts b/lib/modules/manager/pipenv/extract.spec.ts
index 2f36c98092..49727aa800 100644
--- a/lib/modules/manager/pipenv/extract.spec.ts
+++ b/lib/modules/manager/pipenv/extract.spec.ts
@@ -1,9 +1,13 @@
 import { codeBlock } from 'common-tags';
+import * as _fsExtra from 'fs-extra';
+import { join } from 'upath';
 import { Fixtures } from '../../../../test/fixtures';
-import { fs } from '../../../../test/util';
+import { mocked } from '../../../../test/util';
+import { GlobalConfig } from '../../../config/global';
 import { extractPackageFile } from '.';
 
-jest.mock('../../../util/fs');
+jest.mock('fs-extra');
+const fsExtra = mocked(_fsExtra);
 
 const pipfile1 = Fixtures.get('Pipfile1');
 const pipfile2 = Fixtures.get('Pipfile2');
@@ -12,6 +16,16 @@ const pipfile4 = Fixtures.get('Pipfile4');
 const pipfile5 = Fixtures.get('Pipfile5');
 
 describe('modules/manager/pipenv/extract', () => {
+  beforeEach(() => {
+    GlobalConfig.set({
+      localDir: join('/tmp/github/some/repo'),
+    });
+  });
+
+  afterEach(() => {
+    GlobalConfig.reset();
+  });
+
   describe('extractPackageFile()', () => {
     it('returns null for empty', async () => {
       expect(await extractPackageFile('[packages]\r\n', 'Pipfile')).toBeNull();
@@ -22,7 +36,8 @@ describe('modules/manager/pipenv/extract', () => {
     });
 
     it('extracts dependencies', async () => {
-      fs.localPathExists.mockResolvedValueOnce(true);
+      fsExtra.stat.mockResolvedValueOnce({} as never);
+      fsExtra.readFile.mockResolvedValueOnce(pipfile1 as never);
       const res = await extractPackageFile(pipfile1, 'Pipfile');
       expect(res).toMatchObject({
         deps: [
@@ -99,7 +114,7 @@ describe('modules/manager/pipenv/extract', () => {
           },
         ],
         extractedConstraints: {
-          python: '== 3.6.*',
+          python: '== 3.6.2',
         },
         lockFiles: ['Pipfile.lock'],
         registryUrls: [
@@ -118,7 +133,8 @@ describe('modules/manager/pipenv/extract', () => {
     });
 
     it('extracts multiple dependencies', async () => {
-      fs.localPathExists.mockResolvedValueOnce(true);
+      fsExtra.stat.mockResolvedValueOnce({} as never);
+      fsExtra.readFile.mockResolvedValueOnce(pipfile2 as never);
       const res = await extractPackageFile(pipfile2, 'Pipfile');
       expect(res).toMatchObject({
         deps: [
@@ -222,7 +238,8 @@ describe('modules/manager/pipenv/extract', () => {
     });
 
     it('extracts example pipfile', async () => {
-      fs.localPathExists.mockResolvedValueOnce(true);
+      fsExtra.stat.mockResolvedValueOnce({} as never);
+      fsExtra.readFile.mockResolvedValueOnce(pipfile4 as never);
       const res = await extractPackageFile(pipfile4, 'Pipfile');
       expect(res).toMatchObject({
         deps: [
@@ -287,7 +304,7 @@ describe('modules/manager/pipenv/extract', () => {
     });
 
     it('supports custom index', async () => {
-      fs.localPathExists.mockResolvedValueOnce(true);
+      fsExtra.stat.mockResolvedValueOnce({} as never);
       const res = await extractPackageFile(pipfile5, 'Pipfile');
       expect(res).toMatchObject({
         deps: [
@@ -318,6 +335,7 @@ describe('modules/manager/pipenv/extract', () => {
         [requires]
         python_version = "3.8"
       `;
+      fsExtra.readFile.mockResolvedValueOnce(content as never);
       const res = await extractPackageFile(content, 'Pipfile');
       expect(res?.extractedConstraints?.python).toBe('== 3.8.*');
     });
@@ -329,6 +347,7 @@ describe('modules/manager/pipenv/extract', () => {
         [requires]
         python_full_version = "3.8.6"
       `;
+      fsExtra.readFile.mockResolvedValueOnce(content as never);
       const res = await extractPackageFile(content, 'Pipfile');
       expect(res?.extractedConstraints?.python).toBe('== 3.8.6');
     });
@@ -338,6 +357,7 @@ describe('modules/manager/pipenv/extract', () => {
         [packages]
         pipenv = "==2020.8.13"
       `;
+      fsExtra.readFile.mockResolvedValue(content as never);
       const res = await extractPackageFile(content, 'Pipfile');
       expect(res?.extractedConstraints?.pipenv).toBe('==2020.8.13');
     });
@@ -347,6 +367,7 @@ describe('modules/manager/pipenv/extract', () => {
         [dev-packages]
         pipenv = "==2020.8.13"
       `;
+      fsExtra.readFile.mockResolvedValue(content as never);
       const res = await extractPackageFile(content, 'Pipfile');
       expect(res?.extractedConstraints?.pipenv).toBe('==2020.8.13');
     });
diff --git a/lib/modules/manager/pipenv/extract.ts b/lib/modules/manager/pipenv/extract.ts
index cfdae749cb..a770b5b25c 100644
--- a/lib/modules/manager/pipenv/extract.ts
+++ b/lib/modules/manager/pipenv/extract.ts
@@ -1,8 +1,10 @@
+import { pipenv as pipenvDetect } from '@renovatebot/detect-tools';
 import { RANGE_PATTERN } from '@renovatebot/pep440';
 import is from '@sindresorhus/is';
 import { logger } from '../../../logger';
 import type { SkipReason } from '../../../types';
-import { localPathExists } from '../../../util/fs';
+import { getParentDir, localPathExists } from '../../../util/fs';
+import { ensureLocalPath } from '../../../util/fs/util';
 import { regEx } from '../../../util/regex';
 import { parse as parseToml } from '../../../util/toml';
 import { PypiDatasource } from '../../datasource/pypi';
@@ -142,8 +144,6 @@ export async function extractPackageFile(
     res.registryUrls = sources.map((source) => source.url);
   }
 
-  let pipenv_constraint: PipRequirement | undefined;
-
   res.deps = Object.entries(pipfile)
     .map(([category, section]) => {
       if (
@@ -154,10 +154,6 @@ export async function extractPackageFile(
         return [];
       }
 
-      if (section.pipenv && !pipenv_constraint) {
-        pipenv_constraint = section.pipenv;
-      }
-
       return extractFromSection(category, section, sources);
     })
     .flat();
@@ -168,14 +164,16 @@ export async function extractPackageFile(
 
   const extractedConstraints: Record<string, any> = {};
 
-  if (is.nonEmptyString(pipfile.requires?.python_version)) {
-    extractedConstraints.python = `== ${pipfile.requires.python_version}.*`;
-  } else if (is.nonEmptyString(pipfile.requires?.python_full_version)) {
-    extractedConstraints.python = `== ${pipfile.requires.python_full_version}`;
+  const pipfileDir = getParentDir(ensureLocalPath(packageFile));
+
+  const pythonConstraint = await pipenvDetect.getPythonConstraint(pipfileDir);
+  if (pythonConstraint) {
+    extractedConstraints.python = pythonConstraint;
   }
 
-  if (pipenv_constraint) {
-    extractedConstraints.pipenv = pipenv_constraint;
+  const pipenvConstraint = await pipenvDetect.getPipenvConstraint(pipfileDir);
+  if (pipenvConstraint) {
+    extractedConstraints.pipenv = pipenvConstraint;
   }
 
   const lockFileName = `${packageFile}.lock`;
diff --git a/lib/modules/manager/pipenv/schema.ts b/lib/modules/manager/pipenv/schema.ts
deleted file mode 100644
index c7a59de51a..0000000000
--- a/lib/modules/manager/pipenv/schema.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { z } from 'zod';
-import { Json } from '../../../util/schema-utils';
-
-const PipfileLockEntrySchema = z
-  .record(
-    z.string(),
-    z.object({
-      version: z.string().optional(),
-    }),
-  )
-  .optional();
-
-export const PipfileLockSchema = Json.pipe(
-  z.object({
-    _meta: z
-      .object({
-        requires: z
-          .object({
-            python_version: z.string().optional(),
-            python_full_version: z.string().optional(),
-          })
-          .optional(),
-      })
-      .optional(),
-    default: PipfileLockEntrySchema,
-    develop: PipfileLockEntrySchema,
-  }),
-);
-
-export type PipfileLockSchema = z.infer<typeof PipfileLockSchema>;
diff --git a/lib/modules/manager/pipenv/types.ts b/lib/modules/manager/pipenv/types.ts
index 51cbb9c186..9e1cbca605 100644
--- a/lib/modules/manager/pipenv/types.ts
+++ b/lib/modules/manager/pipenv/types.ts
@@ -30,3 +30,14 @@ export type PipRequirement =
       file?: string;
       git?: string;
     };
+
+export interface PipfileLock {
+  _meta?: {
+    requires?: {
+      python_version?: string;
+      python_full_version?: string;
+    };
+  };
+  default?: Record<string, { version?: string }>;
+  develop?: Record<string, { version?: string }>;
+}
diff --git a/package.json b/package.json
index 5009058c59..c05fd221ba 100644
--- a/package.json
+++ b/package.json
@@ -158,6 +158,7 @@
     "@opentelemetry/sdk-trace-node": "1.25.1",
     "@opentelemetry/semantic-conventions": "1.25.1",
     "@qnighy/marshal": "0.1.3",
+    "@renovatebot/detect-tools": "1.0.4",
     "@renovatebot/kbpgp": "3.0.1",
     "@renovatebot/osv-offline": "1.5.9",
     "@renovatebot/pep440": "3.0.20",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b0e2e4e52d..98fe683f99 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,6 +68,9 @@ importers:
       '@qnighy/marshal':
         specifier: 0.1.3
         version: 0.1.3
+      '@renovatebot/detect-tools':
+        specifier: 1.0.4
+        version: 1.0.4
       '@renovatebot/kbpgp':
         specifier: 3.0.1
         version: 3.0.1
@@ -1507,6 +1510,9 @@ packages:
     peerDependencies:
       '@redis/client': ^1.0.0
 
+  '@renovatebot/detect-tools@1.0.4':
+    resolution: {integrity: sha512-s7RvoGgEolHIZ5IQUVoYJMa3uVDxMuz9TDq9zl+j678wNNygT4CoqaArxkZLbGzDU7Znf+HhhMGV5/etUd3ljQ==}
+
   '@renovatebot/eslint-plugin@file:tools/eslint':
     resolution: {directory: tools/eslint, type: directory}
 
@@ -7821,6 +7827,13 @@ snapshots:
     dependencies:
       '@redis/client': 1.6.0
 
+  '@renovatebot/detect-tools@1.0.4':
+    dependencies:
+      fs-extra: 11.2.0
+      toml-eslint-parser: 0.10.0
+      upath: 2.0.1
+      zod: 3.23.8
+
   '@renovatebot/eslint-plugin@file:tools/eslint': {}
 
   '@renovatebot/kbpgp@3.0.1':
-- 
GitLab