Skip to content

Commit f9c0758

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

File tree

12 files changed

+439
-39
lines changed

12 files changed

+439
-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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import org.ossreviewtoolkit.model.config.Excludes
3131
import org.ossreviewtoolkit.plugins.api.OrtPlugin
3232
import org.ossreviewtoolkit.plugins.api.OrtPluginOption
3333
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
34+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.OPTION_PYTHON_VERSION_DEFAULT
35+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PYTHON_VERSIONS
3436
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PythonInspector
3537
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
3638
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtProject
@@ -40,9 +42,6 @@ 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 & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import org.ossreviewtoolkit.model.config.Excludes
3535
import org.ossreviewtoolkit.plugins.api.OrtPlugin
3636
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
3737
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.PythonInspector
38+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersion
39+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersionConstraint
3840
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
3941
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toPackageReferences
4042
import org.ossreviewtoolkit.utils.common.CommandLineTool
@@ -43,9 +45,6 @@ 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

@@ -157,35 +156,3 @@ internal fun parseScopeNamesFromPyproject(pyprojectFile: File): Set<String> {
157156

158157
return scopes
159158
}
160-
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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.getPythonVersion
39+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.getPythonVersionConstraint
40+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toOrtPackages
41+
import org.ossreviewtoolkit.plugins.packagemanagers.python.utils.toPackageReferences
42+
import org.ossreviewtoolkit.utils.common.CommandLineTool
43+
import org.ossreviewtoolkit.utils.common.safeDeleteRecursively
44+
import org.ossreviewtoolkit.utils.ort.createOrtTempFile
45+
46+
internal object UvCommand : CommandLineTool {
47+
override fun command(workingDir: File?) = "uv"
48+
49+
override fun transformVersion(output: String) = output.substringAfter("version ").removeSuffix(")")
50+
}
51+
52+
/**
53+
* [Uv](https://github.com/astral-sh/uv) package manager for Python.
54+
*/
55+
@OrtPlugin(
56+
displayName = "Uv",
57+
description = "An extremely fast Python package and project manager.",
58+
factory = PackageManagerFactory::class
59+
)
60+
class Uv(override val descriptor: PluginDescriptor = UvFactory.descriptor, private val config: PipConfig) :
61+
PackageManager("Uv") {
62+
companion object {
63+
/**
64+
* The name of the build system requirements and information file used by modern Python packages.
65+
*/
66+
internal const val PYPROJECT_FILENAME = "pyproject.toml"
67+
}
68+
69+
override val globsForDefinitionFiles = listOf("uv.lock")
70+
71+
override fun resolveDependencies(
72+
analysisRoot: File,
73+
definitionFile: File,
74+
excludes: Excludes,
75+
analyzerConfig: AnalyzerConfiguration,
76+
labels: Map<String, String>
77+
): List<ProjectAnalyzerResult> {
78+
val scopeName = parseScopeNamesFromPyproject(definitionFile.resolveSibling(PYPROJECT_FILENAME))
79+
val resultsForScopeName = scopeName.associateWith { inspectLockfile(definitionFile) }
80+
81+
val packages = resultsForScopeName
82+
.flatMap { (_, results) -> results.packages }
83+
.toOrtPackages()
84+
.distinctBy { it.id }
85+
.toSet()
86+
87+
val project = Project.EMPTY.copy(
88+
id = Identifier(
89+
type = projectType,
90+
namespace = "",
91+
name = definitionFile.relativeTo(analysisRoot).path,
92+
version = VersionControlSystem.getCloneInfo(definitionFile.parentFile).revision
93+
),
94+
definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
95+
scopeDependencies = resultsForScopeName.mapTo(mutableSetOf()) { (scopeName, results) ->
96+
Scope(scopeName, results.resolvedDependenciesGraph.toPackageReferences())
97+
},
98+
vcsProcessed = processProjectVcs(definitionFile.parentFile)
99+
)
100+
101+
return listOf(ProjectAnalyzerResult(project, packages))
102+
}
103+
104+
/**
105+
* Return the result of running Python inspector against a requirements file generated by exporting the dependencies
106+
* in [lockfile] with the scope named [dependencyGroupName] via the `uv export` command.
107+
*/
108+
private fun inspectLockfile(lockfile: File): PythonInspector.Result {
109+
val workingDir = lockfile.parentFile
110+
val requirementsFile = createOrtTempFile("requirements.txt")
111+
112+
logger.info { "Generating '${requirementsFile.name}' file in '$workingDir' directory..." }
113+
114+
val options = listOf(
115+
"export",
116+
"--no-hashes",
117+
"--no-editable",
118+
"--all-packages"
119+
)
120+
121+
val requirements = UvCommand.run(workingDir, *options.toTypedArray()).requireSuccess().stdout
122+
requirementsFile.writeText(requirements)
123+
124+
return Pip(config = config, projectType = projectType).runPythonInspector(requirementsFile) {
125+
detectPythonVersion(workingDir)
126+
}.also {
127+
requirementsFile.parentFile.safeDeleteRecursively()
128+
}
129+
}
130+
131+
private fun detectPythonVersion(workingDir: File): String? {
132+
val pyprojectFile = workingDir.resolve(PYPROJECT_FILENAME)
133+
val constraint = getPythonVersionConstraint(pyprojectFile) ?: return null
134+
return getPythonVersion(constraint)?.also {
135+
logger.info { "Detected Python version '$it' from '$constraint'." }
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)