All Downloads are FREE. Search and download functionalities are using the official Maven repository.

utils.PythonInspectorExtensions.kt Maven / Gradle / Ivy

/*
 * Copyright (C) 2022 The ORT Project Authors (see )
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 * License-Filename: LICENSE
 */

package org.ossreviewtoolkit.plugins.packagemanagers.python.utils

import java.io.File

import org.ossreviewtoolkit.analyzer.PackageManager
import org.ossreviewtoolkit.downloader.VersionControlSystem
import org.ossreviewtoolkit.model.Hash
import org.ossreviewtoolkit.model.Identifier
import org.ossreviewtoolkit.model.Package
import org.ossreviewtoolkit.model.PackageReference
import org.ossreviewtoolkit.model.Project
import org.ossreviewtoolkit.model.RemoteArtifact
import org.ossreviewtoolkit.model.Scope
import org.ossreviewtoolkit.model.VcsInfo
import org.ossreviewtoolkit.model.VcsType

private const val TYPE = "PyPI"

internal fun PythonInspector.Result.toOrtProject(
    managerName: String,
    analysisRoot: File,
    definitionFile: File
): Project {
    val id = resolveIdentifier(managerName, analysisRoot, definitionFile)

    val setupProject = projects.find { it.path.endsWith("/setup.py") }
    val projectData = setupProject?.packageData?.singleOrNull()
    val homepageUrl = projectData?.homepageUrl.orEmpty()

    val scopes = setOf(Scope("install", resolvedDependenciesGraph.toPackageReferences()))

    return Project(
        id = id,
        definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
        authors = projectData?.parties?.toAuthors().orEmpty(),
        declaredLicenses = projectData?.declaredLicense?.getDeclaredLicenses().orEmpty(),
        vcs = VcsInfo.EMPTY,
        vcsProcessed = PackageManager.processProjectVcs(definitionFile.parentFile, VcsInfo.EMPTY, homepageUrl),
        homepageUrl = homepageUrl,
        scopeDependencies = scopes
    )
}

private fun PythonInspector.Result.resolveIdentifier(
    managerName: String,
    analysisRoot: File,
    definitionFile: File
): Identifier {
    // First try to get identifier components from "setup.py" in any case, even for "requirements.txt" projects.
    val (setupName, setupVersion) = projects.find { it.path.endsWith("/setup.py") }
        ?.let { project ->
            listOf(
                project.packageData.single().name.orEmpty(),
                project.packageData.single().version.orEmpty()
            )
        } ?: listOf("", "")

    val (requirementsName, requirementsVersion) = projects.find { !it.path.endsWith("/setup.py") }
        ?.let {
            // In case of "requirements*.txt" there is no metadata at all available, so use the parent directory name
            // plus what "*" expands to as the project name and the VCS revision, if any, as the project version.
            val suffix = definitionFile.name.removePrefix("requirements").removeSuffix(".txt")
            val name = if (definitionFile.parentFile != analysisRoot) {
                definitionFile.parentFile.name + suffix
            } else {
                PackageManager.getFallbackProjectName(analysisRoot, definitionFile)
            }

            val version = VersionControlSystem.getCloneInfo(definitionFile.parentFile).revision

            listOf(name, version)
        } ?: listOf("", "")

    val hasSetupName = setupName.isNotEmpty()
    val hasRequirementsName = requirementsName.isNotEmpty()

    val projectName = when {
        hasSetupName && !hasRequirementsName -> setupName
        !hasSetupName && hasRequirementsName -> requirementsName
        hasSetupName && hasRequirementsName -> "$setupName-with-requirements-$requirementsName"
        else -> PackageManager.getFallbackProjectName(analysisRoot, definitionFile)
    }

    val projectVersion = setupVersion.takeIf { it.isNotEmpty() } ?: requirementsVersion

    return Identifier(
        type = managerName,
        namespace = "",
        name = projectName,
        version = projectVersion
    )
}

private fun PythonInspector.DeclaredLicense.getDeclaredLicenses() =
    buildSet {
        getLicenseFromLicenseField(license)?.let { add(it) }
        addAll(classifiers.mapNotNull { getLicenseFromClassifier(it) })
    }

internal fun List.toOrtPackages(): Set =
    groupBy { "${it.name}:${it.version}" }.mapTo(mutableSetOf()) { (_, packages) ->
        // The python inspector currently often contains two entries for a package where the only difference is the
        // download URL. In this case, one package contains the URL of the binary artifact, the other for the source
        // artifact. So take all metadata from the first package except for the artifacts.
        val pkg = packages.first()

        @Suppress("UseOrEmpty")
        fun PythonInspector.Package.getHash(): Hash = Hash.create(sha512 ?: sha256 ?: sha1 ?: md5 ?: "")

        fun getArtifact(vararg fileExtensions: String) =
            packages.find { pkg -> fileExtensions.any { pkg.downloadUrl.endsWith(it) } }?.let {
                RemoteArtifact(
                    url = it.downloadUrl,
                    hash = it.getHash()
                )
            } ?: RemoteArtifact.EMPTY

        val id = Identifier(type = TYPE, namespace = "", name = pkg.name, version = pkg.version)
        val declaredLicenses = pkg.declaredLicense?.getDeclaredLicenses().orEmpty()
        val declaredLicensesProcessed = processDeclaredLicenses(id, declaredLicenses)

        Package(
            // The package has a namespace property which is currently always empty. Deliberately set the namespace to
            // an empty string here to be consistent with the resolved packages which do not have a namespace property.
            id = id,
            purl = pkg.purl,
            authors = pkg.parties.toAuthors(),
            declaredLicenses = declaredLicenses,
            declaredLicensesProcessed = declaredLicensesProcessed,
            // Only use the first line of the description because the descriptions provided by python-inspector are
            // currently far too long, see: https://github.com/nexB/python-inspector/issues/74
            description = pkg.description.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
            homepageUrl = pkg.homepageUrl.orEmpty(),
            binaryArtifact = getArtifact(".whl"),
            sourceArtifact = getArtifact(".tar.gz", ".zip"),
            vcs = VcsInfo.EMPTY.copy(url = pkg.vcsUrl.orEmpty()),
            vcsProcessed = PackageManager.processPackageVcs(
                VcsInfo(VcsType.UNKNOWN, pkg.vcsUrl.orEmpty(), revision = ""),
                fallbackUrls = listOfNotNull(
                    pkg.codeViewUrl,
                    pkg.homepageUrl
                ).toTypedArray()
            )
        )
    }

private fun List.toAuthors(): Set =
    filter { it.role == "author" }.mapNotNullTo(mutableSetOf()) { party ->
        buildString {
            party.name?.let { append(it) }
            party.email?.let {
                append(if (party.name != null) " <$it>" else it)
            }
        }.takeIf { it.isNotBlank() }
    }

internal fun List.toPackageReferences(): Set =
    mapTo(mutableSetOf()) { it.toPackageReference() }

private fun PythonInspector.ResolvedDependency.toPackageReference() =
    PackageReference(
        id = Identifier(type = TYPE, namespace = "", name = packageName, version = installedVersion),
        dependencies = dependencies.toPackageReferences()
    )




© 2015 - 2025 Weber Informatics LLC | Privacy Policy