Skip to content

Commit 61dff26

Browse files
committed
feat: Add Uv Python package manager
Signed-off-by: Helio Chissini de Castro <[email protected]>
1 parent 2c8aa0c commit 61dff26

File tree

11 files changed

+452
-39
lines changed

11 files changed

+452
-39
lines changed

analyzer/src/funTest/kotlin/PackageManagerFunTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ class PackageManagerFunTest : WordSpec({
6969
"spdx-project/project.spdx.yml",
7070
"spm-app/Package.resolved",
7171
"spm-lib/Package.swift",
72-
"stack/stack.yaml"
72+
"stack/stack.yaml",
73+
"uv/uv.lock"
7374
)
7475

7576
val projectDir = tempdir()

helper-cli/src/main/kotlin/commands/repoconfig/GenerateScopeExcludesCommand.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ private fun getScopeExcludesForPackageManager(packageManagerName: String): List<
291291
comment = "Packages for testing only."
292292
)
293293
)
294+
"Uv" -> listOf(
295+
ScopeExclude(
296+
pattern = "dev",
297+
reason = ScopeExcludeReason.DEV_DEPENDENCY_OF,
298+
comment = "Packages for development only."
299+
),
300+
)
294301
"SBT" -> listOf(
295302
ScopeExclude(
296303
pattern = "provided",

integrations/schemas/package-managers-schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"Stack",
2929
"SwiftPM",
3030
"Unmanaged",
31+
"Uv",
3132
"Yarn",
3233
"Yarn2"
3334
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
[project]
2+
name = "lixo"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"graphviz>=0.20.3",
9+
"jinja2>=3.1.6",
10+
]
11+
12+
[dependency-groups]
13+
dev = [
14+
"pytest>=8.3.5",
15+
"ruff>=0.9.10",
16+
]
17+
18+
[tool.ruff]
19+
fix = true
20+
line-length = 120
21+
22+
[tool.ruff.lint]
23+
extend-select = [
24+
"E", # pycodestyle error
25+
"W", # pycodestyle warning
26+
"F", # pyflakes
27+
"A", # flakes8-builtins
28+
"COM", # flakes8-commas
29+
"C4", # flake8-comprehensions
30+
"Q", # flake8-quotes
31+
"SIM", # flake8-simplify
32+
"PTH", # flake8-use-pathlib
33+
"I", # isort
34+
"N", # pep8 naming
35+
"UP", # pyupgrade
36+
"S", # bandit
37+
]
38+
ignore = [
39+
'N802', # function name should be lowercase
40+
'SIM105', # Suggest contextlib instead of try/except with pass
41+
'COM812', # missing-trailing-comma from flake8-commas
42+
]
43+
# Allow unused variables when underscore-prefixed.
44+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
45+
flake8-tidy-imports.ban-relative-imports = "all"
46+
isort.required-imports = ["from __future__ import annotations"]
47+
# Unlike Flake8, default to a complexity level of 10.
48+
mccabe.max-complexity = 10
49+
per-file-ignores = {}

plugins/package-managers/python/src/funTest/assets/projects/synthetic/uv/uv.lock

Lines changed: 154 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.ossreviewtoolkit.plugins.packagemanagers.python
21+
22+
import io.kotest.core.spec.style.WordSpec
23+
import io.kotest.matchers.should
24+
25+
import org.ossreviewtoolkit.analyzer.resolveSingleProject
26+
import org.ossreviewtoolkit.model.toYaml
27+
import org.ossreviewtoolkit.utils.test.getAssetFile
28+
import org.ossreviewtoolkit.utils.test.matchExpectedResult
29+
30+
class UvFunTest : WordSpec({
31+
"Python 3" should {
32+
"resolve dependencies correctly" {
33+
val definitionFile = getAssetFile("projects/synthetic/uv/uv.lock")
34+
val expectedResultFile = getAssetFile("projects/synthetic/uv-expected-output.yml")
35+
36+
val result = UvFactory.create().resolveSingleProject(definitionFile)
37+
38+
result.toYaml() should matchExpectedResult(expectedResultFile, definitionFile)
39+
}
40+
}
41+
})

plugins/package-managers/python/src/main/kotlin/Pip.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,16 @@ import org.ossreviewtoolkit.plugins.api.OrtPlugin
3232
import org.ossreviewtoolkit.plugins.api.OrtPluginOption
3333
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
3434
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PythonInspector
35-
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
3635
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtProject
36+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
37+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PYTHON_VERSIONS
38+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.OPTION_PYTHON_VERSION_DEFAULT
3739
import org.ossreviewtoolkit.utils.common.collectMessages
3840
import org.ossreviewtoolkit.utils.common.safeDeleteRecursively
3941
import org.ossreviewtoolkit.utils.ort.showStackTrace
4042

4143
private val OPERATING_SYSTEMS = listOf("linux", "macos", "windows")
4244

43-
private const val OPTION_PYTHON_VERSION_DEFAULT = "3.11"
44-
internal val PYTHON_VERSIONS = listOf("2.7", "3.6", "3.7", "3.8", "3.9", "3.10", OPTION_PYTHON_VERSION_DEFAULT)
45-
4645
data class PipConfig(
4746
/**
4847
* If "true", `python-inspector` resolves dependencies from setup.py files by executing them. This is a potential

plugins/package-managers/python/src/main/kotlin/Poetry.kt

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,14 @@ import org.ossreviewtoolkit.plugins.api.PluginDescriptor
3737
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PythonInspector
3838
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
3939
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toPackageReferences
40+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersion
41+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersionConstraint
4042
import org.ossreviewtoolkit.utils.common.CommandLineTool
4143
import org.ossreviewtoolkit.utils.common.safeDeleteRecursively
4244
import org.ossreviewtoolkit.utils.common.withoutPrefix
4345
import org.ossreviewtoolkit.utils.common.withoutSuffix
4446
import org.ossreviewtoolkit.utils.ort.createOrtTempFile
4547

46-
import org.semver4j.RangesListFactory
47-
import org.semver4j.Semver
48-
4948
internal object PoetryCommand : CommandLineTool {
5049
override fun command(workingDir: File?) = "poetry"
5150

@@ -158,34 +157,3 @@ internal fun parseScopeNamesFromPyproject(pyprojectFile: File): Set<String> {
158157
return scopes
159158
}
160159

161-
internal fun getPythonVersion(constraint: String): String? {
162-
val rangeLists = constraint.split(',')
163-
.map { RangesListFactory.create(it) }
164-
.takeIf { it.isNotEmpty() } ?: return null
165-
166-
return PYTHON_VERSIONS.lastOrNull { version ->
167-
rangeLists.all { rangeList ->
168-
val semver = Semver.coerce(version)
169-
semver != null && rangeList.isSatisfiedBy(semver)
170-
}
171-
}
172-
}
173-
174-
internal fun getPythonVersionConstraint(pyprojectTomlFile: File): String? {
175-
val dependenciesSection = getTomlSectionContent(pyprojectTomlFile, "tool.poetry.dependencies")
176-
?: return null
177-
178-
return dependenciesSection.split('\n').firstNotNullOfOrNull {
179-
it.trim().withoutPrefix("python = ")
180-
}?.removeSurrounding("\"")
181-
}
182-
183-
private fun getTomlSectionContent(tomlFile: File, sectionName: String): String? {
184-
val lines = tomlFile.takeIf { it.isFile }?.readLines() ?: return null
185-
186-
val sectionHeaderIndex = lines.indexOfFirst { it.trim() == "[$sectionName]" }
187-
if (sectionHeaderIndex == -1) return null
188-
189-
val sectionLines = lines.subList(sectionHeaderIndex + 1, lines.size).takeWhile { !it.trim().startsWith('[') }
190-
return sectionLines.joinToString("\n")
191-
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright (C) 2022 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.ossreviewtoolkit.plugins.packagemanagers.python
21+
22+
import java.io.File
23+
24+
import org.apache.logging.log4j.kotlin.logger
25+
26+
import org.ossreviewtoolkit.analyzer.PackageManager
27+
import org.ossreviewtoolkit.analyzer.PackageManagerFactory
28+
import org.ossreviewtoolkit.downloader.VersionControlSystem
29+
import org.ossreviewtoolkit.model.Identifier
30+
import org.ossreviewtoolkit.model.Project
31+
import org.ossreviewtoolkit.model.ProjectAnalyzerResult
32+
import org.ossreviewtoolkit.model.Scope
33+
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
34+
import org.ossreviewtoolkit.model.config.Excludes
35+
import org.ossreviewtoolkit.plugins.api.OrtPlugin
36+
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
37+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PythonInspector
38+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
39+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toPackageReferences
40+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersion
41+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersionConstraint
42+
import org.ossreviewtoolkit.utils.common.CommandLineTool
43+
import org.ossreviewtoolkit.utils.common.safeDeleteRecursively
44+
import org.ossreviewtoolkit.utils.common.withoutPrefix
45+
import org.ossreviewtoolkit.utils.common.withoutSuffix
46+
import org.ossreviewtoolkit.utils.ort.createOrtTempFile
47+
48+
49+
internal object UvCommand : CommandLineTool {
50+
override fun command(workingDir: File?) = "uv"
51+
52+
override fun transformVersion(output: String) = output.substringAfter("version ").removeSuffix(")")
53+
}
54+
55+
/**
56+
* [Uv](https://github.com/astral-sh/uv) package manager for Python.
57+
*/
58+
@OrtPlugin(
59+
displayName = "Uv",
60+
description = "An extremely fast Python package and project manager.",
61+
factory = PackageManagerFactory::class
62+
)
63+
class Uv(override val descriptor: PluginDescriptor = UvFactory.descriptor, private val config: PipConfig) :
64+
PackageManager("Uv") {
65+
companion object {
66+
/**
67+
* The name of the build system requirements and information file used by modern Python packages.
68+
*/
69+
internal const val PYPROJECT_FILENAME = "pyproject.toml"
70+
}
71+
72+
override val globsForDefinitionFiles = listOf("uv.lock")
73+
74+
override fun resolveDependencies(
75+
analysisRoot: File,
76+
definitionFile: File,
77+
excludes: Excludes,
78+
analyzerConfig: AnalyzerConfiguration,
79+
labels: Map<String, String>
80+
): List<ProjectAnalyzerResult> {
81+
val scopeName = parseScopeNamesFromPyproject(definitionFile.resolveSibling(PYPROJECT_FILENAME))
82+
val resultsForScopeName = scopeName.associateWith { inspectLockfile(definitionFile, it) }
83+
84+
val packages = resultsForScopeName
85+
.flatMap { (_, results) -> results.packages }
86+
.toOrtPackages()
87+
.distinctBy { it.id }
88+
.toSet()
89+
90+
val project = Project.EMPTY.copy(
91+
id = Identifier(
92+
type = projectType,
93+
namespace = "",
94+
name = definitionFile.relativeTo(analysisRoot).path,
95+
version = VersionControlSystem.getCloneInfo(definitionFile.parentFile).revision
96+
),
97+
definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
98+
scopeDependencies = resultsForScopeName.mapTo(mutableSetOf()) { (scopeName, results) ->
99+
Scope(scopeName, results.resolvedDependenciesGraph.toPackageReferences())
100+
},
101+
vcsProcessed = processProjectVcs(definitionFile.parentFile)
102+
)
103+
104+
return listOf(ProjectAnalyzerResult(project, packages))
105+
}
106+
107+
/**
108+
* Return the result of running Python inspector against a requirements file generated by exporting the dependencies
109+
* in [lockfile] with the scope named [dependencyGroupName] via the `uv export` command.
110+
*/
111+
private fun inspectLockfile(lockfile: File, dependencyGroupName: String): PythonInspector.Result {
112+
val workingDir = lockfile.parentFile
113+
val requirementsFile = createOrtTempFile("requirements.txt")
114+
115+
logger.info { "Generating '${requirementsFile.name}' file in '$workingDir' directory..." }
116+
117+
val options = listOf(
118+
"export",
119+
"--no-hashes",
120+
"--no-editable",
121+
"--all-packages"
122+
)
123+
124+
val requirements = UvCommand.run(workingDir, *options.toTypedArray()).requireSuccess().stdout
125+
requirementsFile.writeText(requirements)
126+
127+
return Pip(config = config, projectType = projectType).runPythonInspector(requirementsFile) {
128+
detectPythonVersion(workingDir)
129+
}.also {
130+
requirementsFile.parentFile.safeDeleteRecursively()
131+
}
132+
}
133+
134+
private fun detectPythonVersion(workingDir: File): String? {
135+
val pyprojectFile = workingDir.resolve(PYPROJECT_FILENAME)
136+
val constraint = getPythonVersionConstraint(pyprojectFile) ?: return null
137+
return getPythonVersion(constraint)?.also {
138+
logger.info { "Detected Python version '$it' from '$constraint'." }
139+
}
140+
}
141+
}
142+
143+
private fun getTomlSectionContent(tomlFile: File, sectionName: String): String? {
144+
val lines = tomlFile.takeIf { it.isFile }?.readLines() ?: return null
145+
146+
val sectionHeaderIndex = lines.indexOfFirst { it.trim() == "[$sectionName]" }
147+
if (sectionHeaderIndex == -1) return null
148+
149+
val sectionLines = lines.subList(sectionHeaderIndex + 1, lines.size).takeWhile { !it.trim().startsWith('[') }
150+
return sectionLines.joinToString("\n")
151+
}

0 commit comments

Comments
 (0)